diff --git a/ConfigFile.cpp b/ConfigFile.cpp index 21d6da4..29a67cd 100644 --- a/ConfigFile.cpp +++ b/ConfigFile.cpp @@ -6,9 +6,10 @@ extern Preferences pref; -#define SHADE_HDR_VER 10 -#define SHADE_HDR_SIZE 16 +#define SHADE_HDR_VER 11 +#define SHADE_HDR_SIZE 24 #define SHADE_REC_SIZE 248 +#define GROUP_REC_SIZE 176 bool ConfigFile::begin(const char* filename, bool readOnly) { this->file = LittleFS.open(filename, readOnly ? "r" : "w"); @@ -39,8 +40,10 @@ bool ConfigFile::writeHeader(const config_header_t &hdr) { if(!this->isOpen()) return false; this->writeUInt8(hdr.version); this->writeUInt8(hdr.length); - this->writeUInt8(hdr.recordSize); - this->writeUInt8(hdr.records, CFG_REC_END); + this->writeUInt8(hdr.shadeRecordSize); + this->writeUInt8(hdr.shadeRecords); + this->writeUInt8(hdr.groupRecordSize); + this->writeUInt8(hdr.groupRecords, CFG_REC_END); return true; } bool ConfigFile::readHeader() { @@ -49,11 +52,16 @@ bool ConfigFile::readHeader() { Serial.printf("Reading header at %u\n", this->file.position()); this->header.version = this->readUInt8(this->header.version); this->header.length = this->readUInt8(0); - this->header.recordSize = this->readUInt8(this->header.recordSize); - this->header.records = this->readUInt8(this->header.records); - Serial.printf("version:%u len:%u size:%u recs:%u pos:%d\n", this->header.version, this->header.length, this->header.recordSize, this->header.records, this->file.position()); + this->header.shadeRecordSize = this->readUInt8(this->header.shadeRecordSize); + this->header.shadeRecords = this->readUInt8(this->header.shadeRecords); + if(this->header.version > 10) { + this->header.groupRecordSize = this->readUInt8(this->header.groupRecordSize); + this->header.groupRecords = this->readUInt8(this->header.groupRecords); + } + Serial.printf("version:%u len:%u shadeSize:%u shadeRecs:%u groupSize:%u groupRecs: %u pos:%d\n", this->header.version, this->header.length, this->header.shadeRecordSize, this->header.shadeRecords, this->header.groupRecordSize, this->header.groupRecords, this->file.position()); return true; } +/* bool ConfigFile::seekRecordByIndex(uint16_t ndx) { if(!this->file) { return false; @@ -61,6 +69,7 @@ bool ConfigFile::seekRecordByIndex(uint16_t ndx) { if(((this->header.recordSize * ndx) + this->header.length) > this->file.size()) return false; return true; } +*/ bool ConfigFile::readString(char *buff, size_t len) { if(!this->file) return false; memset(buff, 0x00, len); @@ -129,7 +138,6 @@ bool ConfigFile::writeFloat(const float val, const uint8_t prec, const char tok) bool ConfigFile::writeBool(const bool val, const char tok) { return this->writeString(val ? "true" : "false", 6, tok); } - char ConfigFile::readChar(const char defVal) { uint8_t ch; if(this->file.read(&ch, 1) == 1) return (char)ch; @@ -173,7 +181,7 @@ bool ConfigFile::readBool(const bool defVal) { } return defVal; } - +/* bool ShadeConfigFile::seekRecordById(uint8_t id) { if(this->isOpen()) return false; this->file.seek(this->header.length, SeekSet); // Start at the beginning of the file after the header. @@ -191,19 +199,26 @@ bool ShadeConfigFile::seekRecordById(uint8_t id) { } return false; } +*/ bool ShadeConfigFile::begin(bool readOnly) { return this->begin("/shades.cfg", readOnly); } bool ShadeConfigFile::begin(const char *filename, bool readOnly) { return ConfigFile::begin(filename, readOnly); } void ShadeConfigFile::end() { ConfigFile::end(); } bool ShadeConfigFile::save(SomfyShadeController *s) { this->header.version = SHADE_HDR_VER; - this->header.recordSize = SHADE_REC_SIZE; + this->header.shadeRecordSize = SHADE_REC_SIZE; this->header.length = SHADE_HDR_SIZE; - this->header.records = SOMFY_MAX_SHADES; + this->header.shadeRecords = SOMFY_MAX_SHADES; + this->header.groupRecordSize = GROUP_REC_SIZE; + this->header.groupRecords = SOMFY_MAX_GROUPS; this->writeHeader(); for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &s->shades[i]; this->writeShadeRecord(shade); } + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup *group = &s->groups[i]; + this->writeGroupRecord(group); + } return true; } bool ShadeConfigFile::validate() { @@ -213,39 +228,70 @@ bool ShadeConfigFile::validate() { Serial.println(this->header.version); return false; } - if(this->header.recordSize < 100) { - Serial.print("Invalid Record Size:"); - Serial.println(this->header.recordSize); + if(this->header.shadeRecordSize < 100) { + Serial.print("Invalid Shade Record Size:"); + Serial.println(this->header.shadeRecordSize); return false; } - if(this->header.records != 32) { - Serial.print("Invalid Record Count:"); - Serial.println(this->header.records); + if(this->header.shadeRecords != SOMFY_MAX_SHADES) { + Serial.print("Invalid Shade Record Count:"); + Serial.println(this->header.shadeRecords); return false; } + if(this->header.version > 10) { + if(this->header.groupRecordSize < 100) { + Serial.print("Invalid Group Record Size:"); + Serial.println(this->header.groupRecordSize); + return false; + } + if(this->header.groupRecords != SOMFY_MAX_GROUPS) { + Serial.print("Invalid Group Record Count:"); + Serial.println(this->header.groupRecords); + return false; + + } + } if(this->file.position() != this->header.length) { Serial.printf("File not positioned at %u end of header: %d\n", this->header.length, this->file.position()); return false; } + // We should know the file size based upon the record information in the header - if(this->file.size() != this->header.length + (this->header.recordSize * this->header.records)) { - Serial.printf("File size is not correct should be %d and got %d\n", this->header.length + (this->header.recordSize * this->header.records), this->file.size()); + uint32_t fsize = this->header.length + (this->header.shadeRecordSize * this->header.shadeRecords); + if(this->header.version > 10) fsize += (this->header.groupRecordSize * this->header.groupRecords); + if(this->file.size() != fsize) { + Serial.printf("File size is not correct should be %d and got %d\n", fsize, this->file.size()); } // Next check to see if the records match the header length. uint8_t recs = 0; uint32_t startPos = this->file.position(); - while(recs < this->header.records) { + while(recs < this->header.shadeRecords) { uint32_t pos = this->file.position(); if(!this->seekChar(CFG_REC_END)) { - Serial.printf("Failed to find the record end %d\n", recs); + Serial.printf("Failed to find the shade record end %d\n", recs); return false; } - if(this->file.position() - pos != this->header.recordSize) { - Serial.printf("Record length is %d and should be %d\n", this->file.position() - pos, this->header.recordSize); + if(this->file.position() - pos != this->header.shadeRecordSize) { + Serial.printf("Shade record length is %d and should be %d\n", this->file.position() - pos, this->header.shadeRecordSize); return false; } recs++; } + if(this->header.version > 10) { + recs = 0; + while(recs < this->header.groupRecords) { + uint32_t pos = this->file.position(); + if(!this->seekChar(CFG_REC_END)) { + Serial.printf("Failed to find the group record end %d\n", recs); + return false; + } + recs++; + if(this->file.position() - pos != this->header.groupRecordSize) { + Serial.printf("Group record length is %d and should be %d\n", this->file.position() - pos, this->header.groupRecordSize); + return false; + } + } + } this->file.seek(startPos, SeekSet); return true; } @@ -275,7 +321,7 @@ bool ShadeConfigFile::loadFile(SomfyShadeController *s, const char *filename) { } // We should be valid so start reading. pref.begin("ShadeCodes"); - for(uint8_t i = 0; i < this->header.records; i++) { + for(uint8_t i = 0; i < this->header.shadeRecords; i++) { SomfyShade *shade = &s->shades[i]; shade->setShadeId(this->readUInt8(255)); shade->paired = this->readBool(false); @@ -337,6 +383,21 @@ bool ShadeConfigFile::loadFile(SomfyShadeController *s, const char *filename) { } if(shade->getShadeId() == 255) shade->clear(); } + for(uint8_t i = 0; i < this->header.groupRecords; i++) { + SomfyGroup *group = &s->groups[i]; + group->setGroupId(this->readUInt8(255)); + group->groupType = static_cast(this->readUInt8(0)); + group->setRemoteAddress(this->readUInt32(0)); + this->readString(group->name, sizeof(group->name)); + group->proto = static_cast(this->readUInt8(0)); + group->bitLength = this->readUInt8(56); + uint8_t lsd = 0; + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { + uint8_t shadeId = this->readUInt8(0); + // Do this to eliminate gaps. + if(shadeId > 0) group->linkedShades[lsd++] = shadeId; + } + } pref.end(); if(opened) { Serial.println("Closing shade config file"); @@ -344,6 +405,19 @@ bool ShadeConfigFile::loadFile(SomfyShadeController *s, const char *filename) { } return true; } +bool ShadeConfigFile::writeGroupRecord(SomfyGroup *group) { + this->writeUInt8(group->getGroupId()); + this->writeUInt8(static_cast(group->groupType)); + this->writeUInt32(group->getRemoteAddress()); + this->writeString(group->name, sizeof(group->name)); + this->writeUInt8(static_cast(group->proto)); + this->writeUInt8(group->bitLength); + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { + if(j == SOMFY_MAX_GROUPED_SHADES - 1) this->writeUInt8(group->linkedShades[j], CFG_REC_END); + else this->writeUInt8(group->linkedShades[j]); + } + return true; +} bool ShadeConfigFile::writeShadeRecord(SomfyShade *shade) { if(shade->tiltType == tilt_types::none || shade->shadeType != shade_types::blind) { shade->myTiltPos = -1; diff --git a/ConfigFile.h b/ConfigFile.h index 89d0827..3c24869 100644 --- a/ConfigFile.h +++ b/ConfigFile.h @@ -10,8 +10,10 @@ struct config_header_t { uint8_t version = 1; - uint16_t recordSize = 0; - uint16_t records = 0; + uint8_t shadeRecordSize = 0; + uint8_t shadeRecords = 0; + uint8_t groupRecordSize = 0; + uint8_t groupRecords = 0; uint8_t length = 0; }; class ConfigFile { @@ -51,6 +53,7 @@ class ConfigFile { class ShadeConfigFile : public ConfigFile { protected: bool writeShadeRecord(SomfyShade *shade); + bool writeGroupRecord(SomfyGroup *group); public: static bool getAppVersion(appver_t &ver); static bool exists(); @@ -60,7 +63,7 @@ class ShadeConfigFile : public ConfigFile { bool save(SomfyShadeController *sofmy); bool loadFile(SomfyShadeController *somfy, const char *filename = "/shades.cfg"); void end(); - bool seekRecordById(uint8_t id); + //bool seekRecordById(uint8_t id); bool validate(); }; #endif diff --git a/ConfigSettings.cpp b/ConfigSettings.cpp index 38dcb1b..fd77bdd 100644 --- a/ConfigSettings.cpp +++ b/ConfigSettings.cpp @@ -66,6 +66,8 @@ bool ConfigSettings::begin() { (uint16_t)((chipId >> 8) & 0xff), (uint16_t)chipId & 0xff); this->load(); + this->Security.begin(); + this->IP.begin(); this->WIFI.begin(); this->Ethernet.begin(); this->NTP.begin(); @@ -106,6 +108,7 @@ bool ConfigSettings::toJSON(JsonObject &obj) { obj["connType"] = static_cast(this->connType); return true; } +bool ConfigSettings::requiresAuth() { return this->Security.type != security_types::None; } bool ConfigSettings::fromJSON(JsonObject &obj) { if(obj.containsKey("ssdpBroadcast")) this->ssdpBroadcast = obj["ssdpBroadcast"]; if(obj.containsKey("hostname")) this->parseValueString(obj, "hostname", this->hostname, sizeof(this->hostname)); @@ -113,6 +116,7 @@ bool ConfigSettings::fromJSON(JsonObject &obj) { return true; } void ConfigSettings::print() { + this->Security.print(); Serial.printf("Connection Type: %u\n", (unsigned int) this->connType); this->NTP.print(); if(this->connType == conn_types::wifi || this->connType == conn_types::unset) this->WIFI.print(); @@ -226,8 +230,125 @@ bool NTPSettings::apply() { setenv("TZ", this->posixZone, 1); return true; } -WifiSettings::WifiSettings() {} +IPSettings::IPSettings() {} +bool IPSettings::begin() { + this->load(); + return true; +} +bool IPSettings::fromJSON(JsonObject &obj) { + if(obj.containsKey("dhcp")) this->dhcp = obj["dhcp"]; + this->parseIPAddress(obj, "ip", &this->ip); + this->parseIPAddress(obj, "gateway", &this->gateway); + this->parseIPAddress(obj, "subnet", &this->subnet); + this->parseIPAddress(obj, "dns1", &this->dns1); + this->parseIPAddress(obj, "dns2", &this->dns2); + return true; +} +bool IPSettings::toJSON(JsonObject &obj) { + IPAddress ipEmpty(0,0,0,0); + obj["dhcp"] = this->dhcp; + obj["ip"] = this->ip == ipEmpty ? "" : this->ip.toString(); + obj["gateway"] = this->gateway == ipEmpty ? "" : this->gateway.toString(); + obj["subnet"] = this->subnet == ipEmpty ? "" : this->subnet.toString(); + obj["dns1"] = this->dns1 == ipEmpty ? "" : this->dns1.toString(); + obj["dns2"] = this->dns2 == ipEmpty ? "" : this->dns2.toString(); + return true; +} +bool IPSettings::save() { + pref.begin("IP"); + pref.clear(); + pref.putBool("dhcp", this->dhcp); + pref.putString("ip", this->ip.toString()); + pref.putString("gateway", this->gateway.toString()); + pref.putString("subnet", this->subnet.toString()); + pref.putString("dns1", this->dns1.toString()); + pref.putString("dns2", this->dns2.toString()); + pref.end(); + return true; +} +bool IPSettings::load() { + pref.begin("IP"); + this->dhcp = pref.getBool("dhcp", true); + char buff[16]; + if(pref.isKey("ip")) { + pref.getString("ip", buff, sizeof(buff)); + this->ip.fromString(buff); + } + if(pref.isKey("gateway")) { + pref.getString("gateway", buff, sizeof(buff)); + this->gateway.fromString(buff); + } + if(pref.isKey("subnet")) { + pref.getString("subnet", buff, sizeof(buff)); + this->subnet.fromString(buff); + } + if(pref.isKey("dns1")) { + pref.getString("dns1", buff, sizeof(buff)); + this->dns1.fromString(buff); + } + if(pref.isKey("dns2")) { + pref.getString("dns2", buff, sizeof(buff)); + this->dns2.fromString(buff); + } + pref.end(); + return true; +} +bool SecuritySettings::begin() { + this->load(); + return true; +} +bool SecuritySettings::fromJSON(JsonObject &obj) { + if(obj.containsKey("type")) this->type = static_cast(obj["type"].as()); + this->parseValueString(obj, "username", this->username, sizeof(this->username)); + this->parseValueString(obj, "password", this->password, sizeof(this->password)); + this->parseValueString(obj, "pin", this->pin, sizeof(this->pin)); + if(obj.containsKey("permissions")) this->permissions = obj["permissions"]; + return true; +} +bool SecuritySettings::toJSON(JsonObject &obj) { + IPAddress ipEmpty(0,0,0,0); + obj["type"] = static_cast(this->type); + obj["username"] = this->username; + obj["password"] = this->password; + obj["pin"] = this->pin; + obj["permissions"] = this->permissions; + return true; +} +bool SecuritySettings::save() { + pref.begin("SEC"); + pref.clear(); + pref.putChar("type", static_cast(this->type)); + pref.putString("username", this->username); + pref.putString("password", this->password); + pref.putString("pin", this->pin); + pref.putChar("permissions", this->permissions); + pref.end(); + return true; +} +bool SecuritySettings::load() { + pref.begin("SEC"); + this->type = static_cast(pref.getChar("type", 0)); + if(pref.isKey("username")) pref.getString("username", this->username, sizeof(this->username)); + if(pref.isKey("password")) pref.getString("password", this->password, sizeof(this->password)); + if(pref.isKey("pin")) pref.getString("pin", this->pin, sizeof(this->pin)); + if(pref.isKey("permissions")) this->permissions = pref.getChar("permissions", this->permissions); + pref.end(); + return true; +} +void SecuritySettings::print() { + Serial.print("SECURITY Type:"); + Serial.print(static_cast(this->type)); + Serial.print(" Username:["); + Serial.print(this->username); + Serial.print("] Password:["); + Serial.print(this->password); + Serial.print("] Pin:["); + Serial.print(this->pin); + Serial.print("] Permissions:"); + Serial.println(this->permissions); +} +WifiSettings::WifiSettings() {} bool WifiSettings::begin() { this->load(); return true; @@ -318,7 +439,6 @@ bool EthernetSettings::begin() { return true; } bool EthernetSettings::fromJSON(JsonObject &obj) { - if(obj.containsKey("dhcp")) this->dhcp = obj["dhcp"]; if(obj.containsKey("boardType")) this->boardType = obj["boardType"]; if(obj.containsKey("phyAddress")) this->phyAddress = obj["phyAddress"]; if(obj.containsKey("CLKMode")) this->CLKMode = static_cast(obj["CLKMode"]); @@ -326,33 +446,21 @@ bool EthernetSettings::fromJSON(JsonObject &obj) { if(obj.containsKey("PWRPin")) this->PWRPin = obj["PWRPin"]; if(obj.containsKey("MDCPin")) this->MDCPin = obj["MDCPin"]; if(obj.containsKey("MDIOPin")) this->MDIOPin = obj["MDIOPin"]; - this->parseIPAddress(obj, "ip", &this->ip); - this->parseIPAddress(obj, "gateway", &this->gateway); - this->parseIPAddress(obj, "subnet", &this->subnet); - this->parseIPAddress(obj, "dns1", &this->dns1); - this->parseIPAddress(obj, "dns2", &this->dns2); return true; } bool EthernetSettings::toJSON(JsonObject &obj) { obj["boardType"] = this->boardType; obj["phyAddress"] = this->phyAddress; - obj["dhcp"] = this->dhcp; obj["CLKMode"] = static_cast(this->CLKMode); obj["phyType"] = static_cast(this->phyType); obj["PWRPin"] = this->PWRPin; obj["MDCPin"] = this->MDCPin; obj["MDIOPin"] = this->MDIOPin; - obj["ip"] = this->ip.toString(); - obj["gateway"] = this->gateway.toString(); - obj["subnet"] = this->subnet.toString(); - obj["dns1"] = this->dns1.toString(); - obj["dns2"] = this->dns2.toString(); return true; } bool EthernetSettings::save() { pref.begin("ETH"); pref.clear(); - pref.putBool("dhcp", this->dhcp); pref.putChar("boardType", this->boardType); pref.putChar("phyAddress", this->phyAddress); pref.putChar("phyType", static_cast(this->phyType)); @@ -360,17 +468,11 @@ bool EthernetSettings::save() { pref.putChar("PWRPin", this->PWRPin); pref.putChar("MDCPin", this->MDCPin); pref.putChar("MDIOPin", this->MDIOPin); - pref.putString("ip", this->ip.toString()); - pref.putString("gateway", this->gateway.toString()); - pref.putString("subnet", this->subnet.toString()); - pref.putString("dns1", this->dns1.toString()); - pref.putString("dns2", this->dns2.toString()); pref.end(); return true; } bool EthernetSettings::load() { pref.begin("ETH"); - this->dhcp = pref.getBool("dhcp", true); this->boardType = pref.getChar("boardType", this->boardType); this->phyType = static_cast(pref.getChar("phyType", ETH_PHY_LAN8720)); this->CLKMode = static_cast(pref.getChar("CLKMode", ETH_CLOCK_GPIO0_IN)); @@ -378,30 +480,10 @@ bool EthernetSettings::load() { this->PWRPin = pref.getChar("PWRPin", this->PWRPin); this->MDCPin = pref.getChar("MDCPin", this->MDCPin); this->MDIOPin = pref.getChar("MDIOPin", this->MDIOPin); - - char buff[16]; - pref.getString("ip", buff, sizeof(buff)); - this->ip.fromString(buff); - pref.getString("gateway", buff, sizeof(buff)); - this->gateway.fromString(buff); - pref.getString("subnet", buff, sizeof(buff)); - this->subnet.fromString(buff); - pref.getString("dns1", buff, sizeof(buff)); - this->dns1.fromString(buff); - pref.getString("dns2", buff, sizeof(buff)); - this->dns2.fromString(buff); pref.end(); return true; } void EthernetSettings::print() { Serial.println("Ethernet Settings"); Serial.printf("Board:%d PHYType:%d CLK:%d ADDR:%d PWR:%d MDC:%d MDIO:%d\n", this->boardType, this->phyType, this->CLKMode, this->phyAddress, this->PWRPin, this->MDCPin, this->MDIOPin); - Serial.print(" GATEWAY: "); - Serial.println(this->gateway); - Serial.print(" SUBNET: "); - Serial.println(this->subnet); - Serial.print(" DNS1: "); - Serial.println(this->dns1); - Serial.print(" DNS2: "); - Serial.println(this->dns2); } diff --git a/ConfigSettings.h b/ConfigSettings.h index ece6e06..96dbc7f 100644 --- a/ConfigSettings.h +++ b/ConfigSettings.h @@ -3,7 +3,7 @@ #ifndef configsettings_h #define configsettings_h -#define FW_VERSION "v1.7.3" +#define FW_VERSION "v2.0.0" enum DeviceStatus { DS_OK = 0, DS_ERROR = 1, @@ -55,12 +55,6 @@ class EthernetSettings: BaseSettings { public: EthernetSettings(); uint8_t boardType = 0; // These board types are enumerated in the ui and used to set the chip settings. - bool dhcp = true; - IPAddress ip; - IPAddress subnet = IPAddress(255,255,255,0); - IPAddress gateway; - IPAddress dns1; - IPAddress dns2; eth_phy_type_t phyType = ETH_PHY_LAN8720; eth_clock_mode_t CLKMode = ETH_CLOCK_GPIO0_IN; int8_t phyAddress = ETH_PHY_ADDR; @@ -76,6 +70,44 @@ class EthernetSettings: BaseSettings { void print(); }; +class IPSettings: BaseSettings { + public: + IPSettings(); + bool dhcp = true; + IPAddress ip; + IPAddress subnet = IPAddress(255,255,255,0); + IPAddress gateway = IPAddress(0,0,0,0); + IPAddress dns1 = IPAddress(0,0,0,0); + IPAddress dns2 = IPAddress(0,0,0,0); + bool begin(); + bool fromJSON(JsonObject &obj); + bool toJSON(JsonObject &obj); + bool load(); + bool save(); + void print(); +}; +enum class security_types : byte { + None = 0x00, + PinEntry = 0x01, + Password = 0x02 +}; +enum class security_permissions : byte { + ConfigOnly = 0x01 +}; +class SecuritySettings: BaseSettings { + public: + security_types type = security_types::None; + char username[33] = ""; + char password[33] = ""; + char pin[5] = ""; + uint8_t permissions = 0; + bool begin(); + bool save(); + bool load(); + void print(); + bool toJSON(JsonObject &obj); + bool fromJSON(JsonObject &obj); +}; class MQTTSettings: BaseSettings { public: bool enabled = false; @@ -97,7 +129,6 @@ enum class conn_types : byte { ethernet = 0x02, ethernetpref = 0x03 }; - class ConfigSettings: BaseSettings { public: char serverId[10] = ""; @@ -106,10 +137,13 @@ class ConfigSettings: BaseSettings { const char* fwVersion = FW_VERSION; bool ssdpBroadcast = true; uint8_t status; + IPSettings IP; WifiSettings WIFI; EthernetSettings Ethernet; NTPSettings NTP; MQTTSettings MQTT; + SecuritySettings Security; + bool requiresAuth(); bool fromJSON(JsonObject &obj); bool toJSON(JsonObject &obj); bool begin(); diff --git a/MQTT.cpp b/MQTT.cpp index bc3c4c4..70e7091 100644 --- a/MQTT.cpp +++ b/MQTT.cpp @@ -32,80 +32,112 @@ bool MQTTClass::loop() { return true; } void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { - //Serial.print("MQTT Topic:"); - //Serial.print(topic); - //Serial.print(" payload:"); - //for(uint32_t i=0; i= 0 && topic[ndx] != '/') ndx--; // Back off the set command - uint16_t end_command = --ndx; - // --------------+---- - // shades/1/target/set - while(ndx >= 0 && topic[ndx] != '/') ndx--; // Get the start of the leaf. - // --------+---------- - // shades/1/target/set - uint16_t start_command = ndx + 1; - uint16_t id_end = --ndx; - while(ndx >= 0 && topic[ndx] != '/') ndx--; - // ------+------------ - // shades/1/target/set - uint16_t id_start = ndx + 1; - char shadeId[4]; - char command[32]; - memset(command, 0x00, sizeof(command)); - memset(shadeId, 0x00, sizeof(shadeId)); - for(uint16_t i = 0;id_start <= id_end; i++) - shadeId[i] = topic[id_start++]; - for(uint16_t i = 0;start_command <= end_command; i++) - command[i] = topic[start_command++]; - - char value[10]; - memset(value, 0x00, sizeof(value)); - for(uint8_t i = 0; i < length; i++) - value[i] = payload[i]; + uint8_t len = strlen(topic); - Serial.print("MQTT Command:["); + uint8_t slashes = 0; + uint16_t ndx = strlen(topic) - 1; + while(ndx > 0) { + if(topic[ndx] == '/') slashes++; + if(slashes == 4) break; + ndx--; + } + char entityId[4]; + char command[32]; + char entityType[7]; + char value[10]; + memset(command, 0x00, sizeof(command)); + memset(entityId, 0x00, sizeof(entityId)); + memset(entityType, 0x00, sizeof(entityType)); + memset(value, 0x00, sizeof(value)); + uint8_t i = 0; + while(topic[ndx] == '/' && ndx < len) ndx++; + while(ndx < len) { + if(topic[ndx] != '/' && i < sizeof(entityType)) + entityType[i++] = topic[ndx]; + ndx++; + if(topic[ndx] == '/') break; + } + i = 0; + while(topic[ndx] == '/' && ndx < len) ndx++; + while(ndx < len) { + if(topic[ndx] != '/' && i < sizeof(entityId)) + entityId[i++] = topic[ndx]; + ndx++; + if(topic[ndx] == '/') break; + } + i = 0; + while(topic[ndx] == '/' && ndx < len) ndx++; + while(ndx < len) { + if(topic[ndx] != '/' && i < sizeof(command)) + command[i++] = topic[ndx]; + ndx++; + if(topic[ndx] == '/') break; + } + for(uint8_t j = 0; j < length && j < sizeof(value); j++) + value[j] = payload[j]; + + Serial.print("MQTT type:["); + Serial.print(entityType); + Serial.print("] command:["); Serial.print(command); - Serial.print("] shadeId:"); - Serial.print(shadeId); + Serial.print("] entityId:"); + Serial.print(entityId); Serial.print(" value:"); Serial.println(value); - SomfyShade* shade = somfy.getShadeById(atoi(shadeId)); - if (shade) { - int val = atoi(value); - if(strncmp(command, "target", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->moveToTarget(atoi(value)); + if(strncmp(entityType, "shades", sizeof(entityType)) == 0) { + SomfyShade* shade = somfy.getShadeById(atoi(entityId)); + if (shade) { + int val = atoi(value); + if(strncmp(command, "target", sizeof(command)) == 0) { + if(val >= 0 && val <= 100) + shade->moveToTarget(atoi(value)); + } + if(strncmp(command, "tiltTarget", sizeof(command)) == 0) { + if(val >= 0 && val <= 100) + shade->moveToTiltTarget(atoi(value)); + } + else if(strncmp(command, "direction", sizeof(command)) == 0) { + if(val < 0) + shade->sendCommand(somfy_commands::Up); + else if(val > 0) + shade->sendCommand(somfy_commands::Down); + else + shade->sendCommand(somfy_commands::My); + } + else if(strncmp(command, "mypos", sizeof(command)) == 0) { + if(val >= 0 && val <= 100) + shade->setMyPosition(val); + } + else if(strncmp(command, "myTiltPos", sizeof(command)) == 0) { + if(val >= 0 && val <= 100) + shade->setMyPosition(shade->myPos, val); + } + else if(strncmp(command, "sunFlag", sizeof(command)) == 0) { + if(val >= 0) shade->sendCommand(somfy_commands::SunFlag); + else shade->sendCommand(somfy_commands::Flag); + } } - if(strncmp(command, "tiltTarget", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->moveToTiltTarget(atoi(value)); - } - else if(strncmp(command, "direction", sizeof(command)) == 0) { - if(val < 0) - shade->sendCommand(somfy_commands::Up); - else if(val > 0) - shade->sendCommand(somfy_commands::Down); - else - shade->sendCommand(somfy_commands::My); - } - else if(strncmp(command, "mypos", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->setMyPosition(val); - } - else if(strncmp(command, "myTiltPos", sizeof(command)) == 0) { - if(val >= 0 && val <= 100) - shade->setMyPosition(shade->myPos, val); - } - else if(strncmp(command, "sunFlag", sizeof(command)) == 0) { - if(val >= 0) shade->sendCommand(somfy_commands::SunFlag); - else shade->sendCommand(somfy_commands::Flag); + } + else if(strncmp(entityType, "groups", sizeof(entityType)) == 0) { + SomfyGroup* group = somfy.getGroupById(atoi(entityId)); + if (group) { + int val = atoi(value); + if(strncmp(command, "direction", sizeof(command)) == 0) { + if(val < 0) + group->sendCommand(somfy_commands::Up); + else if(val > 0) + group->sendCommand(somfy_commands::Down); + else + group->sendCommand(somfy_commands::My); + } } } } @@ -132,6 +164,7 @@ bool MQTTClass::connect() { this->subscribe("shades/+/mypos/set"); this->subscribe("shades/+/myTiltPos/set"); this->subscribe("shades/+/sunFlag/set"); + this->subscribe("groups/+/direction/set"); mqttClient.setCallback(MQTTClass::receive); this->lastConnect = millis(); return true; diff --git a/Network.cpp b/Network.cpp index 13228e5..56e5547 100644 --- a/Network.cpp +++ b/Network.cpp @@ -113,6 +113,13 @@ void Network::setConnected(conn_types connType) { Serial.print(" ("); Serial.print(this->strength); Serial.println("dbm)"); + if(settings.IP.dhcp) { + settings.IP.ip = WiFi.localIP(); + settings.IP.subnet = WiFi.subnetMask(); + settings.IP.gateway = WiFi.gatewayIP(); + settings.IP.dns1 = WiFi.dnsIP(0); + settings.IP.dns2 = WiFi.dnsIP(1); + } } else { Serial.print("Successfully Connected to Ethernet!!! "); @@ -123,6 +130,13 @@ void Network::setConnected(conn_types connType) { Serial.print(" "); Serial.print(ETH.linkSpeed()); Serial.println("Mbps"); + if(settings.IP.dhcp) { + settings.IP.ip = ETH.localIP(); + settings.IP.subnet = ETH.subnetMask(); + settings.IP.gateway = ETH.gatewayIP(); + settings.IP.dns1 = ETH.dnsIP(0); + settings.IP.dns2 = ETH.dnsIP(1); + } char buf[128]; snprintf(buf, sizeof(buf), "{\"connected\":true,\"speed\":%d,\"fullduplex\":%s}", ETH.linkSpeed(), ETH.fullDuplex() ? "true" : "false"); sockEmit.sendToClients("ethernet", buf); @@ -202,7 +216,11 @@ bool Network::connectWired() { if(!this->ethStarted) { this->ethStarted = true; WiFi.mode(WIFI_OFF); - + if(!settings.IP.dhcp) + if(!ETH.config(settings.IP.ip, settings.IP.gateway, settings.IP.subnet, settings.IP.dns1, settings.IP.dns2)) + ETH.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); + else + ETH.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); WiFi.onEvent(this->networkEvent); if(!ETH.begin(settings.Ethernet.phyAddress, settings.Ethernet.PWRPin, settings.Ethernet.MDCPin, settings.Ethernet.MDIOPin, settings.Ethernet.phyType, settings.Ethernet.CLKMode)) { Serial.println("Ethernet Begin failed"); @@ -253,7 +271,11 @@ bool Network::connectWiFi() { this->connectStart = millis(); Serial.print("Set hostname to:"); Serial.println(WiFi.getHostname()); - WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); + if(!settings.IP.dhcp) + if(!WiFi.config(settings.IP.ip, settings.IP.gateway, settings.IP.subnet, settings.IP.dns1, settings.IP.dns2)) + WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); + else + WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); WiFi.setSleep(false); WiFi.mode(WIFI_STA); delay(100); diff --git a/Somfy.cpp b/Somfy.cpp index e37eb81..3f7cca1 100644 --- a/Somfy.cpp +++ b/Somfy.cpp @@ -440,6 +440,14 @@ SomfyShade *SomfyShadeController::findShadeByRemoteAddress(uint32_t address) { } return nullptr; } +SomfyGroup *SomfyShadeController::findGroupByRemoteAddress(uint32_t address) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup &group = this->groups[i]; + if(group.getRemoteAddress() == address) return &group; + } + return nullptr; +} + bool SomfyShadeController::loadLegacy() { Serial.println("Loading Legacy shades using NVS"); pref.begin("Shades", true); @@ -554,6 +562,12 @@ SomfyShade * SomfyShadeController::getShadeById(uint8_t shadeId) { } return nullptr; } +SomfyGroup * SomfyShadeController::getGroupById(uint8_t groupId) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + if(this->groups[i].getGroupId() == groupId) return &this->groups[i]; + } + return nullptr; +} void SomfyShade::clear() { this->setShadeId(255); this->setRemoteAddress(0); @@ -599,6 +613,10 @@ void SomfyShade::clear() { this->tiltTime = 7000; this->stepSize = 100; } +void SomfyGroup::clear() { + this->setGroupId(255); + this->setRemoteAddress(0); +} bool SomfyShade::linkRemote(uint32_t address, uint16_t rollingCode) { // Check to see if the remote is already linked. If it is // just return true after setting the rolling code @@ -632,6 +650,22 @@ bool SomfyShade::linkRemote(uint32_t address, uint16_t rollingCode) { } return false; } +bool SomfyGroup::linkShade(uint8_t shadeId) { + // Check to see if the shade is already linked. If it is just return true + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] == shadeId) { + return true; + } + } + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] == 0) { + this->linkedShades[i] = shadeId; + somfy.commit(); + return true; + } + } + return false; +} void SomfyShade::commit() { somfy.commit(); } void SomfyShade::commitShadePosition() { somfy.isDirty = true; @@ -694,7 +728,30 @@ bool SomfyShade::unlinkRemote(uint32_t address) { } return false; } +bool SomfyGroup::unlinkShade(uint8_t shadeId) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] == shadeId) { + this->linkedShades[i] = 0; + somfy.commit(); + return true; + } + } + return false; +} +bool SomfyGroup::hasShadeId(uint8_t shadeId) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] == shadeId) return true; + } + return false; +} bool SomfyShade::isAtTarget() { return this->currentPos == this->target && this->currentTiltPos == this->tiltTarget; } +bool SomfyShade::isInGroup() { + if(this->getShadeId() == 255) return false; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + if(somfy.groups[i].getGroupId() != 255 && somfy.groups[i].hasShadeId(this->getShadeId())) return true; + } + return false; +} void SomfyShade::checkMovement() { const uint64_t curTime = millis(); const bool sunFlag = this->flags & static_cast(somfy_flags_t::SunFlag); @@ -1096,7 +1153,6 @@ void SomfyShade::publish() { } } void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); } -int8_t SomfyShade::transformPosition(float fpos) { return static_cast(this->flipPosition && fpos >= 0.00f ? floor(100.0f - fpos) : floor(fpos)); } void SomfyShade::emitState(uint8_t num, const char *evt) { char buf[420]; if(this->tiltType != tilt_types::none) @@ -1114,12 +1170,16 @@ void SomfyShade::emitState(uint8_t num, const char *evt) { else sockEmit.sendToClient(num, evt, buf); if(mqtt.connected()) { char topic[32]; + snprintf(topic, sizeof(topic), "shades/%u/shadeType", this->shadeId); + mqtt.publish(topic, static_cast(this->shadeType)); snprintf(topic, sizeof(topic), "shades/%u/position", this->shadeId); mqtt.publish(topic, this->transformPosition(this->currentPos)); snprintf(topic, sizeof(topic), "shades/%u/direction", this->shadeId); mqtt.publish(topic, this->direction); snprintf(topic, sizeof(topic), "shades/%u/target", this->shadeId); mqtt.publish(topic, this->transformPosition(this->target)); + snprintf(topic, sizeof(topic), "shades/%u/remoteAddress", this->shadeId); + mqtt.publish(topic, this->getRemoteAddress()); snprintf(topic, sizeof(topic), "shades/%u/lastRollingCode", this->shadeId); mqtt.publish(topic, this->lastRollingCode); snprintf(topic, sizeof(topic), "shades/%u/mypos", this->shadeId); @@ -1148,6 +1208,40 @@ void SomfyShade::emitState(uint8_t num, const char *evt) { } } } +void SomfyGroup::emitState(const char *evt) { this->emitState(255, evt); } +void SomfyGroup::emitState(uint8_t num, const char *evt) { + ClientSocketEvent e(evt); + char buf[30]; + snprintf(buf, sizeof(buf), "{\"groupId\":%d,", this->groupId); + e.appendMessage(buf); + snprintf(buf, sizeof(buf), "\"remoteAddress\":%d,", this->getRemoteAddress()); + e.appendMessage(buf); + snprintf(buf, sizeof(buf), "\"name\":\"%s\",", this->name); + e.appendMessage(buf); + snprintf(buf, sizeof(buf), "\"shades\":["); + e.appendMessage(buf); + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] != 255) { + snprintf(buf, sizeof(buf), "%s%d", i != 0 ? "," : "", this->linkedShades[i]); + e.appendMessage(buf); + } + } + e.appendMessage("]}"); + if(num >= 255) sockEmit.sendToClients(&e); + else sockEmit.sendToClient(num, &e); + if(mqtt.connected()) { + char topic[32]; + snprintf(topic, sizeof(topic), "groups/%u/type", this->groupId); + mqtt.publish(topic, static_cast(this->groupType)); + snprintf(topic, sizeof(topic), "groups/%u/remoteAddress", this->groupId); + mqtt.publish(topic, this->getRemoteAddress()); + snprintf(topic, sizeof(topic), "groups/%u/lastRollingCode", this->groupId); + mqtt.publish(topic, this->lastRollingCode); + snprintf(topic, sizeof(topic), "groups/%u/direction", this->groupId); + mqtt.publish(topic, this->direction); + } +} +int8_t SomfyShade::transformPosition(float fpos) { return static_cast(this->flipPosition && fpos >= 0.00f ? floor(100.0f - fpos) : floor(fpos)); } bool SomfyShade::isIdle() { return this->direction == 0 && this->tiltDirection == 0; } void SomfyShade::processWaitingFrame() { if(this->shadeId == 255) { @@ -1355,19 +1449,15 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { case somfy_commands::SunFlag: { const bool isWindy = this->flags & static_cast(somfy_flags_t::Windy); - this->flags |= static_cast(somfy_flags_t::SunFlag); - if (!isWindy) { const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); - if (isSunny && this->sunDone) this->target = this->myPos >= 0 ? this->myPos : 100.0f; else if (!isSunny && this->noSunDone) this->target = 0.0f; } - somfy.isDirty = true; this->emitState(); } @@ -1464,6 +1554,85 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { //if(dir == 0 && this->tiltType == tilt_types::tiltmotor && this->tiltDirection != 0) this->setTiltMovement(0); this->setMovement(dir); } +void SomfyShade::processInternalCommand(somfy_commands cmd, uint8_t repeat) { + // The reason why we are processing all frames here is so + // any linked remotes that may happen to be on the same ESPSomfy RTS + // device can trigger the appropriate actions. + if(this->shadeId == 255) return; + const uint64_t curTime = millis(); + int8_t dir = 0; + this->moveStart = this->tiltStart = curTime; + this->startPos = this->currentPos; + this->startTiltPos = this->currentTiltPos; + // If the command is coming from a remote then we are aborting all these positioning operations. + switch(cmd) { + case somfy_commands::Up: + if(this->tiltType == tilt_types::tiltmotor) { + if(repeat >= TILT_REPEATS) + this->tiltTarget = 0.0f; + else + this->target = 0.0f; + } + else + this->target = this->tiltTarget = 0.0f; + break; + case somfy_commands::Down: + if (!this->windLast || (curTime - this->windLast) >= SOMFY_NO_WIND_REMOTE_TIMEOUT) { + if(this->tiltType == tilt_types::tiltmotor) { + if(repeat >= TILT_REPEATS) + this->tiltTarget = 100.0f; + else + this->target = 100.0f; + } + else { + this->target = 100.0f; + if(this->tiltType != tilt_types::none) this->tiltTarget = 100.0f; + } + } + break; + case somfy_commands::My: + if(this->isIdle()) { + if(this->myTiltPos >= 0.0f && this->myTiltPos >= 100.0f) this->tiltTarget = this->myTiltPos; + if(this->myPos >= 0.0f && this->myPos <= 100.0f) this->target = this->myPos; + } + else { + this->target = this->currentPos; + this->tiltTarget = this->currentTiltPos; + } + break; + case somfy_commands::StepUp: + // With the step commands and integrated shades + // the motor must tilt in the direction first then move + // so we have to calculate the target with this in mind. + if(this->tiltType == tilt_types::integrated && this->currentTiltPos > 0.0f) { + if(this->tiltTime == 0 || this->stepSize == 0) return; + this->tiltTarget = max(0.0f, this->currentTiltPos - (100.0f/(static_cast(this->tiltTime/static_cast(this->stepSize))))); + } + else if(this->currentPos > 0.0f) { + if(this->downTime == 0 || this->stepSize == 0) return; + this->target = max(0.0f, this->currentPos - (100.0f/(static_cast(this->upTime/static_cast(this->stepSize))))); + } + break; + case somfy_commands::StepDown: + dir = 1; + // With the step commands and integrated shades + // the motor must tilt in the direction first then move + // so we have to calculate the target with this in mind. + if(this->tiltType == tilt_types::integrated && this->currentTiltPos < 100.0f) { + if(this->tiltTime == 0 || this->stepSize == 0) return; + this->tiltTarget = min(100.0f, this->currentTiltPos + (100.0f/(static_cast(this->tiltTime/static_cast(this->stepSize))))); + } + else if(this->currentPos < 100.0f) { + if(this->downTime == 0 || this->stepSize == 0) return; + this->target = min(100.0f, this->currentPos + (100.0f/(static_cast(this->downTime/static_cast(this->stepSize))))); + } + break; + default: + dir = 0; + break; + } + this->setMovement(dir); +} void SomfyShade::setTiltMovement(int8_t dir) { int8_t currDir = this->tiltDirection; if(dir == 0) { @@ -1617,6 +1786,32 @@ void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat) { SomfyRemote::sendCommand(cmd, repeat); } } +void SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat) { + // This sendCommand function will always be called externally. sendCommand at the remote level + // is expected to be called internally when the motor needs commanded. + if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; + SomfyRemote::sendCommand(cmd, repeat); + switch(cmd) { + case somfy_commands::My: + this->direction = 0; + break; + case somfy_commands::Up: + this->direction = -1; + break; + case somfy_commands::Down: + this->direction = 1; + break; + } + this->emitState(); + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + if(this->linkedShades[i] != 0) { + SomfyShade * shade = somfy.getShadeById(this->linkedShades[i]); + if(shade) shade->processInternalCommand(cmd, repeat); + } + } + +} + void SomfyShade::sendTiltCommand(somfy_commands cmd) { if(cmd == somfy_commands::Up) { SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : 1); @@ -1718,6 +1913,7 @@ bool SomfyShade::save() { this->commit(); return true; } +bool SomfyGroup::save() { somfy.commit(); return true; } bool SomfyShade::fromJSON(JsonObject &obj) { if(obj.containsKey("name")) strlcpy(this->name, obj["name"], sizeof(this->name)); if(obj.containsKey("upTime")) this->upTime = obj["upTime"]; @@ -1775,6 +1971,18 @@ bool SomfyShade::fromJSON(JsonObject &obj) { if(obj.containsKey("flags")) this->flags = obj["flags"]; return true; } +bool SomfyShade::toJSONRef(JsonObject &obj) { + obj["shadeId"] = this->getShadeId(); + obj["name"] = this->name; + obj["remoteAddress"] = this->m_remoteAddress; + obj["paired"] = this->paired; + obj["shadeType"] = static_cast(this->shadeType); + obj["bitLength"] = this->bitLength; + obj["proto"] = static_cast(this->proto); + obj["flags"] = this->flags; + SomfyRemote::toJSON(obj); + return true; +} bool SomfyShade::toJSON(JsonObject &obj) { //Serial.print("Serializing Shade:"); //Serial.print(this->getShadeId()); @@ -1806,6 +2014,7 @@ bool SomfyShade::toJSON(JsonObject &obj) { obj["flags"] = this->flags; obj["flipCommands"] = this->flipCommands; obj["flipPosition"] = this->flipPosition; + obj["inGroup"] = this->isInGroup(); SomfyRemote::toJSON(obj); JsonArray arr = obj.createNestedArray("linkedRemotes"); for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { @@ -1848,7 +2057,7 @@ bool SomfyGroup::toJSON(JsonObject &obj) { SomfyShade *shade = somfy.getShadeById(shadeId); if(shade) { JsonObject lsd = arr.createNestedObject(); - shade->toJSON(lsd); + shade->toJSONRef(lsd); } } } @@ -1908,6 +2117,26 @@ uint8_t SomfyShadeController::getNextShadeId() { } return 255; } +uint8_t SomfyShadeController::getNextGroupId() { + // There is no shortcut for this since the deletion of + // a group in the middle makes all of this very difficult. + for(uint8_t i = 1; i < SOMFY_MAX_GROUPS - 1; i++) { + bool id_exists = false; + for(uint8_t j = 0; j < SOMFY_MAX_GROUPS; j++) { + SomfyGroup *group = &this->groups[j]; + if(group->getGroupId() == i) { + id_exists = true; + break; + } + } + if(!id_exists) { + Serial.print("Got next Group Id:"); + Serial.print(i); + return i; + } + } + return 255; +} uint8_t SomfyShadeController::shadeCount() { uint8_t count = 0; for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -1915,19 +2144,27 @@ uint8_t SomfyShadeController::shadeCount() { } return count; } -uint32_t SomfyShadeController::getNextRemoteAddress(uint8_t shadeId) { - uint32_t address = this->startingAddress + shadeId; +uint8_t SomfyShadeController::groupCount() { + uint8_t count = 0; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + if(this->groups[i].getGroupId() != 255) count++; + } + return count; +} +uint32_t SomfyShadeController::getNextRemoteAddress(uint8_t id) { + uint32_t address = this->startingAddress + id; uint8_t i = 0; + // The assumption here is that the max number of groups will + // always be less than or equal to the max number of shades. while(i < SOMFY_MAX_SHADES) { - if(this->shades[i].getShadeId() != 255) { - if(this->shades[i].getRemoteAddress() == address) { - address++; - i = 0; // Start over we cannot share addresses. - } - else i++; + if((i < SOMFY_MAX_SHADES && this->shades[i].getShadeId() != 255 && this->shades[i].getRemoteAddress() == address) || + (i < SOMFY_MAX_GROUPS && this->groups[i].getGroupId() != 255 && this->groups[i].getRemoteAddress() == address)) { + address++; + i = 0; // Start over we cannot share addresses. } else i++; } + i = 0; return address; } SomfyShade *SomfyShadeController::addShade(JsonObject &obj) { @@ -1999,6 +2236,29 @@ SomfyShade *SomfyShadeController::addShade() { } return shade; } +SomfyGroup *SomfyShadeController::addGroup(JsonObject &obj) { + SomfyGroup *group = this->addGroup(); + if(group) { + group->fromJSON(obj); + group->save(); + group->emitState("groupAdded"); + } + return group; +} +SomfyGroup *SomfyShadeController::addGroup() { + uint8_t groupId = this->getNextGroupId(); + // So the next shade id will be the first one we run into with an id of 255 so + // if it gets deleted in the middle then it will get the first slot that is empty. + // There is no apparent way around this. In the future we might actually add an indexer + // to it for sorting later. + if(groupId == 255) return nullptr; + SomfyGroup *group = &this->groups[groupId - 1]; + if(group) { + group->setGroupId(groupId); + this->isDirty = true; + } + return group; +} somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { if(this->flipCommands) { switch(cmd) { @@ -2092,6 +2352,17 @@ bool SomfyShadeController::deleteShade(uint8_t shadeId) { this->commit(); return true; } +bool SomfyShadeController::deleteGroup(uint8_t groupId) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + if(this->groups[i].getGroupId() == groupId) { + shades[i].emitState("groupRemoved"); + this->groups[i].clear(); + } + } + this->commit(); + return true; +} + bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); } uint16_t SomfyRemote::getNextRollingCode() { pref.begin("ShadeCodes"); @@ -2113,18 +2384,28 @@ uint16_t SomfyRemote::setRollingCode(uint16_t code) { } bool SomfyShadeController::toJSON(DynamicJsonDocument &doc) { doc["maxShades"] = SOMFY_MAX_SHADES; + doc["maxGroups"] = SOMFY_MAX_GROUPS; + doc["maxGroupedShades"] = SOMFY_MAX_GROUPED_SHADES; doc["maxLinkedRemotes"] = SOMFY_MAX_LINKED_REMOTES; doc["startingAddress"] = this->startingAddress; JsonObject objRadio = doc.createNestedObject("transceiver"); this->transceiver.toJSON(objRadio); - JsonArray arr = doc.createNestedArray("shades"); + JsonArray arrShades = doc.createNestedArray("shades"); for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; if(shade->getShadeId() != 255) { - JsonObject oshade = arr.createNestedObject(); + JsonObject oshade = arrShades.createNestedObject(); shade->toJSON(oshade); } } + JsonArray arrGroups = doc.createNestedArray("groups"); + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup *group = &this->groups[i]; + if(group->getGroupId() != 255) { + JsonObject ogroup = arrGroups.createNestedObject(); + group->toJSON(ogroup); + } + } return true; } bool SomfyShadeController::toJSON(JsonObject &obj) { @@ -2133,11 +2414,13 @@ bool SomfyShadeController::toJSON(JsonObject &obj) { obj["startingAddress"] = this->startingAddress; JsonObject oradio = obj.createNestedObject("transceiver"); this->transceiver.toJSON(oradio); - JsonArray arr = obj.createNestedArray("shades"); - this->toJSON(arr); + JsonArray arrShades = obj.createNestedArray("shades"); + this->toJSONShades(arrShades); + JsonArray arrGroups = obj.createNestedArray("groups"); + this->toJSONGroups(arrGroups); return true; } -bool SomfyShadeController::toJSON(JsonArray &arr) { +bool SomfyShadeController::toJSONShades(JsonArray &arr) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade &shade = this->shades[i]; if(shade.getShadeId() != 255) { @@ -2147,6 +2430,16 @@ bool SomfyShadeController::toJSON(JsonArray &arr) { } return true; } +bool SomfyShadeController::toJSONGroups(JsonArray &arr) { + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup &group = this->groups[i]; + if(group.getGroupId() != 255) { + JsonObject ogroup = arr.createNestedObject(); + group.toJSON(ogroup); + } + } + return true; +} void SomfyShadeController::loop() { this->transceiver.loop(); for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -2183,7 +2476,6 @@ static const uint32_t tempo_if_gap = 30415; // Gap between frames static int16_t bitMin = SYMBOL * TOLERANCE_MIN; static somfy_rx_t somfy_rx; static somfy_rx_queue_t rx_queue; - bool somfy_tx_queue_t::pop(somfy_tx_t *tx) { // Read the oldest index. for(int8_t i = MAX_TX_BUFFER - 1; i >= 0; i--) { @@ -2232,7 +2524,6 @@ void somfy_rx_queue_t::init() { memset(&this->index[0], 0xFF, MAX_RX_BUFFER); this->length = 0; } - bool somfy_rx_queue_t::pop(somfy_rx_t *rx) { // Read off the data from the oldest index. //Serial.println("Popping RX Queue"); @@ -2328,7 +2619,6 @@ void Transceiver::sendFrame(byte *frame, uint8_t sync, uint8_t bitLength) { delayMicroseconds(30415); */ } - void RECEIVE_ATTR Transceiver::handleReceive() { static unsigned long last_time = 0; const long time = micros(); diff --git a/Somfy.h b/Somfy.h index cc9fdd7..d76d958 100644 --- a/Somfy.h +++ b/Somfy.h @@ -46,6 +46,9 @@ enum class somfy_commands : byte { // Command extensions for 80 bit frames StepUp = 0x8B }; +enum class group_types : byte { + channel = 0x00 +}; enum class shade_types : byte { roller = 0x00, blind = 0x01, @@ -227,6 +230,7 @@ class SomfyShade : public SomfyRemote { float myTiltPos = -1.0f; SomfyLinkedRemote linkedRemotes[SOMFY_MAX_LINKED_REMOTES]; bool paired = false; + bool toJSONRef(JsonObject &obj); bool fromJSON(JsonObject &obj); bool toJSON(JsonObject &obj) override; char name[21] = ""; @@ -238,8 +242,10 @@ class SomfyShade : public SomfyRemote { uint16_t stepSize = 100; bool save(); bool isIdle(); + bool isInGroup(); void checkMovement(); void processFrame(somfy_frame_t &frame, bool internal = false); + void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1); void setTiltMovement(int8_t dir); void setMovement(int8_t dir); void setTarget(float target); @@ -267,12 +273,22 @@ class SomfyGroup : public SomfyRemote { protected: uint8_t groupId = 255; public: + group_types groupType = group_types::channel; + int8_t direction = 0; // 0 = stopped, 1=down, -1=up. char name[21] = ""; uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES]; void setGroupId(uint8_t id) { groupId = id; } uint8_t getGroupId() { return groupId; } + bool save(); + void clear(); bool fromJSON(JsonObject &obj); bool toJSON(JsonObject &obj); + bool linkShade(uint8_t shadeId); + bool unlinkShade(uint8_t shadeId); + bool hasShadeId(uint8_t shadeId); + void emitState(const char *evt = "groupState"); + void emitState(uint8_t num, const char *evt = "groupState"); + void sendCommand(somfy_commands cmd, uint8_t repeat = 1); }; struct transceiver_config_t { bool printBuffer = false; @@ -280,8 +296,8 @@ struct transceiver_config_t { uint8_t type = 56; // 56 or 80 bit protocol. radio_proto proto = radio_proto::RTS; uint8_t SCKPin = 18; - uint8_t TXPin = 12; - uint8_t RXPin = 13; + uint8_t TXPin = 13; + uint8_t RXPin = 12; uint8_t MOSIPin = 23; uint8_t MISOPin = 19; uint8_t CSNPin = 5; @@ -381,22 +397,31 @@ class SomfyShadeController { bool isDirty = false; uint32_t startingAddress; uint8_t getNextShadeId(); + uint8_t getNextGroupId(); uint32_t getNextRemoteAddress(uint8_t shadeId); SomfyShadeController(); Transceiver transceiver; SomfyShade *addShade(); SomfyShade *addShade(JsonObject &obj); + SomfyGroup *addGroup(); + SomfyGroup *addGroup(JsonObject &obj); bool deleteShade(uint8_t shadeId); + bool deleteGroup(uint8_t groupId); bool begin(); void loop(); void end(); SomfyShade shades[SOMFY_MAX_SHADES]; + SomfyGroup groups[SOMFY_MAX_GROUPS]; bool toJSON(DynamicJsonDocument &doc); - bool toJSON(JsonArray &arr); bool toJSON(JsonObject &obj); + bool toJSONShades(JsonArray &arr); + bool toJSONGroups(JsonArray &arr); uint8_t shadeCount(); + uint8_t groupCount(); SomfyShade * getShadeById(uint8_t shadeId); + SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); + SomfyGroup * findGroupByRemoteAddress(uint32_t address); void sendFrame(somfy_frame_t &frame, uint8_t repeats = 0); void processFrame(somfy_frame_t &frame, bool internal = false); void emitState(uint8_t num = 255); diff --git a/SomfyController.ino.esp32.bin b/SomfyController.ino.esp32.bin index e529176..30cbdd0 100644 Binary files a/SomfyController.ino.esp32.bin and b/SomfyController.ino.esp32.bin differ diff --git a/SomfyController.littlefs.bin b/SomfyController.littlefs.bin index 65e148e..64f3f36 100644 Binary files a/SomfyController.littlefs.bin and b/SomfyController.littlefs.bin differ diff --git a/Web.cpp b/Web.cpp index 625496a..5a95693 100644 --- a/Web.cpp +++ b/Web.cpp @@ -2,6 +2,7 @@ #include #include #include +#include "mbedtls/md.h" #include "ConfigSettings.h" #include "Web.h" #include "Utils.h" @@ -18,9 +19,11 @@ extern MQTTClass mqtt; #define WEB_MAX_RESPONSE 16384 static char g_content[WEB_MAX_RESPONSE]; + // General responses static const char _response_404[] = "404: Service Not Found"; + // Encodings static const char _encoding_text[] = "text/plain"; static const char _encoding_html[] = "text/html"; @@ -47,9 +50,213 @@ void Web::sendCacheHeaders(uint32_t seconds) { void Web::end() { //server.end(); } +bool Web::isAuthenticated(WebServer &server, bool cfg) { + Serial.println("Checking authentication"); + if(settings.Security.type == security_types::None) return true; + else if(!cfg && (settings.Security.permissions & static_cast(security_permissions::ConfigOnly)) == 0x01) return true; + else if(server.hasHeader("apikey")) { + // Api key was supplied. + Serial.println("Checking API Key..."); + char token[65]; + memset(token, 0x00, sizeof(token)); + this->createAPIToken(server.client().remoteIP(), token); + // Compare the tokens. + if(String(token) != server.header("apikey")) return false; + server.sendHeader("apikey", token); + } + else { + // Send a 401 + Serial.println("Not authenticated..."); + server.send(401, "Unauthorized API Key"); + return false; + } + return true; +} +bool Web::createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token) { + return this->createAPIToken((String(pin) + ":" + ipAddress.toString()).c_str(), token); +} +bool Web::createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token) { + return this->createAPIToken((String(username) + ":" + String(password) + ":" + ipAddress.toString()).c_str(), token); +} +bool Web::createAPIToken(const char *payload, char *token) { + byte hmacResult[32]; + mbedtls_md_context_t ctx; + mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256; + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 1); + mbedtls_md_hmac_starts(&ctx, (const unsigned char *)settings.serverId, strlen(settings.serverId)); + mbedtls_md_hmac_update(&ctx, (const unsigned char *)payload, strlen(payload)); + mbedtls_md_hmac_finish(&ctx, hmacResult); + Serial.print("Hash: "); + token[0] = '\0'; + for(int i = 0; i < sizeof(hmacResult); i++){ + char str[3]; + sprintf(str, "%02x", (int)hmacResult[i]); + strcat(token, str); + } + Serial.println(token); + return true; +} +bool Web::createAPIToken(const IPAddress ipAddress, char *token) { + String payload; + if(settings.Security.type == security_types::Password) createAPIPasswordToken(ipAddress, settings.Security.username, settings.Security.password, token); + else if(settings.Security.type == security_types::PinEntry) createAPIPinToken(ipAddress, settings.Security.pin, token); + else createAPIToken(ipAddress.toString().c_str(), token); + return true; +} +void Web::handleLogout(WebServer &server) { + Serial.println("Logging out of webserver"); + server.sendHeader("Location", "/"); + server.sendHeader("Cache-Control", "no-cache"); + server.sendHeader("Set-Cookie", "ESPSOMFYID=0"); + server.send(301); +} +void Web::handleLogin(WebServer &server) { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + StaticJsonDocument<256> doc; + JsonObject obj = doc.to(); + char token[65]; + memset(&token, 0x00, sizeof(token)); + this->createAPIToken(server.client().remoteIP(), token); + obj["type"] = static_cast(settings.Security.type); + if(settings.Security.type == security_types::None) { + obj["apiKey"] = token; + obj["msg"] = "Success"; + obj["success"] = true; + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + return; + } + Serial.println("Web logging in..."); + char username[33] = ""; + char password[33] = ""; + char pin[5] = ""; + if(server.hasArg("plain")) { + DynamicJsonDocument docin(256); + DeserializationError err = deserializeJson(docin, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + return; + } + else { + JsonObject objin = docin.as(); + if(objin.containsKey("username")) strlcpy(username, objin["username"], sizeof(username)); + if(objin.containsKey("password")) strlcpy(username, objin["password"], sizeof(password)); + if(objin.containsKey("pin")) strlcpy(pin, objin["pin"], sizeof(pin)); + } + } + else { + if(server.hasArg("username")) strlcpy(username, server.arg("username").c_str(), sizeof(username)); + if(server.hasArg("password")) strlcpy(password, server.arg("password").c_str(), sizeof(password)); + if(server.hasArg("pin")) strlcpy(pin, server.arg("pin").c_str(), sizeof(pin)); + } + // At this point we should have all the data we need to login. + if(settings.Security.type == security_types::PinEntry) { + Serial.print("Validating pin "); + Serial.println(pin); + if(strlen(pin) == 0 || strcmp(pin, settings.Security.pin) != 0) { + obj["success"] = false; + obj["msg"] = "Invalid Pin Entry"; + } + else { + obj["success"] = true; + obj["msg"] = "Login successful"; + obj["apiKey"] = token; + } + } + else if(settings.Security.type == security_types::Password) { + if(strlen(username) == 0 || strlen(password) == 0 || strcmp(username, settings.Security.username) != 0 || strcmp(password, settings.Security.password) != 0) { + obj["success"] = false; + obj["msg"] = "Invalid username or password"; + } + else { + obj["success"] = true; + obj["msg"] = "Login successful"; + obj["apiKey"] = token; + } + } + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + return; +} +void Web::handleStreamFile(WebServer &server, const char *filename, const char *encoding) { + webServer.sendCORSHeaders(); + // Load the index html page from the data directory. + Serial.print("Loading file "); + Serial.println(filename); + File file = LittleFS.open(filename, "r"); + if (!file) { + Serial.print("Error opening"); + Serial.println(filename); + server.send(500, _encoding_text, "shades.cfg"); + } + server.streamFile(file, encoding); + file.close(); +} +void Web::handleController(WebServer &server) { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_POST || method == HTTP_GET) { + DynamicJsonDocument doc(16384); + somfy.toJSON(doc); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(404, _encoding_text, _response_404); +} +void Web::handleLoginContext(WebServer &server) { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + DynamicJsonDocument doc(512); + JsonObject obj = doc.to(); + obj["type"] = static_cast(settings.Security.type); + obj["permissions"] = settings.Security.permissions; + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); +} +void Web::handleGetShades(WebServer &server) { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_POST || method == HTTP_GET) { + DynamicJsonDocument doc(16384); + JsonArray arr = doc.to(); + somfy.toJSONShades(arr); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(404, _encoding_text, _response_404); +} +void Web::handleGetGroups(WebServer &server) { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_POST || method == HTTP_GET) { + DynamicJsonDocument doc(16384); + JsonArray arr = doc.to(); + somfy.toJSONGroups(arr); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(404, _encoding_text, _response_404); +} void Web::begin() { Serial.println("Creating Web MicroServices..."); server.enableCORS(true); + const char *keys[1] = {"apikey"}; + server.collectHeaders(keys, 1); + apiServer.collectHeaders(keys, 1); apiServer.enableCORS(true); apiServer.on("/discovery", []() { HTTPMethod method = apiServer.method(); @@ -60,25 +267,18 @@ void Web::begin() { obj["serverId"] = settings.serverId; obj["version"] = settings.fwVersion; obj["model"] = "ESPSomfyRTS"; - JsonArray arr = obj.createNestedArray("shades"); - somfy.toJSON(arr); + JsonArray arrShades = obj.createNestedArray("shades"); + somfy.toJSONShades(arrShades); + JsonArray arrGroups = obj.createNestedArray("groups"); + somfy.toJSONGroups(arrGroups); serializeJson(doc, g_content); apiServer.send(200, _encoding_json, g_content); } apiServer.send(500, _encoding_text, "Invalid http method"); }); - apiServer.on("/shades", []() { - webServer.sendCORSHeaders(); - HTTPMethod method = apiServer.method(); - if (method == HTTP_POST || method == HTTP_GET) { - DynamicJsonDocument doc(16384); - JsonArray arr = doc.to(); - somfy.toJSON(arr); - serializeJson(doc, g_content); - apiServer.send(200, _encoding_json, g_content); - } - else apiServer.send(404, _encoding_text, _response_404); - }); + apiServer.on("/shades", []() { webServer.handleGetShades(apiServer); }); + apiServer.on("/groups", []() { webServer.handleGetGroups(apiServer); }); + apiServer.onNotFound([]() { Serial.print("Request 404:"); HTTPMethod method = apiServer.method(); @@ -92,6 +292,10 @@ void Web::begin() { case HTTP_PUT: Serial.print("PUT "); break; + case HTTP_OPTIONS: + Serial.print("OPTIONS "); + apiServer.send(200, "OK"); + return; default: Serial.print("["); Serial.print(method); @@ -102,17 +306,7 @@ void Web::begin() { snprintf(g_content, sizeof(g_content), "404 Service Not Found: %s", apiServer.uri().c_str()); apiServer.send(404, _encoding_text, g_content); }); - apiServer.on("/controller", []() { - webServer.sendCORSHeaders(); - HTTPMethod method = apiServer.method(); - if (method == HTTP_POST || method == HTTP_GET) { - DynamicJsonDocument doc(16384); - somfy.toJSON(doc); - serializeJson(doc, g_content); - apiServer.send(200, _encoding_json, g_content); - } - else apiServer.send(404, _encoding_text, _response_404); - }); + apiServer.on("/controller", []() { webServer.handleController(apiServer); }); apiServer.on("/shadeCommand", []() { webServer.sendCORSHeaders(); HTTPMethod method = apiServer.method(); @@ -180,6 +374,65 @@ void Web::begin() { apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); } }); + apiServer.on("/groupCommand", []() { + webServer.sendCORSHeaders(); + HTTPMethod method = apiServer.method(); + uint8_t groupId = 255; + uint8_t repeat = 1; + somfy_commands command = somfy_commands::My; + if (method == HTTP_GET || method == HTTP_PUT || method == HTTP_POST) { + if (apiServer.hasArg("shadeId")) { + groupId = atoi(apiServer.arg("groupId").c_str()); + if (apiServer.hasArg("command")) command = translateSomfyCommand(apiServer.arg("command")); + if(apiServer.hasArg("repeat")) repeat = atoi(apiServer.arg("repeat").c_str()); + } + else if (apiServer.hasArg("plain")) { + Serial.println("Sending Group Command"); + DynamicJsonDocument doc(256); + DeserializationError err = deserializeJson(doc, apiServer.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + return; + } + else { + JsonObject obj = doc.as(); + if (obj.containsKey("groupId")) groupId = obj["groupId"]; + else apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); + if (obj.containsKey("command")) { + String scmd = obj["command"]; + command = translateSomfyCommand(scmd); + } + if(obj.containsKey("repeat")) repeat = obj["repeat"].as(); + } + } + else apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); + } + SomfyGroup * group = somfy.getGroupById(groupId); + if (group) { + Serial.print("Received:"); + Serial.println(apiServer.arg("plain")); + // Send the command to the group. + group->sendCommand(command, repeat); + DynamicJsonDocument sdoc(512); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + apiServer.send(200, _encoding_json, g_content); + } + else { + apiServer.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}")); + } + }); apiServer.on("/tiltCommand", []() { webServer.sendCORSHeaders(); HTTPMethod method = apiServer.method(); @@ -248,57 +501,11 @@ void Web::begin() { server.on("/upnp.xml", []() { SSDP.schema(server.client()); }); - server.on("/", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file index.html"); - File file = LittleFS.open("/index.html", "r"); - if (!file) { - Serial.println("Error opening data/index.html"); - server.send(500, _encoding_html, "Unable to open data/index.html"); - } - server.streamFile(file, _encoding_html); - file.close(); - }); - server.on("/configRadio", []() { - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file configRadio.html"); - File file = LittleFS.open("/configRadio.html", "r"); - if (!file) { - Serial.println("Error opening configRadio.html"); - server.send(500, _encoding_text, "configRadio.html"); - } - server.streamFile(file, _encoding_html); - file.close(); - }); - server.on("/shades.cfg", []() { - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file shades.cfg"); - File file = LittleFS.open("/shades.cfg", "r"); - if (!file) { - Serial.println("Error opening shades.cfg"); - server.send(500, _encoding_text, "shades.cfg"); - } - server.streamFile(file, _encoding_text); - file.close(); - - }); - server.on("/shades.tmp", []() { - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file shades.cfg"); - File file = LittleFS.open("/shades.tmp", "r"); - if (!file) { - Serial.println("Error opening shades.tmp"); - server.send(500, _encoding_text, "shades.tmp"); - } - server.streamFile(file, _encoding_text); - file.close(); - }); - + server.on("/", []() { webServer.handleStreamFile(server, "/index.html", _encoding_html); }); + server.on("/login", []() { webServer.handleLogin(server); }); + server.on("/loginContext", []() { webServer.handleLoginContext(server); }); + server.on("/shades.cfg", []() { webServer.handleStreamFile(server, "/shades.cfg", _encoding_text); }); + server.on("/shades.tmp", []() { webServer.handleStreamFile(server, "/shades.tmp", _encoding_text); }); server.on("/backup", []() { webServer.sendCORSHeaders(); char filename[120]; @@ -353,73 +560,12 @@ void Web::begin() { if(somfy.loadShadesFile("/shades.tmp")) somfy.commit(); } }); - server.on("/index.js", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file index.js"); - File file = LittleFS.open("/index.js", "r"); - if (!file) { - Serial.println("Error opening data/index.js"); - server.send(500, _encoding_text, "Unable to open data/index.js"); - } - server.streamFile(file, "text/javascript"); - file.close(); - }); - server.on("/main.css", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file main.css"); - File file = LittleFS.open("/main.css", "r"); - if (!file) { - Serial.println("Error opening data/main.css"); - server.send(500, _encoding_text, "Unable to open data/main.css"); - } - server.streamFile(file, "text/css"); - file.close(); - }); - server.on("/icons.css", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - // Load the index html page from the data directory. - Serial.println("Loading file icons.css"); - File file = LittleFS.open("/icons.css", "r"); - if (!file) { - Serial.println("Error opening data/icons.css"); - server.send(500, _encoding_text, "Unable to open data/icons.css"); - } - server.streamFile(file, "text/css"); - file.close(); - }); - server.on("/favicon.png", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - - // Load the index html page from the data directory. - Serial.println("Loading file favicon.png"); - File file = LittleFS.open("/favicon.png", "r"); - if (!file) { - Serial.println("Error opening data/favicon.png"); - server.send(500, _encoding_text, "Unable to open data/icons.css"); - } - server.streamFile(file, "image/png"); - file.close(); - }); - server.on("/icon.png", []() { - webServer.sendCacheHeaders(604800); - webServer.sendCORSHeaders(); - - // Load the index html page from the data directory. - Serial.println("Loading file favicon.png"); - File file = LittleFS.open("/icon.png", "r"); - if (!file) { - Serial.println("Error opening data/favicon.png"); - server.send(500, _encoding_text, "Unable to open data/icons.css"); - } - server.streamFile(file, "image/png"); - file.close(); - }); + server.on("/index.js", []() { webServer.handleStreamFile(server, "/index.js", "text/javascript"); }); + server.on("/main.css", []() { webServer.handleStreamFile(server, "/main.css", "text/css"); }); + server.on("/widgets.css", []() { webServer.handleStreamFile(server, "/widgets.css", "text/css"); }); + server.on("/icons.css", []() { webServer.sendCacheHeaders(604800); webServer.handleStreamFile(server, "/icons.css", "text/css"); }); + server.on("/favicon.png", []() { webServer.sendCacheHeaders(604800); webServer.handleStreamFile(server, "/favicon.png", "image/png"); }); + server.on("/icon.png", []() { webServer.sendCacheHeaders(604800); webServer.handleStreamFile(server, "/icon.png", "image/png"); }); server.onNotFound([]() { Serial.print("Request 404:"); HTTPMethod method = server.method(); @@ -433,6 +579,10 @@ void Web::begin() { case HTTP_PUT: Serial.print("PUT "); break; + case HTTP_OPTIONS: + Serial.print("OPTIONS "); + server.send(200, "OK"); + return; default: Serial.print("["); Serial.print(method); @@ -443,31 +593,12 @@ void Web::begin() { snprintf(g_content, sizeof(g_content), "404 Service Not Found: %s", server.uri().c_str()); server.send(404, _encoding_text, g_content); }); - server.on("/controller", []() { - webServer.sendCORSHeaders(); - HTTPMethod method = server.method(); - if (method == HTTP_POST || method == HTTP_GET) { - DynamicJsonDocument doc(16384); - somfy.toJSON(doc); - serializeJson(doc, g_content); - server.send(200, _encoding_json, g_content); - } - else server.send(404, _encoding_text, _response_404); - }); - server.on("/shades", []() { - webServer.sendCORSHeaders(); - HTTPMethod method = server.method(); - if (method == HTTP_POST || method == HTTP_GET) { - DynamicJsonDocument doc(16384); - JsonArray arr = doc.to(); - somfy.toJSON(arr); - serializeJson(doc, g_content); - server.send(200, _encoding_json, g_content); - } - else server.send(404, _encoding_text, _response_404); - }); + server.on("/controller", []() { webServer.handleController(server); }); + server.on("/shades", []() { webServer.handleGetShades(server); }); + server.on("/groups", []() { webServer.handleGetGroups(server); }); server.on("/getNextShade", []() { webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } StaticJsonDocument<256> doc; uint8_t shadeId = somfy.getNextShadeId(); JsonObject obj = doc.to(); @@ -479,6 +610,19 @@ void Web::begin() { serializeJson(doc, g_content); server.send(200, _encoding_json, g_content); }); + server.on("/getNextGroup", []() { + webServer.sendCORSHeaders(); + StaticJsonDocument<256> doc; + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + uint8_t groupId = somfy.getNextGroupId(); + JsonObject obj = doc.to(); + obj["groupId"] = groupId; + obj["remoteAddress"] = somfy.getNextRemoteAddress(groupId); + obj["bitLength"] = somfy.transceiver.config.type; + obj["proto"] = static_cast(somfy.transceiver.config.proto); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + }); server.on("/addShade", []() { if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } HTTPMethod method = server.method(); @@ -535,6 +679,61 @@ void Web::begin() { server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error saving Somfy Shade.\"}")); } }); + server.on("/addGroup", []() { + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + SomfyGroup * group = nullptr; + if (method == HTTP_POST || method == HTTP_PUT) { + Serial.println("Adding a group"); + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + Serial.println("Counting shades"); + if (somfy.groupCount() > SOMFY_MAX_GROUPS) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Maximum number of groups exceeded.\"}")); + } + else { + Serial.println("Adding group"); + group = somfy.addGroup(obj); + if (group) { + DynamicJsonDocument sdoc(512); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + else { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error adding group.\"}")); + } + } + } + } + if (group) { + DynamicJsonDocument doc(256); + JsonObject obj = doc.to(); + group->toJSON(obj); + serializeJson(doc, g_content); + Serial.println(g_content); + server.send(200, _encoding_json, g_content); + } + else { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error saving Somfy Group.\"}")); + } + }); server.on("/shade", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -595,6 +794,107 @@ void Web::begin() { } else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); } + }); + server.on("/group", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_GET) { + if (server.hasArg("groupId")) { + int groupId = atoi(server.arg("groupId").c_str()); + SomfyGroup* group = somfy.getGroupById(groupId); + if (group) { + DynamicJsonDocument doc(2048); + JsonObject obj = doc.to(); + group->toJSON(obj); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + } + else { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid shade id.\"}")); + } + } + else if (method == HTTP_PUT || method == HTTP_POST) { + // We are updating an existing group. + if (server.hasArg("plain")) { + Serial.println("Updating a group"); + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + if (obj.containsKey("groupId")) { + SomfyGroup* group = somfy.getGroupById(obj["groupId"]); + if (group) { + group->fromJSON(obj); + group->save(); + DynamicJsonDocument sdoc(2048); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); + } + }); + server.on("/groupOptions", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_GET || method == HTTP_POST) { + if (server.hasArg("groupId")) { + int groupId = atoi(server.arg("groupId").c_str()); + SomfyGroup* group = somfy.getGroupById(groupId); + if (group) { + DynamicJsonDocument doc(4096); + JsonObject obj = doc.to(); + group->toJSON(obj); + JsonArray arr = obj.createNestedArray("availShades"); + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { + SomfyShade *shade = &somfy.shades[i]; + if(shade->getShadeId() != 255) { + bool isLinked = false; + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { + if(group->linkedShades[i] == shade->getShadeId()) { + isLinked = true; + break; + } + } + if(!isLinked) { + JsonObject s = arr.createNestedObject(); + shade->toJSONRef(s); + } + } + } + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + } + else { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); + } + } + }); server.on("/saveShade", []() { webServer.sendCORSHeaders(); @@ -640,6 +940,50 @@ void Web::begin() { else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); } }); + server.on("/saveGroup", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_PUT || method == HTTP_POST) { + // We are updating an existing shade. + if (server.hasArg("plain")) { + Serial.println("Updating a group"); + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + if (obj.containsKey("groupId")) { + SomfyGroup* group = somfy.getGroupById(obj["groupId"]); + if (group) { + group->fromJSON(obj); + group->save(); + DynamicJsonDocument sdoc(512); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); + } + }); server.on("/tiltCommand", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -773,6 +1117,66 @@ void Web::begin() { server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); } }); + server.on("/groupCommand", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + uint8_t groupId = 255; + uint8_t repeat = 1; + somfy_commands command = somfy_commands::My; + if (method == HTTP_GET || method == HTTP_PUT || method == HTTP_POST) { + if (server.hasArg("groupId")) { + groupId = atoi(server.arg("shadeId").c_str()); + if (server.hasArg("command")) command = translateSomfyCommand(server.arg("command")); + if(server.hasArg("repeat")) repeat = atoi(server.arg("repeat").c_str()); + } + else if (server.hasArg("plain")) { + Serial.println("Sending Group Command"); + DynamicJsonDocument doc(256); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + return; + } + else { + JsonObject obj = doc.as(); + if (obj.containsKey("groupId")) groupId = obj["groupId"]; + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); + if (obj.containsKey("command")) { + String scmd = obj["command"]; + command = translateSomfyCommand(scmd); + } + if(obj.containsKey("repeat")) repeat = obj["repeat"].as(); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); + } + SomfyGroup * group = somfy.getGroupById(groupId); + if (group) { + Serial.print("Received:"); + Serial.println(server.arg("plain")); + // Send the command to the group. + group->sendCommand(command, repeat); + DynamicJsonDocument sdoc(512); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + else { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}")); + } + }); server.on("/setMyPosition", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -1070,6 +1474,116 @@ void Web::begin() { else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No remote object supplied.\"}")); } }); + server.on("/linkToGroup", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_PUT || method == HTTP_POST) { + if (server.hasArg("plain")) { + Serial.println("Linking a shade to a group"); + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + uint8_t shadeId = obj.containsKey("shadeId") ? obj["shadeId"] : 0; + uint8_t groupId = obj.containsKey("groupId") ? obj["groupId"] : 0; + if(groupId == 0) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not provided.\"}")); + return; + } + if(shadeId == 0) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not provided.\"}")); + return; + } + SomfyGroup * group = somfy.getGroupById(groupId); + if(!group) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not found.\"}")); + return; + } + SomfyShade * shade = somfy.getShadeById(shadeId); + if(!shade) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not found.\"}")); + return; + } + group->linkShade(shadeId); + DynamicJsonDocument sdoc(2048); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No linking object supplied.\"}")); + } + }); + server.on("/unlinkFromGroup", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + if (method == HTTP_PUT || method == HTTP_POST) { + if (server.hasArg("plain")) { + Serial.println("Unlinking a shade from a group"); + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + uint8_t shadeId = obj.containsKey("shadeId") ? obj["shadeId"] : 0; + uint8_t groupId = obj.containsKey("groupId") ? obj["groupId"] : 0; + if(groupId == 0) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not provided.\"}")); + return; + } + if(shadeId == 0) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not provided.\"}")); + return; + } + SomfyGroup * group = somfy.getGroupById(groupId); + if(!group) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not found.\"}")); + return; + } + SomfyShade * shade = somfy.getShadeById(shadeId); + if(!shade) { + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not found.\"}")); + return; + } + group->unlinkShade(shadeId); + DynamicJsonDocument sdoc(2048); + JsonObject sobj = sdoc.to(); + group->toJSON(sobj); + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No unlinking object supplied.\"}")); + } + }); server.on("/deleteShade", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -1098,7 +1612,7 @@ void Web::begin() { } else { JsonObject obj = doc.as(); - if (obj.containsKey("shadeId")) shadeId = obj["shadeId"];//obj.getMember("shadeId").as(); + if (obj.containsKey("shadeId")) shadeId = obj["shadeId"]; else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); } } @@ -1106,11 +1620,55 @@ void Web::begin() { } SomfyShade* shade = somfy.getShadeById(shadeId); if (!shade) server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + else if(shade->isInGroup()) { + server.send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This shade is a member of a group and cannot be deleted.\"}")); + } else { somfy.deleteShade(shadeId); server.send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Shade deleted.\"}")); } }); + server.on("/deleteGroup", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + HTTPMethod method = server.method(); + uint8_t groupId = 255; + if (method == HTTP_GET || method == HTTP_PUT || method == HTTP_POST) { + if (server.hasArg("groupId")) { + groupId = atoi(server.arg("groupId").c_str()); + } + else if (server.hasArg("plain")) { + Serial.println("Deleting a group"); + DynamicJsonDocument doc(256); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + switch (err.code()) { + case DeserializationError::InvalidInput: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid JSON payload\"}")); + break; + case DeserializationError::NoMemory: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Out of memory parsing JSON\"}")); + break; + default: + server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"General JSON Deserialization failed\"}")); + break; + } + } + else { + JsonObject obj = doc.as(); + if (obj.containsKey("groupId")) groupId = obj["groupId"]; + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); + } + } + else server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); + } + SomfyGroup * group = somfy.getGroupById(groupId); + if (!group) server.send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}")); + else { + somfy.deleteGroup(groupId); + server.send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Group deleted.\"}")); + } + }); server.on("/updateFirmware", HTTP_POST, []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -1248,6 +1806,45 @@ void Web::begin() { server.send(201, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Invalid HTTP Method: \"}"); } }); + server.on("/saveSecurity", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + DynamicJsonDocument doc(512); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + Serial.print("Error parsing JSON "); + Serial.println(err.c_str()); + String msg = err.c_str(); + server.send(400, _encoding_html, "Error parsing JSON body
" + msg); + } + else { + JsonObject obj = doc.as(); + HTTPMethod method = server.method(); + if (method == HTTP_POST || method == HTTP_PUT) { + settings.Security.fromJSON(obj); + settings.Security.save(); + DynamicJsonDocument sdoc(512); + JsonObject sobj = sdoc.to(); + char token[65]; + webServer.createAPIToken(server.client().remoteIP(), token); + obj["apiKey"] = token; + serializeJson(sdoc, g_content); + server.send(200, _encoding_json, g_content); + //server.send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully saved radio\"}"); + } + else { + server.send(201, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Invalid HTTP Method: \"}"); + } + } + }); + server.on("/getSecurity", []() { + webServer.sendCORSHeaders(); + DynamicJsonDocument doc(512); + JsonObject obj = doc.to(); + settings.Security.toJSON(obj); + serializeJson(doc, g_content); + server.send(200, _encoding_json, g_content); + }); server.on("/saveRadio", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -1419,6 +2016,31 @@ void Web::begin() { } } }); + server.on("/setIP", []() { + webServer.sendCORSHeaders(); + if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } + Serial.println("Setting IP..."); + DynamicJsonDocument doc(1024); + DeserializationError err = deserializeJson(doc, server.arg("plain")); + if (err) { + Serial.print("Error parsing JSON "); + Serial.println(err.c_str()); + String msg = err.c_str(); + server.send(400, "text/html", "Error parsing JSON body
" + msg); + } + else { + JsonObject obj = doc.as(); + HTTPMethod method = server.method(); + if (method == HTTP_POST || method == HTTP_PUT) { + settings.IP.fromJSON(obj); + settings.IP.save(); + server.send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + } + else { + server.send(201, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Invalid HTTP Method: \"}"); + } + } + }); server.on("/connectwifi", []() { webServer.sendCORSHeaders(); if(server.method() == HTTP_OPTIONS) { server.send(200, "OK"); return; } @@ -1479,7 +2101,7 @@ void Web::begin() { }); server.on("/networksettings", []() { webServer.sendCORSHeaders(); - DynamicJsonDocument doc(1024); + DynamicJsonDocument doc(2048); JsonObject obj = doc.to(); doc["fwVersion"] = settings.fwVersion; settings.toJSON(obj); @@ -1487,6 +2109,8 @@ void Web::begin() { settings.Ethernet.toJSON(eth); JsonObject wifi = obj.createNestedObject("wifi"); settings.WIFI.toJSON(wifi); + JsonObject ip = obj.createNestedObject("ip"); + settings.IP.toJSON(ip); serializeJson(doc, g_content); server.send(200, _encoding_json, g_content); }); diff --git a/Web.h b/Web.h index b47d6bc..fccbbad 100644 --- a/Web.h +++ b/Web.h @@ -1,3 +1,4 @@ +#include #ifndef webserver_h #define webserver_h class Web { @@ -5,8 +6,21 @@ class Web { void sendCORSHeaders(); void sendCacheHeaders(uint32_t seconds=604800); void startup(); + void handleLogin(WebServer &server); + void handleLogout(WebServer &server); + void handleStreamFile(WebServer &server, const char *filename, const char *encoding); + void handleController(WebServer &server); + void handleLoginContext(WebServer &server); + void handleGetShades(WebServer &server); + void handleGetGroups(WebServer &server); void begin(); void loop(); void end(); + // Web Handlers + bool createAPIToken(const IPAddress ipAddress, char *token); + bool createAPIToken(const char *payload, char *token); + bool createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token); + bool createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token); + bool isAuthenticated(WebServer &server, bool cfg = false); }; #endif diff --git a/data/appversion b/data/appversion index 0a182f2..359a5b9 100644 --- a/data/appversion +++ b/data/appversion @@ -1 +1 @@ -1.7.2 \ No newline at end of file +2.0.0 \ No newline at end of file diff --git a/data/configRadio.html b/data/configRadio.html deleted file mode 100644 index 989c363..0000000 --- a/data/configRadio.html +++ /dev/null @@ -1,423 +0,0 @@ - - - - - - - Configure CC1101 - - - - - - -
- - - - -
-
- - -
-
-
- - - -
- - - diff --git a/data/index.html b/data/index.html index 7a55ddf..6181b0d 100644 --- a/data/index.html +++ b/data/index.html @@ -3,496 +3,617 @@ - - + + + - + -
-
Radio Not Initialized
-

ESPSomfy RTS

- -
-
-
- +
+
+
+
+
- '; - div.classList.add('waitoverlay'); - el.appendChild(div); - return div; -} -function clearErrors() { - let errors = document.querySelectorAll('div.errorMessage'); - if (errors && errors.length > 0) errors.forEach((el) => { el.remove(); }); -} -function serviceError(el, err) { - let msg = ''; - if (typeof err === 'string' && err.startsWith('{')) { - let e = JSON.parse(err); - if (typeof e !== 'undefined' && typeof e.desc === 'string') msg = e.desc; - else msg = err; - } - else if (typeof err === 'string') { - msg = err; - } - else if (typeof err === 'number') { - switch (err) { - case 404: - msg = `404: Service not found`; - break; - default: - msg = `${err}: Network HTTP Error`; - break; - } - } - else if (typeof err !== 'undefined') { - console.log(err); - if (typeof err.desc === 'string') msg = typeof err.desc !== 'undefined' ? err.desc : err.message; - } - return errorMessage(el, msg); -} -function errorMessage(el, msg) { - let div = document.createElement('div'); - div.innerHTML = '
' + msg + '
'; - div.classList.add('errorMessage'); - el.appendChild(div); - return div; -} -function socketError(el, msg) { - let div = document.createElement('div'); - div.innerHTML = '
Attempts:
Could not connect to server

' + msg + '
'; - div.classList.add('errorMessage'); - div.classList.add('socket-error'); - el.appendChild(div); - return div; -} -function promptMessage(el, msg, onYes) { - let div = document.createElement('div'); - div.innerHTML = '
' + msg + '
'; - div.classList.add('errorMessage'); - el.appendChild(div); - div.querySelector('#btnYes').addEventListener('click', onYes); - return div; -} +var httpStatusText = { + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Unused', + '307': 'Temporary Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Required', + '413': 'Request Entry Too Large', + '414': 'Request-URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Requested Range Not Satisfiable', + '417': 'Expectation Failed', + '418': 'I\'m a teapot', + '429': 'Too Many Requests', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported' +}; function getJSON(url, cb) { let xhr = new XMLHttpRequest(); console.log({ get: url }); - xhr.open('GET', url, true); + xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); + xhr.setRequestHeader('apikey', security.apiKey); xhr.responseType = 'json'; xhr.onload = () => { let status = xhr.status; - cb(status === 200 ? null : status, xhr.response); + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `GET ${url}`; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(xhr.response, null); + } + else { + cb(null, xhr.response); + } }; xhr.onerror = (evt) => { - cb(xhr.status || 500, xhr.statusText); + let err = { + htmlError: xhr.status || 500, + service: `GET ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); }; xhr.send(); } +function getJSONSync(url, cb) { + let overlay = ui.waitMessage(document.getElementById('divContainer')); + let xhr = new XMLHttpRequest(); + console.log({ get: url }); + xhr.responseType = 'json'; + xhr.onload = () => { + let status = xhr.status; + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `GET ${url}`; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(xhr.response, null); + } + else { + cb(null, xhr.response); + } + if (typeof overlay !== 'undefined') overlay.remove(); + }; + xhr.onerror = (evt) => { + let err = { + htmlError: xhr.status || 500, + service: `GET ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + if (typeof overlay !== 'undefined') overlay.remove(); + }; + xhr.onabort = (evt) => { + console.log('Aborted'); + if (typeof overlay !== 'undefined') overlay.remove(); + }; + xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); + xhr.setRequestHeader('apikey', security.apiKey); + xhr.send(); +} function getText(url, cb) { let xhr = new XMLHttpRequest(); console.log({ get: url }); - xhr.open('GET', url, true); + xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); + xhr.setRequestHeader('apikey', security.apiKey); xhr.responseType = 'text'; xhr.onload = () => { let status = xhr.status; - cb(status === 200 ? null : status, xhr.responseText); + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `GET ${url}`; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + } + else + cb(null, xhr.response); }; xhr.onerror = (evt) => { - cb(xhr.status || 500, xhr.statusText); + let err = { + htmlError: xhr.status || 500, + service: `GET ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); }; xhr.send(); } +function postJSONSync(url, data, cb) { + let overlay = ui.waitMessage(document.getElementById('divContainer')); + try { + let xhr = new XMLHttpRequest(); + console.log({ post: url, data: data }); + let fd = new FormData(); + for (let name in data) { + fd.append(name, data[name]); + } + xhr.open('POST', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); + xhr.responseType = 'json'; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('apikey', security.apiKey); + xhr.onload = () => { + let status = xhr.status; + console.log(xhr); + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `POST ${url}`; + err.data = data; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + } + else { + cb(null, xhr.response); + } + overlay.remove(); + }; + xhr.onerror = (evt) => { + console.log(xhr); + let err = { + htmlError: xhr.status || 500, + service: `POST ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + overlay.remove(); + }; + xhr.send(fd); + } catch (err) { ui.serviceError(document.getElementById('divContainer'), err); } +} function putJSON(url, data, cb) { let xhr = new XMLHttpRequest(); console.log({ put: url, data: data }); - xhr.open('PUT', url, true); + xhr.open('PUT', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); xhr.responseType = 'json'; xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('apikey', security.apiKey); xhr.onload = () => { let status = xhr.status; - cb(status === 200 ? null : status, xhr.response); + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `PUT ${url}`; + err.data = data; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + } + else { + cb(null, xhr.response); + } }; xhr.onerror = (evt) => { - cb(xhr.status || 500, xhr.statusText); + console.log(xhr); + let err = { + htmlError: xhr.status || 500, + service: `PUT ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); }; xhr.send(JSON.stringify(data)); } +function putJSONSync(url, data, cb) { + let overlay = ui.waitMessage(document.getElementById('divContainer')); + try { + let xhr = new XMLHttpRequest(); + console.log({ put: url, data: data }); + //xhr.open('PUT', url, true); + xhr.open('PUT', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); + xhr.responseType = 'json'; + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('apikey', security.apiKey); + xhr.onload = () => { + let status = xhr.status; + if (status !== 200) { + let err = xhr.response || {}; + err.htmlError = status; + err.service = `PUT ${url}`; + err.data = data; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + } + else { + cb(null, xhr.response); + } + overlay.remove(); + }; + xhr.onerror = (evt) => { + console.log(xhr); + let err = { + htmlError: xhr.status || 500, + service: `PUT ${url}` + }; + if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500]; + cb(err, null); + overlay.remove(); + }; + xhr.send(JSON.stringify(data)); + } catch (err) { ui.serviceError(document.getElementById('divContainer'), err); } +} var socket; var tConnect = null; var sockIsOpen = false; @@ -245,9 +439,10 @@ async function initSockets() { for (let i = 0; i < wms.length; i++) { wms[i].remove(); } - waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); + ui.waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); + let host = window.location.protocol === 'file:' ? 'ESPSomfyRTS' : window.location.hostname; try { - socket = new WebSocket(`ws://${window.location.hostname}:8080/`); + socket = new WebSocket(`ws://${host}:8080/`); socket.onmessage = (evt) => { if (evt.data.startsWith('42')) { let ndx = evt.data.indexOf(','); @@ -315,10 +510,14 @@ async function initSockets() { } else { (async () => { - await general.init(); - await somfy.init(); + await general.loadGeneral(); + await wifi.loadNetwork(); + await somfy.loadSomfy(); + + //await general.init(); + //await somfy.init(); await mqtt.init(); - await wifi.init(); + //await wifi.init(); })(); } }; @@ -326,7 +525,7 @@ async function initSockets() { wifi.procWifiStrength({ ssid: '', channel: -1, strength: -100 }); wifi.procEthernet({ connected: '', speed: 0, fullduplex: false }); if (document.getElementsByClassName('socket-wait').length === 0) - waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); + ui.waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); if (evt.wasClean) { console.log({ msg: 'close-clean', evt: evt }); connectFailed = 0; @@ -346,7 +545,7 @@ async function initSockets() { console.log(`Initial socket did not connect try again (server was busy and timed out ${connectFailed} times)`); tConnect = setTimeout(async () => { await reopenSocket(); }, timeout); if (connectFailed === 5) { - socketError(document.getElementById('divContainer'), 'Too many clients connected. A maximum of 5 clients may be connected at any one time. Close some connections to the ESP Somfy RTS device to proceed.'); + ui.socketError('Too many clients connected. A maximum of 5 clients may be connected at any one time. Close some connections to the ESP Somfy RTS device to proceed.'); } let spanAttempts = document.getElementById('spanSocketAttempts'); if (spanAttempts) spanAttempts.innerHTML = connectFailed.fmt("#,##0"); @@ -377,22 +576,637 @@ async function reopenSocket() { tConnect = null; await initSockets(); } -class General { - appVersion = 'v1.7.3'; - reloadApp = false; +async function init() { + await security.init(); + general.init(); + wifi.init(); + somfy.init(); + mqtt.init(); + firmware.init(); +} +class UIBinder { + setValue(el, val) { + if (el instanceof HTMLInputElement) { + switch (el.type.toLowerCase()) { + case 'checkbox': + el.checked = makeBool(val); + break; + case 'range': + let dt = el.getAttribute('data-datatype'); + let mult = parseInt(el.getAttribute('data-mult') || 1, 10); + switch (dt) { + // We always range with integers + case 'float': + el.value = Math.round(parseInt(val * mult, 10)); + break; + case 'index': + let ivals = JSON.parse(el.getAttribute('data-values')); + for (let i = 0; i < ivals.length; i++) { + if (ivals[i].toString() === val.toString()) { + el.value = i; + break; + } + } + break; + default: + el.value = parseInt(val, 10) * mult; + break; + } + break; + default: + el.value = val; + break; + } + } + else if (el instanceof HTMLSelectElement) { + let ndx = 0; + for (let i = 0; i < el.options.length; i++) { + let opt = el.options[i]; + if (opt.value === val.toString()) { + ndx = i; + break; + } + } + el.selectedIndex = ndx; + } + else if (el instanceof HTMLElement) el.innerHTML = val; + } + getValue(el, defVal) { + let val = defVal; + if (el instanceof HTMLInputElement) { + switch (el.type.toLowerCase()) { + case 'checkbox': + val = el.checked; + break; + case 'range': + let dt = el.getAttribute('data-datatype'); + let mult = parseInt(el.getAttribute('data-mult') || 1, 10); + switch (dt) { + // We always range with integers + case 'float': + val = parseInt(el.value, 10) / mult; + break; + case 'index': + let ivals = JSON.parse(el.getAttribute('data-values')); + val = ivals[parseInt(el.value, 10)]; + break; + default: + val = parseInt(el.value / mult, 10); + break; + } + break; + default: + val = el.value; + break; + } + } + else if (el instanceof HTMLSelectElement) val = el.value; + else if (el instanceof HTMLElement) val = el.innerHTML; + return val; + } + toElement(el, val) { + let flds = el.querySelectorAll('*[data-bind]'); + flds.forEach((fld) => { + let prop = fld.getAttribute('data-bind'); + let arr = prop.split('.'); + let tval = val; + for (let i = 0; i < arr.length; i++) { + var s = arr[i]; + if (typeof s === 'undefined' || !s) continue; + let ndx = s.indexOf('['); + if (ndx !== -1) { + ndx = parseInt(s.substring(ndx + 1, s.indexOf(']') - 1), 10); + s = s.substring(0, ndx - 1); + } + tval = tval[s]; + if (typeof tval === 'undefined') break; + if (ndx >= 0) tval = tval[ndx]; + } + if (typeof tval !== 'undefined') { + if (typeof fld.val === 'function') this.val(tval); + else { + switch (fld.getAttribute('data-fmttype')) { + case 'time': + { + var dt = new Date(); + dt.setHours(0, 0, 0); + dt.addMinutes(tval); + tval = dt.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || ''); + } + break; + case 'date': + case 'datetime': + { + let dt = new Date(tval); + tval = dt.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || ''); + } + break; + case 'number': + if (typeof tval !== 'number') tval = parseFloat(tval); + tval = tval.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || ''); + break; + case 'duration': + tval = dataBinder.formatDuration(tval, $this.attr('data-fmtmask')); + break; + } + this.setValue(fld, tval); + } + } + }); + } + fromElement(el, obj, arrayRef) { + if (typeof arrayRef === 'undefined' || arrayRef === null) arrayRef = []; + if (typeof obj === 'undefined' || obj === null) obj = {}; + if (typeof el.getAttribute('data-bind') !== 'undefined') this._bindValue(obj, el, this.getValue(el), arrayRef); + let flds = el.querySelectorAll('*[data-bind]'); + flds.forEach((fld) => { + if (!makeBool(fld.getAttribute('data-setonly'))) + this._bindValue(obj, fld, this.getValue(fld), arrayRef); + }); + return obj; + } + parseNumber(val) { + if (val === null) return; + if (typeof val === 'undefined') return val; + if (typeof val === 'number') return val; + if (typeof val.getMonth === 'function') return val.getTime(); + var tval = val.replace(/[^0-9\.\-]+/g, ''); + return tval.indexOf('.') !== -1 ? parseFloat(tval) : parseInt(tval, 10); + }; + _bindValue(obj, el, val, arrayRef) { + var binding = el.getAttribute('data-bind'); + var dataType = el.getAttribute('data-datatype'); + if (binding && binding.length > 0) { + var sRef = ''; + var arr = binding.split('.'); + var t = obj; + for (var i = 0; i < arr.length - 1; i++) { + let s = arr[i]; + if (typeof s === 'undefined' || s.length === 0) continue; + sRef += ('.' + s); + var ndx = s.lastIndexOf('['); + if (ndx !== -1) { + var v = s.substring(0, ndx); + var ndxEnd = s.lastIndexOf(']'); + var ord = parseInt(s.substring(ndx + 1, ndxEnd), 10); + if (isNaN(ord)) ord = 0; + if (typeof arrayRef[sRef] === 'undefined') { + if (typeof t[v] === 'undefined') { + t[v] = new Array(); + t[v].push(new Object()); + t = t[v][0]; + arrayRef[sRef] = ord; + } + else { + k = arrayRef[sRef]; + if (typeof k === 'undefined') { + a = t[v]; + k = a.length; + arrayRef[sRef] = k; + a.push(new Object()); + t = a[k]; + } + else + t = t[v][k]; + } + } + else { + k = arrayRef[sRef]; + if (typeof k === 'undefined') { + a = t[v]; + k = a.length; + arrayRef[sRef] = k; + a.push(new Object()); + t = a[k]; + } + else + t = t[v][k]; + } + } + else if (typeof t[s] === 'undefined') { + t[s] = new Object(); + t = t[s]; + } + else + t = t[s]; + } + if (typeof dataType === 'undefined') dataType = 'string'; + t[arr[arr.length - 1]] = this.parseValue(val, dataType); + } + } + parseValue(val, dataType) { + switch (dataType) { + case 'int': + return Math.floor(this.parseNumber(val)); + case 'uint': + return Math.abs(this.parseNumber(val)); + case 'float': + case 'real': + case 'double': + case 'decimal': + case 'number': + return this.parseNumber(val); + case 'date': + if (typeof val === 'string') return Date.parseISO(val); + else if (typeof val === 'number') return new Date(number); + else if (typeof val.getMonth === 'function') return val; + return undefined; + case 'time': + var dt = new Date(); + if (typeof val === 'number') { + dt.setHours(0, 0, 0); + dt.addMinutes(tval); + return dt; + } + else if (typeof val === 'string' && val.indexOf(':') !== -1) { + var n = val.lastIndexOf(':'); + var min = this.parseNumber(val.substring(n)); + var nsp = val.substring(0, n).lastIndexOf(' ') + 1; + var hrs = this.parseNumber(val.substring(nsp, n)); + dt.setHours(0, 0, 0); + if (hrs <= 12 && val.substring(n).indexOf('p')) hrs += 12; + dt.addMinutes(hrs * 60 + min); + return dt; + } + break; + case 'duration': + if (typeof val === 'number') return val; + return Math.floor(this.parseNumber(val)); + default: + return val; + } + } + formatValue(val, dataType, fmtMask, emptyMask) { + var v = this.parseValue(val, dataType); + if (typeof v === 'undefined') return emptyMask || ''; + switch (dataType) { + case 'int': + case 'uint': + case 'float': + case 'real': + case 'double': + case 'decimal': + case 'number': + return v.fmt(fmtMask, emptyMask || ''); + case 'time': + case 'date': + case 'dateTime': + return v.fmt(fmtMask, emptyMask || ''); + } + return v; + } + waitMessage(el) { + let div = document.createElement('div'); + div.innerHTML = '
'; + div.classList.add('wait-overlay'); + if (typeof el === 'undefined') el = document.getElementById('divContainer'); + el.appendChild(div); + return div; + } + serviceError(el, err) { + if (arguments.length === 1) { + err = el; + el = document.getElementById('divContainer'); + } + let msg = ''; + if (typeof err === 'string' && err.startsWith('{')) { + let e = JSON.parse(err); + if (typeof e !== 'undefined' && typeof e.desc === 'string') msg = e.desc; + else msg = err; + } + else if (typeof err === 'string') msg = err; + else if (typeof err === 'number') { + switch (err) { + case 404: + msg = `404: Service not found`; + break; + default: + msg = `${err}: Service Error`; + break; + } + } + else if (typeof err !== 'undefined') { + if (typeof err.desc === 'string') msg = typeof err.desc !== 'undefined' ? err.desc : err.message; + } + console.log(err); + let div = this.errorMessage(`${err.htmlError || 500}:Service Error`); + let sub = div.querySelector('.sub-message'); + sub.innerHTML = `
${err.service}
${msg}
`; + return div; + } + socketError(el, msg) { + if (arguments.length === 1) { + msg = el; + el = document.getElementById('divContainer'); + } + let div = document.createElement('div'); + div.innerHTML = '
Attempts:
Could not connect to server

' + msg + '
'; + div.classList.add('error-message'); + div.classList.add('socket-error'); + div.classList.add('message-overlay'); + el.appendChild(div); + return div; + } + errorMessage(el, msg) { + if (arguments.length === 1) { + msg = el; + el = document.getElementById('divContainer'); + } + let div = document.createElement('div'); + div.innerHTML = '
' + msg + '
'; + div.classList.add('error-message'); + div.classList.add('message-overlay'); + el.appendChild(div); + return div; + } + promptMessage(el, msg, onYes) { + if (arguments.length === 2) { + onYes = msg; + msg = el; + el = document.getElementById('divContainer'); + } + let div = document.createElement('div'); + div.innerHTML = '
' + msg + '
'; + div.classList.add('prompt-message'); + div.classList.add('message-overlay'); + el.appendChild(div); + div.querySelector('#btnYes').addEventListener('click', onYes); + return div; + } + clearErrors() { + let errors = document.querySelectorAll('div.message-overlay'); + if (errors && errors.length > 0) errors.forEach((el) => { el.remove(); }); + } + selectTab(elTab) { + for (let tab of elTab.parentElement.children) { + if (tab.classList.contains('selected')) tab.classList.remove('selected'); + document.getElementById(tab.getAttribute('data-grpid')).style.display = 'none'; + } + if (!elTab.classList.contains('selected')) elTab.classList.add('selected'); + document.getElementById(elTab.getAttribute('data-grpid')).style.display = ''; + } + wizSetPrevStep(el) { this.wizSetStep(el, Math.max(this.wizCurrentStep(el) - 1, 1)); } + wizSetNextStep(el) { this.wizSetStep(el, this.wizCurrentStep(el) + 1); } + wizSetStep(el, step) { + let curr = this.wizCurrentStep(el); + let max = parseInt(el.getAttribute('data-maxsteps'), 10); + if (!isNaN(max)) { + let next = el.querySelector(`#btnNextStep`); + if (next) next.style.display = max < step ? 'inline-block' : 'none'; + } + let prev = el.querySelector(`#btnPrevStep`); + if (prev) prev.style.display = step <= 1 ? 'none' : 'inline-block'; + if (curr !== step) { + el.setAttribute('data-stepid', step); + let evt = new CustomEvent('stepchanged', { detail: { oldStep: curr, newStep: step }, bubbles: true, cancelable: true, composed: false }); + el.dispatchEvent(evt); + } + } + wizCurrentStep(el) { return parseInt(el.getAttribute('data-stepid') || 1, 10); } + pinKeyPressed(evt) { + let parent = evt.srcElement.parentElement; + let digits = parent.querySelectorAll('.pin-digit'); + switch (evt.key) { + case 'Backspace': + setTimeout(() => { + // Focus to the previous element. + for (let i = 0; i < digits.length; i++) { + if (digits[i] === evt.srcElement && i > 0) { + digits[i - 1].focus(); + break; + } + } + }, 0); + return; + case 'ArrowLeft': + setTimeout(() => { + for (let i = 0; i < digits.length; i++) { + if (digits[i] === evt.srcElement && i > 0) { + digits[i - 1].focus(); + } + } + }); + return; + case 'CapsLock': + case 'Control': + case 'Shift': + case 'Enter': + case 'Tab': + return; + case 'ArrowRight': + if (evt.srcElement.value !== '') { + setTimeout(() => { + for (let i = 0; i < digits.length; i++) { + if (digits[i] === evt.srcElement && i < digits.length - 1) { + digits[i + 1].focus(); + } + } + }); + } + return; + default: + if (evt.srcElement.value !== '') evt.srcElement.value = ''; + setTimeout(() => { + let e = new CustomEvent('digitentered', { detail: {}, bubbles: true, cancelable: true, composed: false }); + evt.srcElement.dispatchEvent(e); + }, 100); + break; + } + setTimeout(() => { + // Focus to the first empty element. + for (let i = 0; i < digits.length; i++) { + if (digits[i].value === '') { + if (digits[i] !== evt.srcElement) digits[i].focus(); + break; + } + } + }, 0); + + } + pinDigitFocus(evt) { + // Find the first empty digit and place the cursor there. + if (evt.srcElement.value !== '') return; + let parent = evt.srcElement.parentElement; + let digits = parent.querySelectorAll('.pin-digit'); + for (let i = 0; i < digits.length; i++) { + if (digits[i].value === '') { + if (digits[i] !== evt.srcElement) digits[i].focus(); + break; + } + } + } + isConfigOpen() { return (window.getComputedStyle(document.getElementById('divConfigPnl')).display !== 'none'); } + setConfigPanel() { + if (this.isConfigOpen()) return; + let divCfg = document.getElementById('divConfigPnl'); + let divHome = document.getElementById('divHomePnl'); + divHome.style.display = 'none'; + divCfg.style.display = ''; + document.getElementById('icoConfig').className = 'icss-home'; + if (sockIsOpen) socket.send('join:0'); + let overlay = ui.waitMessage(document.getElementById('divSecurityOptions')); + overlay.style.borderRadius = '5px'; + getJSON('/getSecurity', (err, security) => { + console.log(security); + general.setSecurityConfig(security); + overlay.remove(); + }); + } + setHomePanel() { + if (!this.isConfigOpen()) return; + let divCfg = document.getElementById('divConfigPnl'); + let divHome = document.getElementById('divHomePnl'); + divHome.style.display = ''; + divCfg.style.display = 'none'; + document.getElementById('icoConfig').className = 'icss-gear'; + if (sockIsOpen) socket.send('leave:0'); + general.setSecurityConfig({ type: 0, username: '', password: '', pin: '', permissions: 0 }); + } + toggleConfig() { + if (this.isConfigOpen()) + this.setHomePanel(); + else { + if (!security.authenticated && security.type !== 0) { + document.getElementById('divContainer').addEventListener('afterlogin', (evt) => { + if (security.authenticated) this.setConfigPanel(); + }, { once: true }); + security.authUser(); + } + else this.setConfigPanel(); + } + somfy.showEditShade(false); + } +} +var ui = new UIBinder(); + +class Security { + type = 0; + authenticated = false; + apiKey = ''; + permissions = 0; async init() { + let fld = document.getElementById('divUnauthenticated').querySelector('.pin-digit[data-bind="security.pin.d0"]'); + document.getElementById('divUnauthenticated').querySelector('.pin-digit[data-bind="login.pin.d3"]').addEventListener('digitentered', (evt) => { + security.login(); + }); + await this.loadContext(); + if (this.type === 0 || (this.permissions & 0x01) === 0x01) { // No login required or only the config is protected. + if (typeof socket === 'undefined' || !socket) (async () => { await initSockets(); })(); + //ui.setMode(mode); + document.getElementById('divUnauthenticated').style.display = 'none'; + document.getElementById('divAuthenticated').style.display = ''; + document.getElementById('divContainer').setAttribute('data-auth', true); + } + } + async loadContext() { + let pnl = document.getElementById('divUnauthenticated'); + pnl.querySelector('#loginButtons').style.display = 'none'; + pnl.querySelector('#divLoginPassword').style.display = 'none'; + pnl.querySelector('#divLoginPin').style.display = 'none'; + await new Promise((resolve, reject) => { + getJSONSync('/loginContext', (err, ctx) => { + pnl.querySelector('#loginButtons').style.display = ''; + resolve(); + if (err) ui.serviceError(err); + else { + this.type = ctx.type; + this.permissions = ctx.permissions; + switch (ctx.type) { + case 1: + pnl.querySelector('#divLoginPin').style.display = ''; + pnl.querySelector('#divLoginPassword').style.display = 'none'; + pnl.querySelector('.pin-digit[data-bind="login.pin.d0"]').focus(); + break; + case 2: + pnl.querySelector('#divLoginPassword').style.display = ''; + pnl.querySelector('#divLoginPin').style.display = 'none'; + pnl.querySelector('#fldLoginUsername').focus(); + break; + } + pnl.querySelector('#fldLoginType').value = ctx.type; + } + }); + }); + } + authUser() { + document.getElementById('divAuthenticated').style.display = 'none'; + document.getElementById('divUnauthenticated').style.display = ''; + this.loadContext(); + document.getElementById('btnCancelLogin').style.display = 'inline-block'; + } + cancelLogin() { + let evt = new CustomEvent('afterlogin', { detail: { authenticated: this.authenticated } }); + document.getElementById('divAuthenticated').style.display = ''; + document.getElementById('divUnauthenticated').style.display = 'none'; + document.getElementById('divContainer').dispatchEvent(evt); + } + login() { + console.log('Logging in...'); + let pnl = document.getElementById('divUnauthenticated'); + let msg = pnl.querySelector('#spanLoginMessage'); + msg.innerHTML = ''; + let sec = ui.fromElement(pnl).login; + let pin = ''; + for (let i = 0; i < 4; i++) { + pin += sec.pin[`d${i}`]; + } + if (pin.length !== 4) return; + sec.pin = pin; + console.log(sec); + putJSONSync('/login', sec, (err, log) => { + if (err) ui.serviceError(err); + else { + console.log(log); + if (log.success) { + if (typeof socket === 'undefined' || !socket) (async () => { await initSockets(); })(); + //ui.setMode(mode); + + document.getElementById('divUnauthenticated').style.display = 'none'; + document.getElementById('divAuthenticated').style.display = ''; + document.getElementById('divContainer').setAttribute('data-auth', true); + this.apiKey = log.apiKey; + this.authenticated = true; + let evt = new CustomEvent('afterlogin', { detail: { authenticated: true } }); + document.getElementById('divContainer').dispatchEvent(evt); + } + else + msg.innerHTML = log.msg; + } + }); + } +} +var security = new Security(); + +class General { + initialized = false; + appVersion = 'v2.0.0'; + reloadApp = false; + init() { + if (this.initialized) return; this.setAppVersion(); this.setTimeZones(); - this.loadGeneral(); - if (sockIsOpen && this.isConfigOpen()) socket.send('join:0'); - }; + if (sockIsOpen && ui.isConfigOpen()) socket.send('join:0'); + ui.toElement(document.getElementById('divSystemSettings'), { general: { hostname: 'ESPSomfyRTS', username: '', password: '', posixZone: 'UTC0', ntpServer: 'pool.ntp.org' } }); + this.initialized = true; + } + getCookie(cname) { + let n = cname + '='; + let cookies = document.cookie.split(';'); + console.log(cookies); + for (let i = 0; i < cookies.length; i++) { + let c = cookies[i]; + while (c.charAt(0) === ' ') c = c.substring(0); + if (c.indexOf(n) === 0) return c.substring(n.length, c.length); + } + return ''; + } reload() { let addMetaTag = (name, content) => { let meta = document.createElement('meta'); meta.httpEquiv = name; meta.content = content; document.getElementsByTagName('head')[0].appendChild(meta); - } + }; addMetaTag('pragma', 'no-cache'); addMetaTag('expires', '0'); addMetaTag('cache-control', 'no-cache'); @@ -501,33 +1315,45 @@ class General { { city: 'Pacific/Norfolk', code: '<+11>-11<+12>,M10.1.0,M4.1.0/3' } ]; loadGeneral() { - let overlay = waitMessage(document.getElementById('fsGeneralSettings')); - getJSON('/modulesettings', (err, settings) => { - overlay.remove(); + let pnl = document.getElementById('divSystemOptions'); + getJSONSync('/modulesettings', (err, settings) => { if (err) { console.log(err); } else { console.log(settings); - let dd = document.getElementById('selTimeZone'); - for (let i = 0; i < dd.options.length; i++) { - if (dd.options[i].value === settings.posixZone) { - dd.selectedIndex = i; - break; - } - } - general.setAppVersion(); document.getElementById('spanFwVersion').innerText = settings.fwVersion; - document.getElementsByName('hostname')[0].value = settings.hostname; - document.getElementsByName('ntptimeserver')[0].value = settings.ntpServer; - document.getElementsByName('ssdpBroadcast')[0].checked = settings.ssdpBroadcast; - document.getElementById('fldSsid').value = settings.ssid; - document.getElementById('fldPassphrase').value = settings.passphrase; + general.setAppVersion(); + ui.toElement(pnl, { general: settings }); } }); - - }; - setAppVersion() { document.getElementById('spanAppVersion').innerText = this.appVersion; }; + } + loadLogin() { + getJSONSync('/loginContext', (err, ctx) => { + if (err) ui.serviceError(err); + else { + console.log(ctx); + let pnl = document.getElementById('divContainer'); + pnl.setAttribute('data-securitytype', ctx.type); + let fld; + switch (ctx.type) { + case 1: + document.getElementById('divPinSecurity').style.display = ''; + fld = document.getElementById('divPinSecurity').querySelector('.pin-digit[data-bind="security.pin.d0"]'); + document.getElementById('divPinSecurity').querySelector('.pin-digit[data-bind="security.pin.d3"]').addEventListener('digitentered', (evt) => { + general.login(); + }); + break; + case 2: + document.getElementById('divPasswordSecurity').style.display = ''; + fld = document.getElementById('fldUsername'); + break; + } + if (fld) fld.focus(); + } + }); + } + setAppVersion() { document.getElementById('spanAppVersion').innerText = this.appVersion; } setTimeZones() { let dd = document.getElementById('selTimeZone'); dd.length = 0; @@ -541,76 +1367,133 @@ class General { } dd.value = 'UTC0'; console.log(`Max TZ:${maxLength}`); - }; + } setGeneral() { let valid = true; - let obj = { - hostname: document.getElementsByName('hostname')[0].value, - posixZone: document.getElementById('selTimeZone').value, - ntpServer: document.getElementsByName('ntptimeserver')[0].value, - ssdpBroadcast: document.getElementsByName('ssdpBroadcast')[0].checked - } - console.log(obj); + let pnl = document.getElementById('divSystemSettings'); + let obj = ui.fromElement(pnl).general; if (typeof obj.hostname === 'undefined' || !obj.hostname || obj.hostname === '') { - errorMessage(document.getElementById('fsGeneralSettings'), 'You must supply a valid host name.'); + ui.errorMessage(pnl, 'You must supply a valid host name.'); valid = false; } if (valid && !/^[a-zA-Z0-9-]+$/.test(obj.hostname)) { - errorMessage(document.getElementById('fsGeneralSettings'), 'The host name must only include numbers, letters, or dash.'); + ui.errorMessage(pnl, 'The host name must only include numbers, letters, or dash.'); valid = false; } if (valid && obj.hostname.length > 32) { - errorMessage(document.getElementById('fsGeneralSettings'), 'The host name can only be up to 32 characters long.'); + ui.errorMessage(pnl, 'The host name can only be up to 32 characters long.'); valid = false; } if (valid) { - if (document.getElementById('btnSaveGeneral').classList.contains('disabled')) return; - document.getElementById('btnSaveGeneral').classList.add('disabled'); - let overlay = waitMessage(document.getElementById('fsGeneralSettings')); - putJSON('/setgeneral', obj, (err, response) => { - overlay.remove(); - document.getElementById('btnSaveGeneral').classList.remove('disabled'); + putJSONSync('/setgeneral', obj, (err, response) => { + if (err) ui.serviceError(err); console.log(response); }); } - }; + } + setSecurityConfig(security) { + // We need to transform the security object so that it can be set to the configuration. + let obj = { + security: { + type: security.type, username: security.username, password: security.password, + permissions: { configOnly: makeBool(security.permissions & 0x01) }, + pin: { + d0: security.pin[0], + d1: security.pin[1], + d2: security.pin[2], + d3: security.pin[3] + } + } + }; + ui.toElement(document.getElementById('divSecurityOptions'), obj); + this.onSecurityTypeChanged(); + } rebootDevice() { - promptMessage(document.getElementById('fsGeneralSettings'), 'Are you sure you want to reboot the device?', () => { - socket.close(3000, 'reboot'); - let overlay = waitMessage(document.getElementById('fsGeneralSettings')); - putJSON('/reboot', {}, (err, response) => { - overlay.remove(); + ui.promptMessage(document.getElementById('divContainer'), 'Are you sure you want to reboot the device?', () => { + if(typeof socket !== 'undefined') socket.close(3000, 'reboot'); + putJSONSync('/reboot', {}, (err, response) => { document.getElementById('btnSaveGeneral').classList.remove('disabled'); console.log(response); }); - clearErrors(); + ui.clearErrors(); }); - }; - isConfigOpen() { - let divCfg = document.getElementById('divConfigPnl'); - return (window.getComputedStyle(divCfg).display !== 'none'); - }; - toggleConfig() { - let divCfg = document.getElementById('divConfigPnl'); - let divHome = document.getElementById('divHomePnl'); - if (window.getComputedStyle(divCfg).display === 'none') { - divHome.style.display = 'none'; - divCfg.style.display = ''; - document.getElementById('icoConfig').className = 'icss-home'; - if (sockIsOpen) socket.send('join:0'); + } + onSecurityTypeChanged() { + let pnl = document.getElementById('divSecurityOptions'); + let sec = ui.fromElement(pnl).security; + switch (sec.type) { + case 0: + pnl.querySelector('#divPermissions').style.display = 'none'; + pnl.querySelector('#divPinSecurity').style.display = 'none'; + pnl.querySelector('#divPasswordSecurity').style.display = 'none'; + break; + case 1: + pnl.querySelector('#divPermissions').style.display = ''; + pnl.querySelector('#divPinSecurity').style.display = ''; + pnl.querySelector('#divPasswordSecurity').style.display = 'none'; + break; + case 2: + pnl.querySelector('#divPermissions').style.display = ''; + pnl.querySelector('#divPinSecurity').style.display = 'none'; + pnl.querySelector('#divPasswordSecurity').style.display = ''; + break; + } - else { - divHome.style.display = ''; - divCfg.style.display = 'none'; - document.getElementById('icoConfig').className = 'icss-gear'; - if (sockIsOpen) socket.send('leave:0'); - } - somfy.closeEditShade(); - somfy.closeConfigTransceiver(); - }; -}; + } + saveSecurity() { + let security = ui.fromElement(document.getElementById('divSecurityOptions')).security; + console.log(security); + let sec = { type: security.type, username: security.username, password: security.password, pin: '', perm: 0 }; + // Pin entry. + for (let i = 0; i < 4; i++) { + sec.pin += security.pin[`d${i}`]; + } + sec.permissions |= security.permissions.configOnly ? 0x01 : 0x00; + let confirm = ''; + console.log(sec); + if (security.type === 1) { // Pin Entry + // Make sure our pin is 4 digits. + if (sec.pin.length !== 4) { + ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.'; + return; + } + confirm = '

Please keep your PIN safe and above all remember it. The only way to recover a lost PIN is to completely reload the onboarding firmware which will wipe out your configuration.

Have you stored your PIN in a safe place?

' + } + else if (security.type === 2) { // Password + if (sec.username.length === 0) { + ui.errorMessage('No Username Provided').querySelector('.sub-message').innerHTML = 'You must provide a username for password security.'; + return; + } + if (sec.password.length === 0) { + ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.'; + return; + } + if (security.repeatpassword.length === 0) { + ui.errorMessage('Re-enter Password').querySelector('.sub-message').innerHTML = 'You must re-enter the password in the Re-enter Password field.'; + return; + } + if (sec.password !== security.repeatpassword) { + ui.errorMessage('Passwords do not Match').querySelector('.sub-message').innerHTML = 'Please re-enter the password exactly as you typed it in the Re-enter Password field.'; + return; + } + confirm = '

Please keep your password safe and above all remember it. The only way to recover a password is to completely reload the onboarding firmware which will wipe out your configuration.

Have you stored your username and password in a safe place?

' + } + let prompt = ui.promptMessage('Confirm Security', () => { + putJSONSync('/saveSecurity', sec, (err, objApiKey) => { + prompt.remove(); + if (err) ui.serviceError(err); + else { + console.log(objApiKey); + } + }); + }); + prompt.querySelector('.sub-message').innerHTML = confirm; + + } +} var general = new General(); class Wifi { + initialized = false; ethBoardTypes = [{ val: 0, label: 'Custom Config' }, { val: 1, label: 'WT32-ETH01', clk: 0, ct: 0, addr: 1, pwr: 16, mdc: 23, mdio: 18 }, { val: 2, label: 'Olimex ESP32-POE', clk: 3, ct: 0, addr: 0, pwr: 12, mdc: 23, mdio: 18 }, @@ -623,6 +1506,7 @@ class Wifi { ethPhyTypes = [{ val: 0, label: 'LAN8720' }, { val: 1, label: 'TLK110' }, { val: 2, label: 'RTL8201' }, { val: 3, label: 'DP83848' }, { val: 4, label: 'DM9051' }, { val: 5, label: 'KZ8081' }]; init() { document.getElementById("divNetworkStrength").innerHTML = this.displaySignal(-100); + if (this.initialized) return; let addr = []; this.loadETHDropdown(document.getElementById('selETHClkMode'), this.ethClockModes); this.loadETHDropdown(document.getElementById('selETHPhyType'), this.ethPhyTypes); @@ -632,10 +1516,13 @@ class Wifi { this.loadETHPins(document.getElementById('selETHPWRPin'), 'power'); this.loadETHPins(document.getElementById('selETHMDCPin'), 'mdc', 23); this.loadETHPins(document.getElementById('selETHMDIOPin'), 'mdio', 18); - if (typeof document.querySelector('div.tab-container > span.selected[data-grpid="fsWiFiSettings"]') !== 'undefined') { - this.loadNetwork(); - } - }; + ui.toElement(document.getElementById('divNetAdapter'), { + wifi: {ssid:'', passphrase:''}, + ethernet: { boardType: 1, wirelessFallback: false, dhcp: true, dns1: '', dns2: '', ip: '', gateway: '' } + }); + this.onETHBoardTypeChanged(document.getElementById('selETHBoardType')); + this.initialized = true; + } loadETHPins(sel, type, selected) { let arr = []; switch (type) { @@ -647,14 +1534,14 @@ class Wifi { arr.push({ val: i, label: `GPIO ${i}` }); } this.loadETHDropdown(sel, arr, selected); - }; + } loadETHDropdown(sel, arr, selected) { while (sel.firstChild) sel.removeChild(sel.firstChild); for (let i = 0; i < arr.length; i++) { let elem = arr[i]; sel.options[sel.options.length] = new Option(elem.label, elem.val, elem.val === selected, elem.val === selected); } - }; + } onETHBoardTypeChanged(sel) { let type = this.ethBoardTypes.find(elem => parseInt(sel.value, 10) === elem.val); if (typeof type !== 'undefined') { @@ -667,34 +1554,19 @@ class Wifi { if (typeof type.mdio !== 'undefined') document.getElementById('selETHMDIOPin').value = type.mdio; document.getElementById('divETHSettings').style.display = type.val === 0 ? '' : 'none'; } - }; - onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; }; + } + onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; } loadNetwork() { - let overlay = waitMessage(document.getElementById('fsWiFiSettings')); - getJSON('/networksettings', (err, settings) => { - overlay.remove(); + let pnl = document.getElementById('divNetAdapter'); + getJSONSync('/networksettings', (err, settings) => { console.log(settings); if (err) { - serviceError(document.getElementById('fsWiFiSettings'), err); + ui.serviceError(err); } else { - document.getElementById('fldSsid').value = settings.wifi.ssid; - document.getElementById('fldPassphrase').value = settings.wifi.passphrase; - document.getElementById('selETHBoardType').value = settings.ethernet.boardType; - document.getElementById('cbUseDHCP').checked = settings.ethernet.dhcp; document.getElementById('cbHardwired').checked = settings.connType >= 2; document.getElementById('cbFallbackWireless').checked = settings.connType === 3; - document.getElementById('selETHPhyType').value = settings.ethernet.phyType; - document.getElementById('selETHAddress').value = settings.ethernet.phyAddress; - document.getElementById('selETHClkMode').value = settings.ethernet.CLKMode; - document.getElementById('selETHPWRPin').value = settings.ethernet.PWRPin; - document.getElementById('selETHMDCPin').value = settings.ethernet.MDCPin; - document.getElementById('selETHMDIOPin').value = settings.ethernet.MDIOPin; - document.getElementById('fldIPAddress').value = settings.ethernet.ip; - document.getElementById('fldSubnetMask').value = settings.ethernet.subnet; - document.getElementById('fldGateway').value = settings.ethernet.gateway; - document.getElementById('fldDNS1').value = settings.ethernet.dns1; - document.getElementById('fldDNS2').value = settings.ethernet.dns2; + ui.toElement(pnl, settings); if (settings.connType >= 2) { document.getElementById('divWiFiMode').style.display = 'none'; document.getElementById('divEthernetMode').style.display = ''; @@ -705,16 +1577,13 @@ class Wifi { document.getElementById('divEthernetMode').style.display = 'none'; document.getElementById('divFallbackWireless').style.display = 'none'; } - if (settings.ethernet.boardType === 0) { - document.getElementById('divETHSettings').style.display = ''; - } - else { - document.getElementById('divETHSettings').style.display = 'none'; - } + document.getElementById('divETHSettings').style.display = settings.ethernet.boardType === 0 ? '' : 'none'; + document.getElementById('divStaticIP').style.display = settings.ip.dhcp ? 'none' : ''; + ui.toElement(document.getElementById('divDHCP'), settings); } }); - }; + } useEthernetClicked() { let useEthernet = document.getElementById('cbHardwired').checked; document.getElementById('divWiFiMode').style.display = useEthernet ? 'none' : ''; @@ -737,7 +1606,7 @@ class Wifi { this.displayAPs(aps); } }); - }; + } displayAPs(aps) { let div = ''; let nets = []; @@ -764,7 +1633,7 @@ class Wifi { //document.getElementsByName('ssid')[0].value = aps.connected.name; //document.getElementsByName('passphrase')[0].value = aps.connected.passphrase; //this.procWifiStrength(aps.connected); - }; + } selectSSID(el) { let obj = { name: el.querySelector('span.ssid').innerHTML, @@ -774,7 +1643,7 @@ class Wifi { } console.log(obj); document.getElementsByName('ssid')[0].value = obj.name; - }; + } calcWaveStrength(sig) { let wave = 0; if (sig > -90) wave++; @@ -783,46 +1652,70 @@ class Wifi { if (sig > -67) wave++; if (sig > -30) wave++; return wave; - }; + } displaySignal(sig) { return `
`; - }; - saveNetwork() { - - let obj = { - connType: document.getElementById('cbHardwired').checked ? document.getElementById('cbFallbackWireless').checked ? 3 : 2 : 1, - wifi: {}, - ethernet: {} - }; - if (obj.connType >= 2) { - // We are connecting to a LAN but we need the user to be sure about this since - // the information needs to be correct. Incorrect settings can destroy the board. - obj.ethernet = { - boardType: parseInt(document.getElementById('selETHBoardType').value, 10), - phyType: parseInt(document.getElementById('selETHPhyType').value, 10), - phyAddress: parseInt(document.getElementById('selETHAddress').value, 10), - dhcp: document.getElementById('cbUseDHCP').checked, - CLKMode: parseInt(document.getElementById('selETHClkMode').value, 10), - PWRPin: parseInt(document.getElementById('selETHPWRPin').value, 10), - MDCPin: parseInt(document.getElementById('selETHMDCPin').value, 10), - MDIOPin: parseInt(document.getElementById('selETHMDIOPin').value, 10), - ip: document.getElementById('fldIPAddress').value, - subnet: document.getElementById('fldSubnetMask').value, - gateway: document.getElementById('fldGateway').value, - dns1: document.getElementById('fldDNS1').value, - dns2: document.getElementById('fldDNS2').value + } + saveIPSettings() { + let pnl = document.getElementById('divDHCP'); + let obj = ui.fromElement(pnl).ip; + console.log(obj); + if (!obj.dhcp) { + let fnValidateIP = (addr) => { return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(addr); }; + if (typeof obj.ip !== 'string' || obj.ip.length === 0 || obj.ip === '0.0.0.0') { + ui.errorMessage('You must supply a valid IP address for the Static IP Address'); + return; } - - + else if (!fnValidateIP(obj.ip)) { + ui.errorMessage('Invalid Static IP Address. IP addresses are in the form XXX.XXX.XXX.XXX'); + return; + } + if (typeof obj.subnet !== 'string' || obj.subnet.length === 0 || obj.subnet === '0.0.0.0') { + ui.errorMessage('You must supply a valid IP address for the Subnet Mask'); + return; + } + else if (!fnValidateIP(obj.subnet)) { + ui.errorMessage('Invalid Subnet IP Address. IP addresses are in the form XXX.XXX.XXX.XXX'); + return; + } + if (typeof obj.gateway !== 'string' || obj.gateway.length === 0 || obj.gateway === '0.0.0.0') { + ui.errorMessage('You must supply a valid Gateway IP address'); + return; + } + else if (!fnValidateIP(obj.gateway)) { + ui.errorMessage('Invalid Gateway IP Address. IP addresses are in the form XXX.XXX.XXX.XXX'); + return; + } + if (obj.dns1.length !== 0 && !fnValidateIP(obj.dns1)) { + ui.errorMessage('Invalid Domain Name Server 1 IP Address. IP addresses are in the form XXX.XXX.XXX.XXX'); + return; + } + if (obj.dns2.length !== 0 && !fnValidateIP(obj.dns2)) { + ui.errorMessage('Invalid Domain Name Server 2 IP Address. IP addresses are in the form XXX.XXX.XXX.XXX'); + return; + } + } + putJSONSync('/setIP', obj, (err, response) => { + if (err) { + ui.serviceError(err); + } + console.log(response); + }); + } + saveNetwork() { + let pnl = document.getElementById('divNetAdapter'); + let obj = ui.fromElement(pnl); + obj.connType = obj.ethernet.hardwired ? obj.ethernet.wirelessFallback ? 3 : 2 : 1; + console.log(obj); + if (obj.connType >= 2) { let boardType = this.ethBoardTypes.find(elem => obj.ethernet.boardType === elem.val); let phyType = this.ethPhyTypes.find(elem => obj.ethernet.phyType === elem.val); let clkMode = this.ethClockModes.find(elem => obj.ethernet.CLKMode === elem.val); let div = document.createElement('div'); - let html = `
`; - html += '
BEWARE ... WARNING ... DANGER
'; - html += '
'; + let html = `
`; + html += '
BEWARE ... WARNING ... DANGER
'; html += '

Incorrect Ethernet settings can damage your ESP32. Please verify the settings below and ensure they match the manufacturer spec sheet.

'; - html += '

If you are unsure do not press the Red button and press the Green button. If any of the settings are incorrect please use the Custom Board type and set them to the correct values.'; + html += '

If you are unsure do not press the Red button and press the Green button. If any of the settings are incorrect please use the Custom Board type and set them to the correct values.'; html += '


'; html += `
${boardType.label} [${boardType.val}]
`; html += `
${phyType.label} [${phyType.val}]
`; @@ -834,31 +1727,25 @@ class Wifi { html += '
' html += `
` html += `` - html += `` + html += `` html += `
`; div.innerHTML = html; document.getElementById('divContainer').appendChild(div); div.querySelector('#btnSaveEthernet').addEventListener('click', (el, event) => { console.log(obj); - document.getElementById('frmSetLAN').remove(); this.sendNetworkSettings(obj); + setTimeout(() => { div.remove(); }, 1); }); } else { - obj.wifi = { - ssid: document.getElementsByName('ssid')[0].value, - passphrase: document.getElementsByName('passphrase')[0].value - }; this.sendNetworkSettings(obj); } } sendNetworkSettings(obj) { - if (document.getElementById('btnSaveNetwork').classList.contains('disabled')) return; - document.getElementById('btnSaveNetwork').classList.add('disabled'); - let overlay = waitMessage(document.getElementById('divContainer')); - putJSON('/setNetwork', obj, (err, response) => { - overlay.remove(); - document.getElementById('btnSaveNetwork').classList.remove('disabled'); + putJSONSync('/setNetwork', obj, (err, response) => { + if (err) { + ui.serviceError(err); + } console.log(response); }); } @@ -869,14 +1756,14 @@ class Wifi { ssid: document.getElementsByName('ssid')[0].value, passphrase: document.getElementsByName('passphrase')[0].value } - let overlay = waitMessage(document.getElementById('fsWiFiSettings')); + let overlay = ui.waitMessage(document.getElementById('divNetAdapter')); putJSON('/connectwifi', obj, (err, response) => { overlay.remove(); document.getElementById('btnConnectWiFi').classList.remove('disabled'); console.log(response); }); - }; + } procWifiStrength(strength) { let ssid = strength.ssid || strength.name; document.getElementById('spanNetworkSSID').innerHTML = !ssid || ssid === '' ? '-------------' : ssid; @@ -899,90 +1786,52 @@ class Wifi { }; var wifi = new Wifi(); class Somfy { + initialized = false; frames = []; - async init() { + init() { + if (this.initialized) return; this.loadPins('inout', document.getElementById('selTransSCKPin')); this.loadPins('inout', document.getElementById('selTransCSNPin')); this.loadPins('inout', document.getElementById('selTransMOSIPin')); this.loadPins('input', document.getElementById('selTransMISOPin')); this.loadPins('out', document.getElementById('selTransTXPin')); this.loadPins('input', document.getElementById('selTransRXPin')); - this.loadSomfy(); + //this.loadSomfy(); + ui.toElement(document.getElementById('divTransceiverSettings'), { + transceiver: { config: { proto: 0, SCKPin: 18, CSNPin: 5, MOSIPin: 23, MISOPin: 19, TXPin: 12, RXPin: 13, frequency: 433.42, rxBandwidth: 97.96, type:56, deviation: 11.43, txPower: 10, enabled: false } } + }); + this.initialized = true; } async loadSomfy() { - console.log(self); - let overlay = waitMessage(document.getElementById('fsSomfySettings')); - getJSON('/controller', (err, somfy) => { - overlay.remove(); + getJSONSync('/controller', (err, somfy) => { if (err) { console.log(err); - serviceError(document.getElementById('fsSomfySettings'), err); + ui.serviceError(err); } else { console.log(somfy); - document.getElementById('selTransSCKPin').value = somfy.transceiver.config.SCKPin.toString(); - document.getElementById('selTransCSNPin').value = somfy.transceiver.config.CSNPin.toString(); - document.getElementById('selTransMOSIPin').value = somfy.transceiver.config.MOSIPin.toString(); - document.getElementById('selTransMISOPin').value = somfy.transceiver.config.MISOPin.toString(); - document.getElementById('selTransTXPin').value = somfy.transceiver.config.TXPin.toString(); - document.getElementById('selTransRXPin').value = somfy.transceiver.config.RXPin.toString(); - document.getElementById('selRadioType').value = somfy.transceiver.config.type; - document.getElementById('selRadioProto').value = somfy.transceiver.config.proto; document.getElementById('spanMaxShades').innerText = somfy.maxShades; - document.getElementById('spanFrequency').innerText = (Math.round(somfy.transceiver.config.frequency * 1000) / 1000).fmt('#,##0.000'); - document.getElementById('slidFrequency').value = Math.round(somfy.transceiver.config.frequency * 1000); - document.getElementById('spanRxBandwidth').innerText = (Math.round(somfy.transceiver.config.rxBandwidth * 100) / 100).fmt('#,##0.00'); - document.getElementById('slidRxBandwidth').value = Math.round(somfy.transceiver.config.rxBandwidth * 100); - document.getElementById('spanTxPower').innerText = somfy.transceiver.config.txPower; - document.getElementById('spanDeviation').innerText = (Math.round(somfy.transceiver.config.deviation * 100) / 100).fmt('#,##0.00'); - document.getElementById('slidDeviation').value = Math.round(somfy.transceiver.config.deviation * 100); - document.getElementsByName('enableRadio')[0].checked = somfy.transceiver.config.enabled; + document.getElementById('spanMaxGroups').innerText = somfy.maxGroups; + ui.toElement(document.getElementById('divTransceiverSettings'), somfy); if (somfy.transceiver.config.radioInit) { document.getElementById('divRadioError').style.display = 'none'; } else { document.getElementById('divRadioError').style.display = ''; } - - - let tx = document.getElementById('slidTxPower'); - let lvls = [-30, -20, -15, -10, -6, 0, 5, 7, 10, 11, 12]; - for (let i = lvls.length - 1; i >= 0; i--) { - if (lvls[i] === somfy.transceiver.config.txPower) { - tx.value = i; - } - else if (lvls[i < somfy.transceiver.config.txPower] < somfy.transceiver.txPower) { - tx.value = i + 1; - } - } - // Create the shades list. this.setShadesList(somfy.shades); + this.setGroupsList(somfy.groups); } }); - }; + } saveRadio() { let valid = true; let getIntValue = (fld) => { return parseInt(document.getElementById(fld).value, 10); } - let obj = { - enabled: document.getElementsByName('enableRadio')[0].checked, - type: parseInt(document.getElementById('selRadioType').value, 10), - proto: parseInt(document.getElementById('selRadioProto').value, 10), - SCKPin: getIntValue('selTransSCKPin'), - CSNPin: getIntValue('selTransCSNPin'), - MOSIPin: getIntValue('selTransMOSIPin'), - MISOPin: getIntValue('selTransMISOPin'), - TXPin: getIntValue('selTransTXPin'), - RXPin: getIntValue('selTransRXPin'), - frequency: (Math.round(parseFloat(document.getElementById('spanFrequency').innerText) * 1000)) / 1000, - rxBandwidth: (Math.round(parseFloat(document.getElementById('spanRxBandwidth').innerText) * 100)) / 100, - txPower: parseInt(document.getElementById('spanTxPower').innerText, 10), - deviation: (Math.round(parseFloat(document.getElementById('spanDeviation').innerText) * 100)) / 100 - }; - console.log(obj); + let trans = ui.fromElement(document.getElementById('divTransceiverSettings')).transceiver; // Check to make sure we have a trans type. - if (typeof obj.type === 'undefined' || obj.type === '' || obj.type === 'none') { - errorMessage(document.getElementById('fsSomfySettings'), 'You must select a radio type.'); + if (typeof trans.config.type === 'undefined' || trans.config.type === '' || trans.config.type === 'none') { + ui.errorMessage('You must select a radio type.'); valid = false; } // Check to make sure no pins were duplicated and defined @@ -990,14 +1839,14 @@ class Somfy { let fnValDup = (o, name) => { let val = o[name]; if (typeof val === 'undefined' || isNaN(val)) { - errorMessage(document.getElementById('fsSomfySettings'), 'You must define all the pins for the radio.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'You must define all the pins for the radio.'); return false; } for (let s in o) { if (s.endsWith('Pin') && s !== name) { let sval = o[s]; if (typeof sval === 'undefined' || isNaN(sval)) { - errorMessage(document.getElementById('fsSomfySettings'), 'You must define all the pins for the radio.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'You must define all the pins for the radio.'); return false; } if (sval === val) { @@ -1005,7 +1854,7 @@ class Somfy { (name === 'RXPin' && s === 'TXPin')) continue; // The RX and TX pins can share the same value. In this instance the radio will only use GDO0. else { - errorMessage(document.getElementById('fsSomfySettings'), `The ${name.replace('Pin', '')} pin is duplicated by the ${s.replace('Pin', '')}. All pin definitions must be unique`); + ui.errorMessage(document.getElementById('fsSomfySettings'), `The ${name.replace('Pin', '')} pin is duplicated by the ${s.replace('Pin', '')}. All pin definitions must be unique`); valid = false; return false; } @@ -1014,19 +1863,16 @@ class Somfy { } return true; }; - if (valid) valid = fnValDup(obj, 'SCKPin'); - if (valid) valid = fnValDup(obj, 'CSNPin'); - if (valid) valid = fnValDup(obj, 'MOSIPin'); - if (valid) valid = fnValDup(obj, 'MISOPin'); - if (valid) valid = fnValDup(obj, 'TXPin'); - if (valid) valid = fnValDup(obj, 'RXPin'); + if (valid) valid = fnValDup(trans.config, 'SCKPin'); + if (valid) valid = fnValDup(trans.config, 'CSNPin'); + if (valid) valid = fnValDup(trans.config, 'MOSIPin'); + if (valid) valid = fnValDup(trans.config, 'MISOPin'); + if (valid) valid = fnValDup(trans.config, 'TXPin'); + if (valid) valid = fnValDup(trans.config, 'RXPin'); if (valid) { - let overlay = waitMessage(document.getElementById('fsSomfySettings')); - putJSON('/saveRadio', { config: obj }, (err, trans) => { - overlay.remove(); - if (err) { - serviceError(document.getElementById('fsSomfySettings'), err); - } + putJSONSync('/saveRadio', trans, (err, trans) => { + if (err) + ui.serviceError(err); else { document.getElementById('btnSaveRadio').classList.remove('disabled'); if (trans.config.radioInit) { @@ -1179,7 +2025,52 @@ class Somfy { } }, true); } - }; + } + setGroupsList(groups) { + let divCfg = ''; + let divCtl = ''; + for (let i = 0; i < groups.length; i++) { + let group = groups[i]; + divCfg += `
`; + divCfg += `
`; + //divCfg += ``; + divCfg += `${group.name}`; + divCfg += `${group.remoteAddress}`; + divCfg += `
`; + divCfg += '
'; + + divCtl += `
`; + divCtl += `
`; + divCtl += `${group.name}`; + divCtl += `
` + for (let j = 0; j < group.linkedShades.length; j++) { + divCtl += ''; + if (j !== 0) divCtl += ', '; + divCtl += group.linkedShades[j].name; + divCtl += ''; + } + divCtl += '
'; + divCtl += `
`; + divCtl += `
`; + divCtl += `
my
`; + divCtl += `
`; + divCtl += '
'; + } + document.getElementById('divGroupList').innerHTML = divCfg; + let groupControls = document.getElementById('divGroupControls'); + groupControls.innerHTML = divCtl; + // Attach the timer for setting the My Position for the Group. + let btns = groupControls.querySelectorAll('div.cmd-button'); + for (let i = 0; i < btns.length; i++) { + btns[i].addEventListener('click', (event) => { + console.log(this); + console.log(event); + let cmd = event.currentTarget.getAttribute('data-cmd'); + let groupId = parseInt(event.currentTarget.getAttribute('data-groupid'), 10); + this.sendGroupCommand(groupId, cmd); + }, true); + } + } closeShadePositioners() { let ctls = document.querySelectorAll('.shade-positioner'); for (let i = 0; i < ctls.length; i++) { @@ -1258,7 +2149,7 @@ class Somfy { } sendShadeMyPosition(shadeId, pos, tilt) { console.log(`Sending My Position for shade id ${shadeId} to ${pos} and ${tilt}`); - let overlay = waitMessage(document.getElementById('divContainer')); + let overlay = ui.waitMessage(document.getElementById('divContainer')); putJSON('/setMyPosition', { shadeId: shadeId, pos: pos, tilt: tilt }, (err, response) => { this.closeShadePositioners(); overlay.remove(); @@ -1276,7 +2167,19 @@ class Somfy { divCfg += '
'; } document.getElementById('divLinkedRemoteList').innerHTML = divCfg; - }; + } + setLinkedShadesList(group) { + let divCfg = ''; + for (let i = 0; i < group.linkedShades.length; i++) { + let shade = group.linkedShades[i]; + divCfg += `
`; + divCfg += `${shade.name}`; + divCfg += `${shade.remoteAddress}`; + divCfg += `
`; + divCfg += '
'; + } + document.getElementById('divLinkedShadeList').innerHTML = divCfg; + } loadPins(type, sel, opt) { while (sel.firstChild) sel.removeChild(sel.firstChild); for (let i = 0; i < 40; i++) { @@ -1303,7 +2206,7 @@ class Somfy { } sel.options[sel.options.length] = new Option(`GPIO-${i > 9 ? i.toString() : '0' + i.toString()}`, i, typeof opt !== 'undefined' && opt === i); } - }; + } procShadeState(state) { console.log(state); let icons = document.querySelectorAll(`.somfy-shade-icon[data-shadeid="${state.shadeId}"]`); @@ -1342,7 +2245,7 @@ class Somfy { span = divs[i].querySelector('#spanMyTiltPos'); if (span) span.innerHTML = typeof state.myTiltPos !== 'undefined' && state.myTiltPos >= 0 ? `${state.myTiltPos}%` : '---'; } - }; + } procRemoteFrame(frame) { console.log(frame); document.getElementById('spanRssi').innerHTML = frame.rssi; @@ -1354,7 +2257,7 @@ class Somfy { remoteAddress: frame.address, rollingCode: frame.rcode }; - let overlay = waitMessage(document.getElementById('divLinking')); + let overlay = ui.waitMessage(document.getElementById('divLinking')); putJSON('/linkRemote', obj, (err, shade) => { console.log(shade); overlay.remove(); @@ -1384,7 +2287,7 @@ class Somfy { row.innerHTML = html; frames.prepend(row); this.frames.push(frame); - }; + } JSONPretty(obj, indent = 2) { if (Array.isArray(obj)) { let output = '['; @@ -1421,14 +2324,6 @@ class Somfy { document.body.removeChild(dummy); } } - openConfigTransceiver() { - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyTransceiver').style.display = ''; - }; - closeConfigTransceiver() { - document.getElementById('somfyTransceiver').style.display = 'none'; - document.getElementById('somfyMain').style.display = ''; - }; onShadeTypeChanged(el) { let sel = document.getElementById('selShadeType'); let tilt = parseInt(document.getElementById('selTiltType').value, 10); @@ -1467,41 +2362,35 @@ class Somfy { } document.getElementById('fldTiltTime').parentElement.style.display = tilt ? 'inline-block' : 'none'; document.querySelector('#divSomfyButtons i.icss-window-tilt').style.display = tilt ? '' : 'none'; - }; + } onShadeBitLengthChanged(el) { document.getElementById('divStepSettings').style.display = parseInt(el.value, 10) === 80 ? '' : 'none'; } openEditShade(shadeId) { - console.log('Opening Edit Shade'); if (typeof shadeId === 'undefined') { - let overlay = waitMessage(document.getElementById('fsSomfySettings')); - getJSON('/getNextShade', (err, shade) => { - overlay.remove(); + getJSONSync('/getNextShade', (err, shade) => { + document.getElementById('btnPairShade').style.display = 'none'; + document.getElementById('btnUnpairShade').style.display = 'none'; + document.getElementById('btnLinkRemote').style.display = 'none'; + document.getElementById('btnSaveShade').innerText = 'Add Shade'; + document.getElementById('spanShadeId').innerText = '*'; + document.getElementById('divLinkedRemoteList').innerHTML = ''; + document.getElementById('btnSetRollingCode').style.display = 'none'; + document.getElementById('selShadeBitLength').value = 56; + document.getElementById('cbFlipCommands').value = false; + document.getElementById('cbFlipPosition').value = false; + if (err) { - serviceError(document.getElementById('fsSomfySettings'), err); + ui.serviceError(err); } else { console.log(shade); - document.getElementById('btnPairShade').style.display = 'none'; - document.getElementById('btnUnpairShade').style.display = 'none'; - document.getElementById('btnLinkRemote').style.display = 'none'; - document.getElementsByName('shadeUpTime')[0].value = 10000; - document.getElementsByName('shadeDownTime')[0].value = 10000; - document.getElementById('fldTiltTime').value = 7000; - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyShade').style.display = ''; - document.getElementById('btnSaveShade').innerText = 'Add Shade'; - document.getElementById('spanShadeId').innerText = '*'; - document.getElementsByName('shadeName')[0].value = ''; - document.getElementsByName('shadeAddress')[0].value = shade.remoteAddress; - document.getElementById('divLinkedRemoteList').innerHTML = ''; - document.getElementById('btnSetRollingCode').style.display = 'none'; - document.getElementById('selShadeBitLength').value = shade.bitLength || 56; - document.getElementById('selShadeProto').value = shade.proto || 0; - document.getElementById('slidStepSize').value = shade.stepSize || 100; - document.getElementById('spanStepSize').innerHTML = shade.stepSize.fmt('#,##0'); - document.getElementById('cbFlipCommands').value = shade.flipCommands || false; - document.getElementById('cbFlipPosition').value = shade.flipPosition || false; + shade.name = ''; + shade.downTime = shade.upTime = 10000; + shade.tiltTime = 7000; + shade.flipCommands = shade.flipPosition = false; + ui.toElement(document.getElementById('somfyShade'), shade); + this.showEditShade(true); } }); } @@ -1514,29 +2403,16 @@ class Somfy { document.getElementById('btnSaveShade').innerText = 'Save Shade'; document.getElementById('spanShadeId').innerText = shadeId; - let overlay = waitMessage(document.getElementById('fsSomfySettings')); - getJSON(`/shade?shadeId=${shadeId}`, (err, shade) => { + getJSONSync(`/shade?shadeId=${shadeId}`, (err, shade) => { if (err) { - serviceError(document.getElementById('fsSomfySettings'), err); + ui.serviceError(err); } else { - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyShade').style.display = ''; + console.log(shade); + ui.toElement(document.getElementById('somfyShade'), shade); + this.showEditShade(true); document.getElementById('btnSaveShade').style.display = 'inline-block'; document.getElementById('btnLinkRemote').style.display = ''; - document.getElementById('selShadeType').value = shade.shadeType; - document.getElementById('selShadeBitLength').value = shade.bitLength; - document.getElementById('selShadeProto').value = shade.proto; - document.getElementsByName('shadeAddress')[0].value = shade.remoteAddress; - document.getElementsByName('shadeName')[0].value = shade.name; - document.getElementsByName('shadeUpTime')[0].value = shade.upTime; - document.getElementsByName('shadeDownTime')[0].value = shade.downTime; - document.getElementById('slidStepSize').value = shade.stepSize; - document.getElementById('spanStepSize').innerHTML = shade.stepSize.fmt('#,##0'); - document.getElementById('fldTiltTime').value = shade.tiltTime; - document.getElementById('selTiltType').value = shade.tiltType; - document.getElementById('cbFlipCommands').checked = shade.flipCommands; - document.getElementById('cbFlipPosition').checked = shade.flipPosition; this.onShadeTypeChanged(document.getElementById('selShadeType')); let ico = document.getElementById('icoShade'); switch (shade.shadeType) { @@ -1570,128 +2446,236 @@ class Somfy { } this.setLinkedRemotesList(shade); } - overlay.remove(); }); } - }; - closeEditShade() { - let el = document.getElementById('somfyShade'); - if (el) el.style.display = 'none'; - document.getElementById('somfyMain').style.display = ''; - //document.getElementById('somfyShade').style.display = 'none'; - el = document.getElementById('divLinking'); + } + openEditGroup(groupId) { + document.getElementById('btnLinkShade').style.display = 'none'; + if (typeof groupId === 'undefined') { + getJSONSync('/getNextGroup', (err, group) => { + document.getElementById('btnSaveGroup').innerText = 'Add Group'; + document.getElementById('spanGroupId').innerText = '*'; + document.getElementById('divLinkedShadeList').innerHTML = ''; + //document.getElementById('btnSetRollingCode').style.display = 'none'; + if (err) { + ui.serviceError(err); + } + else { + console.log(group); + group.name = ''; + ui.toElement(document.getElementById('somfyGroup'), group); + this.showEditGroup(true); + } + }); + } + else { + // Load up an existing group. + document.getElementById('btnSaveGroup').style.display = 'none'; + document.getElementById('btnSaveGroup').innerText = 'Save Group'; + document.getElementById('spanGroupId').innerText = groupId; + getJSONSync(`/group?groupId=${groupId}`, (err, group) => { + if (err) { + ui.serviceError(err); + } + else { + console.log(group); + ui.toElement(document.getElementById('somfyGroup'), group); + this.showEditGroup(true); + document.getElementById('btnSaveGroup').style.display = 'inline-block'; + document.getElementById('btnLinkShade').style.display = ''; + document.getElementById('btnSetRollingCode').style.display = 'inline-block'; + this.setLinkedShadesList(group); + } + }); + } + } + showEditShade(bShow) { + let el = document.getElementById('divLinking'); if (el) el.remove(); el = document.getElementById('divPairing'); if (el) el.remove(); - el = document.getElementById('frmSetRollingCode'); + el = document.getElementById('divRollingCode'); if (el) el.remove(); - - }; + el = document.getElementById('somfyShade'); + if (el) el.style.display = bShow ? '' : 'none'; + el = document.getElementById('divShadeListContainer'); + if (el) el.style.display = bShow ? 'none' : ''; + } + showEditGroup(bShow) { + let el = document.getElementById('divLinking'); + if (el) el.remove(); + el = document.getElementById('divPairing'); + if (el) el.remove(); + el = document.getElementById('divRollingCode'); + if (el) el.remove(); + el = document.getElementById('somfyGroup'); + if (el) el.style.display = bShow ? '' : 'none'; + el = document.getElementById('divGroupListContainer'); + if (el) el.style.display = bShow ? 'none' : ''; + } saveShade() { let shadeId = parseInt(document.getElementById('spanShadeId').innerText, 10); - let obj = { - remoteAddress: parseInt(document.getElementsByName('shadeAddress')[0].value, 10), - name: document.getElementsByName('shadeName')[0].value, - upTime: parseInt(document.getElementsByName('shadeUpTime')[0].value, 10), - downTime: parseInt(document.getElementsByName('shadeDownTime')[0].value, 10), - shadeType: parseInt(document.getElementById('selShadeType').value, 10), - tiltTime: parseInt(document.getElementById('fldTiltTime').value, 10), - bitLength: parseInt(document.getElementById('selShadeBitLength').value, 10) || 56, - proto: parseInt(document.getElementById('selShadeProto').value, 10) || 0, - stepSize: parseInt(document.getElementById('slidStepSize').value, 10) || 100, - flipCommands: document.getElementById('cbFlipCommands').checked, - flipPosition: document.getElementById('cbFlipPosition').checked - }; - if (obj.shadeType === 1) { - obj.tiltType = parseInt(document.getElementById('selTiltType').value, 10); - } - else obj.tiltType = 0; + let obj = ui.fromElement(document.getElementById('somfyShade')); let valid = true; if (valid && (isNaN(obj.remoteAddress) || obj.remoteAddress < 1 || obj.remoteAddress > 16777215)) { - errorMessage(document.getElementById('fsSomfySettings'), 'The remote address must be a number between 1 and 16777215. This number must be unique for all shades.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'The remote address must be a number between 1 and 16777215. This number must be unique for all shades.'); valid = false; } if (valid && (typeof obj.name !== 'string' || obj.name === '' || obj.name.length > 20)) { - errorMessage(document.getElementById('fsSomfySettings'), 'You must provide a name for the shade between 1 and 20 characters.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'You must provide a name for the shade between 1 and 20 characters.'); valid = false; } if (valid && (isNaN(obj.upTime) || obj.upTime < 1 || obj.upTime > 4294967295)) { - errorMessage(document.getElementById('fsSomfySettings'), 'Up Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full closed to full open.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'Up Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full closed to full open.'); valid = false; } if (valid && (isNaN(obj.downTime) || obj.downTime < 1 || obj.downTime > 4294967295)) { - errorMessage(document.getElementById('fsSomfySettings'), 'Down Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full open to full closed.'); + ui.errorMessage(document.getElementById('fsSomfySettings'), 'Down Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full open to full closed.'); valid = false; } if (valid) { - let overlay = waitMessage(document.getElementById('fsSomfySettings')); if (isNaN(shadeId) || shadeId >= 255) { // We are adding. - putJSON('/addShade', obj, (err, shade) => { - console.log(shade); - document.getElementById('spanShadeId').innerText = shade.shadeId; - document.getElementById('btnSaveShade').innerText = 'Save Shade'; - - overlay.remove(); - document.getElementById('btnSaveShade').style.display = 'inline-block'; - document.getElementById('btnLinkRemote').style.display = ''; - if (shade.paired) { - document.getElementById('btnUnpairShade').style.display = 'inline-block'; - } + putJSONSync('/addShade', obj, (err, shade) => { + if (err) ui.serviceError(err); else { - document.getElementById('btnPairShade').style.display = 'inline-block'; + console.log(shade); + document.getElementById('spanShadeId').innerText = shade.shadeId; + document.getElementById('btnSaveShade').innerText = 'Save Shade'; + document.getElementById('btnSaveShade').style.display = 'inline-block'; + document.getElementById('btnLinkRemote').style.display = ''; + document.getElementById(shade.paired ? 'btnUnpairShade' : 'btnPairShade').style.display = 'inline-block'; + document.getElementById('btnSetRollingCode').style.display = 'inline-block'; + this.updateShadeList(); } - document.getElementById('btnSetRollingCode').style.display = 'inline-block'; - }); - } else { obj.shadeId = shadeId; - console.log(obj); - putJSON('/saveShade', obj, (err, shade) => { + putJSONSync('/saveShade', obj, (err, shade) => { + if (err) ui.serviceError(err); + else this.updateShadeList(); console.log(shade); - // We are updating. - overlay.remove(); }); } - this.updateShadeList(); } - }; - updateShadeList() { - let overlayCfg = waitMessage(document.getElementById('divShadeSection')); - let overlayControl = waitMessage(document.getElementById('divShadeControls')); - getJSON('/controller', (err, somfy) => { - overlayCfg.remove(); - overlayControl.remove(); - if (err) { - console.log(err); - serviceError(document.getElementById('fsSomfySettings'), err); - } - else { - console.log(somfy); - // Create the shades list. - this.setShadesList(somfy.shades); - } - }); - }; - deleteShade(shadeId) { + } + saveGroup() { + let groupId = parseInt(document.getElementById('spanGroupId').innerText, 10); + let obj = ui.fromElement(document.getElementById('somfyGroup')); let valid = true; - if (isNaN(shadeId) || shadeId >= 255 || shadeId <= 0) { - errorMessage(document.getElementById('fsSomfySettings'), 'A valid shade id was not supplied.'); + if (valid && (isNaN(obj.remoteAddress) || obj.remoteAddress < 1 || obj.remoteAddress > 16777215)) { + ui.errorMessage('The remote address must be a number between 1 and 16777215. This number must be unique for all shades.'); + valid = false; + } + if (valid && (typeof obj.name !== 'string' || obj.name === '' || obj.name.length > 20)) { + ui.errorMessage('You must provide a name for the shade between 1 and 20 characters.'); valid = false; } if (valid) { - promptMessage(document.getElementById('fsSomfySettings'), 'Are you sure you want to delete this shade?', () => { - clearErrors(); - let overlay = waitMessage(document.getElementById('fsSomfySettings')); - putJSON('/deleteShade', { shadeId: shadeId }, (err, shade) => { - overlay.remove(); - this.updateShadeList(); + if (isNaN(groupId) || groupId >= 255) { + // We are adding. + putJSONSync('/addGroup', obj, (err, group) => { + if (err) ui.serviceError(err); + else { + console.log(group); + document.getElementById('spanGroupId').innerText = group.groupId; + document.getElementById('btnSaveGroup').innerText = 'Save Group'; + document.getElementById('btnSaveGroup').style.display = 'inline-block'; + document.getElementById('btnLinkShade').style.display = ''; + //document.getElementById('btnSetRollingCode').style.display = 'inline-block'; + this.updateGroupList(); + } }); + } + else { + obj.groupId = groupId; + putJSONSync('/saveGroup', obj, (err, shade) => { + if (err) ui.serviceError(err); + else this.updateShadeList(); + console.log(shade); + }); + } + } + } + updateShadeList() { + getJSONSync('/shades', (err, shades) => { + if (err) { + console.log(err); + ui.serviceError(err); + } + else { + console.log(shades); + // Create the shades list. + this.setShadesList(shades); + } + }); + } + updateGroupList() { + getJSONSync('/groups', (err, groups) => { + if (err) { + console.log(err); + ui.serviceError(err); + } + else { + console.log(groups); + // Create the groups list. + this.setGroupsList(groups); + } + }); + } + deleteShade(shadeId) { + let valid = true; + if (isNaN(shadeId) || shadeId >= 255 || shadeId <= 0) { + ui.errorMessage('A valid shade id was not supplied.'); + valid = false; + } + if (valid) { + getJSONSync(`/shade?shadeId=${shadeId}`, (err, shade) => { + if (err) ui.serviceError(err); + else if (shade.inGroup) ui.errorMessage(`You may not delete this shade because it is a member of a group.`); + else { + let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { + ui.clearErrors(); + putJSONSync('/deleteShade', { shadeId: shadeId }, (err, shade) => { + this.updateShadeList(); + prompt.remove; + }); + }); + prompt.querySelector('.sub-message').innerHTML = `

If this shade was previously paired with a motor, you should first unpair it from the motor and remove it from any groups. Otherwise its address will remain in the motor memory.

Press YES to delete ${shade.name} or NO to cancel this operation.

`; + } }); } - }; + } + deleteGroup(groupId) { + let valid = true; + if (isNaN(groupId) || groupId >= 255 || groupId <= 0) { + ui.errorMessage('A valid shade id was not supplied.'); + valid = false; + } + if (valid) { + getJSONSync(`/group?groupId=${groupId}`, (err, group) => { + if (err) ui.serviceError(err); + else { + if (group.linkedShades.length > 0) { + ui.errorMessage('You may not delete this group until all shades have been removed from it.'); + } + else { + let prompt = ui.promptMessage(`Are you sure you want to delete this group?`, () => { + putJSONSync('/deleteGroup', { groupId: groupId }, (err, g) => { + if (err) ui.serviceError(err); + this.updateGroupList(); + prompt.remove(); + }); + + }); + prompt.querySelector('.sub-message').innerHTML = `

Press YES to delete the ${group.name} group or NO to cancel this operation.

`; + + } + } + }); + } + } sendPairCommand(shadeId) { putJSON('/pairShade', { shadeId: shadeId }, (err, shade) => { if (err) { @@ -1723,7 +2707,7 @@ class Somfy { } }); - }; + } sendUnpairCommand(shadeId) { putJSON('/unpairShade', { shadeId: shadeId }, (err, shade) => { if (err) { @@ -1754,32 +2738,27 @@ class Somfy { document.getElementById('divPairing').remove(); } }); - }; + } setRollingCode(shadeId, rollingCode) { - let dlg = document.getElementById('frmSetRollingCode'); - let overlay = waitMessage(dlg || document.getElementById('fsSomfySettings')); - putJSON('/setRollingCode', { shadeId: shadeId, rollingCode: rollingCode }, (err, shade) => { - overlay.remove(); - if (err) { - serviceError(document.getElementById('fsSomfySettings'), err); - } + putJSONSync('/setRollingCode', { shadeId: shadeId, rollingCode: rollingCode }, (err, shade) => { + if (err) ui.serviceError(document.getElementById('fsSomfySettings'), err); else { + let dlg = document.getElementById('divRollingCode'); if (dlg) dlg.remove(); - } }); } openSetRollingCode(shadeId) { - let overlay = waitMessage(document.getElementById('fsSomfySettings')); + let overlay = ui.waitMessage(document.getElementById('divContainer')); getJSON(`/shade?shadeId=${shadeId}`, (err, shade) => { overlay.remove(); if (err) { - serviceError(document.getElementById('fsSomfySettings'), err); + ui.serviceError(err); } else { console.log(shade); let div = document.createElement('div'); - let html = `
`; + let html = `
`; html += '
BEWARE ... WARNING ... DANGER
'; html += '
'; html += '

If this shade is already paired with a motor then changing the rolling code WILL cause it to stop working. Rolling codes are tied to the remote address and the Somfy motor expects these to be sequential.

'; @@ -1791,7 +2770,7 @@ class Somfy { html += `
` html += `` html += `` - html += `
`; + html += `
`; div.innerHTML = html; document.getElementById('somfyShade').appendChild(div); } @@ -1800,26 +2779,18 @@ class Somfy { setPaired(shadeId, paired) { let obj = { shadeId: shadeId, paired: paired || false }; let div = document.getElementById('divPairing'); - let overlay = typeof div === 'undefined' ? undefined : waitMessage(div); - putJSON('/setPaired', obj, (err, shade) => { + let overlay = typeof div === 'undefined' ? undefined : ui.waitMessage(div); + putJSONSync('/setPaired', obj, (err, shade) => { if (overlay) overlay.remove(); if (err) { console.log(err); - errorMessage(div, err.message); + ui.errorMessage(err.message); } else if (div) { console.log(shade); - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyShade').style.display = ''; + this.showEditShade(true); document.getElementById('btnSaveShade').style.display = 'inline-block'; document.getElementById('btnLinkRemote').style.display = ''; - document.getElementsByName('shadeAddress')[0].value = shade.remoteAddress; - document.getElementsByName('shadeName')[0].value = shade.name; - document.getElementsByName('shadeUpTime')[0].value = shade.upTime; - document.getElementsByName('shadeDownTime')[0].value = shade.downTime; - let ico = document.getElementById('icoShade'); - ico.style.setProperty('--shade-position', `${shade.flipPosition ? 100 - shade.position : shade.position}%`); - ico.setAttribute('data-shadeid', shade.shadeId); if (shade.paired) { document.getElementById('btnUnpairShade').style.display = 'inline-block'; document.getElementById('btnPairShade').style.display = 'none'; @@ -1854,7 +2825,7 @@ class Somfy { div.innerHTML = html; document.getElementById('somfyShade').appendChild(div); return div; - }; + } unpairShade(shadeId) { let div = document.createElement('div'); let html = `
`; @@ -1876,7 +2847,7 @@ class Somfy { div.innerHTML = html; document.getElementById('somfyShade').appendChild(div); return div; - }; + } sendCommand(shadeId, command, repeat) { console.log(`Sending Shade command ${shadeId}-${command}`); let obj = { shadeId: shadeId }; @@ -1886,7 +2857,16 @@ class Somfy { putJSON('/shadeCommand', obj, (err, shade) => { }); - }; + } + sendGroupCommand(groupId, command, repeat) { + console.log(`Sending Group command ${groupId}-${command}`); + let obj = { groupId: groupId }; + if (isNaN(parseInt(command, 10))) obj.command = command; + if (typeof repeat === 'number') obj.repeat = parseInt(repeat); + putJSON('/groupCommand', obj, (err, group) => { + + }); + } sendTiltCommand(shadeId, command) { console.log(`Sending Tilt command ${shadeId}-${command}`); if (isNaN(parseInt(command, 10))) @@ -1895,7 +2875,7 @@ class Somfy { else putJSON('/tiltCommand', { shadeId: shadeId, target: parseInt(command, 10) }, (err, shade) => { }); - }; + } linkRemote(shadeId) { let div = document.createElement('div'); let html = `
`; @@ -1906,14 +2886,213 @@ class Somfy { div.innerHTML = html; document.getElementById('somfyShade').appendChild(div); return div; - }; + } + linkGroupShade(groupId) { + let div = document.createElement('div'); + let html = `
`; + html += '
ADD SHADE TO GROUP
'; + + html += '
'; + html += '

This wizard will walk you through the steps required to add shades into a group. Follow all instructions at each step until the shade is added to the group.

'; + html += '

During this process the shade should jog exactly two times. The first time indicates that the motor memory has been enabled and the second time adds the group to the motor memory

'; + html += '

Each shade must be paired individually to the group. When you are ready to begin pairing your shade to the group press the NEXT button.


' + + html += '
' + + html += '
'; + html += '

Choose a shade that you would like to include in this group. Once you have chosen the shade to include in the link press the NEXT button.

'; + html += '

Only shades that have not already been included in this group are available the dropdown. Each shade can be included in multiple groups.

'; + html += '
'; + html += `
`; + html += `
`; + html += '
'; + + html += '
'; + html += '

Now that you have chosen a shade to pair. Open the memory for the shade by pressing the OPEN MEMORY button. The shade should jog to indicate the memory has been opened.

'; + html += '

The motor should jog only once. If it jogs more than once then you have again closed the memory on the motor. Once the motor has jogged press the NEXT button to proceed.

'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + html += '
'; + + html += '
'; + html += '

Now that the memory is opened on the motor you need to send the pairing command for the group.

'; + html += '

To do this press the PAIR TO GROUP button below and once the motor jogs the process will be complete.

'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + html += '
'; + + + + html += `
`; + html += `
` + html += '
'; + div.innerHTML = html; + document.getElementById('divContainer').appendChild(div); + ui.wizSetStep(div, 1); + let btnOpenMemory = div.querySelector('#btnOpenMemory'); + btnOpenMemory.addEventListener('click', (evt) => { + let obj = ui.fromElement(div); + console.log(obj); + putJSONSync('/shadeCommand', { shadeId: obj.shadeId, command: 'prog', repeat: 40 }, (err, shade) => { + if (err) ui.serviceError(err); + else { + let prompt = ui.promptMessage('Confirm Motor Response', () => { + ui.wizSetNextStep(document.getElementById('divLinkGroup')); + prompt.remove(); + }); + prompt.querySelector('.sub-message').innerHTML = `

Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.

Once the shade has jogged the shade will be removed from the group and this process will be finished.

`; + } + }); + }); + let btnPairToGroup = div.querySelector('#btnPairToGroup'); + btnPairToGroup.addEventListener('click', (evt) => { + let obj = ui.fromElement(div); + putJSONSync('/groupCommand', { groupId: groupId, command: 'prog', repeat: 1 }, (err, shade) => { + if (err) ui.serviceError(err); + else { + let prompt = ui.promptMessage('Confirm Motor Response', () => { + putJSONSync('/linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, (err, group) => { + console.log(group); + somfy.setLinkedShadesList(group); + this.updateGroupList(); + }); + prompt.remove(); + div.remove(); + }); + prompt.querySelector('.sub-message').innerHTML = `

Did the shade jog? If the shade jogged press the YES button and your shade will be linked to the group. If it did not press the NO button and try again.

Once the shade has jogged the shade will be added to the group and this process will be finished.

`; + } + }); + }); + getJSONSync(`/groupOptions?groupId=${groupId}`, (err, options) => { + if (err) { + div.remove(); + ui.serviceError(err); + } + else { + console.log(options); + if (options.availShades.length > 0) { + // Add in all the available shades. + let selAvail = div.querySelector('#selAvailShades'); + let grpName = div.querySelector('#divGroupName'); + if (grpName) grpName.innerHTML = options.name; + for (let i = 0; i < options.availShades.length; i++) { + let shade = options.availShades[i]; + selAvail.options.add(new Option(shade.name, shade.shadeId)); + } + let divWizShadeName = div.querySelector('#divWizShadeName'); + if (divWizShadeName) divWizShadeName.innerHTML = options.availShades[0].name; + } + else { + div.remove(); + ui.errorMessage('There are no available shades to pair to this group.'); + } + } + }); + return div; + } + unlinkGroupShade(groupId, shadeId) { + let div = document.createElement('div'); + let html = `
`; + html += '
REMOVE SHADE FROM GROUP
'; + + html += '
'; + html += '

This wizard will walk you through the steps required to remove a shade from a group. Follow all instructions at each step until the shade is removed from the group.

'; + html += '

During this process the shade should jog exactly two times. The first time indicates that the motor memory has been enabled and the second time removes the group from the motor memory

'; + html += '

Each shade must be removed from the group individually. When you are ready to begin unpairing your shade from the group press the NEXT button to begin.


' + html += '
' + + html += '
'; + html += '

You must first open the memory for the shade by pressing the OPEN MEMORY button. The shade should jog to indicate the memory has been opened.

'; + html += '

The motor should jog only once. If it jogs more than once then you have again closed the memory on the motor. Once the motor has jogged press the NEXT button to proceed.

'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + html += '
'; + + html += '
'; + html += '

Now that the memory is opened on the motor you need to send the un-pairing command for the group.

'; + html += '

To do this press the UNPAIR FROM GROUP button below and once the motor jogs the process will be complete.

'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + html += '
'; + html += `
`; + html += `
` + html += '
'; + div.innerHTML = html; + document.getElementById('divContainer').appendChild(div); + ui.wizSetStep(div, 1); + let btnOpenMemory = div.querySelector('#btnOpenMemory'); + btnOpenMemory.addEventListener('click', (evt) => { + let obj = ui.fromElement(div); + console.log(obj); + putJSONSync('/shadeCommand', { shadeId: shadeId, command: 'prog', repeat: 40 }, (err, shade) => { + if (err) ui.serviceError(err); + else { + let prompt = ui.promptMessage('Confirm Motor Response', () => { + ui.wizSetNextStep(document.getElementById('divUnlinkGroup')); + prompt.remove(); + }); + prompt.querySelector('.sub-message').innerHTML = `

Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.

If you are having trouble getting the motor to jog on this step you may try to open the memory using a remote. Most often this is done by selecting the channel, then a long press on the prog button.

If you opened the memory using the alternate method press the NO button to close this message, then press NEXT button to skip the step.

`; + } + }); + }); + let btnUnpairFromGroup = div.querySelector('#btnUnpairFromGroup'); + btnUnpairFromGroup.addEventListener('click', (evt) => { + let obj = ui.fromElement(div); + putJSONSync('/groupCommand', { groupId: groupId, command: 'prog', repeat: 1 }, (err, shade) => { + if (err) ui.serviceError(err); + else { + let prompt = ui.promptMessage('Confirm Motor Response', () => { + putJSONSync('/unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, (err, group) => { + console.log(group); + somfy.setLinkedShadesList(group); + this.updateGroupList(); + }); + prompt.remove(); + div.remove(); + }); + prompt.querySelector('.sub-message').innerHTML = `

Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.

Once the shade has jogged the shade will be removed from the group and this process will be finished.

`; + } + }); + }); + getJSONSync(`/group?groupId=${groupId}`, (err, group) => { + if (err) { + div.remove(); + ui.serviceError(err); + } + else { + console.log(group); + console.log(shadeId); + let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }) + if (typeof shade !== 'undefined') { + // Add in all the available shades. + let grpName = div.querySelector('#divGroupName'); + if (grpName) grpName.innerHTML = group.name; + let divWizShadeName = div.querySelector('#divWizShadeName'); + if (divWizShadeName) divWizShadeName.innerHTML = shade.name; + } + else { + div.remove(); + ui.errorMessage('The specified shade could not be found in this group.'); + } + } + }); + return div; + } unlinkRemote(shadeId, remoteAddress) { - let prompt = promptMessage(document.getElementById('fsSomfySettings'), 'Are you sure you want to unlink this remote from the shade?', () => { + let prompt = ui.promptMessage(document.getElementById('fsSomfySettings'), 'Are you sure you want to unlink this remote from the shade?', () => { let obj = { shadeId: shadeId, remoteAddress: remoteAddress }; - let overlay = waitMessage(prompt); + let overlay = ui.waitMessage(prompt); putJSON('/unlinkRemote', obj, (err, shade) => { console.log(shade); overlay.remove(); @@ -1922,13 +3101,13 @@ class Somfy { }); }); - }; + } deviationChanged(el) { document.getElementById('spanDeviation').innerText = (el.value / 100).fmt('#,##0.00'); - }; + } rxBandwidthChanged(el) { document.getElementById('spanRxBandwidth').innerText = (el.value / 100).fmt('#,##0.00'); - }; + } frequencyChanged(el) { document.getElementById('spanFrequency').innerText = (el.value / 1000).fmt('#,##0.000'); } @@ -1936,10 +3115,10 @@ class Somfy { console.log(el.value); let lvls = [-30, -20, -15, -10, -6, 0, 5, 7, 10, 11, 12]; document.getElementById('spanTxPower').innerText = lvls[el.value]; - }; + } stepSizeChanged(el) { document.getElementById('spanStepSize').innerText = parseInt(el.value, 10).fmt('#,##0'); - }; + } processShadeTarget(el, shadeId) { let positioner = document.querySelector(`.shade-positioner[data-shadeid="${shadeId}"]`); @@ -1997,65 +3176,46 @@ class Somfy { } } } -}; +} var somfy = new Somfy(); class MQTT { - async init() { this.loadMQTT(); } + initialized = false; + init() { this.initialized = true; } async loadMQTT() { - let overlay = waitMessage(document.getElementById('fsMQTTSettings')); - getJSON('/mqttsettings', (err, settings) => { - overlay.remove(); - if (err) { + getJSONSync('/mqttsettings', (err, settings) => { + if (err) console.log(err); - } else { console.log(settings); - let dd = document.getElementsByName('mqtt-protocol')[0]; - for (let i = 0; i < dd.options.length; i++) { - if (dd.options[i].text === settings.proto) { - dd.selectedIndex = i; - break; - } - } - if (dd.selectedIndex < 0) dd.selectedIndex = 0; - document.getElementsByName('mqtt-host')[0].value = settings.hostname; - document.getElementsByName('mqtt-port')[0].value = settings.port; - document.getElementsByName('mqtt-username')[0].value = settings.username; - document.getElementsByName('mqtt-password')[0].value = settings.password; - document.getElementsByName('mqtt-topic')[0].value = settings.rootTopic; - document.getElementsByName('mqtt-enabled')[0].checked = settings.enabled; + ui.toElement(document.getElementById('divMQTT'), { mqtt: settings }); } }); }; connectMQTT() { - if (document.getElementById('btnConnectMQTT').classList.contains('disabled')) return; - document.getElementById('btnConnectMQTT').classList.add('disabled'); - let obj = { - enabled: document.getElementsByName('mqtt-enabled')[0].checked, - protocol: document.getElementsByName('mqtt-protocol')[0].value, - hostname: document.getElementsByName('mqtt-host')[0].value, - port: parseInt(document.getElementsByName('mqtt-port')[0].value, 10), - username: document.getElementsByName('mqtt-username')[0].value, - password: document.getElementsByName('mqtt-password')[0].value, - rootTopic: document.getElementsByName('mqtt-topic')[0].value + let obj = ui.fromElement(document.getElementById('divMQTT')); + console.log(obj); + if (obj.mqtt.enabled) { + if (typeof obj.mqtt.hostname !== 'string' || obj.mqtt.hostname.length === 0) { + ui.errorMessage('Invalid host name.').querySelector('.sub-message').innerHTML = 'You must supply a host name to connect to MQTT.'; + return; + } + if (isNaN(obj.mqtt.port) || obj.mqtt.port < 0) { + ui.errorMessage('Invalid port number.').querySelector('.sub-message').innerHTML = 'Likely ports are 1183, 8883 for MQTT/S or 80,443 for HTTP/S'; + return; + } } - console.log(obj); - if (isNaN(obj.port) || obj.port < 0) { - errorMessage(document.getElementById('fsMQTTSettings'), 'Invalid port number. Likely ports are 1183, 8883 for MQTT/S or 80,443 for HTTP/S'); - return; - } - let overlay = waitMessage(document.getElementById('fsMQTTSettings')); - putJSON('/connectmqtt', obj, (err, response) => { - overlay.remove(); - document.getElementById('btnConnectMQTT').classList.remove('disabled'); + + putJSONSync('/connectmqtt', obj.mqtt, (err, response) => { + if (err) ui.serviceError(err); console.log(response); }); }; -}; +} var mqtt = new MQTT(); class Firmware { - async init() { } + initialized = false; + init() { this.initialized = true; } isMobile() { let agt = navigator.userAgent.toLowerCase(); return /Android|iPhone|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Kindle|Silk|Opera Mini/i.test(navigator.userAgent) @@ -2072,13 +3232,14 @@ class Firmware { let div = this.createFileUploader('/restore'); let inst = div.querySelector('div[id=divInstText]'); inst.innerHTML = '
Select a backup file that you would like to restore then press the Upload File button.
'; - document.getElementById('fsUpdates').appendChild(div); + document.getElementById('divContainer').appendChild(div); }; createFileUploader(service) { let div = document.createElement('div'); div.setAttribute('id', 'divUploadFile'); - div.setAttribute('class', 'instructions'); + div.setAttribute('class', 'inst-overlay'); div.style.width = '100%'; + div.style.alignContent = 'center'; let html = `
`; html += `
`; html += ``; @@ -2099,7 +3260,7 @@ class Firmware { let div = this.createFileUploader('/updateFirmware'); let inst = div.querySelector('div[id=divInstText]'); inst.innerHTML = '
Select a binary file [SomfyController.ino.esp32.bin] containing the device firmware then press the Upload File button.
'; - document.getElementById('fsUpdates').appendChild(div); + document.getElementById('divContainer').appendChild(div); }; updateApplication() { let div = this.createFileUploader('/updateApplication'); @@ -2112,7 +3273,7 @@ class Firmware { } else inst.innerHTML += '
A backup file for your configuration will be downloaded to your browser. If the application update process fails please restore this file using the restore button
'; - document.getElementById('fsUpdates').appendChild(div); + document.getElementById('divContainer').appendChild(div); if(this.isMobile()) document.getElementById('btnBackupCfg').style.display = 'inline-block'; }; async uploadFile(service, el) { @@ -2121,8 +3282,12 @@ class Firmware { console.log(filename); switch (service) { case '/updateApplication': - if (filename.indexOf('.littlefs') === -1 || !filename.endsWith('.bin')) { - errorMessage(el, 'This file is not a valid littleFS file system.'); + if (typeof filename !== 'string' || filename.length === 0) { + ui.errorMessage('You must select a littleFS binary file to proceed.'); + return; + } + else if (filename.indexOf('.littlefs') === -1 || !filename.endsWith('.bin')) { + ui.errorMessage('This file is not a valid littleFS binary file.'); return; } if (!this.isMobile()) { @@ -2141,27 +3306,35 @@ class Firmware { } }); } catch (err) { - serviceError(el, err); + ui.serviceError(el, err); reject(err); return; } }).catch((err) => { - serviceError(el, err); + ui.serviceError(el, err); }); } break; case '/updateFirmware': - if (filename.indexOf('.ino.') === -1 || !filename.endsWith('.bin')) { - errorMessage(el, 'This file is not a valid firmware binary file.'); + if (typeof filename !== 'string' || filename.length === 0) { + ui.errorMessage('You must select a valid firmware binary file to proceed.'); + return; + } + else if (filename.indexOf('.ino.') === -1 || !filename.endsWith('.bin')) { + ui.errorMessage(el, 'This file is not a valid firmware binary file.'); return; } break; case '/restore': - if (!filename.endsWith('.backup') || filename.indexOf('ESPSomfyRTS') === -1) { - errorMessage(el, 'This file is not a valid backup file') + if (typeof filename !== 'string' || filename.length === 0) { + ui.errorMessage('You must select a valid backup file to proceed.'); return; } - + else if (!filename.endsWith('.backup') || filename.indexOf('ESPSomfyRTS') === -1) { + ui.errorMessage(el, 'This file is not a valid backup file') + return; + } + break; } let formData = new FormData(); let btnUpload = el.querySelector('button[id="btnUploadFile"]'); @@ -2186,7 +3359,7 @@ class Firmware { }; xhr.onerror = function (err) { console.log(err); - serviceError(el, err); + ui.serviceError(el, err); }; xhr.onload = function () { console.log('File upload load called'); @@ -2205,5 +3378,6 @@ class Firmware { }; xhr.send(formData); }; -}; +} var firmware = new Firmware(); + diff --git a/data/login.html b/data/login.html new file mode 100644 index 0000000..3f9b68c --- /dev/null +++ b/data/login.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + +
+

+ +
+ + + diff --git a/data/main.css b/data/main.css index 0d35e55..7eab8d2 100644 --- a/data/main.css +++ b/data/main.css @@ -4,6 +4,29 @@ * { box-sizing: border-box; } + +body { + color: #434343; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + font-size: 14px; + background-color: #eeeeee; + margin-top: 77px; + box-sizing: border-box; +} + + +h1 { + text-align: center; + margin-bottom: 10px; + margin-top: 0px; + color: #939393; + font-size: 28px; +} +hr { + width:100%; +} + + input[type="range"] { accent-color: #00bcd4; outline:none; @@ -12,6 +35,9 @@ input[type="range"] { input[type="range"]:active { outline: dotted 1px silver; } + + + .button-outline { -webkit-user-select: none; -moz-user-select: none; @@ -48,34 +74,11 @@ input[type="range"] { transform: rotate(7deg); } } -body { - color: #434343; - font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; - font-size: 14px; - background-color: #eeeeee; - margin-top: 77px; - box-sizing: border-box; -} -.container { - margin: 0 auto; - max-width: 450px; - padding: 20px; - box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); - background-color: #ffffff; -} - -h1 { - text-align: center; - margin-bottom: 10px; - margin-top: 0px; - color: #939393; - font-size: 28px; -} form { margin-top:-18px; } -form .field-group { +.field-group { box-sizing: border-box; clear: both; padding: 4px 0; @@ -84,7 +87,7 @@ form .field-group { width: 100%; } -form .field-group1 { +.field-group1 { box-sizing: border-box; clear: both; padding: 4px 0; @@ -93,7 +96,7 @@ form .field-group1 { width: 100%; } - form .field-group1 > span { + .field-group1 > span { color: #757575; display: inline-block; margin: 0 0 5px 0; @@ -104,7 +107,7 @@ form .field-group1 { font-weight: bold; } -form .field-group > label { +.field-group > label { display: block; margin: 0px; padding: 3px 0 0; @@ -116,11 +119,11 @@ form .field-group > label { margin-top:-10px; color:#00bcd4; } -form .field-group1 > div.field-group { +.field-group1 > div.field-group { display:inline-block; width:auto; } -::placeholder { +*::placeholder { opacity: .5; } @@ -156,9 +159,9 @@ select { margin-bottom:14px; } -form .field-group input[type=password], -form .field-group input[type=number], -form .field-group input[type=text] { +.field-group input[type=password], +.field-group input[type=number], +.field-group input[type=text] { display: block; width: 100%; } @@ -214,7 +217,8 @@ a { bottom: 7px; border-bottom: 1px solid #00bcd4; } -div.errorMessage { +div.prompt-message, +div.error-message { position:absolute; left:0px; top:0px; @@ -229,17 +233,31 @@ div.errorMessage { align-items:center; align-content:center; font-size:32px; - border-radius:5px; + border-radius:27px; + transition-duration:3s; } -div.errorMessage > div { +div.prompt-message, +div.error-message > div { padding:10px; } +div.error-message > .sub-message { + font-size:14px; +} div.socket-error { opacity: 1; font-size: 20px; min-height: 277px; z-index: 20001; } +.prompt-message > .button-container { + text-align:center; +} +.prompt-message > .button-container > button { + width: calc(50% - 22px); + margin-left:7px; + margin-right:7px; + display:inline-block; +} div.instructions { position: absolute; left: 0px; @@ -247,11 +265,11 @@ div.instructions { width: 100%; height: 100%; color: white; - display: flex; - flex-wrap: wrap; background: gray; opacity: .9; padding: 10px; + display: flex; + flex-wrap: wrap; align-items: center; align-content: center; font-size: 20px; @@ -279,6 +297,45 @@ div.instructions { border-bottom-width: 3px; font-weight: bold; } +.subtab-container { + margin-top:3px; + margin-left: 3px; + display: flex; + width: calc(100% - 7px); + margin-bottom: -1px; + overflow: visible; + border-bottom: 1px solid #ccc; +} + .subtab-container > span { + display:inline-block; + background:white; + margin-bottom:-1px; + padding-left: 10px; + padding-right:10px; + padding-top:5px; + padding-bottom:5px; + border-top: 1px solid #ccc; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + cursor:pointer; + background:ghostwhite; + } + .subtab-container > span.selected { + border-bottom:none; + background:white; + } +.subtab-content { + display: block; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-radius: 5px; + padding: 25px; + position: relative; +} fieldset { display: block; border: 1px solid #ccc; @@ -371,7 +428,7 @@ span.strength { button.disabled { opacity:.3; } -div.waitoverlay { +div.wait-overlay { width: 100%; height: 100%; position: absolute; @@ -383,8 +440,9 @@ div.waitoverlay { background:rgba(227, 226, 230, 0.46); cursor:no-drop; z-index:20000; + border-radius:27px; } -div.waitoverlay > .lds-roller { +div.wait-overlay > .lds-roller { z-index:1000; opacity:1; } @@ -463,10 +521,10 @@ div.waitoverlay > .lds-roller { top: 63px; left: 17px; } + .lds-roller div:nth-child(8) { animation-delay: -0.288s; } - .lds-roller div:nth-child(8):after { top: 56px; left: 12px; @@ -486,17 +544,6 @@ div.waitoverlay > .lds-roller { .radioPins > label { text-align:center; } -.somfyShade { - text-align:center; - padding-bottom:4px; - display:inline-table; - padding-left:4px; - padding-right:4px; -} -.somfyShade > div, -.somfyShade > span { - display:table-cell; -} .button-outline { background-color: #00bcd4; border-radius:50%; @@ -507,6 +554,7 @@ div.waitoverlay > .lds-roller { font-size:1.5em; color:white; } +.group-name, .shade-name { text-align:left; width: 100%; @@ -517,6 +565,7 @@ div.waitoverlay > .lds-roller { overflow:hidden; vertical-align:middle; } +.group-address, .shade-address { width:77px; padding-left:2px; @@ -558,6 +607,7 @@ div.waitoverlay > .lds-roller { display: block; overflow: hidden; } +.somfyGroupCtl, .somfyShadeCtl { height:60px; border-bottom:dotted 2px gainsboro; @@ -576,12 +626,14 @@ div.waitoverlay > .lds-roller { font-size: 48px; position:relative; } +.somfyGroupCtl .group-name, .somfyShadeCtl .shade-name { display:inline-block; padding:7px; vertical-align:middle; width:100%; -} +} + .somfyGroupCtl .groupctl-name, .somfyShadeCtl .shadectl-name { font-size: 1.5em; color: silver; @@ -590,18 +642,23 @@ div.waitoverlay > .lds-roller { white-space: nowrap; width: 100%; } + .somfyGroupCtl label, .somfyShadeCtl label { color:silver; } + .somfyGroupCtl .groupctl-mypos, .somfyShadeCtl .shadectl-mypos { white-space: nowrap; font-size: 12px; } +.somfyGroupCtl .groupctl-buttons, .somfyShadeCtl .shadectl-buttons { margin-top:3px; float:right; white-space:nowrap; } + + .somfyGroupCtl .groupctl-buttons .button-outline, .somfyShadeCtl .shadectl-buttons .button-outline { display: inline-block; padding: 7px; @@ -675,30 +732,24 @@ div.eth-setting-line label { color:mediumspringgreen; } div.frame-log { - position:absolute; - top:0px; - left:0px; - width:100%; - height:100%; - border-radius:27px; - border:solid 2px gray; background:gainsboro; } div.frame-list { position:relative; - width: calc(100% - 14px); - height: calc(100% - 147px); - left: 7px; + width: 100%; + max-height:357px; background:white; + min-height:127px; overflow-y:auto; - border:solid 1px silver; + border-bottom:solid 1px silver; } div.frame-header { + border-top-left-radius:5px; + border-top-right-radius:5px; position: relative; font-size: 12px; background: #00bcd4; - width: calc(100% - 14px); - left: 7px; + width: 100%; color:white; padding-top:4px; } diff --git a/data/widgets.css b/data/widgets.css new file mode 100644 index 0000000..2a6a600 --- /dev/null +++ b/data/widgets.css @@ -0,0 +1,112 @@ +.container { + margin: 0 auto; + max-width: 450px; + padding: 20px; + box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + background-color: #ffffff; + user-select: none; + position: relative; + border-radius: 27px; +} +.main.container { + min-height:327px; +} + +.header-message { + text-align: center; + background: gainsboro; + color: gray; + margin-bottom: 7px; + text-transform: uppercase; + font-weight: bold; + padding: 4px; + border-radius: 5px; +} +.inst-overlay { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: flex-start; + position: absolute; + border-radius: 27px; + background: gray; + opacity: .9; + padding: 10px; + font-size: 20px; + height: auto; + min-height: 100%; + top: 0px; + left: 0px; + color: white; +} +.edit-grouplist, +.edit-motorlist { + overflow-y: auto; + max-height: 400px; + padding-top: 2px; + padding-bottom: 2px; + min-height: 147px; +} +.prompt-message .sub-message { + font-size: 17px; + padding-left: 10px; + padding-right: 10px; +} +.prompt-message .prompt-text { + text-align:center; +} +.promp-message > .inner-error, +.error-message > .inner-error { + width:100%; +} + +.wizard[data-stepid="1"] .wizard-step:not([data-stepid="1"]) { display: none; } +.wizard[data-stepid="2"] .wizard-step:not([data-stepid="2"]) { display: none; } +.wizard[data-stepid="3"] .wizard-step:not([data-stepid="3"]) { display: none; } +.wizard[data-stepid="4"] .wizard-step:not([data-stepid="4"]) { display: none; } +.wizard[data-stepid="5"] .wizard-step:not([data-stepid="5"]) { display: none; } +.wizard[data-stepid="6"] .wizard-step:not([data-stepid="6"]) { display: none; } +.wizard[data-stepid="7"] .wizard-step:not([data-stepid="7"]) { display: none; } + +.somfyGroup, +.somfyShade { + text-align: center; + padding-bottom: 4px; + display: inline-table; + padding-left: 4px; + padding-right: 4px; +} + .linked-shade > div, + .linked-shade > span, + .somfyGroup > div, + .somfyGroup > span, + .somfyShade > div, + .somfyShade > span { + display: table-cell; + padding-left:4px; + padding-right:4px; + } +.linked-shade { + width: 100%; + padding-bottom: 4px; + display: inline-table; + padding-left: 4px; + padding-right: 4px; +} + .linked-shade > .linkedshade-name { width: 100%; } +.pin-digit { + margin: 0 0.3rem; + padding: 0.5rem; + border: 1px solid #00bcd4; + border-radius:5px; + width: 50px; + height: 50px; + text-align: center; + font-size: 3rem; +} +.login-content { + display: block; + padding: 25px; + position: relative; +} +