initialize

This commit is contained in:
2026-04-24 16:34:10 -04:00
commit 9aca623336
202 changed files with 6718 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
# powermon patches
Drop-in replacements for files inside a `uv tool install powermon==1.0.18` tree.
Each file below lands at the indicated path — the top-level `install.sh` does the copy.
| Snapshot here | Target (inside `$POWERMON_SITE`) | Purpose |
|--------------------------|---------------------------------------|---------|
| `pi18.py` | `protocols/pi18.py` | (a) rename `"DC/AC power direction"``"DC-AC power direction"` so the slash doesn't create a bogus MQTT topic level. (d) add `FWS` (fault + warning status) and `PGS<n>` (parallel general status) query commands; bump `check_definitions_count(expected=24)``26`. |
| `usbport.py` | `ports/usbport.py` | (b) drain leftover bytes from the hidraw fd before sending (non-blocking read loop swallowing `BlockingIOError`); wrap the main `os.read` in the retry loop so an empty first read doesn't abort. Otherwise late HID bytes from a prior command get parsed as the next reply → `KeyError`. |
| `mqttbroker.py` | `libs/mqttbroker.py` | (c) broaden `connect()`'s `except ConnectionRefusedError` to `(ConnectionRefusedError, OSError)` and narrow `publish()`'s bare `except Exception` to `(OSError, RuntimeError, ValueError)`. Otherwise any broker blip (HA restart, `Errno 113 No route to host`) crashes the daemon. |
| `port_config_model.py` | `configmodel/port_config_model.py` | (e) add `serial_number: None \| str \| int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel`, so powermon rejects `serial_number:` at the port level without this. |
| `ports_init.py` | `ports/__init__.py` | (f) in `from_config()`, make `port_config['serial_number'] = serial_number` a fallback (`if port_config.get('serial_number') is None:`). Device-level `serial_number` is the HA identifier (e.g. `lvx6048_1`); the port-level one is the hardware PI18 serial — they must not be conflated. |
Patches (a)(d) are load-bearing for the live setup. Patches (e) and (f) enable
powermon's native wildcard-path + serial-matching flow for a single-daemon
setup; we don't currently exercise that because two services probing
independently at startup race each other — the external `lvx-resolve-links`
oneshot handles identification instead. (e)/(f) are kept applied for future
flexibility.
## Upgrade path
These patches are pinned against **powermon 1.0.18**. Before bumping powermon:
1. Install the new version in a scratch location: `uv tool install --prefix /tmp/pm-next 'powermon==X.Y.Z'`
2. Diff each of the five files against the pristine upstream copy.
3. Re-apply each patch by hand into the new files (they're short — see descriptions above).
4. Drop the new files into this folder and re-run `./install.sh` on the target.

View File

@@ -0,0 +1,220 @@
""" powermon / libs / mqttbroker.py """
import logging
from time import sleep
import paho.mqtt.client as mqtt_client
from powermon.libs.config import safe_config
# Set-up logger
log = logging.getLogger("mqttbroker")
class MqttBroker:
""" Wrapper for mqtt broker connectivity and message proccessing """
def __str__(self):
if self.disabled:
return "MqttBroker DISABLED"
else:
return f"MqttBroker name: {self.name}, port: {self.port}, user: {self.username}"
@classmethod
def from_config(cls, config=None) -> 'MqttBroker':
""" build the mqtt broker object from a config dict """
log.debug("mqttbroker config: %s", safe_config(config))
if config:
name = config.get("name")
port = config.get("port", 1883)
username = config.get("username")
password = config.get("password")
mqtt_broker = cls(name=name, port=port, username=username, password=password)
mqtt_broker.adhoc_topic = config.get("adhoc_topic")
mqtt_broker.adhoc_result_topic = config.get("adhoc_result_topic")
return mqtt_broker
else:
return cls(name=None)
def __init__(self, name, port=None, username=None, password=None):
self.name = name
self.port = port
self.username = username
self.password = password
self.is_connected = False
if self.name is None:
self.disabled = True
else:
self.disabled = False
self.mqttc = mqtt_client.Client()
@property
def adhoc_topic(self) -> str:
""" return the adhoc command topic """
return getattr(self, "_adhoc_topic", None)
@adhoc_topic.setter
def adhoc_topic(self, value):
log.debug("setting adhoc topic to: %s", value)
self._adhoc_topic = value
@property
def adhoc_result_topic(self) -> str:
""" return the adhoc result topic """
return getattr(self, "_adhoc_result_topic", None)
@adhoc_result_topic.setter
def adhoc_result_topic(self, value):
log.debug("setting adhoc result topic to: %s", value)
self._adhoc_result_topic = value
def on_connect(self, client, userdata, flags, rc):
""" callback for connect """
# 0: Connection successful
# 1: Connection refused - incorrect protocol version
# 2: Connection refused - invalid client identifier
# 3: Connection refused - server unavailable
# 4: Connection refused - bad username or password
# 5: Connection refused - not authorised
# 6-255: Currently unused.
log.debug("on_connect called - client: %s, userdata: %s, flags: %s", client, userdata, flags)
connection_result = [
"Connection successful",
"Connection refused - incorrect protocol version",
"Connection refused - invalid client identifier",
"Connection refused - server unavailable",
"Connection refused - bad username or password",
"Connection refused - not authorised",
]
log.debug("MqttBroker connection returned result: %s %s", rc, connection_result[rc])
if rc == 0:
self.is_connected = True
return
self.is_connected = False
def on_disconnect(self, client, userdata, rc):
""" callback for disconnect """
log.debug("on_disconnect called - client: %s, userdata: %s, rc: %s", client, userdata, rc)
self.is_connected = False
def connect(self):
""" connect to mqtt broker """
if self.disabled:
log.info("MQTT broker not enabled, was a broker name defined? '%s'", self.name)
return
if not self.name:
log.info("MQTT could not connect as no broker name")
return
self.mqttc.on_connect = self.on_connect
self.mqttc.on_disconnect = self.on_disconnect
# if name is screen just return without connecting
if self.name == "screen":
# allows checking of message formats
return
try:
log.debug("Connecting to %s on port %s", self.name, self.port)
if self.username:
# auth = {"username": mqtt_user, "password": mqtt_pass}
_password = "********" if self.password is not None else "None"
log.info("Using mqtt authentication, username: %s, password: %s", self.username, _password)
self.mqttc.username_pw_set(self.username, password=self.password)
else:
log.debug("No mqtt authentication used")
# auth = None
self.mqttc.connect(self.name, port=self.port, keepalive=60)
self.mqttc.loop_start()
sleep(1)
except (ConnectionRefusedError, OSError) as ex:
log.warning("%s connection failed: '%s'", self.name, ex)
def start(self):
""" start mqtt broker """
if self.disabled:
return
if self.is_connected:
self.mqttc.loop_start()
def stop(self):
""" stop mqtt broker """
log.debug("Stopping mqttbroker connection")
if self.disabled:
return
self.mqttc.loop_stop()
# def set(self, variable, value):
# setattr(self, variable, value)
# def update(self, variable, value):
# # only override if value is not None
# if value is None:
# return
# setattr(self, variable, value)
def subscribe(self, topic, callback):
""" subscribe to a mqtt topic """
if not self.name:
return
if self.disabled:
return
# check if connected, connect if not
if not self.is_connected:
log.debug("Not connected, connecting")
self.connect()
# Register callback
self.mqttc.on_message = callback
if self.is_connected:
# Subscribe to command topic
log.debug("Subscribing to topic: %s", topic)
self.mqttc.subscribe(topic, qos=0)
else:
log.warning("Did not subscribe to topic: %s as not connected to broker", topic)
def post_adhoc_command(self, command_code):
""" shortcut function to publish an adhoc command """
self.publish(topic=self.adhoc_topic, payload=command_code)
def post_adhoc_result(self, payload):
""" shortcut function to publish the results of an adhoc command """
self.publish(topic=self.adhoc_result_topic, payload=payload)
def publish(self, topic: str = None, payload: str = None):
""" function to publish messages to mqtt broker """
if self.disabled:
log.debug("Cannot publish msg as mqttbroker disabled")
return
# log.debug("Publishing '%s' to '%s'", payload, topic)
if self.name == "screen":
print(f"mqtt debug - topic: '{topic}', payload: '{payload}'")
return
# check if connected, connect if not
if not self.is_connected:
log.debug("Not connected, connecting")
self.connect()
sleep(1)
if not self.is_connected:
log.warning("mqtt broker did not connect")
return
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
if isinstance(payload, bytes):
payload = payload.decode('utf-8')
try:
infot = self.mqttc.publish(topic, payload)
infot.wait_for_publish(5)
except (OSError, RuntimeError, ValueError) as e:
log.warning("mqtt publish failed: %s", e)
# def setAdhocCommands(self, config={}, callback=None):
# if not config:
# return
# if self.disabled:
# log.debug("Cannot setAdhocCommands as mqttbroker disabled")
# return
# adhoc_commands = config.get("adhoc_commands")
# # sub to command topic if defined
# adhoc_commands_topic = adhoc_commands.get("topic")
# if adhoc_commands_topic is not None:
# log.info("Setting adhoc commands topic to %s", adhoc_commands_topic)
# self.subscribe(adhoc_commands_topic, callback)

View File

@@ -0,0 +1,651 @@
""" powermon / protocols / pi18.py """
import logging
from powermon.commands.command import CommandType
from powermon.commands.command_definition import CommandDefinition
from powermon.commands.reading_definition import ReadingType, ResponseType
from powermon.commands.result import ResultType
from powermon.libs.errors import CommandDefinitionMissing, InvalidCRC, InvalidResponse
from powermon.ports import PortType
from powermon.protocols.abstractprotocol import AbstractProtocol
from powermon.protocols.helpers import crc_pi30 as crc
from powermon.protocols.pi30 import BATTERY_TYPE_LIST, OUTPUT_MODE_LIST
log = logging.getLogger("pi18")
SETTER_COMMANDS = {
"POP": {
"name": "POP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Device Output Source Priority",
"help": " -- examples: POP0 (set utility first), POP01 (set solar first)",
"regex": "POP([01])$",
},
"PSP": {
"name": "PSP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Solar Power priority",
"help": " -- examples: PSP0 (Battery-Load-Utility +AC Charge), PSP1 (Load-Battery-Utility)",
"regex": "PSP([01])$",
},
"PEI": {
"name": "PEI",
"command_type": CommandType.PI18_SETTER,
"description": "Set Machine type, enable: Grid-Tie",
"help": " -- examples: PEI (enable Grid-Tie)",
},
"PDI": {
"name": "PDI",
"command_type": CommandType.PI18_SETTER,
"description": "Set Machine type, disable: Grid-Tie",
"help": " -- examples: PDI (disable Grid-Tie)",
},
"PCP": {
"name": "PCP",
"command_type": CommandType.PI18_SETTER,
"description": "Set Device Charger Priority",
"help": " -- examples: PCP0,1 (set unit 0 [0-9] to Solar and Utility) PCP0,0 (set unit 0 to Solar first), PCP0,1 (set unit 0 to Solar and Utility), PCP0,2 (set unit 0 to solar only charging)",
"regex": "PCP([0-9],[012])$",
},
"MCHGC": {
"name": "MCHGC",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Max Charging Current Solar + AC",
"help": " -- examples: MCHGC0,040 (set unit 0 to max charging current of 40A), MCHGC1,060 (set unit 1 to max charging current of 60A) [010 020 030 040 050 060 070 080]",
"regex": "MCHGC([0-9],0[1-8]0)$",
},
"MUCHGC": {
"name": "MUCHGC",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Max AC Charging Current",
"help": " -- examples: MUCHGC0,040 (set unit 0 to max charging current of 40A), MUCHGC1,060 (set unit 1 to max charging current of 60A) [002 010 020 030 040 050 060 070 080]",
"regex": "MUCHGC([0-9]),(002|0[1-8]0)$",
},
"PBT": {
"name": "PBT",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Type",
"help": " -- examples: PBT0 (set battery as AGM), PBT1 (set battery as FLOODED), PBT2 (set battery as USER)",
"regex": "PBT([012])$",
},
"MCHGV": {
"name": "MCHGV",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Bulk,Float Charging Voltages",
"help": " -- example MCHGV552,540 - set battery charging voltage Bulk to 52.2V, float 54V (set Bulk Voltage [480~584] in 0.1V xxx, Float Voltage [480~584] in 0.1V yyy)",
# Regex 48.0 - 58.4 Volt
"regex": "MCHGV(4[8-9][0-9]|5[0-7][0-9]|58[0-5]),(4[8-9][0-9]|5[0-7][0-9]|58[0-4])$",
},
"PSDV": {
"name": "PSDV",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Cut-off Voltage",
"help": " -- example PSDV400 - set battery cut-off voltage to 40V [400~480V] for 48V unit)",
# Regex 40 to 48V
"regex": "PSDV(4[0-7][0-9]|480)$",
},
"BUCD": {
"name": "BUCD",
"command_type": CommandType.PI18_SETTER,
"description": "Set Battery Stop dis,charging when Grid is available",
"help": " -- example BUCD440,480 - set Stop discharge Voltage [440~510] in 0.1V xxx, Stop Charge Voltage [000(Full) or 480~580] in 0.1V yyy",
# Regex 44 to 51V, Full|48V to 58V
"regex": "BUCD((4[4-9]0|5[0-1]0),(000|4[8-9]0|5[0-8]0))$",
},
}
QUERY_COMMANDS = {
"PI": {
"name": "PI",
"command_type": CommandType.PI18_QUERY,
"description": "Protocol ID inquiry",
"help": " -- queries the protocol ID",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Protocol ID"},
],
"test_responses": [
b"^D00518m\xae\r"
]
},
"ID": {
"name": "ID",
"aliases": ["default", "get_id"],
"command_type": CommandType.PI18_QUERY,
"description": "Device Serial Number inquiry",
"help": " -- queries the device serial number",
"result_type": ResultType.SINGLE,
"reading_definitions": [{"description": "Serial Number"}],
"test_responses": [
b"^D02514012345678901234567\r",
],
},
"ET": {
"name": "ET",
"command_type": CommandType.PI18_QUERY,
"description": "Total PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Total PV Generated Energy", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
],
"test_responses": [
b""
],
},
"EY": {
"name": "EY",
"command_type": CommandType.PI18_QUERY,
"description": "Yearly PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Year", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:counter", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:])"},
],
"test_responses": [
b"^D01105580051\x0b\x9f\r",
],
"regex": "EY(\\d\\d\\d\\d)$",
},
"EM": {
"name": "EM",
"command_type": CommandType.PI18_QUERY,
"description": "Monthly PV Generated Energy Inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Month", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
{"description": "Month", "reading_type": ReadingType.MONTH,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:])]"},
],
"test_responses": [
b"",
],
"regex": "EM(\\d\\d\\d\\d\\d\\d)$",
},
"ED": {
"name": "ED",
"command_type": CommandType.PI18_QUERY,
"description": "Daily PV Generated Energy Inquiry",
"help": " -- display daily generated energy, format is QEDyyyymmdd",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "PV Generated Energy for Day", "reading_type": ReadingType.WATT_HOURS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "energy", "state_class": "total"},
{"description": "Year", "reading_type": ReadingType.YEAR,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[3:7])"},
{"description": "Month", "reading_type": ReadingType.MONTH,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "calendar.month_name[int(cn[7:9])]"},
{"description": "Day", "reading_type": ReadingType.DAY,
"response_type": ResponseType.INFO_FROM_COMMAND, "format_template": "int(cn[9:11])"},
],
"test_responses": [
b"(00238800!J\r",
],
"regex": "ED(\\d\\d\\d\\d\\d\\d\\d\\d)$",
},
"PIRI": {
"name": "PIRI",
"command_type": CommandType.PI18_QUERY,
"description": "Current Settings inquiry",
"help": " -- queries the current settings from the Inverter",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Input Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER},
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS},
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery re-charge Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery re-discharge Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Under Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Bulk Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Float Charge Voltage", "reading_type": ReadingType.VOLTS, "response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10"},
{"description": "Battery Type", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": BATTERY_TYPE_LIST},
{"description": "Max AC Charging Current", "reading_type": ReadingType.CURRENT},
{"description": "Max Charging Current", "reading_type": ReadingType.CURRENT},
{"description": "Input Voltage Range", "response_type": ResponseType.LIST, "options": ["Appliance", "UPS"]},
{"description": "Output Source Priority",
"response_type": ResponseType.LIST, "options": ["Solar - Utility - Battery", "Solar - Battery - Utility"]},
{"description": "Charger Source Priority",
"response_type": ResponseType.LIST, "options": ["Solar First", "Solar + Utility", "Only solar charging permitted"]},
{"description": "Max Parallel Units"},
{"description": "Machine Type", "response_type": ResponseType.LIST, "options": ["Off Grid", "Grid Tie"]},
{"description": "Topology", "response_type": ResponseType.LIST, "options": ["transformerless", "transformer"]},
{"description": "Output Mode", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.LIST, "options": OUTPUT_MODE_LIST},
{"description": "Solar power priority", "response_type": ResponseType.LIST, "options": ["Battery-Load-Utiliy + AC Charger", "Load-Battery-Utiliy"]},
{"description": "MPPT strings"},
{"description": "Unknown flags?", "response_type": ResponseType.STRING},
],
"test_responses": [
b"^D0882300,217,2300,500,217,5000,5000,480,480,530,440,570,570,2,10,070,1,1,1,9,0,0,0,0,1,00\xe1k\r",
]
},
"GS": {
"name": "GS",
"command_type": CommandType.PI18_QUERY,
"description": "General Status Parameters inquiry",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "AC Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
{"description": "AC Input Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
{"description": "AC Output Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:transmission-tower-export", "device_class": "voltage"},
{"description": "AC Output Frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:current-ac", "device_class": "frequency"},
{"description": "AC Output Apparent Power", "reading_type": ReadingType.APPARENT_POWER,
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "apparent_power"},
{"description": "AC Output Active Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:power-plug", "device_class": "power", "state_class": "measurement"},
{"description": "AC Output Load", "reading_type": ReadingType.PERCENTAGE,
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent"},
{"description": "Battery Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Voltage from SCC", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Voltage from SCC2", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:battery-outline", "device_class": "voltage"},
{"description": "Battery Discharge Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.INT, "icon": "mdi:battery-negative", "device_class": "current"},
{"description": "Battery Charging Current", "reading_type": ReadingType.CURRENT,
"response_type": ResponseType.INT, "icon": "mdi:current-dc", "device_class": "current"},
{"description": "Battery Capacity", "reading_type": ReadingType.PERCENTAGE,
"response_type": ResponseType.INT, "icon": "mdi:brightness-percent", "device_class": "battery"},
{"description": "Inverter heat sink temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT1 charger temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT2 charger temperature", "reading_type": ReadingType.TEMPERATURE,
"response_type": ResponseType.INT, "icon": "mdi:details", "device_class": "temperature"},
{"description": "MPPT1 Input Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
{"description": "MPPT2 Input Power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "icon": "mdi:solar-power", "device_class": "power", "state_class": "measurement"},
{"description": "MPPT1 Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
{"description": "MPPT2 Input Voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "icon": "mdi:solar-power", "device_class": "voltage"},
{"description": "Setting value configuration state", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "Nothing changed",
"1": "Something changed",
},
},
{"description": "MPPT1 charger status", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "abnormal",
"1": "normal but not charged",
"2": "charging",
},
},
{"description": "MPPT2 charger status", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "abnormal",
"1": "normal but not charged",
"2": "charging",
},
},
{"description": "Load connection", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "disconnect",
"1": "connect",
},
},
{"description": "Battery power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "charge",
"2": "discharge",
},
},
{"description": "DC-AC power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "AC-DC",
"2": "DC-AC",
},
},
{"description": "Line power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"0": "donothing",
"1": "input",
"2": "output",
},
},
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.LIST,
"options": ["Not valid", "valid"],
},
],
"test_responses": [
b"D1062232,499,2232,499,0971,0710,019,008,000,000,000,000,000,044,000,000,0520,0000,1941,0000,0,2,0,1,0,2,1,0\x09\x7b\r",
b"^D1062232,499,2232,499,1406,1376,028,549,000,000,000,010,095,060,000,000,0082,0000,1604,0000,0,2,0,1,1,1,1,0D\x12\r",
],
},
"MOD": {
"name": "MOD",
"command_type": CommandType.PI18_QUERY,
"description": "Mode inquiry",
"result_type": ResultType.SINGLE,
"reading_definitions": [
{"description": "Device Mode", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"00": "Power on",
"01": "Standby",
"02": "Bypass",
"03": "Battery",
"04": "Fault",
"05": "Hybrid mode(Line mode, Grid mode)",
}
},
],
"test_responses": [
b"^D00505\xd9\x9f\r",
],
},
"MCHGCR": {
"name": "MCHGCR",
"command_type": CommandType.PI18_QUERY,
"description": "Max Charging Current Options inquiry",
"help": " -- queries the maximum charging current setting of the Inverter",
"result_type": ResultType.MULTIVALUED,
"reading_definitions": [
{"description": "Max Charging Current Options", "reading_type": ReadingType.MESSAGE_AMPS,
"response_type": ResponseType.STRING
}
],
"test_responses": [
b"^D034010,020,030,040,050,060,070,080\x161\r",
],
},
"MUCHGCR": {
"name": "MUCHGCR",
"command_type": CommandType.PI18_QUERY,
"description": "Max Utility Charging Current Options inquiry",
"help": " -- queries the maximum utility charging current setting of the Inverter",
"result_type": ResultType.MULTIVALUED,
"reading_definitions": [
{"reading_type": ReadingType.MESSAGE_AMPS, "description": "Max Utility Charging Current", "response_type": ResponseType.STRING}
],
"test_responses": [
b"^D038002,010,020,030,040,050,060,070,080\xd01\r"
],
},
"FLAG": {
"name": "FLAG",
"command_type": CommandType.PI18_QUERY,
"description": "Query enable/disable flag status",
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Buzzer beep", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload bypass function", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Display back to default page", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Over temperature restart", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Backlight on", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Alarm primary source interrupt", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Fault code record", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.ENABLED_BOOL},
{"description": "Reserved", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D0200,0,0,0,0,1,0,0,12\xc2\x39\r",
],
},
"VFW": {
"name": "VFW",
"description": "Device CPU version inquiry",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Main CPU Version", "reading_type": ReadingType.MESSAGE},
{"description": "Slave 1 CPU Version", "reading_type": ReadingType.MESSAGE},
{"description": "Slave 2 CPU Version", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D02005220,00000,00000\x3e\xf8\r",
],
},
# Fault + warning bitmap. 2-digit fault code followed by ~32 0/1 warning bits.
# Fault-code list cross-referenced with PI30 QPGS (same firmware family).
"FWS": {
"name": "FWS",
"description": "Fault and warning status inquiry",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Fault code", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION,
"options": {
"00": "No fault",
"01": "Fan is locked",
"02": "Over temperature",
"03": "Battery voltage is too high",
"04": "Battery voltage is too low",
"05": "Output short circuited or Over temperature",
"06": "Output voltage is too high",
"07": "Over load time out",
"08": "Bus voltage is too high",
"09": "Bus soft start failed",
"11": "Main relay failed",
"51": "Over current inverter",
"52": "Bus soft start failed",
"53": "Inverter soft start failed",
"54": "Self-test failed",
"55": "Over DC voltage on output of inverter",
"56": "Battery connection is open",
"57": "Current sensor failed",
"58": "Output voltage is too low",
"60": "Inverter negative power",
"71": "Parallel version different",
"72": "Output circuit failed",
"80": "CAN communication failed",
"81": "Parallel host line lost",
"82": "Parallel synchronized signal lost",
"83": "Parallel battery voltage detect different",
"84": "Parallel Line voltage or frequency detect different",
"85": "Parallel Line input current unbalanced",
"86": "Parallel output setting different",
}},
{"description": "PV loss warning", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus over", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus under", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Bus soft fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Line fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "OPV short", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter voltage too low", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter voltage too high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Over temperature", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Fan locked", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery voltage high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery low alarm", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery under shutdown", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery derating", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Overload", "response_type": ResponseType.ENABLED_BOOL},
{"description": "EEPROM fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter over current", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Inverter soft fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Self test fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "OP DC voltage over", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery open", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Current sensor fail", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery short", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Power limit", "response_type": ResponseType.ENABLED_BOOL},
{"description": "PV voltage high", "response_type": ResponseType.ENABLED_BOOL},
{"description": "MPPT overload fault", "response_type": ResponseType.ENABLED_BOOL},
{"description": "MPPT overload warning", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery too low to charge", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery weak", "response_type": ResponseType.ENABLED_BOOL},
{"description": "Battery equalization", "response_type": ResponseType.ENABLED_BOOL},
],
"test_responses": [
b"^D07100,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\xaa\xaa\r",
],
},
# Per-unit parallel view. PGS<n>, n = 0..N-1 (0 is master).
# LVX6048 emits a 30-field layout that differs from the PI30 QPGS layout;
# only fields confirmed against live responses are semantically named here.
# The rest are exposed as raw strings so the command doesn't error out, and
# can be tightened later as more firmware rev responses are confirmed.
# Observed unit-1 response (Not valid + fault 71 "Parallel version different"):
# 0,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,
# 000,0008,0000,2925,0000,1,0,0,0,0,0,016
"PGS": {
"name": "PGS",
"description": "Parallel general status inquiry",
"help": " -- example: PGS0 queries parallel status for instance 0 (master)",
"command_type": CommandType.PI18_QUERY,
"result_type": ResultType.COMMA_DELIMITED,
"reading_definitions": [
{"description": "Parallel instance number", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.LIST, "options": ["Not valid", "valid"]},
{"description": "Parallel unit count", "reading_type": ReadingType.MESSAGE},
{"description": "Fault code", "reading_type": ReadingType.MESSAGE},
{"description": "Field 4 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Grid frequency", "reading_type": ReadingType.FREQUENCY,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "frequency"},
{"description": "AC output voltage", "reading_type": ReadingType.VOLTS,
"response_type": ResponseType.TEMPLATE_INT, "format_template": "r/10", "device_class": "voltage"},
{"description": "AC output frequency (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "AC output apparent power", "reading_type": ReadingType.APPARENT_POWER,
"response_type": ResponseType.INT, "device_class": "apparent_power"},
{"description": "AC output active power", "reading_type": ReadingType.WATTS,
"response_type": ResponseType.INT, "device_class": "power"},
{"description": "Total AC output apparent power", "reading_type": ReadingType.APPARENT_POWER, "response_type": ResponseType.INT},
{"description": "Total AC output active power", "reading_type": ReadingType.WATTS, "response_type": ResponseType.INT},
{"description": "Load percentage", "reading_type": ReadingType.PERCENTAGE, "response_type": ResponseType.INT},
{"description": "Field 13 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 14 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 15 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 16 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 17 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 18 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 19 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 20 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 21 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 22 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 23 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 24 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 25 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 26 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 27 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Flag 28 (raw)", "reading_type": ReadingType.MESSAGE},
{"description": "Field 30 (raw)", "reading_type": ReadingType.MESSAGE},
],
"test_responses": [
b"^D1130,4,71,2453,599,0000,000,0000,0000,00000,00000,000,211,005,000,000,000,000,0008,0000,2925,0000,1,0,0,0,0,0,016\x8f\xad\r",
],
"regex": "PGS(\\d+)$",
},
}
COMMANDS_TO_REMOVE = []
class PI18(AbstractProtocol):
""" pi18 protocol handler """
def __str__(self):
return "PI18 protocol handler"
def __init__(self) -> None:
super().__init__()
self.protocol_id = b"PI18"
self.add_command_definitions(QUERY_COMMANDS)
self.add_command_definitions(SETTER_COMMANDS, result_type=ResultType.PI18_ACK)
self.remove_command_definitions(COMMANDS_TO_REMOVE)
self.check_definitions_count(expected=26) # Count of all Commands
self.add_supported_ports([PortType.SERIAL, PortType.USB])
def check_crc(self, response: str, command_definition: CommandDefinition = None):
""" crc check, override for now """
log.debug("check crc for %s in pi18", response)
if response.startswith(b"^D") or response.startswith(b"^1") or response.startswith(b"^0"):
# get response CRC
data_to_check = response[:-3]
crc_high, crc_low = crc(data_to_check)
# print(crc_high, crc_low)
# print(response[-3], response[-2])
if (crc_high, crc_low) == (response[-3], response[-2]):
return True
else:
log.info("PI18 response check_crc doesnt match calc (%x, %x), got (%x, %x)", crc_high, crc_low, response[-3], response[-2])
raise InvalidCRC(f"PI18 response check_crc doesnt match calc ({crc_high:02x}, {crc_low:02x}), got ({response[-3]:02x}, {response[-2]:02x})")
else:
log.info("PI18 response doesnt start with ^D - check_crc fails")
raise InvalidResponse("PI18 response starts with invalid character - crc check fails")
log.info("PI18 response check_crc fall through")
return False
def trim_response(self, response: str, command_definition: CommandDefinition = None) -> str:
""" Remove extra characters from response """
log.debug("trim %s, definition: %s", response, command_definition)
if response.startswith(b"^D"):
# trim ^Dxxx where xxx is data length
response = response[5:]
if response.endswith(b'\r'):
# has checksum, so trim last 3 chars
response = response[:-3]
if response.startswith(b'('):
# pi30 style response
response = response[1:]
# if response.startswith(b'^1') or response.startswith(b'^0'):
# # ACK / NACK response
# response = response[1:]
return response
def get_full_command(self, command: str) -> bytes:
""" generate the full command including prefix, crc and \n as needed """
log.info("Using protocol: %s with %i commands", self.protocol_id, len(self.command_definitions))
command_defn = self.get_command_definition(command)
# raise exception if no command definition is found
if command_defn is None:
raise CommandDefinitionMissing(f"No definition found in PI18 for {command}")
# full command is ^PlllCCCcrc\n or ^SlllCCCcrc\n
# lll = length of all except ^Dlll
# CCC = command
# crc = 2 bytes
length = len(command) + 3
# Determine prefix
match command_defn.command_type:
case CommandType.PI18_QUERY:
prefix = "^P"
case CommandType.PI18_SETTER:
prefix = "^S"
case _:
# edge case / default PI30 command / maybe this should raise an error
prefix = "("
full_command = bytes(f"{prefix}{length:#03d}{command}", "utf-8")
crc_high, crc_low = crc(full_command)
full_command += bytes([crc_high, crc_low, 13])
log.debug("full command: %s", full_command)
return full_command

View File

@@ -0,0 +1,38 @@
""" pydantic definitions for the powermon port config model
"""
from typing import Literal
from pydantic import Field
from powermon.configmodel import NoExtraBaseModel
class BlePortConfig(NoExtraBaseModel):
""" model/allowed elements for ble port config """
type: Literal["ble"]
mac: str
protocol: None | str
victron_key: None | str = Field(default=None, repr=False)
class SerialPortConfig(NoExtraBaseModel):
""" model/allowed elements for serial port config """
type: Literal["serial"]
path: str
baud: None | int = Field(default=None)
protocol: None | str
class UsbPortConfig(NoExtraBaseModel):
""" model/allowed elements for usb port config """
type: Literal["usb"]
path: None | str
protocol: None | str
serial_number: None | str | int = Field(default=None)
class TestPortConfig(NoExtraBaseModel):
""" model/allowed elements for test port config """
type: Literal["test"]
response_number: None | int = Field(default=None)
protocol: None | str = Field(default=None)

View File

@@ -0,0 +1,76 @@
""" powermon / ports / __init__.py """
import logging
from enum import StrEnum, auto
from pydantic import BaseModel
from powermon.libs.errors import ConfigError
# Set-up logger
log = logging.getLogger("ports")
class PortType(StrEnum):
""" enumeration of supported / known port types """
UNKNOWN = auto()
TEST = auto()
SERIAL = auto()
USB = auto()
BLE = auto()
JKBLE = auto()
MQTT = auto()
VSERIAL = auto()
DALYSERIAL = auto()
ESP32 = auto()
class PortTypeDTO(BaseModel):
""" data transfer model for PortType class """
port_type: PortType
def from_config(port_config, serial_number=None):
""" get a port object from config data """
log.debug("port_config: %s", port_config)
port_object = None
if not port_config:
raise ConfigError("no port config supplied")
# port type is mandatory
port_type = port_config.get("type")
log.debug("portType: %s", port_type)
# return None if port type is not defined
if port_type is None:
return None
# add serial_number to config — but only if the port config didn't already
# specify one. Port-level serial_number is the hardware serial used by
# USBPort.resolve_path for wildcard matching; device-level serial_number is
# the logical HA identifier and is unrelated.
if port_config.get('serial_number') is None:
port_config['serial_number'] = serial_number
# build port object
match port_type:
case PortType.TEST:
from powermon.ports.testport import TestPort
port_object = TestPort.from_config(config=port_config)
case PortType.SERIAL:
from powermon.ports.serialport import SerialPort
port_object = SerialPort.from_config(config=port_config)
case PortType.USB:
from powermon.ports.usbport import USBPort
port_object = USBPort.from_config(config=port_config)
# Pattern for port types that cause problems when imported
case PortType.BLE:
log.debug("port_type BLE found")
from powermon.ports.bleport import BlePort
port_object = BlePort.from_config(config=port_config)
case _:
log.info("port type object not found for %s", port_type)
raise ConfigError(f"Invalid port type: '{port_type}'")
return port_object

View File

@@ -0,0 +1,169 @@
""" powermon / ports / usbport.py """
# import asyncio
import logging
import os
import time
from glob import glob
from powermon.commands.command import Command
from powermon.commands.result import Result, ResultType
from powermon.libs.errors import ConfigError, PowermonProtocolError
from powermon.ports import PortType
from powermon.ports.abstractport import AbstractPort, _AbstractPortDTO
from powermon.protocols import get_protocol_definition
log = logging.getLogger("USBPort")
class UsbPortDTO(_AbstractPortDTO):
""" data transfer model for SerialPort class """
path: str
serial_number: None | int | str
class USBPort(AbstractPort):
""" usb port object """
@classmethod
async def from_config(cls, config=None):
log.debug("building usb port. config:%s", config)
path = config.get("path", "/dev/hidraw0")
serial_number = config.get("serial_number")
# get protocol handler, default to PI30 if not supplied
protocol = get_protocol_definition(protocol=config.get("protocol", "PI30"))
# instantiate class
_class = cls(path=path, protocol=protocol)
# deal with wildcard path resolution
_class.path = await _class.resolve_path(path, serial_number)
return _class
def __init__(self, path, protocol) -> None:
self.port_type = PortType.USB
super().__init__(protocol=protocol)
self.path = None
self.port = None
async def resolve_path(self, path, serial_number):
"""Async method to resolve a valid path by testing each one."""
# expand 'wildcard'
paths = glob(path)
if not paths:
raise ConfigError(f"No matching paths found on this system for {path}")
if len(paths) == 1:
return paths[0] # only one valid result
# More than one valid path
# check we have something to look for
if serial_number is None:
raise ConfigError("Wildcard paths require a serial_number in config.")
# check we have get_id in this protocol
try:
command = self.protocol.get_id_command()
except PowermonProtocolError as ex:
raise ConfigError(f"No get_id in protocol: {self.protocol.protocol_id}") from ex
for _path in paths:
log.debug("Checking path: %s for serial_number: %s", _path, serial_number)
self.path = _path
await self.connect()
res = await self.send_and_receive(command=command)
await self.disconnect()
if res.is_valid and str(res.readings[0].data_value) == str(serial_number):
log.info("SUCCESS: path: %s matches serial_number: %s", _path, serial_number)
return _path # return the matching path
raise ConfigError(f"None of the paths match serial_number: {serial_number}")
def to_dto(self):
dto = UsbPortDTO(port_type="usb", path=self.path, protocol=self.protocol.to_dto())
return dto
def is_connected(self) -> bool:
return self.port is not None
async def connect(self) -> bool:
if self.is_connected():
log.debug("USBPort already connected")
return True
log.debug("USBPort connecting. path:%s, protocol:%s", self.path, self.protocol)
try:
self.port = os.open(self.path, os.O_RDWR | os.O_NONBLOCK)
log.debug("USBPort port number $%s", self.port)
except Exception as e:
log.warning("Error openning usb port: %s", e)
self.port = None
self.error_message = e
return self.is_connected()
async def disconnect(self) -> None:
log.debug("USBPort disconnecting: %i", self.port)
if self.port is not None:
os.close(self.port)
self.port = None
async def send_and_receive(self, command: Command) -> Result:
if not self.is_connected():
log.warning("USBPort not connected")
return command.build_result(result_type=ResultType.ERROR, raw_response=b"USBPort not connected", protocol=self.protocol)
response_line = bytes()
# Drain any leftover bytes from previous command's response so we
# don't parse them as this command's reply.
try:
while True:
stale = os.read(self.port, 256)
if not stale:
break
log.debug("drained %i stale bytes: %s", len(stale), stale)
except BlockingIOError:
pass
# Send the command to the open usb connection
full_command = command.full_command
cmd_len = len(full_command)
log.debug("length of to_send: %i", cmd_len)
# for command of len < 8 it ok just to send
# otherwise need to pack to a multiple of 8 bytes and send 8 at a time
try:
if cmd_len <= 8:
# Send all at once
log.debug("sending full_command in on shot")
time.sleep(0.05)
os.write(self.port, full_command)
else:
log.debug("multiple chunk send")
chunks = [full_command[i:i + 8] for i in range(0, cmd_len, 8)]
for chunk in chunks:
# pad chunk to 8 bytes
if len(chunk) < 8:
padding = 8 - len(chunk)
chunk += b'\x00' * padding
log.debug("sending chunk: %s", chunk)
time.sleep(0.05)
os.write(self.port, chunk)
time.sleep(0.25)
# Read from the usb connection
# try to a max of 100 times
for _ in range(100):
# attempt to deal with resource busy and other failures to read
time.sleep(0.15)
try:
r = os.read(self.port, 256)
except BlockingIOError:
continue
response_line += r
# Finished is \r is in byte_response
if bytes([13]) in response_line:
# remove anything after the \r
response_line = response_line[: response_line.find(bytes([13])) + 1]
break
except BrokenPipeError as e:
log.debug("USB read error: %s", e)
log.debug("usb response was: %s", response_line)
# response = self.get_protocol().check_response_and_trim(response_line)
result = command.build_result(raw_response=response_line, protocol=self.protocol)
return result