initialize
This commit is contained in:
28
LVX6048/powermon-patches/README.md
Normal file
28
LVX6048/powermon-patches/README.md
Normal 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.
|
||||
220
LVX6048/powermon-patches/mqttbroker.py
Normal file
220
LVX6048/powermon-patches/mqttbroker.py
Normal 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)
|
||||
651
LVX6048/powermon-patches/pi18.py
Normal file
651
LVX6048/powermon-patches/pi18.py
Normal 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
|
||||
38
LVX6048/powermon-patches/port_config_model.py
Normal file
38
LVX6048/powermon-patches/port_config_model.py
Normal 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)
|
||||
76
LVX6048/powermon-patches/ports_init.py
Normal file
76
LVX6048/powermon-patches/ports_init.py
Normal 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
|
||||
169
LVX6048/powermon-patches/usbport.py
Normal file
169
LVX6048/powermon-patches/usbport.py
Normal 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
|
||||
Reference in New Issue
Block a user