diff --git a/.sops.yaml b/.sops.yaml index 1a54ba9..2a386fb 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -43,4 +43,7 @@ creation_rules: age: age1rvyt7nlnjqeyt6ykwh9fj9d88kjmq9vetcx667ul3ql6fyfz4gqqh9lq9q - path_regex: ntfy.*\.enc\.yaml$ encrypted_regex: ^(data|stringData)$ - age: age1uzs33ep6k3htnuwyyp54y373wkf7urd0qvk5kxcmptr5tt0fffus4v06gn \ No newline at end of file + age: age1uzs33ep6k3htnuwyyp54y373wkf7urd0qvk5kxcmptr5tt0fffus4v06gn + - path_regex: map_monitor.*\.enc\.yaml$ + encrypted_regex: ^(data|stringData)$ + age: age1k4j5vyqqt7w7k52gvkpfe6znqr9mlgt076l7xgdtyc75lamsh4aqshj5qs \ No newline at end of file diff --git a/clusters/base/apps/map_monitor/helmrelease.yaml b/clusters/base/apps/map_monitor/helmrelease.yaml new file mode 100644 index 0000000..ca79a92 --- /dev/null +++ b/clusters/base/apps/map_monitor/helmrelease.yaml @@ -0,0 +1,57 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: map-monitor + namespace: iot +spec: + interval: 30m + chartRef: + kind: OCIRepository + name: map-monitor + driftDetection: + mode: enabled + values: + defaultPodOptions: + securityContext: + runAsUser: 65534 + runAsGroup: 65534 + runAsNonRoot: true + + controllers: + map-monitor: + containers: + app: + image: + repository: python + tag: 3.12-slim + command: + - sh + - -c + - pip install --no-cache-dir --target /tmp/site -r /app/requirements.txt && exec python /app/map_mqtt.py + env: + PYTHONPATH: /tmp/site + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + HOME: /tmp + envFrom: + - secretRef: + name: map-monitor-secrets + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi + + persistence: + code: + type: configMap + name: map-monitor-code + globalMounts: + - path: /app + readOnly: true + tmp: + type: emptyDir + globalMounts: + - path: /tmp diff --git a/clusters/base/apps/map_monitor/helmrepository.yaml b/clusters/base/apps/map_monitor/helmrepository.yaml new file mode 100644 index 0000000..48dbb89 --- /dev/null +++ b/clusters/base/apps/map_monitor/helmrepository.yaml @@ -0,0 +1,10 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: map-monitor + namespace: iot +spec: + interval: 1h + url: oci://ghcr.io/bjw-s-labs/helm/app-template + ref: + tag: "5.0.1" diff --git a/clusters/base/apps/map_monitor/kustomization.yaml b/clusters/base/apps/map_monitor/kustomization.yaml new file mode 100644 index 0000000..36ff60c --- /dev/null +++ b/clusters/base/apps/map_monitor/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - helmrepository.yaml + - secret.enc.yaml + - helmrelease.yaml +configMapGenerator: + - name: map-monitor-code + namespace: iot + files: + - src/map_mqtt.py + - src/map_read.py + - src/requirements.txt +generatorOptions: + disableNameSuffixHash: true diff --git a/clusters/base/apps/map_monitor/secret.enc.yaml b/clusters/base/apps/map_monitor/secret.enc.yaml new file mode 100644 index 0000000..4cc7aa2 --- /dev/null +++ b/clusters/base/apps/map_monitor/secret.enc.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: Secret +metadata: + name: map-monitor-secrets + namespace: iot +stringData: + SERIAL_HOST: ENC[AES256_GCM,data:6cCm6shCy2kymQ==,iv:mqUOG6RZ6HGh3dBWy2LdWWXklU2FFhra9Xi3VgFdQi4=,tag:sJq/S+QslUWxeuK1JgQuBA==,type:str] + SERIAL_PORT: ENC[AES256_GCM,data:ZiqkpB0=,iv:SryQv6eUrTZwA896oG83xHeLjzABqnn4pwlDPA56S3A=,tag:WPmz9uw+G+QQZW4L/Qfm1Q==,type:str] + SERIAL_POLL_INTERVAL: ENC[AES256_GCM,data:Cw==,iv:myGXUNhVKALsedj1Do56clisSiAvqE19rqTGQjtYxCI=,tag:XvTcCvI9B5YyizJ4skfB1A==,type:str] + SERIAL_TIMEOUT: ENC[AES256_GCM,data:mMtT,iv:nz7nJXuLU3tpO2l04+aRTczCwnmgubLOw7a/cg6Yjic=,tag:nMed9+RH6VKZDQURwuMd/g==,type:str] + MQTT_HOST: ENC[AES256_GCM,data:qyjHpa6fNG+IaQvj6LsUGF8iPxWU4NzdzPCt1ZcodFE=,iv:dxCP9gfasYovuBI6pxkynKkNAh99bUIEr8fn9Qbqxww=,tag:mz1BgruIsUXE/sJdiCqH6g==,type:str] + MQTT_PORT: ENC[AES256_GCM,data:PPxXlQ==,iv:s4b8gCGna5Va0B9BN02ZLlofYlIQiVn9KU8qJNECK5I=,tag:neXH/dwhfW1u1UfkRuY2mg==,type:str] + MQTT_USER: ENC[AES256_GCM,data:Xs6v,iv:DlyX9ErjbcJ4WxQFBhCdiBCRUqOpEYwWSg/X3uA4sSk=,tag:4N/85y5h2u754qP4iyIeiw==,type:str] + MQTT_PASSWORD: ENC[AES256_GCM,data:CD/cSnauAZ29KHuwGblqqDxSdrw=,iv:1mUsaq847o4rjbMIpSRErVIF8ZI+4oS4LDJGwbas0Jo=,tag:y7uSxXNFFM5HD/1IMRj5og==,type:str] + MQTT_TLS: ENC[AES256_GCM,data:lhmlb+8=,iv:mZiHwfgmkpWiBBRF5SvuSP/wlXMnAQmNnSZTYc0kMRM=,tag:VNxesMInF1Vj/gbeK/FlJA==,type:str] + MQTT_CLIENT_ID: ENC[AES256_GCM,data:Epdn4DGpQtgl53sc+Qo=,iv:gjVPQA1g0IFrzNJuqOWAAUgaFZsnZ2te5V32Q/SPTJg=,tag:zSauVShmjG0K8FDVevvolQ==,type:str] + MQTT_DISCOVERY_PREFIX: ENC[AES256_GCM,data:9ThR6rD3yXShgW1TmA==,iv:yn+89NBc6rXqbUnnKSqiWhzCruf0HPt3DfgxrhGZq7g=,tag:YoWynvyWRxdJ+UCbfjCJtQ==,type:str] + MQTT_BASE_TOPIC: ENC[AES256_GCM,data:Y8fGesOJmsXOvdE0khg=,iv:CyyDnQkUzGivWWAQnzWyElqoLeXmT7uLns5te03Hq0E=,tag:5CCuHp2ZeaZyXF4LmoLTUg==,type:str] + DEVICE_ID: ENC[AES256_GCM,data:RF2wLrHe/+CA0em/Yc0=,iv:IQx6zlX31rywQlhxchmGu3pjYg7JJlyNw1KT1/ELsL4=,tag:wVQ2s6XDN1l+tM9EQnjX/g==,type:str] + DEVICE_NAME: ENC[AES256_GCM,data:fHTyCXeA0aDlNqp7HRCuppTGkYnP,iv:wMp+umM2Y6S/54t5nc4h9PYCZd5NloOYIpCp3/4vOoE=,tag:PcP3e3hrJ+r78oPC7etm5g==,type:str] + LOG_LEVEL: ENC[AES256_GCM,data:/oGKyg==,iv:3A/971xbMz+dm+czrslz2nS3J1PRaM+7zLNACEHX7OM=,tag:OmLgstVHAT4GpviplU5V8Q==,type:str] +sops: + age: + - recipient: age1k4j5vyqqt7w7k52gvkpfe6znqr9mlgt076l7xgdtyc75lamsh4aqshj5qs + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZZ0R5UzhmOXV6T01KbTdr + Y2tCR2owUHZVV2VVakpEWXZjK3BNcTkzOVdZCjAzUnJaK2lMTWFhS0tTY2EvSU9u + OGh4SlI0em9YYUxkTGZZOUR2SG0yZjAKLS0tIFE5MWt4b3VWcmtVWXpwWjlTbHlT + R3ZQckkwMVJTekNRZW1uQVZVYXAvcTQKTeXGo0pBSFXderktCrgX6/hDRBraiOMF + SPr6hDe0ejMfWyOUSA9XQRQnn20gssRtA5DzNfpkjA3Gam+CU9Lu0w== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-10-26T11:29:23Z" + mac: ENC[AES256_GCM,data:jVnj8WpNjbFHwDfU2NBCcoqyfRYotmvc7w1b2A7e40GPYBKmkqVkZUlxBp4oc2p4qj+SRfPPKSUqDfxNqIS+oiqidui41/EhsJtPBjqyjoWmLfMMriwYUMlxrdFeCNNL97cUJin+Cy/OvPXgIU1BX9hnqyz0mTU5ce1zWju25Mc=,iv:4GJialKu0t9WmGoxKAA1P6wuan5gD8fSn3sToEL0QRU=,tag:yZbaQpIfUFh5eZvWQlKwxg==,type:str] + encrypted_regex: ^(data|stringData)$ + version: 3.11.0 diff --git a/clusters/base/apps/map_monitor/src/Dockerfile b/clusters/base/apps/map_monitor/src/Dockerfile new file mode 100644 index 0000000..327d7f8 --- /dev/null +++ b/clusters/base/apps/map_monitor/src/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY map_read.py map_mqtt.py ./ + +ENV PYTHONUNBUFFERED=1 +USER nobody +CMD ["python", "map_mqtt.py"] diff --git a/clusters/base/apps/map_monitor/src/map_mqtt.py b/clusters/base/apps/map_monitor/src/map_mqtt.py new file mode 100644 index 0000000..b31a547 --- /dev/null +++ b/clusters/base/apps/map_monitor/src/map_mqtt.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Service: polls a MAP inverter via ser2net and publishes to MQTT for Home Assistant +(MQTT discovery). Values are also written to the console for diagnostics. + +All configuration is done via environment variables (see .env.example). +""" + +import json +import logging +import os +import signal +import sys +import threading + +import paho.mqtt.client as mqtt + +from map_read import MapClient, MapError, read_status + +log = logging.getLogger("map_mqtt") + + +def env(name, default=None, cast=str): + v = os.environ.get(name, "") + if v == "": + if default is None: + sys.exit("Environment variable %s is not set" % name) + return default + return cast(v) + + +SERIAL_HOST = env("SERIAL_HOST", "10.0.64.24") +SERIAL_PORT = env("SERIAL_PORT", 33333, int) +SERIAL_TIMEOUT = env("SERIAL_TIMEOUT", 1.0, float) +SERIAL_POLL_INTERVAL = env("SERIAL_POLL_INTERVAL", 5.0, float) + +MQTT_HOST = env("MQTT_HOST") +MQTT_PORT = env("MQTT_PORT", 1883, int) +MQTT_USER = env("MQTT_USER", "") +MQTT_PASSWORD = env("MQTT_PASSWORD", "") +MQTT_CLIENT_ID = env("MQTT_CLIENT_ID", "map-inverter") +MQTT_TLS = env("MQTT_TLS", "false").lower() in ("1", "true", "yes") +DISCOVERY_PREFIX = env("MQTT_DISCOVERY_PREFIX", "homeassistant") +BASE_TOPIC = env("MQTT_BASE_TOPIC", "map_inverter") + +DEVICE_ID = env("DEVICE_ID", "map_inverter") +DEVICE_NAME = env("DEVICE_NAME", "MAP Energy") + +STATE_TOPIC = "%s/state" % BASE_TOPIC +AVAIL_TOPIC = "%s/availability" % BASE_TOPIC +HA_STATUS_TOPIC = "%s/status" % DISCOVERY_PREFIX + +# (key, component, name, unit, device_class, state_class, extra) +ENTITIES = [ + ("mode_text", "sensor", "Mode", None, None, None, {"icon": "mdi:state-machine"}), + ("mode", "sensor", "Mode code", None, None, None, + {"icon": "mdi:numeric", "entity_category": "diagnostic"}), + ("u_acc", "sensor", "Battery voltage", "V", "voltage", "measurement", {}), + ("i_acc", "sensor", "Battery current", "A", "current", "measurement", {}), + ("p_acc", "sensor", "Battery power", "W", "power", "measurement", {}), + ("p_load", "sensor", "Load power", "W", "power", "measurement", {}), + ("u_net", "sensor", "Grid voltage", "V", "voltage", "measurement", {}), + ("i_net", "sensor", "Grid current", "A", "current", "measurement", {}), + ("p_net", "sensor", "Grid power", "W", "power", "measurement", {}), + ("f_net", "sensor", "Grid frequency", "Hz", "frequency", "measurement", {}), + ("u_out", "sensor", "Output voltage", "V", "voltage", "measurement", {}), + ("f_out", "sensor", "Output frequency", "Hz", "frequency", "measurement", {}), + ("t_acc", "sensor", "Battery temperature", "°C", "temperature", "measurement", {}), + ("t_tor", "sensor", "Transformer temperature", "°C", "temperature", "measurement", {}), + ("t_trans", "sensor", "Transistor temperature", "°C", "temperature", "measurement", {}), + ("u_net_limit", "sensor", "Last out-of-range grid voltage", "V", "voltage", None, + {"entity_category": "diagnostic"}), + ("e_net", "sensor", "Energy from grid", "kWh", "energy", "total_increasing", {}), + ("e_acc", "sensor", "Energy from battery", "kWh", "energy", "total_increasing", {}), + ("e_acc_charge", "sensor", "Battery charge energy", "kWh", "energy", "total_increasing", {}), + ("errors_text", "sensor", "Errors", None, None, None, + {"icon": "mdi:alert-circle-outline", "entity_category": "diagnostic"}), + ("problem", "binary_sensor", "Problem", None, "problem", None, + {"entity_category": "diagnostic"}), + ("net_reverse", "binary_sensor", "Grid export", None, None, None, + {"icon": "mdi:transmission-tower-export"}), + ("relay1", "binary_sensor", "Relay 1", None, None, None, {"icon": "mdi:electric-switch"}), + ("relay2", "binary_sensor", "Relay 2", None, None, None, {"icon": "mdi:electric-switch"}), +] + + +def to_payload(s): + """Flatten the read_status result into JSON for the state topic.""" + p = {k: s[k] for k in ("mode", "mode_text", "u_acc", "i_acc", "p_acc", "p_load", "u_net", + "i_net", "p_net", "f_net", "u_out", "f_out", "t_acc", "t_tor", + "t_trans", "u_net_limit")} + # counter unit is 0.01 kWh (malina html/meters.js; confirmed against integrated grid power) + p["e_net"] = s["e_net_raw"] / 100 + p["e_acc"] = s["e_acc_raw"] / 100 + p["e_acc_charge"] = s["e_acc_charge_raw"] / 100 + for k in ("net_reverse", "relay1", "relay2"): + p[k] = "ON" if s[k] else "OFF" + p["problem"] = "ON" if s["errors"] else "OFF" + texts = ["%s: %s" % (name, "; ".join(e["bits"])) for name, e in s["errors"].items()] + p["errors_text"] = (" | ".join(texts) or "none")[:255] + p["errors"] = {name: e["value"] for name, e in s["errors"].items()} + return p + + +def discovery_messages(): + device = { + "identifiers": [DEVICE_ID], + "name": DEVICE_NAME, + "manufacturer": "microART", + "model": "MAP", + } + for key, component, name, unit, dev_class, state_class, extra in ENTITIES: + cfg = { + "name": name, + "unique_id": "%s_%s" % (DEVICE_ID, key), + "state_topic": STATE_TOPIC, + "value_template": "{{ value_json.%s }}" % key, + "availability_topic": AVAIL_TOPIC, + "device": device, + } + if unit: + cfg["unit_of_measurement"] = unit + if dev_class: + cfg["device_class"] = dev_class + if state_class: + cfg["state_class"] = state_class + if key == "errors_text": + cfg["json_attributes_topic"] = STATE_TOPIC + cfg["json_attributes_template"] = "{{ value_json.errors | tojson }}" + cfg.update(extra) + topic = "%s/%s/%s/%s/config" % (DISCOVERY_PREFIX, component, DEVICE_ID, key) + yield topic, json.dumps(cfg, ensure_ascii=False) + + +class Publisher: + def __init__(self): + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=MQTT_CLIENT_ID) + if MQTT_USER: + self.client.username_pw_set(MQTT_USER, MQTT_PASSWORD) + if MQTT_TLS: + self.client.tls_set() + self.client.will_set(AVAIL_TOPIC, "offline", qos=1, retain=True) + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.on_message = self._on_message + self.online = False + self._connack = threading.Event() + self._connect_error = None + + def start(self, timeout=10): + """Connect to the broker; raise on failure (the program will exit). + Later disconnects are handled by paho's automatic reconnect.""" + self.client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) # OSError on a bad address + self.client.loop_start() + if not self._connack.wait(timeout): + self.client.loop_stop() + raise ConnectionError("broker did not respond within %d s" % timeout) + if self._connect_error: + self.client.loop_stop() + raise ConnectionError("broker refused connection: %s" % self._connect_error) + + def stop(self): + self.client.publish(AVAIL_TOPIC, "offline", qos=1, retain=True).wait_for_publish(2) + self.client.disconnect() + self.client.loop_stop() + + def _on_connect(self, client, userdata, flags, reason_code, properties): + if reason_code.is_failure: + log.error("MQTT: broker refused connection: %s", reason_code) + if not self._connack.is_set(): + self._connect_error = reason_code + self._connack.set() + return + self._connack.set() + log.info("MQTT: connected to %s:%d", MQTT_HOST, MQTT_PORT) + client.subscribe(HA_STATUS_TOPIC) + self.publish_discovery() + self.set_available(self.online) + + def _on_disconnect(self, client, userdata, flags, reason_code, properties): + log.warning("MQTT: disconnected (%s), reconnecting...", reason_code) + + def _on_message(self, client, userdata, msg): + # Home Assistant restarted - republish discovery + if msg.topic == HA_STATUS_TOPIC and msg.payload == b"online": + log.info("MQTT: Home Assistant online, republishing discovery") + self.publish_discovery() + self.set_available(self.online) + + def publish_discovery(self): + for topic, payload in discovery_messages(): + self.client.publish(topic, payload, qos=1, retain=True) + + def set_available(self, online): + self.online = online + self.client.publish(AVAIL_TOPIC, "online" if online else "offline", qos=1, retain=True) + + def publish_state(self, payload): + self.client.publish(STATE_TOPIC, json.dumps(payload, ensure_ascii=False), qos=0) + + +def format_line(p): + return ("mode=%d (%s) Ubat=%.1fV Ibat=%.1fA Pbat=%dW Pload=%dW Ugrid=%dV Igrid=%.1fA " + "Pgrid=%dW Fgrid=%sHz Uout=%dV Fout=%sHz Tbat=%d Ttr=%d Ttrans=%d " + "Egrid=%.2f Ebat=%.2f Echarge=%.2f errors=%s") % ( + p["mode"], p["mode_text"], p["u_acc"], p["i_acc"], p["p_acc"], p["p_load"], + p["u_net"], p["i_net"], p["p_net"], p["f_net"], p["u_out"], p["f_out"], + p["t_acc"], p["t_tor"], p["t_trans"], p["e_net"], p["e_acc"], p["e_acc_charge"], + p["errors_text"]) + + +def main(): + logging.basicConfig(level=env("LOG_LEVEL", "INFO").upper(), stream=sys.stdout, + format="%(asctime)s %(levelname)s %(message)s") + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", line_buffering=True) + + stop = threading.Event() + signal.signal(signal.SIGTERM, lambda *a: stop.set()) + signal.signal(signal.SIGINT, lambda *a: stop.set()) + + log.info("Starting: ser2net %s:%d, poll every %.1f s, MQTT %s:%d, topic %s", + SERIAL_HOST, SERIAL_PORT, SERIAL_POLL_INTERVAL, MQTT_HOST, MQTT_PORT, STATE_TOPIC) + pub = Publisher() + try: + pub.start() + except OSError as e: # ConnectionError is an OSError too + log.error("MQTT: failed to connect to %s:%d: %s", MQTT_HOST, MQTT_PORT, e) + sys.exit(1) + + client = None + while not stop.is_set(): + try: + if client is None: + client = MapClient(SERIAL_HOST, SERIAL_PORT, SERIAL_TIMEOUT) + client.connect() + log.info("ser2net: connected to %s:%d", SERIAL_HOST, SERIAL_PORT) + payload = to_payload(read_status(client)) + log.info(format_line(payload)) + pub.publish_state(payload) + if not pub.online: + pub.set_available(True) + except (MapError, OSError) as e: + log.error("ser2net: %s", e) + if client is not None: + client.close() + client = None + if pub.online: + pub.set_available(False) + stop.wait(SERIAL_POLL_INTERVAL) + + log.info("Stopping") + if client is not None: + client.close() + pub.stop() + + +if __name__ == "__main__": + main() diff --git a/clusters/base/apps/map_monitor/src/map_read.py b/clusters/base/apps/map_monitor/src/map_read.py new file mode 100644 index 0000000..f48bf56 --- /dev/null +++ b/clusters/base/apps/map_monitor/src/map_read.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +""" +Read the current operating parameters of a MAP inverter (microART) via ser2net (raw TCP). + +Protocol reverse-engineered from thirdparty/malina/usr/bin/mapd.c: + * 19200 8N1 (configured on the ser2net side); + * every byte sent by the host is echoed back by the MAP; every byte of the MAP + reply must be echoed back by the host, otherwise the MAP won't send the next one; + * request: cmd('r'=0x72) | count-1 | addr_hi | addr_lo | crc | '\\n' + * reply: 'o'(0x6F) | data... | crc | '\\n' ('e'=0x65 means error) + * byte 0x0A inside a frame is encoded as DB DC, byte 0xDB as DB DD; + * crc = (0x100 - sum of all frame bytes in encoded form) & 0xFF; + if crc == 0x0A it also serves as the frame terminator. + +Usage: + python map_read.py # single read, 10.0.64.24:33333 + python map_read.py --loop 2 # poll every 2 s + python map_read.py --json # JSON output + python map_read.py --host 1.2.3.4 --port 4001 + +The ser2net port must be in raw mode (not telnet), for example: + 33333:raw:0:/dev/ttyUSB0:19200 8DATABITS NONE 1STOPBIT +""" + +import argparse +import json +import select +import socket +import sys +import time + +DEFAULT_HOST = "10.0.64.24" +DEFAULT_PORT = 33333 + +CMD_READ = 0x72 # 'r' +ANS_OK = 0x6F # 'o' +ANS_ERR = 0x65 # 'e' +LF = 0x0A +ESC = 0xDB + +MODES = { + 0: "System: OFF, Input grid: OFF", + 1: "System: OFF, Input grid: ON", + 2: "System: ON, Input grid: OFF, Local grid: BATTERY, Battery: DISCHARGING", + 3: "System: ON, Input grid: ON, Local grid: PASS-THROUGH", + 4: "System: ON, Input grid: ON, Local grid: PASS-THROUGH, Battery: CHARGING", +} + +# Error bit field descriptions (from html/local/RU/local.inc) +ERROR_BITS = { + "RSErrJobM": [ + "Battery fully discharged, voltage critically low", + "Battery overvoltage, inverting/charging suspended", + "Battery short circuit while charging, charging suspended", + "Battery short circuit, inverting/charging suspended", + "Main relay may be stuck", + "Short circuit on 220V grid", + "External voltage on 220V output, inverting disabled", + "Firmware reset (interference)", + ], + "RSErrJob": [ + "Battery discharged, inverting will stop in 1 minute", + "Battery overload", + "Load above rated power (limited operating time)", + "Overheating, inverting/charging suspended", + "Fan not spinning", + "Grid overload, switching to inverter", + "Operating mode failure (power loss/interference)", + "Shutdown after repeated short circuits while charging (cleared after 2 h)", + ], + "RSWarning": [ + "No action for START+CHARGE button combination", + "Grid voltage out of range", + "External voltage on output or voltage spikes from load", + "Voltage spikes on grid input", + "START or CHARGE button stuck", + "Charging not possible - no grid", + "Load power above EE_LCD_NetMaxPow, switching to inverter", + "Grid unstable", + ], + "RSErrDop": [ + "Invalid data from previous phase / no link", + "Phase shift outside 60 deg synchronization window", + "BMS I2C error", + "MPPT I2C error", + "Outdated MPPT firmware", + "Parallel MAP synchronization error", + "Slave MAP has no grid while master switched to grid", + None, + ], + "I2C_Err": ["I2C Err Ack", "I2C Err Sum", "I2C Err Data Size", "I2C Err Protocol", + None, None, None, None], + "F_Acc_Over": [ + "Shutdown: battery overcurrent", + "Shutdown: battery overcurrent while charging", + "Shutdown: battery voltage critically low", + "Shutdown: fan failure", + "Shutdown: power above rated for more than 30 min", + "Shutdown: battery fully discharged", + "Shutdown: temperature sensor out of range", + "Full shutdown after repeated battery overloads (press START)", + ], + "F_Net_Over": [ + "Inverting disabled - external voltage on output or faulty relay", + "Switched to inverter - grid overload", + None, + "Shutdown after repeated grid overloads (press START)", + None, None, None, None, + ], +} + + +class MapError(Exception): + pass + + +class MapClient: + def __init__(self, host, port, timeout=1.0): + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + + def connect(self): + self.sock = socket.create_connection((self.host, self.port), timeout=5) + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.sock.settimeout(self.timeout) + self.flush() + + def close(self): + if self.sock: + self.sock.close() + self.sock = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *exc): + self.close() + + # --- byte level ------------------------------------------------------ + + def flush(self): + """Discard everything accumulated in the input buffer.""" + while True: + r, _, _ = select.select([self.sock], [], [], 0.05) + if not r: + return + if not self.sock.recv(4096): + raise MapError("connection closed by ser2net") + + def _recv1(self): + try: + b = self.sock.recv(1) + except socket.timeout: + raise MapError("MAP response timeout") + if not b: + raise MapError("connection closed by ser2net") + return b[0] + + def _put(self, a): + """Send a byte and wait for its echo (resyncing to the stream, as mapd.c does).""" + self.sock.sendall(bytes([a])) + for _ in range(21): + if self._recv1() == a: + return + raise MapError("no echo from MAP (0x%02X)" % a) + + def _put_coded(self, a, csum): + if a == LF: + seq = (ESC, 0xDC) + elif a == ESC: + seq = (ESC, 0xDD) + else: + seq = (a,) + for b in seq: + self._put(b) + csum += b + return csum + + # --- frames ---------------------------------------------------------- + + def _send_command(self, cmd, addr, count): + csum = 0 + for b in (cmd, count - 1, addr >> 8, addr & 0xFF): + csum = self._put_coded(b, csum) + crc = (-csum) & 0xFF + self._put(crc) + if crc != LF: + self._put(LF) + + def _read_answer(self): + raw = bytearray() + while True: + b = self._recv1() + raw.append(b) + self.sock.sendall(bytes([b])) # echo back to the MAP + if b == LF or len(raw) > 600: + break + + if raw[0] == ANS_ERR: + raise MapError("MAP returned an error ('e')") + if raw[0] != ANS_OK or len(raw) < 3: + raise MapError("unexpected reply: %s" % raw[:16].hex()) + + if ((-sum(raw[:-2])) & 0xFF) == raw[-2]: + body = raw[1:-2] + elif ((-sum(raw[:-1])) & 0xFF) == LF: + body = raw[1:-1] # crc == 0x0A is itself the frame terminator + else: + raise MapError("checksum error") + + data = bytearray() + i = 0 + while i < len(body): + if body[i] == ESC and i + 1 < len(body) and body[i + 1] in (0xDC, 0xDD): + data.append(LF if body[i + 1] == 0xDC else ESC) + i += 2 + else: + data.append(body[i]) + i += 1 + return bytes(data) + + def read(self, addr, count, retries=3): + """Read count (1..256) bytes of MAP memory starting at addr.""" + last = None + for _ in range(retries): + try: + self._send_command(CMD_READ, addr, count) + data = self._read_answer() + if len(data) < count: + raise MapError("short reply: %d of %d bytes" % (len(data), count)) + return data[:count] + except MapError as e: + last = e + time.sleep(0.2) + self.flush() + raise last + + +# --- data decoding ------------------------------------------------------- + +def decode_bits(name, value): + texts = ERROR_BITS.get(name, []) + out = [] + for bit in range(8): + if value >> bit & 1: + t = texts[bit] if bit < len(texts) and texts[bit] else "bit %d" % bit + out.append(t) + return out + + +def phase_current(lo, hi): + v = (lo + (hi & 0x7F) * 256) / 10 + return -v if hi & 0x80 else v + + +def read_status(client): + m = client.read(0x400, 256) + x = lambda addr: m[addr - 0x400] + e = client.read(0x527, 96) + y = lambda addr: e[addr - 0x527] + + unet = x(0x422) + 100 if x(0x422) else 0 + uout = x(0x427) + 100 if x(0x427) else 0 + inet_raw = x(0x423) + hz = lambda t: round(6250 / t, 1) if t else None + + s = { + "mode": x(0x400), + "mode_text": MODES.get(x(0x400), "unknown mode"), + "status_char": x(0x402), + "u_acc": (x(0x405) * 256 + x(0x406)) / 10, + "i_acc": round(x(0x432) / 16 + x(0x433) * 16, 1), + "i_acc_coarse": x(0x408) * 2, + "p_load": x(0x409) * 100, + "u_net": unet, + "i_net": round(x(0x431) / (16 if inet_raw < 16 else 4), 1), + "p_net": x(0x424) * 100, + "f_net": hz(x(0x425)), + "u_out": uout, + "f_out": hz(x(0x426)), + "u_net_limit": x(0x429) + 100, + "f_net_limit": round(2500 / x(0x428), 1) if x(0x428) else None, + "t_acc": x(0x42E) - 50, + "t_tor": x(0x42F) - 50, + "t_trans": x(0x430) - 50, + "net_reverse": (x(0x443) + x(0x444) * 256) > 32768, + "e_net_raw": x(0x44D) | x(0x44E) << 8 | x(0x44F) << 16, + "e_acc_raw": x(0x450) | x(0x451) << 8 | x(0x452) << 16, + "e_acc_charge_raw": x(0x453) | x(0x454) << 8 | x(0x455) << 16, + "flag_eco": y(0x585), + "relay1": bool(y(0x586) & 1), + "relay2": bool(y(0x586) & 2), + # meaningful only in a three-phase setup, otherwise these cells hold garbage + "i_phase": [phase_current(y(0x528), y(0x529)), + phase_current(y(0x52A), y(0x52B)), + phase_current(y(0x52C), y(0x52D))], + "errors": {}, + } + s["p_acc"] = round(s["u_acc"] * s["i_acc"]) + + for name, addr in (("RSErrSis", 0x42A), ("RSErrJobM", 0x42B), ("RSErrJob", 0x42C), + ("RSWarning", 0x42D), ("RSErrDop", 0x447), ("I2C_Err", 0x45A), + ("F_Acc_Over", 0x41C), ("F_Net_Over", 0x41D)): + v = x(addr) + if v: + s["errors"][name] = {"value": v, "bits": decode_bits(name, v)} + return s + + +def print_status(s): + rows = [ + ("Mode", "%d - %s" % (s["mode"], s["mode_text"])), + ("Battery voltage", "%.1f V" % s["u_acc"]), + ("Battery current", "%.1f A (coarse %d A)" % (s["i_acc"], s["i_acc_coarse"])), + ("Battery power (calc.)", "%d W" % s["p_acc"]), + ("MAP load power", "%d W" % s["p_load"]), + ("Grid voltage", "%d V" % s["u_net"]), + ("Grid current", "%.1f A" % s["i_net"]), + ("Grid power", "%d W%s" % (s["p_net"], " (export to grid)" if s["net_reverse"] else "")), + ("Grid frequency", "%s Hz" % s["f_net"]), + ("Output voltage", "%d V" % s["u_out"]), + ("Output frequency", "%s Hz" % s["f_out"]), + ("Battery temp.", "%d °C" % s["t_acc"]), + ("Transformer temp.", "%d °C" % s["t_tor"]), + ("Transistor temp.", "%d °C" % s["t_trans"]), + ("Last out-of-range U", "%d V" % s["u_net_limit"]), + ("Relay 1 / Relay 2", "%s / %s" % ("on" if s["relay1"] else "off", + "on" if s["relay2"] else "off")), + ("Energy from grid", "%.2f kWh" % (s["e_net_raw"] / 100)), + ("Energy from battery", "%.2f kWh" % (s["e_acc_raw"] / 100)), + ("Battery charge energy", "%.2f kWh" % (s["e_acc_charge_raw"] / 100)), + ] + w =max(len(r[0]) for r in rows) + print(time.strftime("%Y-%m-%d %H:%M:%S")) + for k, v in rows: + print(" %-*s : %s" % (w, k, v)) + if s["errors"]: + print(" Errors:") + for name, err in s["errors"].items(): + print(" %s = 0x%02X: %s" % (name, err["value"], "; ".join(err["bits"]))) + else: + print(" No errors") + + +def main(): + ap = argparse.ArgumentParser(description="Read MAP inverter parameters via ser2net") + ap.add_argument("--host", default=DEFAULT_HOST) + ap.add_argument("--port", type=int, default=DEFAULT_PORT) + ap.add_argument("--timeout", type=float, default=1.0, help="per-byte timeout, s") + ap.add_argument("--loop", type=float, metavar="SEC", help="poll every SEC seconds") + ap.add_argument("--json", action="store_true", help="JSON output") + args = ap.parse_args() + + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + with MapClient(args.host, args.port, args.timeout) as c: + while True: + try: + s = read_status(c) + if args.json: + print(json.dumps(s, ensure_ascii=False), flush=True) + else: + print_status(s) + print(flush=True) + except MapError as e: + print("Error: %s" % e, file=sys.stderr) + if not args.loop: + return 1 + if not args.loop: + return 0 + time.sleep(args.loop) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + pass + except OSError as e: + print("ser2net connection error: %s" % e, file=sys.stderr) + sys.exit(2) diff --git a/clusters/base/apps/map_monitor/src/requirements.txt b/clusters/base/apps/map_monitor/src/requirements.txt new file mode 100644 index 0000000..96a2716 --- /dev/null +++ b/clusters/base/apps/map_monitor/src/requirements.txt @@ -0,0 +1 @@ +paho-mqtt==2.1.0 diff --git a/clusters/base/namespaces/iot.yaml b/clusters/base/namespaces/iot.yaml new file mode 100644 index 0000000..fe42f03 --- /dev/null +++ b/clusters/base/namespaces/iot.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: iot diff --git a/clusters/home/apps/kustomization.yaml b/clusters/home/apps/kustomization.yaml index 52512a3..9f9bc24 100644 --- a/clusters/home/apps/kustomization.yaml +++ b/clusters/home/apps/kustomization.yaml @@ -20,4 +20,5 @@ resources: - woodpecker.yaml - tinyauth.yaml - it-tools.yaml - - ntfy.yaml \ No newline at end of file + - ntfy.yaml + - map-monitor.yaml \ No newline at end of file diff --git a/clusters/home/apps/map-monitor.yaml b/clusters/home/apps/map-monitor.yaml new file mode 100644 index 0000000..f1ab9eb --- /dev/null +++ b/clusters/home/apps/map-monitor.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: map-monitor + namespace: flux-system +spec: + interval: 10m + sourceRef: + kind: GitRepository + name: flux-system + path: ./clusters/base/apps/map_monitor + prune: true + wait: true + dependsOn: + - name: namespaces + - name: infra + retryInterval: 5s + decryption: + provider: sops + secretRef: + name: sops-age-map-monitor diff --git a/clusters/home/namespaces/kustomization.yaml b/clusters/home/namespaces/kustomization.yaml index 1acce06..5b2ce5b 100644 --- a/clusters/home/namespaces/kustomization.yaml +++ b/clusters/home/namespaces/kustomization.yaml @@ -20,4 +20,5 @@ resources: - ../../base/namespaces/dev.yaml - ../../base/namespaces/ci.yaml - ../../base/namespaces/tools.yaml - - ../../base/namespaces/notify.yaml \ No newline at end of file + - ../../base/namespaces/notify.yaml + - ../../base/namespaces/iot.yaml \ No newline at end of file