Tasmota/lib/libesp32/berry_matter/src/embedded/Matter_HTTP_remote.be
2024-06-07 20:44:17 +02:00

382 lines
14 KiB
Plaintext

#
# Matter_HTTP_remote.be - implements an interface to query remotely Tasmota device via HTTP
#
# Copyright (C) 2023 Stephan Hadinger & Theo Arends
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import matter
#@ solidify:Matter_HTTP_remote,weak
# dummy declaration for solidification
class Matter_HTTP_async end
#############################################################
# This class has the following purposes:
#
# 1. Have multiple classes registered for async requests
# and dispatch to all instances results calling `cb(status, payload, index)`
# Async requests are currently only `Status <x>` requests
# and are dispatched with the `<x>` index
#
# 2. Implement rate limiting when sending async `Status <x>`
# requests to align with the lowest cycle time.
# I.e. if plugin sends every 2 seconds and another every 3 seconds
# we only send every 2 seconds and dispatch results to all plugins.
class Matter_HTTP_remote : Matter_HTTP_async
var device # reference to matter_device
var probe_update_time_map # number of milliseconds to wait for each async command (map)
var probe_next_timestamp_map # timestamp for last probe (in millis()) for each `Status <x>`
# if timestamp is `0`, this should be scheduled in priority
var async_cb_map # list of callbacks to call with `cb(http_status, payload)`
# information about current async command in-flight
var current_cmd # current async command
# `nil` if current request is synchronous
var reachable # is the device reachable
var reachable_utc # last tick when the reachability was seen (avoids sending superfluous ping commands)
static var STATUS_PREFIX = [
"Status", # 0
"StatusPRM", # 1
"StatusFWR", # 2
"StatusLOG", # 3
"StatusMEM", # 4
"StatusNET", # 5
"StatusMQT", # 6
"StatusTIM", # 7
nil, # 8 is deprecated and synonym of 10
"StatusPTH", # 9
"StatusSNS", # 10
"StatusSTS", # 11
"StatusSTK", # 12
"StatusSHT" # 13
]
# information gathered about the remote device (name, version...)
static var UPDATE_TIME = 5000 # update every 5s until first response
static var UPDATE_TIME2 = 300000 # then update every 5 minutes
static var UPDATE_CMD0 = "Status" # command to send for updates
static var UPDATE_CMD2 = "Status 2" # command to send for updates
static var UPDATE_CMD5 = "Status 5" # command to send for updates
var info # as a map
#############################################################
# init
def init(device, addr, timeout, fastloop)
self.device = device # if device is null, it's a temporary object not linked to a device
self.probe_update_time_map = {}
self.probe_next_timestamp_map = {}
self.async_cb_map = {}
self.current_cmd = nil
self.reachable = false # force a valid bool value
super(self).init(addr, 80, timeout, fastloop)
# set up some reading information about the device
self.info = {}
if self.device
# we need different callbacks per command (don't create a single one for both calls)
self.add_schedule(self.UPDATE_CMD0, self.UPDATE_TIME, / status,payload,cmd -> self.parse_status_response_and_call_method(status,payload,cmd,self,self.parse_status_http))
self.add_schedule(self.UPDATE_CMD2, self.UPDATE_TIME, / status,payload,cmd -> self.parse_status_response_and_call_method(status,payload,cmd,self,self.parse_status_http))
self.add_schedule(self.UPDATE_CMD5, self.UPDATE_TIME, / status,payload,cmd -> self.parse_status_response_and_call_method(status,payload,cmd,self,self.parse_status_http))
end
end
#############################################################
# get/set remote_info map
def get_info() return self.info end
def set_info(v) self.info = v end
def info_changed() self.device.save_param() end # send a signal to global device that remote information changed
#############################################################
# parse response for `Status` and `Status 2`
#
# Payload can be a string (unparsed) or a map
def parse_status_response_and_call_method(status, payload, cmd, obj, method)
if status != nil && status > 0
# device is known to be reachable
self.device_is_alive(true)
var j = payload
if type(j) == 'string'
import json
j = json.load(j)
end
var code = nil # index of Status, nil of none
if j != nil
# detect any Status prefix and compute Status<code>
var i = 0
var prefix_tab = self.STATUS_PREFIX # move to local variable to avoid many dereferencing
while i < size(prefix_tab)
var status_prefix = prefix_tab[i]
if status_prefix != nil
if j.contains(status_prefix)
j = j[status_prefix]
code = i
break
end
end
i = i + 1
end
# dispatch to method in charge of converting to shadow values
method(obj, j, code)
else
log(f"MTR: *** failed to parse JSON response {payload=}", 3)
end
end
end
#############################################################
# Stub for updating shadow values (local copies of what we published to the Matter gateway)
#
# This call is synchronous and blocking.
def parse_status_http(data, index)
var changed = false
if index == 0 # Status
var device_name = data.find("DeviceName") # we consider 'Tasmota' as the non-information default
if device_name == "Tasmota" device_name = nil end
# did the value changed?
if self.info.find('name') != device_name
if device_name != nil
self.info['name'] = device_name
else
self.info.remove("name")
end
log(f"MTR: update '{self.addr}' name='{device_name}'", 3)
changed = true
end
# reduce the update time after a read is succesful
self.change_schedule(self.UPDATE_CMD0, self.UPDATE_TIME2)
elif index == 2 # Status 2
var version = data.find("Version")
var hardware = data.find("Hardware")
if self.info.find('version') != version
if version != nil
self.info['version'] = version
else
self.info.remove('version')
end
log(f"MTR: update '{self.addr}' version='{version}'", 3)
changed = true
end
if self.info.find('hardware') != hardware
if hardware != nil
self.info['hardware'] = hardware
else
self.info.remove('hardware')
end
log(f"MTR: update '{self.addr}' hardware='{hardware}'", 3)
changed = true
end
# reduce the update time after a read is succesful
self.change_schedule(self.UPDATE_CMD2, self.UPDATE_TIME2)
elif index == 5
var mac = data.find("Mac")
# did the value changed?
if self.info.find('mac') != mac
if mac != nil
self.info['mac'] = mac
else
self.info.remove("mac")
end
log(f"MTR: update '{self.addr}' mac='{mac}'", 3)
changed = true
end
# reduce the update time after a read is succesful
self.change_schedule(self.UPDATE_CMD5, self.UPDATE_TIME2)
end
if changed self.info_changed() end
end
#############################################################
# device is alive, update reachable_utc
def device_is_alive(alive)
if alive
# device is known to be reachable
self.reachable = true
self.reachable_utc = tasmota.rtc_utc()
else
self.reachable = false
end
end
#############################################################
# Add an async command to scheduler
#
# cmd: the Tasmota command as string
# update_time: recurrence in ms (min time of all updates)
# cb: callback to this command (optional)
def add_schedule(cmd, update_time, cb)
if !self.probe_update_time_map.contains(cmd) || update_time < self.probe_update_time_map[cmd]
# if cmd is not already registered, or if the update_time needs to be reduced
self.probe_update_time_map[cmd] = update_time
self.probe_next_timestamp_map[cmd] = matter.jitter(update_time)
end
# do we add a cb?
if cb != nil
self.add_async_cb(cb, cmd)
end
end
#############################################################
# Change schedule of current cmd
def change_schedule(cmd, update_time)
if self.probe_update_time_map.contains(cmd)
self.probe_update_time_map[cmd] = update_time
self.probe_next_timestamp_map[cmd] = matter.jitter(update_time)
end
end
#############################################################
# Add a callback to be called for a specific `cmd` or `nil` for all commands
def add_async_cb(cb, cmd)
self.async_cb_map[cb] = cmd
end
#############################################################
# Send event to all registered cb
#
def dispatch_cb(status, payload)
var idx = 0
for cb: self.async_cb_map.keys()
var cmd_filter = self.async_cb_map[cb]
if cmd_filter == self.current_cmd || cmd_filter == nil # match command or match any
cb(status, payload, self.current_cmd) # call cb
end
end
end
#############################################################
# probe_async
#
# Sends a command like `Status <x>` asynchronously to get device status.
# Implement rate limiting for devices with multiple sub-devices
def probe_async(cmd)
import string
import webserver
if !tasmota.wifi()['up'] && !tasmota.eth()['up'] return nil end # no network
self.current_cmd = cmd
var cmd_url = "/cm?cmnd=" + string.tr(cmd, ' ', '+')
log(format("MTR: HTTP async request 'http://%s:%i%s'", self.addr, self.port, cmd_url), 4)
var ret = self.begin(cmd_url)
end
#############################################################
# begin_sync
#
# Synchronous (blocking version)
#
# returns nil if something went wrong
# returns the payload as string
# Note: sync request aborts any ongoing async request
def call_sync(cmd, timeout)
import string
import webserver
if !tasmota.wifi()['up'] && !tasmota.eth()['up'] return nil end # no network
self.current_cmd = nil
var cmd_url = "/cm?cmnd=" + string.tr(cmd, ' ', '+')
log(format("MTR: HTTP sync request 'http://%s:%i%s'", self.addr, self.port, cmd_url), 4)
var ret = super(self).begin_sync(cmd_url, timeout)
var payload_short = (ret) ? ret : 'nil'
if size(payload_short) > 30 payload_short = payload_short[0..29] + '...' end
log(format("MTR: HTTP sync-resp in %i ms from %s: [%i] '%s'", tasmota.millis() - self.time_start, self.addr, size(self.payload), payload_short), 3)
return ret
end
def event_http_finished()
if self.current_cmd == nil return end # do nothing if sync request
var payload_short = (self.payload != nil) ? self.payload : 'nil'
if size(payload_short) > 30 payload_short = payload_short[0..29] + '...' end
log(format("MTR: HTTP async-resp in %i ms from %s: [%i] '%s'", tasmota.millis() - self.time_start, self.addr, size(self.payload), payload_short), 3)
self.dispatch_cb(self.http_status, self.payload)
end
def event_http_failed()
if self.current_cmd == nil return end # do nothing if sync request
log("MTR: HTTP failed", 3)
self.dispatch_cb(self.http_status, nil)
end
def event_http_timeout()
if self.current_cmd == nil return end # do nothing if sync request
log(format("MTR: HTTP timeout http_status=%i phase=%i tcp_status=%i size_payload=%i", self.http_status, self.phase, self.status, size(self.payload)), 3)
self.dispatch_cb(self.http_status, nil)
end
#############################################################
# scheduler
#
# check if the timer expired and update_shadow() needs to be called
def scheduler()
var cmd = nil # keep trakck of command
for k: self.probe_next_timestamp_map.keys()
if self.probe_next_timestamp_map[k] == 0
cmd = k # schedule in priority
break
end
end
if cmd == nil # if no priotity, find first eligible
for k: self.probe_next_timestamp_map.keys()
if tasmota.time_reached(self.probe_next_timestamp_map[k])
cmd = k
break
end
end
end
if cmd == nil return end # nothing to do
if self.tcp_connected # we have already an async request in-flight
self.probe_next_timestamp_map[cmd] = 0 # mark as priority scheduling for near future
return
end
# set new next timestamp
self.probe_next_timestamp_map[cmd] = tasmota.millis(self.probe_update_time_map[cmd]) # add update_time for command
# trigger the command
self.probe_async(cmd)
end
#############################################################
# web_last_seen
#
# Show when the device was last seen
def web_last_seen()
import webserver
var seconds = -1 # default if no known value
if self.reachable_utc != nil
seconds = tasmota.rtc_utc() - self.reachable_utc
end
return matter.seconds_to_dhm(seconds)
end
end
matter.HTTP_remote = Matter_HTTP_remote