From e851d23ce55f8d98e9cd33cb14626f355f921018 Mon Sep 17 00:00:00 2001 From: Theo Arends <11044339+arendst@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:13:07 +0200 Subject: [PATCH] Add Berry Devices Online Extension App --- CHANGELOG.md | 1 + RELEASENOTES.md | 1 + tasmota/berry/extensions/Devices_Online.tapp | Bin 0 -> 19511 bytes .../extensions/Devices_Online/autoexec.be | 9 + .../Devices_Online/devices_online.be | 359 ++++++++++++++++++ .../extensions/Devices_Online/manifest.json | 8 + 6 files changed, 378 insertions(+) create mode 100644 tasmota/berry/extensions/Devices_Online.tapp create mode 100644 tasmota/berry/extensions/Devices_Online/autoexec.be create mode 100644 tasmota/berry/extensions/Devices_Online/devices_online.be create mode 100644 tasmota/berry/extensions/Devices_Online/manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b304a91..2af2f675e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - ESP32 Extension Manager, replacing loading of Partition Wizard (#23955) - Berry animation framework web ui to compile DSL (#23962) +- Berry Devices Online Extension App ### Breaking Changed diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 41f2f7070..6fbe3b7b4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -135,6 +135,7 @@ The latter links can be used for OTA upgrades too like ``OtaUrl https://ota.tasm - Berry `call()` now works for classes [#23744](https://github.com/arendst/Tasmota/issues/23744) - Berry multiplication between string and int [#23850](https://github.com/arendst/Tasmota/issues/23850) - Berry animation framework web ui to compile DSL [#23962](https://github.com/arendst/Tasmota/issues/23962) +- Berry Devices Online Extension App ### Breaking Changed - Berry `animate` framework is DEPRECATED, will be replace by `animation` framework [#23854](https://github.com/arendst/Tasmota/issues/23854) diff --git a/tasmota/berry/extensions/Devices_Online.tapp b/tasmota/berry/extensions/Devices_Online.tapp new file mode 100644 index 0000000000000000000000000000000000000000..93468ce38275babac3ef6dd20ea776a738c40cc6 GIT binary patch literal 19511 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 self.handle_state_data(topic, idx, data, databytes)) + mqtt.subscribe("tasmota/discovery/+/config", /topic, idx, data, databytes -> self.handle_discovery_data(topic, idx, data, databytes)) + + tasmota.add_driver(self) + end + + ################################################################################# + # unload + # + # Uninstall the extension and deallocate all resources + ################################################################################# + def unload() + mqtt.unsubscribe("tasmota/discovery/+/config") + mqtt.unsubscribe(self.mqtt_tele) + tasmota.remove_driver(self) + end + + ################################################################################# + # handle_discovery_data(discovery_topic, idx, data, databytes) + # + # Handle MQTT Tasmota Discovery Config data + ################################################################################# + def handle_discovery_data(discovery_topic, idx, data, databytes) + var config = json.load(data) + if config + # tasmota/discovery/142B2F9FAF38/config = {"ip":"192.168.2.208","dn":"AtomLite2","fn":["Tasmota",null,null,null,null,null,null,null],"hn":"atomlite2","mac":"142B2F9FAF38","md":"M5Stack Atom Lite","ty":0,"if":0,"cam":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"15.0.1.4","t":"atomlite2","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":0,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":1,"lt_st":3,"bat":0,"dslp":0,"sho":[],"sht":[],"ver":1} (retained) + var topic = config['t'] + var hostname = config['hn'] + var ipaddress = config['ip'] + var devicename = config['dn'] + var version = config['sw'] + var line = format("%s\001%s\001%s\001%s\001%s", topic, hostname, ipaddress, devicename, version) +# tasmota.log(format("STD: 111 Size %03d, Topic '%s', Line '%s'", self.list_config.size(), topic, line), 3) + if self.list_config.size() + var list_index = 0 + var list_size = size(self.list_config) + var topic_delim = format("%s\001", topic) # Add find delimiter + while list_index < list_size # Use while loop as counter is decremented + if 0 == string.find(self.list_config[list_index], topic_delim) + self.list_config.remove(list_index) # Remove current config + list_size -= 1 # Continue for duplicates + end + list_index += 1 + end + end + self.list_config.push(line) # Add (re-discovered) config as last entry +# tasmota.log(format("STD: 222 Size %03d, Topic '%s', Line '%s'", self.list_config.size(), topic, line), 3) + end + return true # return true to stop propagation as a Tasmota cmd + end + + ################################################################################# + # handle_state_data(tele_topic, idx, data, databytes) + # + # Handle MQTT STATE data + ################################################################################# + def handle_state_data(tele_topic, idx, data, databytes) + var subtopic = string.split(tele_topic, "/") + if subtopic[-1] == "STATE" # tele/atomlite2/STATE + var topic = subtopic[1] # Assume default Fulltopic (%prefix%/%topic%/) = tele/atomlite2/STATE = atomlite2 + + var topic_index = -1 + for i: self.list_config.keys() + if 0 == string.find(self.list_config[i], topic) + topic_index = i + break + end + end +# tasmota.log(format("STD: Topic '%s', Index %d, Size %d, Line '%s'", topic, topic_index, self.list_config.size(), self.list_config[topic_index]), 3) + if topic_index == -1 return true end # return true to stop propagation as a Tasmota cmd + + var state = json.load(data) # Assume topic is in retained discovery list + if state # Valid JSON state message + var config_splits = string.split(self.list_config[topic_index], "\001") + var hostname = config_splits[1] + var ipaddress = config_splits[2] + var devicename = config_splits[3] + var version = config_splits[4] + + # tele/atomlite2/STATE = {"Time":"2025-09-24T14:13:00","Uptime":"0T00:15:09","UptimeSec":909,"Heap":142,"SleepMode":"Dynamic","Sleep":50,"LoadAvg":19,"MqttCount":1,"Berry":{"HeapUsed":12,"Objects":167},"POWER":"OFF","Dimmer":10,"Color":"1A0000","HSBColor":"0,100,10","Channel":[10,0,0],"Scheme":0,"Width":1,"Fade":"OFF","Speed":1,"LedTable":"ON","Wifi":{"AP":1,"SSId":"indebuurt_IoT","BSSId":"18:E8:29:CA:17:C1","Channel":11,"Mode":"HT40","RSSI":100,"Signal":-28,"LinkCount":1,"Downtime":"0T00:00:04"},"Hostname":"atomlite2","IPAddress":"192.168.2.208"} + var uptime = state['Uptime'] # 0T00:15:09 + if state.find('Hostname') + hostname = state['Hostname'] # atomlite2 + ipaddress = state['IPAddress'] # 192.168.2.208 + end + var last_seen = tasmota.rtc('local') + var line = format("%s\001%s\001%s\001%d\001%s\001%s", hostname, ipaddress, uptime, last_seen, devicename, version) + + if self.list_buffer.size() + var list_index = 0 + var list_size = size(self.list_buffer) + var hostname_delim = format("%s\001", hostname) # Add find delimiter + while list_index < list_size # Use while loop as counter is decremented + if 0 == string.find(self.list_buffer[list_index], hostname_delim) + self.list_buffer.remove(list_index) # Remove current state + list_size -= 1 # Continue for duplicates + end + list_index += 1 + end + end + self.list_buffer.push(line) # Add state as last entry + + end + end + return true # return true to stop propagation as a Tasmota cmd + end + + ################################################################################# + # sort_col(l, col, dir) + # + # Shell sort list of online devices based on user selected column and direction + ################################################################################# + def sort_col(l, col, dir) # Sort list based on col and Hostname (is first entry in line) + # For 50 records takes 6ms (primary key) or 25ms(ESP32S3&240MHz) / 50ms(ESP32@160MHz) (primary and secondary key) + var cmp = /a,b -> a < b # Sort up + if dir + cmp = /a,b -> a > b # Sort down + end + if col # col is new primary key (not Hostname) + for i:l.keys() + var splits = string.split(l[i], "\001") + l[i] = splits[col] + "\002" + l[i] # Add primary key to secondary key as "col" + Hostname + end + end + for i:1..size(l)-1 + var k = l[i] + var j = i + while (j > 0) && !cmp(l[j-1], k) + l[j] = l[j-1] + j -= 1 + end + l[j] = k + end + if col + for i:l.keys() + var splits = string.split(l[i], "\002") # Remove primary key + l[i] = splits[1] + end + end + return l + end + + ################################################################################# + # persist_save + # + # Save user data to be used on restart + ################################################################################# + def persist_save() + persist.std_devicename = self.bool_devicename + persist.std_version = self.bool_version + persist.std_ipaddress = self.bool_ipaddress + persist.std_column = self.sort_column + persist.std_direction = self.sort_direction + persist.save() +# tasmota.log("STD: Persist saved", 3) + end + + ################################################################################# + # web_sensor + # + # Display Devices Online in user selected sorted columns + ################################################################################# + def web_sensor() + if webserver.has_arg("sd_dn") + # Toggle display Device Name + if self.bool_devicename self.bool_devicename = false else self.bool_devicename = true end + self.persist_save() + elif webserver.has_arg("sd_sw") + # Toggle display software version + if self.bool_version self.bool_version = false else self.bool_version = true end + self.persist_save() + elif webserver.has_arg("sd_ip") + # Toggle display IP address + if self.bool_ipaddress self.bool_ipaddress = false else self.bool_ipaddress = true end + self.persist_save() + elif webserver.has_arg("sd_sort") + # Toggle sort column + self.sort_column = int(webserver.arg("sd_sort")) + if self.sort_last_column == self.sort_column + self.sort_direction ^= 1 + end + self.sort_last_column = self.sort_column + self.persist_save() + end + + if self.list_buffer.size() + var now = tasmota.rtc('local') + var time_window = now - self.line_teleperiod + var list_index = 0 + var list_size = size(self.list_buffer) + while list_index < list_size + var splits = string.split(self.list_buffer[list_index], "\001") + var last_seen = int(splits[3]) + if time_window > last_seen # Remove offline devices + self.list_buffer.remove(list_index) + list_size -= 1 + end + list_index += 1 + end + if !list_size return end # If list became empty bail out + + var msg = "" # Terminate two column table and open new table + msg += "" + + list_index = 0 + if 1 == self.line_option + list_index = list_size - self.line_cnt # Offset in list using self.line_cnt + if list_index < 0 list_index = 0 end + + if self.bool_devicename + msg += "" + end + if self.bool_version + msg += "" + end + msg += "" + if self.bool_ipaddress + msg += "" + end + msg += "" + else + self.sort_col(self.list_buffer, self.sort_column, self.sort_direction) # Sort list by column + + var icon_direction = self.sort_direction ? "▼" : "▲" + if self.bool_devicename + msg += format("", self.sort_column == 4 ? icon_direction : "") + end + if self.bool_version + msg += format("", self.sort_column == 5 ? icon_direction : "") + end + msg += format("", self.sort_column == 0 ? icon_direction : "") + if self.bool_ipaddress + msg += format("", self.sort_column == 1 ? icon_direction : "") + end + msg += format("", self.sort_column == 2 ? icon_direction : "") + end + + msg += "" + + while list_index < list_size + var splits = string.split(self.list_buffer[list_index], "\001") + var hostname = splits[0] + var ipaddress = splits[1] + var uptime = splits[2] + var last_seen = int(splits[3]) + var devicename = splits[4] + var version = splits[5] + + msg += "" + if self.bool_devicename + msg += format("", devicename) + end + if self.bool_version + msg += format("", version) + end + msg += format("", hostname, hostname) + if self.bool_ipaddress + msg += format("", ipaddress, ipaddress) + end + + var uptime_str = string.replace(uptime, "T", ":") # 11T21:50:34 -> 11:21:50:34 + var uptime_splits = string.split(uptime_str, ":") + var uptime_sec = (int(uptime_splits[0]) * 86400) + # 11 * 86400 + (int(uptime_splits[1]) * 3600) + # 21 * 3600 + (int(uptime_splits[2]) * 60) + # 50 * 60 + int(uptime_splits[3]) # 34 + if last_seen >= (now - self.line_highlight) # Highlight changes within latest seconds + msg += format("", self.line_highlight_color, uptime) + elif uptime_sec < self.line_teleperiod # Highlight changes just after restart + msg += format("", self.line_lowuptime_color, uptime) + else + msg += format("", uptime) + end + + msg += "" + list_index += 1 + end + msg += "
Device Name Version Hostname IP Address Uptime Device Name%s Version%s Hostname%s IP Address%s Uptime%s 
%s %s %s %s %s%s%s
{t}" # Terminate three/four/five column table and open new table: + msg += format("{s}Devices online{m}%d{e}", list_size) # + + msg += "
Devices online%d

{t}" # Terminate two column table and open new table: + msg += "" + msg += "" + msg += "" + msg += "
{t}" # Terminate two column table and open new table: + + tasmota.web_send(msg) # Do not use tasmota.web_send_decimal() which will replace IPAddress dots + tasmota.web_send_decimal("") # Force horizontal line + end + end + +end + +return devices_online() diff --git a/tasmota/berry/extensions/Devices_Online/manifest.json b/tasmota/berry/extensions/Devices_Online/manifest.json new file mode 100644 index 000000000..91b25c37b --- /dev/null +++ b/tasmota/berry/extensions/Devices_Online/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Devices Online", + "version": "0x01010100", + "description": "Display devices online", + "author": "Theo Arends", + "min_tasmota": "0x0E060001", + "features": "" +}