Browse Source

implement living room switches. load last tab on page refresh.

Thomas Buck 1 year ago
parent
commit
2288cd95ed
5 changed files with 426 additions and 116 deletions
  1. 1
    0
      README.md
  2. 31
    0
      lights/autotab.js
  3. 84
    71
      lights/index.html
  4. 246
    45
      lights/lights.js
  5. 64
    0
      lights/mqtt.js

+ 1
- 0
README.md View File

@@ -21,3 +21,4 @@ Then run `localtest.py` and open `http://localhost:8080` to access local test in
21 21
  * [JS Radio Buttons](https://www.javascripttutorial.net/javascript-dom/javascript-radio-button/)
22 22
  * [Python webserver](https://stackoverflow.com/a/52531444)
23 23
  * [Re-use socket address](https://stackoverflow.com/a/16641793)
24
+ * [Store last tab in LocalStorage](https://stackoverflow.com/a/72358089)

+ 31
- 0
lights/autotab.js View File

@@ -0,0 +1,31 @@
1
+// https://stackoverflow.com/a/72358089
2
+
3
+const tabs = document.querySelector('#nav-tab').querySelectorAll('button[data-bs-toggle="tab"]');
4
+
5
+tabs.forEach(tab => {
6
+  tab.addEventListener('shown.bs.tab', (event) => {
7
+    const { target } = event;
8
+    const { id: targetId } = target;
9
+
10
+    saveTabId(targetId);
11
+  });
12
+});
13
+
14
+const saveTabId = (selector) => {
15
+  localStorage.setItem('activeTabId', selector);
16
+};
17
+
18
+const getTabId = () => {
19
+  const activeTabId = localStorage.getItem('activeTabId');
20
+
21
+  // if local storage item is null, show default tab
22
+  if (!activeTabId) return;
23
+
24
+  // call 'show' function
25
+  const someTabTriggerEl = document.querySelector(`#${activeTabId}`)
26
+  const tab = new bootstrap.Tab(someTabTriggerEl);
27
+
28
+  tab.show();
29
+};
30
+
31
+getTabId();

+ 84
- 71
lights/index.html View File

@@ -45,6 +45,8 @@
45 45
                 <div class="col-2"></div>
46 46
             </div>
47 47
 
48
+            <!-- Bathroom Tab Panel -->
49
+
48 50
             <div class="tab-content" id="nav-tabContent">
49 51
                 <div class="tab-pane fade show active" id="nav-bathroom" role="tabpanel" aria-labelledby="nav-bathroom-tab" tabindex="0">
50 52
                     <div class="row">
@@ -139,10 +141,12 @@
139 141
                     </div>
140 142
                 </div>
141 143
 
144
+                <!-- Livingroom Tab Panel -->
145
+
142 146
                 <div class="tab-pane fade" id="nav-livingroom" role="tabpanel" aria-labelledby="nav-livingroom-tab" tabindex="0">
143 147
                     <div class="row">
144 148
                         <div class="col">
145
-                            <p>No controls available yet.</p>
149
+                            <h2>Actors</h2>
146 150
                         </div>
147 151
                     </div>
148 152
 
@@ -156,6 +160,14 @@
156 160
                                 <label class="btn btn-outline-primary" for="workspaceon">
157 161
                                     On
158 162
                                 </label>
163
+                                <input type="radio" class="btn-check" name="workspaceradio" id="workspacebench" autocomplete="off">
164
+                                <label class="btn btn-outline-info" for="workspacebench">
165
+                                    Bench
166
+                                </label>
167
+                                <input type="radio" class="btn-check" name="workspaceradio" id="workspacepc" autocomplete="off">
168
+                                <label class="btn btn-outline-success" for="workspacepc">
169
+                                    PC
170
+                                </label>
159 171
                                 <input type="radio" class="btn-check" name="workspaceradio" id="workspaceoff" autocomplete="off">
160 172
                                 <label class="btn btn-outline-dark" for="workspaceoff">
161 173
                                     Off
@@ -174,6 +186,14 @@
174 186
                                 <label class="btn btn-outline-primary" for="tvon">
175 187
                                     On
176 188
                                 </label>
189
+                                <input type="radio" class="btn-check" name="tvradio" id="tvbox" autocomplete="off">
190
+                                <label class="btn btn-outline-info" for="tvbox">
191
+                                    Box
192
+                                </label>
193
+                                <input type="radio" class="btn-check" name="tvradio" id="tvamp" autocomplete="off">
194
+                                <label class="btn btn-outline-success" for="tvamp">
195
+                                    Amp
196
+                                </label>
177 197
                                 <input type="radio" class="btn-check" name="tvradio" id="tvoff" autocomplete="off">
178 198
                                 <label class="btn btn-outline-dark" for="tvoff">
179 199
                                     Off
@@ -199,8 +219,69 @@
199 219
                             </div>
200 220
                         </div>
201 221
                     </div>
222
+
223
+                    <div class="row">
224
+                        <div class="col-2"></div>
225
+                        <div class="col-8">
226
+                            <hr>
227
+                        </div>
228
+                        <div class="col-2"></div>
229
+                    </div>
230
+
231
+                    <div class="row">
232
+                        <div class="col">
233
+                            <h2>Sensors</h2>
234
+                        </div>
235
+                    </div>
236
+
237
+                    <div class="row">
238
+                        <div class="col text-end">
239
+                            <p>Temperature</p>
240
+                        </div>
241
+                        <div class="col text-start" id="livingtemp">
242
+                            <p>Unknown</p>
243
+                        </div>
244
+                    </div>
245
+
246
+                    <div class="row">
247
+                        <div class="col text-end">
248
+                            <p>Relative Humidity</p>
249
+                        </div>
250
+                        <div class="col text-start" id="livinghumid">
251
+                            <p>Unknown</p>
252
+                        </div>
253
+                    </div>
254
+
255
+                    <div class="row">
256
+                        <div class="col text-end">
257
+                            <p>tVOC</p>
258
+                        </div>
259
+                        <div class="col text-start" id="livingtvoc">
260
+                            <p>Unknown</p>
261
+                        </div>
262
+                    </div>
263
+
264
+                    <div class="row">
265
+                        <div class="col text-end">
266
+                            <p>eCO2</p>
267
+                        </div>
268
+                        <div class="col text-start" id="livingeco2">
269
+                            <p>Unknown</p>
270
+                        </div>
271
+                    </div>
272
+
273
+                    <div class="row">
274
+                        <div class="col text-end">
275
+                            <p>Air Pressure</p>
276
+                        </div>
277
+                        <div class="col text-start" id="livingpress">
278
+                            <p>Unknown</p>
279
+                        </div>
280
+                    </div>
202 281
                 </div>
203 282
 
283
+                <!-- Help Tab Panel -->
284
+
204 285
                 <div class="tab-pane fade" id="nav-help" role="tabpanel" aria-labelledby="nav-help-tab" tabindex="0">
205 286
                     <div class="row">
206 287
                         <div class="col">
@@ -245,77 +326,9 @@
245 326
         <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script>
246 327
         <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
247 328
 
329
+        <script src="autotab.js"></script>
248 330
         <script src="credentials.js"></script>
331
+        <script src="mqtt.js"></script>
249 332
         <script src="lights.js"></script>
250
-
251
-        <script>
252
-            const btnsBath = document.querySelectorAll("#bathroomlightauto, #bathroomlightbig, #bathroomlightsmall, #bathroomlightoff")
253
-
254
-            // handle changes to bathroom lights
255
-            subscribeTopic("bathroom/force_light", function (msg) {
256
-                // clear all buttons
257
-                for (const btn of btnsBath) {
258
-                    btn.checked = false
259
-                }
260
-
261
-                // activate proper button
262
-                if (msg == "none") {
263
-                    const btn = document.querySelector("#bathroomlightauto")
264
-                    btn.checked = true
265
-                } else if (msg == "big") {
266
-                    const btn = document.querySelector("#bathroomlightbig")
267
-                    btn.checked = true
268
-                } else if (msg == "small") {
269
-                    const btn = document.querySelector("#bathroomlightsmall")
270
-                    btn.checked = true
271
-                } else if (msg == "off") {
272
-                    const btn = document.querySelector("#bathroomlightoff")
273
-                    btn.checked = true
274
-                } else {
275
-                    console.log("unknown msg " + msg)
276
-                }
277
-            })
278
-
279
-            // set new bathroom light state
280
-            for (const btn of btnsBath) {
281
-                btn.addEventListener('change', function (e) {
282
-                    if (this.checked) {
283
-                        if (this == document.querySelector("#bathroomlightauto")) {
284
-                            setTopic("bathroom/force_light", "none")
285
-                        } else if (this == document.querySelector("#bathroomlightbig")) {
286
-                            setTopic("bathroom/force_light", "big")
287
-                        } else if (this == document.querySelector("#bathroomlightsmall")) {
288
-                            setTopic("bathroom/force_light", "small")
289
-                        } else if (this == document.querySelector("#bathroomlightoff")) {
290
-                            setTopic("bathroom/force_light", "off")
291
-                        } else {
292
-                            console.log("unknown btn value " + this.value)
293
-                        }
294
-                    }
295
-                })
296
-            }
297
-
298
-            // handle bathroom sensors
299
-            subscribeTopic("bathroom/temperature", function (msg) {
300
-                const txt = document.querySelector("#bathtemp")
301
-                txt.innerHTML = "<p>" + parseInt(msg) + " °C</p>"
302
-            })
303
-            subscribeTopic("bathroom/humidity", function (msg) {
304
-                const txt = document.querySelector("#bathhumid")
305
-                txt.innerHTML = "<p>" + parseInt(msg) + " %</p>"
306
-            })
307
-            subscribeTopic("bathroom/pressure", function (msg) {
308
-                const txt = document.querySelector("#bathpress")
309
-                txt.innerHTML = "<p>" + parseInt(msg / 100.0) + " mbar</p>"
310
-            })
311
-            subscribeTopic("bathroom/tvoc", function (msg) {
312
-                const txt = document.querySelector("#bathtvoc")
313
-                txt.innerHTML = "<p>" + parseInt(msg) + " ppb</p>"
314
-            })
315
-            subscribeTopic("bathroom/eco2", function (msg) {
316
-                const txt = document.querySelector("#batheco2")
317
-                txt.innerHTML = "<p>" + parseInt(msg) + " ppm</p>"
318
-            })
319
-        </script>
320 333
     </body>
321 334
 </html>

+ 246
- 45
lights/lights.js View File

@@ -1,64 +1,265 @@
1
-/*
2
- * The idea is to use retained messages.
3
- * This way we can keep the state of the lights.
4
- * Make sure all senders use retained messages!
5
- * (in here and shell scripts on PC)
6
- */
7
-
8
-const options = {
9
-    clean: true,
10
-    connectTimeout: 4000,
11
-    clientId: 'lights-web',
12
-    username: mqttUsername,
13
-    password: mqttPassword,
14
-}
15
-const callbacks = []
16
-const client  = mqtt.connect(mqttUrl, options)
1
+// --------------------------
2
+// bathroom
3
+// --------------------------
17 4
 
18
-client.on('connect', function () {
19
-    console.log('MQTT Connected')
20
-})
5
+const btnsBath = document.querySelectorAll("#bathroomlightauto, #bathroomlightbig, #bathroomlightsmall, #bathroomlightoff")
21 6
 
22
-client.on('message', function (topic, message) {
23
-    console.log("Rx \"" + topic.toString() + "\": \"" + message.toString() + "\"")
7
+// handle changes to bathroom lights
8
+subscribeTopic("bathroom/force_light", function (msg) {
9
+    // clear all buttons
10
+    for (const btn of btnsBath) {
11
+        btn.checked = false
12
+    }
24 13
 
25
-    for (const cb of callbacks) {
26
-        if (cb.topic == topic) {
27
-            console.log("Routing to Callback")
28
-            cb.callback(message)
14
+    // activate proper button
15
+    if (msg == "none") {
16
+        const btn = document.querySelector("#bathroomlightauto")
17
+        btn.checked = true
18
+    } else if (msg == "big") {
19
+        const btn = document.querySelector("#bathroomlightbig")
20
+        btn.checked = true
21
+    } else if (msg == "small") {
22
+        const btn = document.querySelector("#bathroomlightsmall")
23
+        btn.checked = true
24
+    } else if (msg == "off") {
25
+        const btn = document.querySelector("#bathroomlightoff")
26
+        btn.checked = true
27
+    } else {
28
+        console.log("unknown bathroom/force_light msg " + msg)
29
+    }
30
+})
31
+
32
+// set new bathroom light state
33
+for (const btn of btnsBath) {
34
+    btn.addEventListener('change', function (e) {
35
+        if (this.checked) {
36
+            if (this == document.querySelector("#bathroomlightauto")) {
37
+                setTopic("bathroom/force_light", "none")
38
+            } else if (this == document.querySelector("#bathroomlightbig")) {
39
+                setTopic("bathroom/force_light", "big")
40
+            } else if (this == document.querySelector("#bathroomlightsmall")) {
41
+                setTopic("bathroom/force_light", "small")
42
+            } else if (this == document.querySelector("#bathroomlightoff")) {
43
+                setTopic("bathroom/force_light", "off")
44
+            } else {
45
+                console.log("unknown bathroom/force_light btn value " + this.value)
46
+            }
29 47
         }
48
+    })
49
+}
50
+
51
+// handle bathroom sensors
52
+subscribeSensor("bathroom/temperature", "°C", "#bathtemp")
53
+subscribeSensor("bathroom/humidity", "%", "#bathhumid")
54
+subscribeSensor("bathroom/pressure", "mbar", "#bathpress", 100.0)
55
+subscribeSensor("bathroom/tvoc", "ppb", "#bathtvoc")
56
+subscribeSensor("bathroom/eco2", "ppm", "#batheco2")
57
+
58
+// --------------------------
59
+// livingroom
60
+// --------------------------
61
+
62
+const btnsKitchen = document.querySelectorAll("#kitchenon, #kitchenoff")
63
+const btnsWorkspace = document.querySelectorAll("#workspaceon, #workspaceoff, #workspacepc, #workspacebench")
64
+const btnsTv = document.querySelectorAll("#tvon, #tvamp, #tvbox, #tvoff")
65
+
66
+// handle changes to kitchen lights
67
+subscribeTopic("livingroom/light_kitchen", function (msg) {
68
+    // clear all buttons
69
+    for (const btn of btnsKitchen) {
70
+        btn.checked = false
71
+    }
72
+
73
+    // activate proper button
74
+    if (msg == "on") {
75
+        const btn = document.querySelector("#kitchenon")
76
+        btn.checked = true
77
+    } else if (msg == "off") {
78
+        const btn = document.querySelector("#kitchenoff")
79
+        btn.checked = true
80
+    } else {
81
+        console.log("unknown livingroom/light_kitchen msg " + msg)
30 82
     }
31 83
 })
32 84
 
33
-/*
34
-function clearSubscriptions() {
35
-    for (const cb of callbacks) {
36
-        client.unsubscribe(cb.topic)
85
+// set new kitchen light state
86
+for (const btn of btnsKitchen) {
87
+    btn.addEventListener('change', function (e) {
88
+        if (this.checked) {
89
+            if (this == document.querySelector("#kitchenon")) {
90
+                setTopic("livingroom/light_kitchen", "on")
91
+            } else if (this == document.querySelector("#kitchenoff")) {
92
+                setTopic("livingroom/light_kitchen", "off")
93
+            } else {
94
+                console.log("unknown livingroom/light_kitchen btn value " + this.value)
95
+            }
96
+        }
97
+    })
98
+}
99
+
100
+const state_light_pc = 0
101
+const state_light_bench = 0
102
+
103
+function setWorkspaceLightsButtons() {
104
+    // clear all buttons
105
+    for (const btn of btnsWorkspace) {
106
+        btn.checked = false
107
+    }
108
+
109
+    // activate proper button
110
+    if ((state_light_pc == 1) && (state_light_bench == 1)) {
111
+        const btn = document.querySelector("#workspaceon")
112
+        btn.checked = true
113
+    } else if ((state_light_pc == 1) && (state_light_bench == 0)) {
114
+        const btn = document.querySelector("#workspacepc")
115
+        btn.checked = true
116
+    } else if ((state_light_pc == 0) && (state_light_bench == 1)) {
117
+        const btn = document.querySelector("#workspacebench")
118
+        btn.checked = true
119
+    } else {
120
+        const btn = document.querySelector("#workspaceoff")
121
+        btn.checked = true
37 122
     }
38
-    callbacks = []
39 123
 }
40
-*/
41 124
 
42
-function subscribeTopic(topic, callback) {
43
-    console.log("Sub to \"" + topic.toString() + "\"")
125
+// handle changes to workspace lights
126
+subscribeTopic("livingroom/light_pc", function (msg) {
127
+    if (msg == "on") {
128
+        state_light_pc = 1
129
+    } else if (msg == "off") {
130
+        state_light_pc = 0
131
+    } else {
132
+        console.log("unknown livingroom/light_pc msg " + msg)
133
+    }
134
+
135
+    setWorkspaceLightsButtons()
136
+})
137
+subscribeTopic("livingroom/light_bench", function (msg) {
138
+    if (msg == "on") {
139
+        state_light_bench = 1
140
+    } else if (msg == "off") {
141
+        state_light_bench = 0
142
+    } else {
143
+        console.log("unknown livingroom/light_bench msg " + msg)
144
+    }
145
+
146
+    setWorkspaceLightsButtons()
147
+})
148
+
149
+// set new workspace light state
150
+for (const btn of btnsWorkspace) {
151
+    btn.addEventListener('change', function (e) {
152
+        if (this.checked) {
153
+            if (this == document.querySelector("#workspaceon")) {
154
+                state_light_pc = 1
155
+                state_light_bench = 1
156
+                setTopic("livingroom/light_pc", "on")
157
+                setTopic("livingroom/light_bench", "on")
158
+            } else if (this == document.querySelector("#workspaceoff")) {
159
+                state_light_pc = 0
160
+                state_light_bench = 0
161
+                setTopic("livingroom/light_pc", "off")
162
+                setTopic("livingroom/light_bench", "off")
163
+            } else if (this == document.querySelector("#workspacepc")) {
164
+                state_light_pc = 1
165
+                state_light_bench = 0
166
+                setTopic("livingroom/light_pc", "on")
167
+                setTopic("livingroom/light_bench", "off")
168
+            } else if (this == document.querySelector("#workspacebench")) {
169
+                state_light_bench = 1
170
+                state_light_pc = 0
171
+                setTopic("livingroom/light_bench", "on")
172
+                setTopic("livingroom/light_pc", "off")
173
+            } else {
174
+                console.log("unknown btn value " + this.value)
175
+            }
176
+        }
177
+    })
178
+}
179
+
180
+const state_light_amp = 0
181
+const state_light_box = 0
44 182
 
45
-    subOptions = {
46
-        rh: true,
183
+function setTvLightsButtons() {
184
+    // clear all buttons
185
+    for (const btn of btnsTv) {
186
+        btn.checked = false
47 187
     }
48
-    client.subscribe(topic)
49 188
 
50
-    callbackObj = {
51
-        topic: topic,
52
-        callback: callback,
189
+    // activate proper button
190
+    if ((state_light_amp == 1) && (state_light_box == 1)) {
191
+        const btn = document.querySelector("#tvon")
192
+        btn.checked = true
193
+    } else if ((state_light_amp == 1) && (state_light_box == 0)) {
194
+        const btn = document.querySelector("#tvamp")
195
+        btn.checked = true
196
+    } else if ((state_light_amp == 0) && (state_light_box == 1)) {
197
+        const btn = document.querySelector("#tvbox")
198
+        btn.checked = true
199
+    } else {
200
+        const btn = document.querySelector("#tvoff")
201
+        btn.checked = true
53 202
     }
54
-    callbacks.push(callbackObj)
55 203
 }
56 204
 
57
-function setTopic(topic, message) {
58
-    console.log("Tx \"" + topic.toString() + "\": \"" + message.toString() + "\"")
205
+// handle changes to tv lights
206
+subscribeTopic("livingroom/light_amp", function (msg) {
207
+    if (msg == "on") {
208
+        state_light_amp = 1
209
+    } else if (msg == "off") {
210
+        state_light_amp = 0
211
+    } else {
212
+        console.log("unknown livingroom/light_amp msg " + msg)
213
+    }
59 214
 
60
-    pubOptions = {
61
-        retain: true,
215
+    setWorkspaceLightsButtons()
216
+})
217
+subscribeTopic("livingroom/light_box", function (msg) {
218
+    if (msg == "on") {
219
+        state_light_box = 1
220
+    } else if (msg == "off") {
221
+        state_light_box = 0
222
+    } else {
223
+        console.log("unknown livingroom/light_box msg " + msg)
62 224
     }
63
-    client.publish(topic, message, pubOptions)
225
+
226
+    setWorkspaceLightsButtons()
227
+})
228
+
229
+// set new tv light state
230
+for (const btn of btnsTv) {
231
+    btn.addEventListener('change', function (e) {
232
+        if (this.checked) {
233
+            if (this == document.querySelector("#tvon")) {
234
+                state_light_amp = 1
235
+                state_light_box = 1
236
+                setTopic("livingroom/light_amp", "on")
237
+                setTopic("livingroom/light_box", "on")
238
+            } else if (this == document.querySelector("#tvoff")) {
239
+                state_light_amp = 0
240
+                state_light_box = 0
241
+                setTopic("livingroom/light_amp", "off")
242
+                setTopic("livingroom/light_box", "off")
243
+            } else if (this == document.querySelector("#tvamp")) {
244
+                state_light_amp = 1
245
+                state_light_box = 0
246
+                setTopic("livingroom/light_amp", "on")
247
+                setTopic("livingroom/light_box", "off")
248
+            } else if (this == document.querySelector("#tvbox")) {
249
+                state_light_box = 1
250
+                state_light_amp = 0
251
+                setTopic("livingroom/light_box", "on")
252
+                setTopic("livingroom/light_amp", "off")
253
+            } else {
254
+                console.log("unknown btn value " + this.value)
255
+            }
256
+        }
257
+    })
64 258
 }
259
+
260
+// handle livingroom sensors
261
+subscribeSensor("livingroom/temperature", "°C", "#livingtemp")
262
+subscribeSensor("livingroom/humidity", "%", "#livinghumid")
263
+subscribeSensor("livingroom/pressure", "mbar", "#livingpress", 100.0)
264
+subscribeSensor("livingroom/tvoc", "ppb", "#livingtvoc")
265
+subscribeSensor("livingroom/eco2", "ppm", "#livingeco2")

+ 64
- 0
lights/mqtt.js View File

@@ -0,0 +1,64 @@
1
+/*
2
+ * The idea is to use retained messages.
3
+ * This way we can keep the state of the lights.
4
+ * Make sure all senders use retained messages!
5
+ * (in here and shell scripts on PC)
6
+ */
7
+
8
+const clientId = ("00" + Math.floor(Math.random() * 1000)).substr(-3)
9
+
10
+const options = {
11
+    clean: true,
12
+    connectTimeout: 4000,
13
+    clientId: 'lights-web-' + clientId,
14
+    username: mqttUsername,
15
+    password: mqttPassword,
16
+}
17
+const callbacks = []
18
+const client  = mqtt.connect(mqttUrl, options)
19
+
20
+client.on('connect', function () {
21
+    console.log('MQTT Connected')
22
+})
23
+
24
+client.on('message', function (topic, message) {
25
+    console.log("Rx \"" + topic.toString() + "\": \"" + message.toString() + "\"")
26
+
27
+    for (const cb of callbacks) {
28
+        if (cb.topic == topic) {
29
+            console.log("Routing to Callback")
30
+            cb.callback(message)
31
+        }
32
+    }
33
+})
34
+
35
+function subscribeTopic(topic, callback) {
36
+    console.log("Sub to \"" + topic.toString() + "\"")
37
+
38
+    subOptions = {
39
+        rh: true,
40
+    }
41
+    client.subscribe(topic)
42
+
43
+    callbackObj = {
44
+        topic: topic,
45
+        callback: callback,
46
+    }
47
+    callbacks.push(callbackObj)
48
+}
49
+
50
+function subscribeSensor(topic, unit, selector, divisor = 1.0) {
51
+    subscribeTopic(topic, function (msg) {
52
+        const txt = document.querySelector(selector)
53
+        txt.innerHTML = "<p>" + parseInt(msg / divisor) + " " + unit + "</p>"
54
+    })
55
+}
56
+
57
+function setTopic(topic, message) {
58
+    console.log("Tx \"" + topic.toString() + "\": \"" + message.toString() + "\"")
59
+
60
+    pubOptions = {
61
+        retain: true,
62
+    }
63
+    client.publish(topic, message, pubOptions)
64
+}

Loading…
Cancel
Save