From 5493323272206e6cbe666263971f270c0e5424d0 Mon Sep 17 00:00:00 2001
From: Fabrizio Amodio <32312585+ZioFabry@users.noreply.github.com>
Date: Mon, 11 Aug 2025 14:23:27 +0200
Subject: [PATCH] LoRaWAN Decoder Framework Enhancement (#23772)
* LwDecode.be refactoring & optimization by Claude.ai
* SendDownlinkMap implemented
* LwReload command implemented to live reload of the LoRaWAN decoder without restarting berry
* SendDownLink response refactory
---
tasmota/berry/lorawan/decoders/LwDecode.be | 426 ++++++++++++++-------
1 file changed, 291 insertions(+), 135 deletions(-)
diff --git a/tasmota/berry/lorawan/decoders/LwDecode.be b/tasmota/berry/lorawan/decoders/LwDecode.be
index 758f3637f..87f4987dd 100644
--- a/tasmota/berry/lorawan/decoders/LwDecode.be
+++ b/tasmota/berry/lorawan/decoders/LwDecode.be
@@ -1,174 +1,300 @@
# Decoder files are modeled on the *.js files found here:
# https://github.com/TheThingsNetwork/lorawan-devices/tree/master/vendor
+import mqtt
+import string
+
var LwRegions = ["EU868","US915","IN865","AU915","KZ865","RU864","AS923","AS923-1","AS923-2","AS923-3"]
var LwDeco
-import mqtt
-import string
-
class LwSensorFormatter_cls
- var Msg
-
- static Formatter = {
+ static var Formatter = {
"string": { "u": nil, "f": " %s", "i": nil },
- "volt": { "u": "V", "f": " %.1f", "i": "⚡" }, # High Voltage ⚡
- "milliamp": { "u": "mA", "f": " %.0f", "i": "🔌" }, # Electric Plug 🔌
- "power_factor%": { "u": "%", "f": " %.0f", "i": "📊" }, # Bar Chart 📊
- "power": { "u": "W", "f": " %.0f", "i": "💡" }, # Light Bulb 💡
- "energy": { "u": "Wh", "f": " %.0f", "i": "🧮" }, # Abacus 🧮
- "altitude": { "u": "mt", "f": " %d", "i": "⛰" }, # Moutain ⛰
- "empty": { "u": nil, "f": nil, "i": nil }
+ "volt": { "u": "V", "f": " %.1f", "i": "⚡" },
+ "milliamp": { "u": "mA", "f": " %.0f", "i": "🔌" },
+ "power_factor%": { "u": "%", "f": " %.0f", "i": "📊" },
+ "power": { "u": "W", "f": " %.0f", "i": "💡" },
+ "energy": { "u": "Wh", "f": " %.0f", "i": "🧮" },
+ "altitude": { "u": "mt", "f": " %d", "i": "⛰" },
+ "empty": { "u": nil, "f": nil, "i": nil }
}
+ var msg_buffer
+
def init()
- self.Msg = ""
+ self.msg_buffer = bytes(512)
+ self.msg_buffer.clear()
end
-
+
def start_line()
- self.Msg += format("
┆" ) # | ...
+ self.msg_buffer .. "| ┆"
return self
end
def end_line()
- self.Msg += "{e}" # End of line
+ self.msg_buffer .. "{e}"
return self
end
def next_line()
- return self.end_line().start_line() # End of current line and start new line
+ self.msg_buffer .. "{e} | ┆"
+ return self
end
def begin_tooltip(ttip)
- self.Msg += format(" ", ttip)
+ self.msg_buffer .. format(" ", ttip)
return self
end
def end_tooltip()
- self.Msg += " "
+ self.msg_buffer .. " "
return self
end
def add_link(title, url, target)
- self.Msg += format( " %s", ( target ? target : "_blank" ), url, title )
+ if !target target = "_blank" end
+ self.msg_buffer .. format(" %s", target, url, title)
return self
end
def add_sensor(formatter, value, tooltip, alt_icon)
- if tooltip
- self.begin_tooltip(tooltip)
- end
+
+ if tooltip self.begin_tooltip(tooltip) end
var fmt = self.Formatter.find(formatter)
- if alt_icon
- self.Msg += format(" %s", alt_icon ) # Use alternative icon
+ if alt_icon
+ self.msg_buffer .. format(" %s", alt_icon)
elif fmt && fmt.find("i") && fmt["i"]
- self.Msg += format(" %s", fmt["i"] ) # Use icon from formatter
+ self.msg_buffer .. format(" %s", fmt["i"])
end
if fmt && fmt.find("f") && fmt["f"]
- self.Msg += format(fmt["f"], value)
+ self.msg_buffer .. format(fmt["f"], value)
else
- self.Msg += str(value) # Default to string representation
+ self.msg_buffer .. str(value)
end
if fmt && fmt.find("u") && fmt["u"]
- self.Msg += format("%s", fmt["u"]) # Append unit if defined
- end
-
- if tooltip
- return self.end_tooltip()
+ self.msg_buffer .. format("%s", fmt["u"])
end
+ if tooltip self.end_tooltip() end
return self
end
def get_msg()
- return self.Msg
+ return self.msg_buffer.asstring()
end
end
class lwdecode_cls
- var LwDecoders
- var topic
+ var lw_decoders
+ var topic_cached
+ var last_payload_hash
+ var web_msg_cache
+ var cache_timeout
+ var decoder_timestamps
def init()
- self.LwDecoders = {}
- self.topic = string.replace(string.replace(
- tasmota.cmd('_FullTopic',true)['FullTopic'],
- '%topic%', tasmota.cmd('_Status',true)['Status']['Topic']),
- '%prefix%', tasmota.cmd('_Prefix',true)['Prefix3']) # tele
- + 'SENSOR'
+ self.lw_decoders = {}
+ self.last_payload_hash = 0
+ self.web_msg_cache = ""
+ self.cache_timeout = 0
+ self.decoder_timestamps = {}
+
+ self._cache_topic()
if global.lwdecode_driver
global.lwdecode_driver.stop() # Let previous instance bail out cleanly
end
tasmota.add_driver(global.lwdecode_driver := self)
- tasmota.add_rule("LwReceived", /value, trigger, payload -> self.LwDecode(payload))
+ tasmota.add_rule("LwReceived", /value, trigger, payload -> self.lw_decode(payload))
+ tasmota.add_cmd('LwReload', /cmd, idx, payload, payload_json -> self.cmd_reload_decoder(cmd, idx, payload))
+ end
+
+ def uint16le(value)
+ return string.format( "%02x%02x",
+ value & 0xFF,
+ (value >> 8) & 0xFF
+ )
+ end
+
+ def uint32le(value)
+ return string.format( "%02x%02x%02x%02x",
+ value & 0xFF,
+ (value >> 8) & 0xFF,
+ (value >> 16) & 0xFF,
+ (value >> 24) & 0xFF
+ )
+ end
+
+ def _cache_topic()
+ var full_topic = tasmota.cmd('_FullTopic',true)['FullTopic']
+ var topic = tasmota.cmd('_Status',true)['Status']['Topic']
+ var prefix = tasmota.cmd('_Prefix',true)['Prefix3']
+
+ self.topic_cached = string.replace(string.replace(full_topic, '%topic%', topic), '%prefix%', prefix) + 'SENSOR'
end
def SendDownlink(nodes, cmd, idx, payload, ok_result)
- if nodes.find(idx)
- var sendcmd = 'LoRaWanSend'
- var output = tasmota.cmd( f'{sendcmd}{idx} {payload}', true)
- if output.find(sendcmd) == 'Done'
- return tasmota.resp_cmnd(f'{{"{cmd}{idx}":"{ok_result}"}}')
+ if !nodes.find(idx) return nil end
+
+ var _send = 'LoRaWanSend'
+ var _cmdSend = _send + str(idx) + ' ' + payload
+ var _out = tasmota.cmd(_cmdSend, true)
+
+ return tasmota.resp_cmnd(
+ format('{"%s%i":"%s","%s":"%s","Payload":"%s"}',
+ cmd,
+ idx,
+ ok_result,
+ _send,
+ _out[_send],
+ payload
+ )
+ )
+ end
+
+ def SendDownlinkMap(nodes, cmd, idx, payload, choice_map)
+ var key = string.toupper(str(payload))
+ for choice_key : choice_map.keys()
+ if string.find(choice_key, key) >= 0 && (choice_key == key || string.find(choice_key, '|' + key + '|') >= 0 || string.find(choice_key, key + '|') == 0 || string.find(choice_key, '|' + key) == size(choice_key) - size(key) - 1)
+ var choice = choice_map[choice_key]
+ return self.SendDownlink(nodes, cmd, idx, choice[0], choice[1])
end
- return output
+ end
+ return tasmota.resp_cmnd_error()
+ end
+
+ def reload_decoder(decoder_name)
+ try
+ if self.lw_decoders.find(decoder_name)
+ self.lw_decoders.remove(decoder_name)
+ end
+ LwDeco = nil
+ load(decoder_name)
+ if LwDeco
+ self.lw_decoders[decoder_name] = LwDeco
+ self.decoder_timestamps[decoder_name] = tasmota.millis()
+ print(format("Decoder %s reloaded successfully", decoder_name))
+ return true
+ else
+ print(format("Failed to reload decoder %s", decoder_name))
+ return false
+ end
+ except .. as e, m
+ print(format("Error reloading decoder %s: %s", decoder_name, m))
+ return false
end
end
- def LwDecode(data)
+ def cmd_reload_decoder(cmd, idx, payload)
+ if payload == ""
+ var reloaded = []
+ var failed = []
+ for decoder_name : self.lw_decoders.keys()
+ if self.reload_decoder(decoder_name)
+ reloaded.push(decoder_name)
+ else
+ failed.push(decoder_name)
+ end
+ end
+ var result = format("Reloaded: %i", reloaded.size())
+ if failed.size() > 0
+ result += format(", Failed: %i", failed.size())
+ end
+ return tasmota.resp_cmnd(format('{"LwReload":"%s"}', result))
+ else
+ var success = self.reload_decoder(payload)
+ var status = success ? "OK" : "Failed"
+ return tasmota.resp_cmnd(format('{"LwReload":"%s %s"}', payload, status))
+ end
+ end
+
+ def _calculate_payload_hash(payload)
+ var hash = 0
+ for i:0..payload.size()-1
+ hash = (hash * 31 + payload[i]) & 0xFFFFFFFF
+ end
+ return hash
+ end
+
+ def lw_decode(data)
import json
- var deviceData = data['LwReceived']
- var deviceName = deviceData.keys()()
- var Name = deviceData[deviceName]['Name']
- var Node = deviceData[deviceName]['Node']
- var RSSI = deviceData[deviceName]['RSSI']
- var Payload = deviceData[deviceName]['Payload']
- var FPort = deviceData[deviceName]['FPort']
- var decoder = deviceData[deviceName].find('Decoder')
- if !decoder
- return true
+ var device_data = data['LwReceived']
+ var device_name = device_data.keys()()
+ var device_info = device_data[device_name]
+
+ var decoder = device_info.find('Decoder')
+ if !decoder return true end
+
+ var payload = device_info['Payload']
+ if !payload || payload.size() == 0 return true end
+
+ var current_hash = self._calculate_payload_hash(payload)
+ if current_hash == self.last_payload_hash return true end
+ self.last_payload_hash = current_hash
+
+ if !self.lw_decoders.find(decoder)
+ try
+ LwDeco = nil
+ load(decoder)
+ if LwDeco
+ self.lw_decoders[decoder] = LwDeco
+ else
+ return true
+ end
+ except .. as e, m
+ print(format("Decoder load error: %s", m))
+ return true
+ end
end
-
- if !self.LwDecoders.find(decoder)
- LwDeco = nil
- load(decoder) # Sets LwDeco if found
- if LwDeco
- self.LwDecoders.insert(decoder, LwDeco)
- end
- end
- if Payload.size() && self.LwDecoders.find(decoder)
- var decoded = self.LwDecoders[decoder].decodeUplink(Name, Node, RSSI, FPort, Payload)
- decoded.insert("Node", Node)
- decoded.insert("RSSI", RSSI)
- var mqttData = {deviceName:decoded}
- # Abuse SetOption83 - (Zigbee) Use FriendlyNames (1) instead of ShortAddresses (0) when possible
+ try
+ var decoded = self.lw_decoders[decoder].decodeUplink(
+ device_info['Name'],
+ device_info['Node'],
+ device_info['RSSI'],
+ device_info['FPort'],
+ payload
+ )
+
+ decoded['Node'] = device_info['Node']
+ decoded['RSSI'] = device_info['RSSI']
+
+ var mqtt_data
if tasmota.get_option(83) == 0 # SetOption83 - Remove LwDecoded form JSON message (1)
- mqttData = {"LwDecoded":{deviceName:decoded}}
+ mqtt_data = {"LwDecoded": {device_name: decoded}}
+ else
+ mqtt_data = {device_name: decoded}
end
- var topic = self.topic
- if tasmota.get_option(89) == 1 # SetOption89 - Distinct MQTT topics per device (1)
- topic = self.topic + "/" + deviceData[deviceName]['DevEUIh'] + deviceData[deviceName]['DevEUIl']
- end
- mqtt.publish(topic, json.dump(mqttData))
- tasmota.global.restart_flag = 0 # Signal LwDecoded successful (default state)
- end
- return true #processed
+ var topic
+ if tasmota.get_option(89) == 1 # SetOption89 - Distinct MQTT topics per device (1)
+ topic = format("%s/%s%s", self.topic_cached, device_info['DevEUIh'], device_info['DevEUIl'])
+ else
+ topic = self.topic_cached
+ end
+
+ mqtt.publish(topic, json.dump(mqtt_data))
+ tasmota.global.restart_flag = 0 # Signal LwDecoded successful (default state)
+
+ except .. as e, m
+ print(format("Decode error for %s: %s", device_name, m))
+ end
+
+ return true
end
def dhm(last_time)
var since = tasmota.rtc('local') - last_time
var unit = "d"
- if since > (24 * 3600)
- since /= (24 * 3600)
+ if since > 86400
+ since /= 86400
if since > 99 since = 99 end
- elif since > 3600
+ elif since > 3600
since /= 3600
unit = "h"
else
@@ -179,13 +305,13 @@ class lwdecode_cls
end
def dhm_tt(last_time)
- return format( "Received %s ago", self.dhm(last_time) )
+ return format("Received %s ago", self.dhm(last_time))
end
def header(name, name_tooltip, battery, battery_last_seen, rssi, last_seen)
- var color_text = f'{tasmota.webcolor(0 #-COL_TEXT-#)}' # '#eaeaea'
- var msg = " | " # ==== Start first table row
- msg += format("| %s | ", name_tooltip, name)
+ var color_text = format('%s', tasmota.webcolor(0))
+ var msg = format(" | %s | ", name_tooltip, name)
+
if battery < 1000
# Battery low <= 2.5V (0%), high >= 3.1V (100%)
var batt_percent = (battery * 1000) - 2500
@@ -205,6 +331,7 @@ class lwdecode_cls
else
msg += " | "
end
+
if rssi < 1000
if rssi < -132 rssi = -132 end
var num_bars = 4 - ((rssi * -1) / 33)
@@ -216,9 +343,9 @@ class lwdecode_cls
else
msg += " | "
end
- msg += format("🕗%s | ", # Clock
- color_text, self.dhm(last_seen))
- msg += " " # ==== End first table row
+
+ msg += format("🕗%s | |
", color_text, self.dhm(last_seen))
+
return msg
end #sensor()
@@ -227,13 +354,21 @@ class lwdecode_cls
Called every WebRefresh time
------------------------------------------------------------#
def web_sensor()
- var msg = ""
- for decoder: self.LwDecoders
- msg += decoder.add_web_sensor()
+ var current_time = tasmota.millis()
+
+ if current_time < self.cache_timeout
+ tasmota.web_send_decimal(self.web_msg_cache)
+ return
end
+
+ var msg = ""
+ for decoder: self.lw_decoders
+ msg += decoder.add_web_sensor()
+ end
+
if msg
- var color_text = f'{tasmota.webcolor(0 #-COL_TEXT-#)}' # '#eaeaea'
- var full_msg = format("" # Terminate current two column table and open new table
+ var color_text = format('%s', tasmota.webcolor(0 #-COL_TEXT-#)) # '#eaeaea'
+ var full_msg = format("" # Terminate current two column table and open new table
""
- "{t}", # Open new table
- color_text)
- full_msg += msg
- full_msg += "{t}" # Close table and open new table
+ "{t}%s{t}",
+ color_text, msg)
+ self.web_msg_cache = full_msg
+ self.cache_timeout = current_time + 5000
tasmota.web_send_decimal(full_msg)
end
end
@@ -259,28 +394,46 @@ lwdecode = lwdecode_cls()
import webserver
class webPageLoRaWAN : Driver
+ var max_node_cached
+
+ def init()
+ self.max_node_cached = nil
+ end
+
def web_add_config_button()
webserver.content_send("