Files
tsun-gen3-proxy/app/src/messages.py
Stefan Allius 093ec03d60 S allius/issue391 (#392)
* design counter on connection board

* display time of last update and add reload button

* chance `Updated` field to a real button

* Provide counter values for the dashboard

* change background color ot the top branch

- use dark-grey instead of black to reduce the contrast

* change color of counter tiles

* test proxy connection counter handling

* prepare conn-table and notes list building

* remove obsolete menue points

* store client mode for dashboard

* store inverters serial number for the dashboard

* store inverters serial number

* build connection table for dashboard

* add connection table to dashboard

* fix responsiveness of the tiles

* adapt unit tests

* remove test fake code

* increase test coverage, remove obsolete if statement
2025-04-24 23:12:26 +02:00

254 lines
8.2 KiB
Python

import logging
import weakref
from typing import Callable
from enum import Enum
from async_ifc import AsyncIfc
from protocol_ifc import ProtocolIfc
from infos import Infos, Register
from modbus import Modbus
from my_timer import Timer
logger = logging.getLogger('msg')
def __hex_val(n, data, data_len):
line = ''
for j in range(n-16, n):
if j >= data_len:
break
line += '%02x ' % abs(data[j])
return line
def __asc_val(n, data, data_len):
line = ''
for j in range(n-16, n):
if j >= data_len:
break
c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.'
line += '%c' % c
return line
def hex_dump(data, data_len) -> list:
n = 0
lines = []
for i in range(0, data_len, 16):
line = ' '
line += '%04x | ' % (i)
n += 16
line += __hex_val(n, data, data_len)
line += ' ' * (3 * 16 + 9 - len(line)) + ' | '
line += __asc_val(n, data, data_len)
lines.append(line)
return lines
def hex_dump_str(data, data_len):
lines = hex_dump(data, data_len)
return '\n'.join(lines)
def hex_dump_memory(level, info, data, data_len):
lines = []
lines.append(info)
tracer = logging.getLogger('tracer')
if not tracer.isEnabledFor(level):
return
lines += hex_dump(data, data_len)
tracer.log(level, '\n'.join(lines))
class State(Enum):
'''state of the logical connection'''
init = 0
'''just created'''
received = 1
'''at least one packet received'''
up = 2
'''at least one cmd-rsp transaction'''
pend = 3
'''inverter transaction pending, don't send MODBUS cmds'''
closed = 4
'''connection closed'''
class Message(ProtocolIfc):
MAX_START_TIME = 400
'''maximum time without a received msg in sec'''
MAX_INV_IDLE_TIME = 120
'''maximum time without a received msg from the inverter in sec'''
MAX_DEF_IDLE_TIME = 360
'''maximum default time without a received msg in sec'''
MB_START_TIMEOUT = 40
'''start delay for Modbus polling in server mode'''
MB_REGULAR_TIMEOUT = 60
'''regular Modbus polling time in server mode'''
def __init__(self, node_id, ifc: "AsyncIfc", server_side: bool,
send_modbus_cb: Callable[[bytes, int, str], None],
mb_timeout: int):
self._registry.append(weakref.ref(self))
self.server_side = server_side
self.ifc = ifc
self.node_id = node_id
if server_side:
self.mb = Modbus(send_modbus_cb, mb_timeout)
self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
else:
self.mb = None
self.mb_timer = None
self.header_valid = False
self.header_len = 0
self.data_len = 0
self.unique_id = 0
self.inv_serial = ''
self.sug_area = ''
self.new_data = {}
self.state = State.init
self.shutdown_started = False
self.modbus_elms = 0 # for unit tests
self.mb_timeout = self.MB_REGULAR_TIMEOUT
self.mb_first_timeout = self.MB_START_TIMEOUT
'''timer value for next Modbus polling request'''
self.modbus_polling = False
self.mb_start_reg = 0
self.mb_step = 0
self.mb_bytes = 0
self.mb_inv_no = 1
self.mb_scan = False
@property
def node_id(self):
return self._node_id
@node_id.setter
def node_id(self, value):
self._node_id = value
self.ifc.set_node_id(value)
'''
Empty methods, that have to be implemented in any child class which
don't use asyncio
'''
def _read(self) -> None: # read data bytes from socket and copy them
# to our _recv_buffer
return # pragma: no cover
def _set_config_parms(self, inv: dict, inv_serial: str):
'''init connection with params from the configuration'''
self.inv_serial = inv_serial
self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
if 'modbus_scanning' in inv:
scan = inv['modbus_scanning']
self.mb_scan = True
self.mb_start_reg = scan['start']
self.mb_step = scan['step']
self.mb_bytes = scan['bytes']
if 'client_mode' in inv:
self.mb_start_reg = scan['start']
else:
self.mb_start_reg = scan['start'] - scan['step']
self.mb_start_reg &= 0xffff
if self.mb:
self.mb.set_node_id(self.node_id)
def _set_mqtt_timestamp(self, key, ts: float | None):
if key not in self.new_data or \
not self.new_data[key]:
if key == 'grid':
info_id = Register.TS_GRID
elif key == 'input':
info_id = Register.TS_INPUT
elif key == 'total':
info_id = Register.TS_TOTAL
else:
return
# tstr = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
# logger.info(f'update: key: {key} ts:{tstr}'
self.db.set_db_def_value(info_id, round(ts))
def _timeout(self) -> int:
if self.state == State.init or self.state == State.received:
to = self.MAX_START_TIME
elif self.state == State.up and \
self.server_side and self.modbus_polling:
to = self.MAX_INV_IDLE_TIME
else:
to = self.MAX_DEF_IDLE_TIME
return to
def _send_modbus_cmd(self, dev_id, func, addr, val, log_lvl) -> None:
if self.state != State.up:
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
' as the state is not UP')
return
self.mb.build_msg(dev_id, func, addr, val, log_lvl)
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
self._send_modbus_cmd(Modbus.INV_ADDR, func, addr, val, log_lvl)
def _send_modbus_scan(self):
self.mb_start_reg += self.mb_step
if self.mb_start_reg > 0xffff:
self.mb_start_reg = self.mb_start_reg & 0xffff
self.mb_inv_no += 1
logging.info(f"Next Round: inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
if (self.mb_start_reg & 0xfffc) % 0x80 == 0:
logging.info(f"[{self.node_id}] Scan info: "
f"inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS,
self.mb_start_reg, self.mb_bytes,
logging.INFO)
def _dump_modbus_scan(self, data, hdr_len, modbus_msg_len):
if (data[hdr_len] == self.mb_inv_no and
data[hdr_len+1] == Modbus.READ_REGS):
logging.info(f'[{self.node_id}] Valid MODBUS data '
f'(reg: 0x{self.mb.last_reg:04x}):')
hex_dump_memory(logging.INFO, 'Valid MODBUS data '
f'(reg: 0x{self.mb.last_reg:04x}):',
data[hdr_len:], modbus_msg_len)
'''
Our puplic methods
'''
def close(self) -> None:
if self.server_side:
# set inverter state to offline, if output power is very low
logging.debug('close power: '
f'{self.db.get_db_value(Register.OUTPUT_POWER, -1)}')
if self.db.get_db_value(Register.OUTPUT_POWER, 999) < 2:
self.db.set_db_def_value(Register.INVERTER_STATUS, 0)
self.new_data['env'] = True
self.mb_timer.close()
self.state = State.closed
self.ifc.rx_set_cb(None)
self.ifc.prot_set_timeout_cb(None)
self.ifc.prot_set_init_new_client_conn_cb(None)
self.ifc.prot_set_update_header_cb(None)
self.ifc = None
if self.mb:
self.mb.close()
self.mb = None
# pragma: no cover
def inc_counter(self, counter: str) -> None:
self.db.inc_counter(counter)
Infos.new_stat_data['proxy'] = True
def dec_counter(self, counter: str) -> None:
self.db.dec_counter(counter)
Infos.new_stat_data['proxy'] = True