├── .clang-format ├── LICENSE ├── README.md ├── firmware ├── .clang-format ├── arduino │ └── SmartLight.ino └── esp-idf │ ├── Makefile │ ├── main │ ├── component.mk │ └── main.c │ ├── partitions.csv │ └── sdkconfig.defaults └── mobile-app ├── css ├── style.css └── toggle.css ├── images ├── logo-192x192.png └── logo-512x512.png ├── index.html ├── js ├── app.js └── service-worker.js └── manifest.json /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Google 2 | AllowShortFunctionsOnASingleLine: false 3 | SpaceAfterCStyleCast: true 4 | PointerBindsToType: false 5 | DerivePointerBinding: false 6 | UseTab: Never 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mDash Smart Light - an IoT product reference design 2 | 3 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 4 | 5 | See documentation at https://mdash.net/docs/ 6 | -------------------------------------------------------------------------------- /firmware/.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Google 2 | AllowShortFunctionsOnASingleLine: false 3 | SpaceAfterCStyleCast: true 4 | PointerBindsToType: false 5 | DerivePointerBinding: false 6 | UseTab: Never 7 | -------------------------------------------------------------------------------- /firmware/arduino/SmartLight.ino: -------------------------------------------------------------------------------- 1 | // Smart Light reference project. 2 | // See documentation at https://mdash.net/docs/ 3 | 4 | #define MDASH_APP_NAME "SmartLight" 5 | #include 6 | 7 | #include 8 | 9 | struct device_state { 10 | bool on; // If true, LED is on. If false, LED is off 11 | char *name; // Device name. If null, a default name is used 12 | }; 13 | 14 | #define RESET_PIN -1 // Set to your reset button pin 15 | #define LED_PIN 5 // Set to your LED pin 16 | 17 | static void reportShadowState(struct device_state *state) { 18 | mDashShadowUpdate("{\"state\":{\"reported\":{\"on\":%B,\"name\":%Q}}}", 19 | state->on, state->name == NULL ? "My Light" : state->name); 20 | } 21 | 22 | // "Shadow.Delta" RPC handler 23 | // Called by the mDash when it generates shadow delta 24 | static void onShadowDelta(struct jsonrpc_request *r) { 25 | struct device_state *state = (struct device_state *) r->userdata; 26 | char buf[50]; 27 | int iv; 28 | if (mjson_get_bool(r->params, r->params_len, "$.state.on", &iv)) { 29 | state->on = iv; 30 | } 31 | if (mjson_get_string(r->params, r->params_len, "$.state.app.name", buf, 32 | sizeof(buf)) > 0) { 33 | free(state->name); 34 | state->name = strdup(buf); 35 | } 36 | digitalWrite(LED_PIN, state->on); // Synchronise with the shadow 37 | reportShadowState(state); // And report to mDash 38 | } 39 | 40 | // When we're reconnected, report our current state to shadow 41 | static void onConnected(void *event_data, void *user_data) { 42 | struct device_state *state = (struct device_state *) user_data; 43 | reportShadowState(state); 44 | } 45 | 46 | // Wifi setup function. Called by the mDash library 47 | static void init_wifi(const char *wifi_network_name, const char *wifi_pass) { 48 | if (wifi_network_name == NULL) { 49 | WiFi.softAP(MDASH_APP_NAME, ""); 50 | MLOG(LL_INFO, "%s", "Starting access point ..."); 51 | } else { 52 | MLOG(LL_INFO, "Joining WiFi network %s", wifi_network_name); 53 | WiFi.begin(wifi_network_name, wifi_pass); 54 | // while (WiFi.status() != WL_CONNECTED) delay(500); 55 | } 56 | } 57 | 58 | void setup() { 59 | static struct device_state state; 60 | Serial.begin(115200); 61 | 62 | // Start mDash library. Pass NULLs to read credentials from the config file 63 | mDashBeginWithWifi(init_wifi, NULL, NULL, NULL); 64 | jsonrpc_export("Shadow.Delta", onShadowDelta, &state); 65 | mDashRegisterEventHandler(MDASH_EVENT_CLOUD_CONNECTED, onConnected, &state); 66 | 67 | // Configure pins 68 | pinMode(LED_PIN, OUTPUT); 69 | pinMode(RESET_PIN, INPUT); 70 | } 71 | 72 | void loop() { 73 | // If the reset button was pressed for > 3 seconds, reset device 74 | static unsigned long t; 75 | if (RESET_PIN >= 0 && t > 0 && millis() - t > 3000) mDashConfigReset(); 76 | t = digitalRead(RESET_PIN) == HIGH ? 0 : t == 0 ? millis() : t; 77 | // MLOG(LL_INFO, "%lu %d", t, digitalRead(RESET_PIN)); 78 | 79 | // Allow to set credentials over the serial line 80 | if (Serial.available() > 0) mDashCLI(Serial.read()); 81 | 82 | delay(10); 83 | } 84 | -------------------------------------------------------------------------------- /firmware/esp-idf/Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME := smartlight 2 | EXTRA_LDFLAGS = -L$(CURDIR)/mDash/src/esp32 -lmDash 3 | CFLAGS += -DAPPVERSION=20191009 4 | 5 | COMPONENTS = app_trace app_update bootloader bootloader_support cxx driver \ 6 | esp_common esp_adc_cal esp_event esp32 esp_ringbuf esptool_py \ 7 | ethernet freertos heap log lwip main micro-ecc mbedtls esp_rom \ 8 | newlib nvs_flash partition_table pthread soc spi_flash spiffs \ 9 | tcpip_adapter vfs wpa_supplicant efuse xtensa esp_wifi esp_eth \ 10 | espcoredump \ 11 | xtensa-debug-module smartconfig smartconfig_ack 12 | 13 | V=0 14 | .PHONY: mdash 15 | build: mdash 16 | 17 | mdash: 18 | test -d mDash || git clone https://github.com/cesanta/mDash.git 19 | cd mDash && git pull 20 | 21 | include $(IDF_PATH)/make/project.mk 22 | -------------------------------------------------------------------------------- /firmware/esp-idf/main/component.mk: -------------------------------------------------------------------------------- 1 | # 2 | # "main" pseudo-component makefile. 3 | # 4 | # (Uses default behaviour of compiling all source files in directory, adding 'include' to include path.) 5 | 6 | -------------------------------------------------------------------------------- /firmware/esp-idf/main/main.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) Cesanta Software Limited 2 | // All rights reserved 3 | 4 | // Instructions: 5 | // 1. make defconfig flash simple_monitor ESPPORT=/dev/whatever 6 | // 2. Press Ctrl-T Ctrl-E to enable serial echo 7 | // 3. Type the following device provisionin commands into serial console 8 | // (done only once. Those settings persist reboots, reflashes and OTA) 9 | // 10 | // set wifi.sta.ssid WIFI_NETWORK_NAME 11 | // set wifi.sta.pass WIFI_PASSWORD 12 | // set device.pass DEVICE_MDASH_TOKEN 13 | // reboot 14 | 15 | #include 16 | #include 17 | #include 18 | #include "freertos/FreeRTOS.h" 19 | #include "freertos/task.h" 20 | 21 | #include "driver/gpio.h" 22 | #include "esp_wifi.h" 23 | 24 | #define MDASH_APP_NAME "mdash-smart-light" 25 | #include "../mDash/src/mDash.h" 26 | 27 | #define RESET_PIN -1 28 | #define LED_PIN 5 29 | 30 | struct device_state { 31 | bool on; // If true, LED is on. If false, LED is off 32 | char *name; // Device name. If null, a default name is used 33 | }; 34 | 35 | static void init_wifi(const char *wifi_network_name, const char *wifi_pass) { 36 | wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); 37 | ESP_ERROR_CHECK(esp_wifi_init(&cfg)); 38 | ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); 39 | wifi_config_t wc = {}; 40 | if (wifi_network_name != NULL) { 41 | strncpy((char *) wc.sta.ssid, wifi_network_name, sizeof(wc.sta.ssid)); 42 | strncpy((char *) wc.sta.password, wifi_pass, sizeof(wc.sta.password)); 43 | MLOG(LL_INFO, "Connecting to WiFi network %s...", wc.sta.ssid); 44 | ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); 45 | ESP_ERROR_CHECK(esp_wifi_set_config(ESP_IF_WIFI_STA, &wc)); 46 | } else { 47 | uint8_t mac[6]; 48 | esp_wifi_get_mac(WIFI_IF_STA, mac); 49 | wc.ap.ssid_len = snprintf((char *) wc.ap.ssid, sizeof(wc.ap.ssid), 50 | "%s-%02X%02X", MDASH_APP_NAME, mac[4], mac[5]); 51 | wc.ap.max_connection = 5; 52 | memset(wc.ap.password, 0, sizeof(wc.ap.password)); 53 | wc.ap.authmode = WIFI_AUTH_OPEN; 54 | MLOG(LL_INFO, "Starting WiFi network %s...", wc.ap.ssid); 55 | ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); 56 | ESP_ERROR_CHECK(esp_wifi_set_config(ESP_IF_WIFI_AP, &wc)); 57 | } 58 | ESP_ERROR_CHECK(esp_wifi_start()); 59 | } 60 | 61 | static void reportShadowState(struct device_state *state) { 62 | mDashShadowUpdate("{\"state\":{\"reported\":{\"on\":%B,\"name\":%Q}}}", 63 | state->on, state->name == NULL ? "My Light" : state->name); 64 | } 65 | 66 | // "Shadow.Delta" RPC handler 67 | // Called by the mDash when it generates shadow delta 68 | static void onShadowDelta(struct jsonrpc_request *r) { 69 | struct device_state *state = (struct device_state *) r->userdata; 70 | char buf[50]; 71 | int iv; 72 | if (mjson_get_bool(r->params, r->params_len, "$.state.on", &iv)) { 73 | state->on = iv; 74 | } 75 | if (mjson_get_string(r->params, r->params_len, "$.state.app.name", buf, 76 | sizeof(buf)) > 0) { 77 | free(state->name); 78 | state->name = strdup(buf); 79 | } 80 | gpio_set_level(LED_PIN, state->on); // Synchronise with the shadow 81 | reportShadowState(state); // And report to mDash 82 | } 83 | 84 | // When we're reconnected, report our current state to shadow 85 | static void onConnnected(void *event_data, void *user_data) { 86 | struct device_state *state = (struct device_state *) user_data; 87 | (void) event_data; 88 | reportShadowState(state); 89 | } 90 | 91 | static void setup(void) { 92 | gpio_pad_select_gpio(LED_PIN); 93 | gpio_pad_select_gpio(RESET_PIN); 94 | gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); 95 | gpio_set_direction(RESET_PIN, GPIO_MODE_INPUT); 96 | } 97 | 98 | static void reset_on_long_press(int reset_pin, int duration_ms) { 99 | static unsigned long t; 100 | unsigned long uptime_ms = esp_timer_get_time() / 1000; 101 | if (t > 0 && uptime_ms - t > duration_ms) { 102 | mDashConfigReset(); 103 | esp_restart(); 104 | } 105 | t = gpio_get_level(reset_pin) > 0 ? 0 : t == 0 ? uptime_ms : t; 106 | } 107 | 108 | static void loop(void) { 109 | if (RESET_PIN >= 0) reset_on_long_press(RESET_PIN, 3000); 110 | mDashCLI(getchar()); // Handle CLI input 111 | vTaskDelay(10 / portTICK_PERIOD_MS); // Sleep 10ms 112 | } 113 | 114 | static void pause_then_reboot(int sleep_seconds) { 115 | MLOG(LL_CRIT, "Restarting after crash. Sleeping for %ds", sleep_seconds); 116 | vTaskDelay(sleep_seconds * 1000 / portTICK_PERIOD_MS); 117 | esp_restart(); 118 | } 119 | 120 | void app_main() { 121 | struct device_state state = {.on = false, .name = NULL}; 122 | mDashBeginWithWifi(init_wifi, NULL, NULL, NULL); 123 | jsonrpc_export("Shadow.Delta", onShadowDelta, &state); 124 | mDashRegisterEventHandler(MDASH_EVENT_CLOUD_CONNECTED, onConnnected, &state); 125 | 126 | // If a device recovered after a crash, do not start firmware logic 127 | // because it could crash again, thus fall into a crash-loop and make 128 | // a device unusable and un-recoverable. 129 | // Instead, wait for a possible recovery action. 130 | // Then reboot (this will change the crash reason) to try the firmware again. 131 | if (esp_reset_reason() == ESP_RST_PANIC) pause_then_reboot(600); 132 | 133 | setup(); 134 | for (;;) loop(); 135 | } 136 | -------------------------------------------------------------------------------- /firmware/esp-idf/partitions.csv: -------------------------------------------------------------------------------- 1 | nvs, data, nvs, 0x9000, 0x5000, 2 | otadata, data, ota, 0xe000, 0x2000, 3 | app0, app, ota_0, 0x10000, 0x1E0000, 4 | app1, app, ota_1, 0x1F0000,0x1E0000, 5 | eeprom, data, 0x99, 0x3D0000,0x1000, 6 | spiffs, data, spiffs, 0x3D1000,0x2F000, 7 | -------------------------------------------------------------------------------- /firmware/esp-idf/sdkconfig.defaults: -------------------------------------------------------------------------------- 1 | CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y 2 | CONFIG_PARTITION_TABLE_CUSTOM=y 3 | CONFIG_APP_OFFSET=0x10000 4 | CONFIG_PARTITION_TABLE_CUSTOM_APP_BIN_OFFSET=0x10000 5 | CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" 6 | CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" 7 | 8 | CONFIG_ESPTOOLPY_PORT="/dev/cu.SLAB_USBtoUART" 9 | CONFIG_ESPTOOLPY_BAUD_921600B=y 10 | -------------------------------------------------------------------------------- /mobile-app/css/style.css: -------------------------------------------------------------------------------- 1 | html,body { height: 100%; } 2 | select,input,label::before,textarea,.button,.btn:focus,.btn:active:focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn.active.focus { 3 | outline: none; box-shadow: none !important; 4 | } 5 | -------------------------------------------------------------------------------- /mobile-app/css/toggle.css: -------------------------------------------------------------------------------- 1 | .toggle { display: inline; height: 2em; } 2 | .toggle > input { display: none; padding: 0; margin: 0; } 3 | .toggle > label { 4 | background: #ddd; width: 5em; height: 2em; 5 | border-radius: 1em; position: relative; cursor: pointer; 6 | } 7 | .toggle > label > span { 8 | display: inline-block; position: absolute; border-radius: 1em; 9 | width: 1.6em; height: 1.6em; margin-top: 0.2em; margin-left: 0.2em; 10 | left: 0; background: #aaa; transition: transform .2s ease-in; 11 | } 12 | .toggle > input:checked ~ label { background: #9f9; } 13 | .toggle > input:checked ~ label > span { 14 | background: #2ecc71; 15 | transform: translatex(3em); 16 | transition: transform .2s ease-in; 17 | } 18 | -------------------------------------------------------------------------------- /mobile-app/images/logo-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/mdash-smart-light/8f9db0d3de854d5410f77ad557c6331f7735dc72/mobile-app/images/logo-192x192.png -------------------------------------------------------------------------------- /mobile-app/images/logo-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesanta/mdash-smart-light/8f9db0d3de854d5410f77ad557c6331f7735dc72/mobile-app/images/logo-512x512.png -------------------------------------------------------------------------------- /mobile-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mDash Smart Light 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /mobile-app/js/app.js: -------------------------------------------------------------------------------- 1 | var h = preact.h; 2 | var App = {}; 3 | 4 | App.settings = { 5 | // Go to WebApp page, copy the App ID and page below 6 | // https://mdash.net/a/YOUR_APP_ID/ 7 | appID: '', // <-- Set this to YOUR_APP_ID 8 | provisionURL: 'http://192.168.4.1', 9 | mdashURL: 'https://mdash.net', 10 | callTimeoutMilli: 10000, // 10 seconds 11 | }; 12 | 13 | App.Header = function(props) { 14 | return h( 15 | 'div', {class: 'p-2 border-bottom bg-light'}, 16 | h('b', {}, props.app.state.title), 17 | h('div', {class: 'float-right'}, 18 | h('small', {class: 'text-muted mr-2 font-weight-light'}, 19 | 'mDash Smart Light'), 20 | h('img', {height: 24, src: 'images/logo-512x512.png'}))); 21 | }; 22 | 23 | App.Footer = function(props) { 24 | var self = this, app = props.app; 25 | 26 | var mkTabButton = function(title, icon, tab, href) { 27 | var active = (location.hash == href.replace(/.*#/, '#')); 28 | return h( 29 | 'a', { 30 | href: href, 31 | class: 'text-center ' + 32 | (active ? 'font-weight-bold text-primary' : 'text-dark'), 33 | style: 'flex:1;height:3em;text-decoration:none;' + 34 | 'border-top: 3px solid ' + (active ? '#007bff' : 'transparent'), 35 | }, 36 | h('div', {class: '', style: 'line-height: 1.4em'}, 37 | h('i', {class: 'mr-0 fa-fw fa ' + icon, style: 'width: 2em;'}), 38 | h('div', {class: 'small'}, title))); 39 | }; 40 | 41 | var proto = App.settings.mdashURL.split(':')[0]; 42 | var base = proto + '://' + location.host + location.pathname; 43 | var ibase = base.replace(/^https/, 'http'); 44 | return h( 45 | 'footer', { 46 | class: 'd-flex align-items-stretch border-top', 47 | style: 'flex-shrink: 0;' 48 | }, 49 | mkTabButton('My Devices', 'fa-server', App.PageDevices, base + '#/'), 50 | mkTabButton( 51 | 'Add Device', 'fa-plus-circle', App.PageAddDevice, 52 | ibase + '?' + app.state.u.token + '#/new')); 53 | }; 54 | 55 | App.errorHandler = function(e) { 56 | var o = ((e.response || {}).data || {}).error || {}; 57 | alert(o.message || e.message || e); 58 | }; 59 | 60 | App.setKey = function(obj, key, val) { 61 | var parts = key.split('.'); 62 | for (var i = 0; i < parts.length; i++) { 63 | if (i >= parts.length - 1) { 64 | obj[parts[i]] = val; 65 | } else { 66 | if (!obj[parts[i]]) obj[parts[i]] = {}; 67 | obj = obj[parts[i]]; 68 | } 69 | } 70 | }; 71 | 72 | App.getKey = function(obj, key) { 73 | var parts = key.split('.'); 74 | for (var i = 0; i < parts.length; i++) { 75 | if (typeof (obj) != 'object') return undefined; 76 | if (!(parts[i] in obj)) return undefined; 77 | obj = obj[parts[i]]; 78 | } 79 | return obj; 80 | }; 81 | 82 | App.Toggler = function(props) { 83 | var self = this, state = self.state; 84 | self.componentDidMount = function() { 85 | state.expanded = props.expanded || false; 86 | }; 87 | var div = state.expanded ? 88 | props.children : 89 | props.dnone ? h('div', {class: 'd-none'}, props.children) : null; 90 | return h( 91 | 'span', {class: props.class || '', style: 'z-index: 999;'}, 92 | h('a', { 93 | onClick: function(ev) { 94 | ev.preventDefault(); 95 | self.setState({expanded: !state.expanded}); 96 | }, 97 | href: '#' 98 | }, 99 | props.text || '', h('i', { 100 | class: 101 | 'ml-2 fa ' + (state.expanded ? 'fa-caret-down' : 'fa-caret-right') 102 | })), 103 | props.extra, div); 104 | }; 105 | 106 | App.Login = function(props) { 107 | var self = this; 108 | self.componentDidMount = function() { 109 | self.setState({email: '', pass: ''}); 110 | }; 111 | 112 | self.render = function(props, state) { 113 | return h( 114 | 'div', { 115 | class: 'mx-auto bg-light rounded border my-5', 116 | style: 'max-width: 480px;' 117 | }, 118 | h('h3', {class: 'text-center py-3 text-muted'}, 'Smart Light login'), 119 | h('div', {class: 'form p-3 rounded w-100'}, h('input', { 120 | type: 'email', 121 | placeholder: 'Email', 122 | class: 'my-2 form-control', 123 | onInput: function(ev) { 124 | self.setState({email: ev.target.value}); 125 | } 126 | }), 127 | h('input', { 128 | type: 'password', 129 | placeholder: 'Password', 130 | class: 'my-2 form-control', 131 | onInput: function(ev) { 132 | self.setState({pass: ev.target.value}); 133 | } 134 | }), 135 | h(App.SpinButton, { 136 | class: 'btn-block btn-secondary', 137 | disabled: !state.email || !state.pass, 138 | title: 'Sign In', 139 | icon: 'fa-sign-in', 140 | onClick: function() { 141 | var h = { 142 | Authorization: 'Basic ' + btoa(state.email + ':' + state.pass) 143 | }; 144 | return axios 145 | .get(App.settings.mdashURL + '/customer', {headers: h}) 146 | .then(function(res) { 147 | props.app.login(res.data); 148 | preactRouter.route(''); 149 | }) 150 | .catch(App.errorHandler); 151 | } 152 | }), 153 | h('div', {class: 'mt-2'}, 'No account yet? ', 154 | h(App.Toggler, {text: 'Register'}, 155 | h('div', {}, h('input', { 156 | type: 'email', 157 | placeholder: 'Email', 158 | class: 'my-2 form-control', 159 | onInput: function(ev) { 160 | self.setState({email: ev.target.value}); 161 | }, 162 | }), 163 | h(App.SpinButton, { 164 | class: 'btn-block btn-secondary', 165 | icon: 'fa-envelope', 166 | title: 'Send invitation', 167 | disabled: !state.email, 168 | onClick: function() { 169 | var app_id = App.settings.appID || 170 | location.pathname.split('/')[2] || 'setme'; 171 | var args = { 172 | email: state.email, 173 | url: App.settings.mdashURL, 174 | from: 'SmartLight', 175 | redir: location.href, 176 | app_id: app_id, 177 | text: 'Thank you for registering with SmartLight.\n' + 178 | 'Your login: EMAIL\n' + 179 | 'Your password: PASS\n' + 180 | 'Click on the link below to activate your account ' + 181 | 'and login:\nREGLINK' 182 | }; 183 | return axios.post(App.settings.mdashURL + '/invite', args) 184 | .then(function(res) { 185 | alert('Thank you! Check your inbox and login.'); 186 | self.setState({email: ''}); 187 | location.reload(); 188 | }) 189 | .catch(App.errorHandler); 190 | }, 191 | })))))); 192 | }; 193 | }; 194 | 195 | 196 | App.SpinButton = function(props) { 197 | var self = this, state = self.state; 198 | self.componentDidMount = function() { 199 | self.setState({spin: false}); 200 | }; 201 | return h( 202 | 'button', { 203 | class: 'btn ' + (props.class || ''), 204 | disabled: props.disabled || state.spin, 205 | style: props.style || '', 206 | ref: props.ref, 207 | onClick: function() { 208 | if (!props.onClick) return; 209 | self.setState({spin: true}); 210 | props.onClick().catch(App.errorHandler).then(function() { 211 | self.setState({spin: false}); 212 | }); 213 | } 214 | }, 215 | h('i', { 216 | class: 'mr-1 fa fa-fw ' + 217 | (state.spin ? 'fa-refresh' : (props.icon || 'fa-save')) + 218 | (state.spin ? ' fa-spin' : '') 219 | }), 220 | props.title || 'submit'); 221 | }; 222 | 223 | App.DeviceWidget = function(props) { 224 | var self = this; 225 | var url = App.settings.mdashURL + '/api/v2/m/device?access_token=' + props.k; 226 | 227 | self.componentDidMount = function() { 228 | self.setState({device: null}); 229 | self.refresh(); 230 | }; 231 | 232 | self.refresh = function() { 233 | return axios.get(url) 234 | .then(function(res) { 235 | self.setState({device: res.data}); 236 | }) 237 | .catch(function(err) { 238 | self.setState({device: {id: ''}}); 239 | }); 240 | }; 241 | 242 | self.render = function(props, state) { 243 | var d = state.device; 244 | if (!d) 245 | return h( 246 | 'div', {class: 'py-2 border-bottom'}, 247 | h('div', {class: 'h-100 d-flex align-items-center'}, 248 | h('div', {class: 'text-center w-100 text-muted'}, 249 | h('i', {class: 'fa fa-refresh fa-spin'}), h('br'), 250 | 'Initialising device...'))); 251 | 252 | var shadow = d.shadow || {}; 253 | var reported = (shadow.state || {}).reported || {}; 254 | var ar = reported.app || {}; 255 | var online = reported.online || false; 256 | var cbid = 'toggle-' + d.id; 257 | var checked = ar.on || false; 258 | var toggle = 259 | h('span', {class: 'text-nowrap d-flex justify-content-end'}, 260 | h('small', {class: 'mr-2 my-auto text-muted'}, 'toggle light:'), 261 | h('span', {class: 'toggle my-auto'}, h('input', { 262 | type: 'checkbox', 263 | id: cbid, 264 | disabled: !online, 265 | checked: checked, 266 | onChange: function(ev) { 267 | var body = {shadow: {state: {desired: {}}}}; 268 | body.shadow.state.desired.on = ev.target.checked; 269 | axios.post(url, body).catch(App.errorHandler); 270 | }, 271 | }), 272 | h('label', {'for': cbid}, h('span')))); 273 | return h( 274 | 'div', {class: 'py-2 border-bottom d-flex flex-row'}, 275 | h('div', {class: 'mr-5'}, 276 | h('b', {class: 'small font-weight-bold'}, d.id), 277 | h('div', {class: ''}, 278 | h('b', 279 | {class: 'small ' + (online ? 'text-success' : 'text-danger')}, 280 | online ? 'online' : 'offline'))), 281 | toggle, 282 | h('div', {class: 'flex-grow-1 d-flex justify-content-end mr-2 mt-1'}, 283 | h('a', {href: '/devices/' + encodeURIComponent(props.k)}, 284 | h('i', {class: 'fa fa-cog'})))); 285 | }; 286 | }; 287 | 288 | App.PageDevices = function(props) { 289 | var self = this; 290 | self.componentDidMount = function() { 291 | props.app.setState({title: 'My Devices'}); 292 | }; 293 | self.render = function(props, state) { 294 | var pubkeys = Object.keys((props.app.state.u || {}).pubkeys || {}); 295 | return h( 296 | 'div', {class: 'overflow-auto p-2'}, 297 | pubkeys.length == 0 ? 298 | h('div', {class: 'h-100 d-flex align-items-center'}, 299 | h('div', 300 | {class: 'text-center w-100 text-muted font-weight-light'}, 301 | h('i', {class: 'fa fa-bell-o fa-2x'}), h('br'), 302 | 'No devices yet')) : 303 | h('div', {class: 'h-100'}, pubkeys.map(function(k) { 304 | return h(App.DeviceWidget, {k: k, app: props.app}); 305 | }))); 306 | } 307 | }; 308 | 309 | App.PageAddDevice = function(props) { 310 | var self = this; 311 | 312 | self.componentDidMount = function() { 313 | props.app.setState({title: 'Add Device'}); 314 | self.setState({step: 0, ssid: '', pass: '', public_key: ''}); 315 | }; 316 | 317 | self.componentWillUnmount = function() { 318 | self.unmounted = true; 319 | }; 320 | 321 | var alertClass = 'p-2 small text-muted font-weight-light'; 322 | var Step0 = 323 | h('div', {}, 324 | h('div', {class: alertClass}, 'Go to your phone settings', h('br'), 325 | 'Join WiFi network SmartLight-XXXX', h('br'), 326 | 'Return to this screen and press the Scan button'), 327 | h(App.SpinButton, { 328 | class: 'btn-block btn-primary border font-weight-light', 329 | title: 'Scan', 330 | icon: 'fa-search', 331 | onClick: function() { 332 | return new Promise(function(resolve, reject) { 333 | var attempts = 0; 334 | var f = function() { 335 | var error = function(err) { 336 | console.log('Error: ', err); 337 | if (!self.unmounted) setTimeout(f, 500); 338 | }; 339 | var success = function(res) { 340 | var key = res.data.result; 341 | if (key) { 342 | self.setState({step: 1, public_key: key}); 343 | resolve(); 344 | return; 345 | } else { 346 | reject(res.data.error); 347 | } 348 | }; 349 | axios({ 350 | url : App.settings.provisionURL + '/GetKey', 351 | timeout : App.settings.callTimeoutMilli, 352 | }).then(success, error); 353 | attempts++; 354 | console.log('attempt', attempts); 355 | }; 356 | f(); 357 | }); 358 | }, 359 | })); 360 | var Step1 = h( 361 | 'div', {}, 362 | h('a', { 363 | href: location.href, 364 | class: 'link text-decoration-none', 365 | onClick: function() { 366 | self.setState({step: 0}); 367 | } 368 | }, 369 | '\u2190', ' back'), 370 | h('div', {class: alertClass + ' mt-2'}, 'Found new device!'), h('input', { 371 | class: 'form-control mb-2', 372 | type: 'text', 373 | placeholder: 'WiFi network name', 374 | onInput: function(ev) { 375 | self.setState({ssid: ev.target.value}); 376 | }, 377 | }), 378 | h('input', { 379 | class: 'form-control mb-2', 380 | type: 'text', 381 | placeholder: 'WiFi password', 382 | onInput: function(ev) { 383 | self.setState({pass: ev.target.value}); 384 | }, 385 | }), 386 | h(App.SpinButton, { 387 | class: 'btn-block btn-primary font-weight-light', 388 | title: 'Configure device WiFi', 389 | icon: 'fa-save', 390 | disabled: !self.state.ssid, 391 | onClick: function() { 392 | var data = 393 | JSON.stringify({ssid: self.state.ssid, pass: self.state.pass}); 394 | return axios({ 395 | method : 'POST', 396 | url : App.settings.provisionURL + '/setup', 397 | timeout : App.settings.callTimeoutMilli, 398 | data : data, 399 | }) 400 | .then(function(res) { 401 | if (res.data.result) { 402 | self.setState({step: 2}); 403 | } else { 404 | alert('Error: ' + res.data.error); 405 | } 406 | }); 407 | }, 408 | })); 409 | var Step2 = 410 | h('div', {}, 411 | h('a', { 412 | href: location.href, 413 | class: 'link text-decoration-none', 414 | onClick: function() { 415 | self.setState({step: 1}); 416 | } 417 | }, 418 | '\u2190', ' back'), 419 | h('div', {class: alertClass + ' mt-2'}, 'WiFi configuretion applied. ', 420 | 'Go to your phone settings,', h('br'), 421 | 'Join back to your WiFi network,', h('br'), 422 | 'Return to this screen and press on Register device.'), 423 | h(App.SpinButton, { 424 | class: 'btn-block btn-primary border font-weight-light', 425 | title: 'Register device', 426 | icon: 'fa-plus-circle', 427 | onClick: function() { 428 | var url = App.settings.mdashURL + 429 | '/customer?access_token=' + props.app.state.u.token; 430 | return axios.get(url) 431 | .then(function(res) { 432 | var data = res.data; 433 | if (!data.pubkeys) data.pubkeys = {}; 434 | data.pubkeys[self.state.public_key] = {}; 435 | return axios({method: 'POST', url: url, data: data}); 436 | }) 437 | .then(function(res) { 438 | var proto = App.settings.mdashURL.split(':')[0]; 439 | location.href = 440 | proto + '://' + location.host + location.pathname; 441 | }) 442 | .catch(function(err) { 443 | alert( 444 | 'Error registering device (' + err + 445 | '). Join your WiFi network and retry.'); 446 | }); 447 | } 448 | })); 449 | var steps = [Step0, Step1, Step2]; 450 | return h('div', {class: 'overflow-auto p-2'}, steps[self.state.step]); 451 | }; 452 | 453 | App.PageDeviceSettings = function(props) { 454 | var self = this; 455 | var url = App.settings.mdashURL + '/api/v2/m/device?access_token=' + props.k; 456 | 457 | self.componentDidMount = function() { 458 | props.app.setState({title: 'Devices / '}); 459 | self.setState({device: null, c: {}}); 460 | self.refresh(); 461 | }; 462 | 463 | self.componentWillUnmount = function() { 464 | self.unmounted = true; 465 | }; 466 | 467 | self.refresh = function() { 468 | return axios.get(url) 469 | .then(function(res) { 470 | self.setState({device: res.data}); 471 | props.app.setState({title: 'Devices / ' + res.data.id}); 472 | }) 473 | .catch(function(err) { 474 | self.setState({device: {id: ''}}); 475 | }); 476 | }; 477 | 478 | var mkin = function(ph) { 479 | return h( 480 | 'input', {type: 'text', placeholder: ph, class: 'my-2 form-control'}); 481 | }; 482 | var mkrow = function(label, k, dis, c, r) { 483 | return h( 484 | 'div', {class: 'form-group row my-2'}, 485 | h('label', {class: 'col-form-label col-4'}, label), 486 | h('div', {class: 'col-8'}, h('input', { 487 | type: 'text', 488 | // value: state.c[k] || r.config[k] || '', 489 | value: App.getKey(c, k) || App.getKey(r, k) || '', 490 | placeholder: label, 491 | disabled: !!dis || !r.online, 492 | class: 'form-control', 493 | onInput: function(ev) { 494 | App.setKey(c, k, ev.target.value); 495 | }, 496 | }))); 497 | }; 498 | 499 | self.render = function(props, state) { 500 | // if (!state.device) return 'loading ...'; 501 | var r = (((state.device || {}).shadow || {}).state || {}).reported || {}; 502 | return h( 503 | 'div', {class: 'px-2 form'}, h('div', {class: 'my-1'}, '\u00a0'), 504 | mkrow('Name', 'name', false, state.c, r), h(App.SpinButton, { 505 | class: 'btn-block btn-primary mt-3', 506 | title: 'Save device settings', 507 | icon: 'fa-save', 508 | onClick: function() { 509 | var url = 510 | App.settings.mdashURL + '/m/device?access_token=' + props.k; 511 | var data = {shadow: {desired: {name: state.c.name}}}; 512 | return axios({method: 'POST', url: url, data: data}) 513 | .then(self.refresh) 514 | .catch(App.errorHandler); 515 | } 516 | }), 517 | h('hr'), 518 | h('div', {class: 'small text-muted mt-4'}, 519 | 'NOTE: device deletion cannot be undone'), 520 | h(App.SpinButton, { 521 | class: 'btn-block btn-danger mt-3', 522 | title: 'Delete device', 523 | icon: 'fa-times', 524 | onClick: function() { 525 | var url = App.settings.mdashURL + 526 | '/customer?access_token=' + props.app.state.u.token; 527 | var keys = props.app.state.u.pubkeys || {}; 528 | delete keys[props.k]; 529 | return axios({method: 'POST', url: url, data: {pubkeys: keys}}) 530 | .then(function(res) { 531 | props.app.setState({u: res.data}); 532 | preactRouter.route(''); 533 | }) 534 | .catch(App.errorHandler); 535 | } 536 | })); 537 | }; 538 | }; 539 | 540 | App.Content = function(props) { 541 | return h( 542 | preactRouter.Router, { 543 | history: History.createHashHistory(), 544 | onChange: function(ev) { 545 | props.app.setState({url: ev.url}); 546 | } 547 | }, 548 | h(App.PageDevices, {app: props.app, default: true}), 549 | h(App.PageDeviceSettings, {app: props.app, path: '/devices/:k'}), 550 | h(App.PageAddDevice, {app: props.app, path: 'new'})); 551 | }; 552 | 553 | App.Instance = function(props) { 554 | var self = this; 555 | App.self = self; 556 | 557 | self.componentDidMount = function() { 558 | var access_token = location.search.substring(1) || localStorage.sltok; 559 | if (access_token === 'undefined') access_token = undefined; 560 | if (access_token) { 561 | var url = 562 | App.settings.mdashURL + '/customer?access_token=' + access_token; 563 | self.setState({loading: true}); 564 | return axios.get(url) 565 | .then(function(res) { 566 | self.login(res.data); 567 | }) 568 | .catch(function(e) {}) 569 | .then(function() { 570 | self.setState({loading: false}); 571 | }); 572 | } 573 | }; 574 | 575 | self.logout = function() { 576 | delete localStorage.sltok; 577 | self.setState({u: null}); 578 | return Promise.resolve(); 579 | }; 580 | 581 | self.login = function(u) { 582 | self.setState({u: u}); 583 | localStorage.sltok = u.token; 584 | if (location.search.length > 0) 585 | location.href = location.protocol + '//' + location.host + 586 | location.pathname + location.hash; 587 | }; 588 | 589 | self.render = function(props, state) { 590 | var p = {app: self}; 591 | if (self.state.loading) return h('div'); // Show blank page when loading 592 | if (!self.state.u) return h(App.Login, p); // Show login unless logged 593 | return h( 594 | 'div', { 595 | class: 'main border', 596 | style: 'max-width: 480px; margin: 0 auto; ' + 597 | 'min-height: 100%; max-height: 100%;' + 598 | 'display:grid;grid-template-rows: auto 1fr auto;' + 599 | 'grid-template-columns: 100%;', 600 | }, 601 | h(App.Header, p), h(App.Content, p), h(App.Footer, p)); 602 | }; 603 | 604 | return self.render(props, self.state); 605 | }; 606 | 607 | window.onload = function() { 608 | if (!window.localStorage) alert('Unsupported platform!'); 609 | preact.render(h(App.Instance), document.body); 610 | 611 | if ('serviceWorker' in navigator) // for PWA 612 | navigator.serviceWorker.register('js/service-worker.js') 613 | .catch(function(err) {}); 614 | }; 615 | -------------------------------------------------------------------------------- /mobile-app/js/service-worker.js: -------------------------------------------------------------------------------- 1 | var cacheName = 'smart-light-pwa'; 2 | var filesToCache = ['/', 'index.html', 'js/app.js']; 3 | 4 | self.addEventListener('install', function(e) { 5 | e.waitUntil(caches.open(cacheName).then(function(cache) { 6 | // return cache.addAll(filesToCache); 7 | })); 8 | }); 9 | 10 | self.addEventListener('fetch', function(e) { 11 | e.respondWith(caches.match(e.request).then(function(response) { 12 | return response || fetch(e.request); 13 | })); 14 | }); 15 | -------------------------------------------------------------------------------- /mobile-app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "mDash Smart Light", 3 | "name": "mDash Smart Light full IoT product reference", 4 | "lang": "en-US", 5 | "start_url": "/", 6 | "theme_color": "#fff", 7 | "background_color": "#fff", 8 | "display": "standalone", 9 | "icons": [ 10 | { "src": "images/logo-512x512.png", "sizes": "512x512", "type": "image/png" }, 11 | { "src": "images/logo-192x192.png", "sizes": "192x192", "type": "image/png" } 12 | ] 13 | } 14 | --------------------------------------------------------------------------------