run.py 8.17 KB
Newer Older
Johann Kellerman's avatar
Johann Kellerman committed
1 2 3 4 5 6
#!/usr/bin/env python3
"""Run the addon."""
import asyncio
import logging
import sys
from asyncio.events import AbstractEventLoop
7
from collections import defaultdict
Johann Kellerman's avatar
Johann Kellerman committed
8 9 10 11 12 13
from json import loads
from math import modf
from pathlib import Path
from typing import Dict, List, Sequence

import yaml
14
from filter import RROBIN, Filter, getfilter, suggested_filter
Johann Kellerman's avatar
Johann Kellerman committed
15 16 17 18
from mqtt import MQTT, Device, Entity, SensorEntity
from options import OPT, SS_TOPIC
from profiles import profile_add_entities, profile_poll

19
from sunsynk.definitions import ALL_SENSORS, DEPRECATED
Johann Kellerman's avatar
images  
Johann Kellerman committed
20
from sunsynk.sensor import slug
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
21
from sunsynk.sunsynk import Sensor, Sunsynk
Johann Kellerman's avatar
Johann Kellerman committed
22 23 24 25 26 27 28

_LOGGER = logging.getLogger(__name__)


SENSORS: List[Filter] = []


Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
29
SUNSYNK: Sunsynk = None  # type: ignore
Johann Kellerman's avatar
Johann Kellerman committed
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79


async def publish_sensors(sensors: List[Filter], *, force: bool = False) -> None:
    """Publish sensors."""
    for fsen in sensors:
        res = fsen.sensor.value
        if not force:
            res = fsen.update(res)
            if res is None:
                continue
        if isinstance(res, float):
            if modf(res)[0] == 0:
                res = int(res)
            else:
                res = f"{res:.2f}".rstrip("0")

        await MQTT.connect(OPT)
        await MQTT.publish(
            topic=f"{SS_TOPIC}/{OPT.sunsynk_id}/{fsen.sensor.id}", payload=str(res)
        )


async def hass_discover_sensors(serial: str) -> None:
    """Discover all sensors."""
    ents: List[Entity] = []
    dev = Device(
        identifiers=[OPT.sunsynk_id],
        name=f"Sunsynk Inverter {serial}",
        model=f"Inverter {serial}",
        manufacturer="Sunsynk",
    )

    for filt in SENSORS:
        sensor = filt.sensor
        ents.append(
            SensorEntity(
                name=f"{OPT.sensor_prefix} {sensor.name}".strip(),
                state_topic=f"{SS_TOPIC}/{OPT.sunsynk_id}/{sensor.id}",
                unit_of_measurement=sensor.unit,
                unique_id=f"{OPT.sunsynk_id}_{sensor.id}",
                device=dev,
            )
        )

    profile_add_entities(entities=ents, device=dev)

    await MQTT.connect(OPT)
    await MQTT.publish_discovery_info(entities=ents)


80 81 82
SERIAL = ALL_SENSORS["serial"]


Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
83 84
def setup_driver() -> None:
    """Setup the correct driver."""
Johann Kellerman's avatar
lint  
Johann Kellerman committed
85 86
    # pylint: disable=import-outside-toplevel
    global SUNSYNK  # pylint: disable=global-statement
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
87 88 89 90
    if OPT.driver == "pymodbus":
        from sunsynk.pysunsynk import pySunsynk

        SUNSYNK = pySunsynk()
Johann Kellerman's avatar
images  
Johann Kellerman committed
91 92 93
        if not OPT.port:
            OPT.port = OPT.device

Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
94 95 96 97
    elif OPT.driver == "umodbus":
        from sunsynk.usunsynk import uSunsynk

        SUNSYNK = uSunsynk()
Johann Kellerman's avatar
images  
Johann Kellerman committed
98 99 100
        if not OPT.port:
            OPT.port = "serial://" + OPT.device

Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
101 102 103 104 105 106 107
    else:
        _LOGGER.critical("Invalid DRIVER: %s. Expected umodbus, pymodbus", OPT.driver)
        sys.exit(-1)

    SUNSYNK.port = OPT.port
    SUNSYNK.server_id = OPT.modbus_server_id
    SUNSYNK.timeout = OPT.timeout
108
    SUNSYNK.read_sensors_batch_size = OPT.read_sensors_batch_size
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
109 110


Johann Kellerman's avatar
Johann Kellerman committed
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
def startup() -> None:
    """Read the hassos configuration."""
    logging.basicConfig(
        format="%(asctime)s %(levelname)-7s %(message)s", level=logging.DEBUG
    )

    hassosf = Path("/data/options.json")
    if hassosf.exists():
        _LOGGER.info("Loading HASS OS configuration")
        OPT.update(loads(hassosf.read_text(encoding="utf-8")))
    else:
        _LOGGER.info(
            "Local test mode - Defaults apply. Pass MQTT host & password as arguments"
        )
        configf = Path(__file__).parent / "config.yaml"
        OPT.update(yaml.safe_load(configf.read_text()).get("options", {}))
        OPT.mqtt_host = sys.argv[1]
        OPT.mqtt_password = sys.argv[2]
        OPT.debug = 1

    MQTT.availability_topic = f"{SS_TOPIC}/{OPT.sunsynk_id}/availability"

Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
133
    setup_driver()
Johann Kellerman's avatar
Johann Kellerman committed
134 135 136 137 138 139 140 141 142 143

    if OPT.debug < 2:
        logging.basicConfig(
            format="%(asctime)s %(levelname)-7s %(message)s",
            level=logging.INFO,
            force=True,
        )

    sens = {}

144
    msg: Dict[str, List[str]] = defaultdict(list)
Johann Kellerman's avatar
Johann Kellerman committed
145 146 147

    for sensor_def in OPT.sensors:
        name, _, fstr = sensor_def.partition(":")
Johann Kellerman's avatar
images  
Johann Kellerman committed
148
        name = slug(name)
Johann Kellerman's avatar
Johann Kellerman committed
149 150 151 152 153
        if name in sens:
            _LOGGER.warning("Sensor %s only allowed once", name)
            continue
        sens[name] = True

154 155
        sen = ALL_SENSORS.get(name)
        if not isinstance(sen, Sensor):
Johann Kellerman's avatar
Johann Kellerman committed
156 157
            log_bold(f"Unknown sensor in config: {sensor_def}")
            continue
158 159
        if sen.id in DEPRECATED:
            log_bold(f"Sensor deprecated: {sen.id} -> {DEPRECATED[sen.id].id}")
Johann Kellerman's avatar
Johann Kellerman committed
160 161
        if not fstr:
            fstr = suggested_filter(sen)
162
            msg[f"*{fstr}"].append(name)  # type: ignore
Johann Kellerman's avatar
Johann Kellerman committed
163
        else:
164
            msg[fstr].append(name)  # type: ignore
Johann Kellerman's avatar
Johann Kellerman committed
165 166 167 168

        SENSORS.append(getfilter(fstr, sensor=sen))

    for nme, val in msg.items():
169
        _LOGGER.info("Filter %s used for %s", nme, ", ".join(sorted(val)))
Johann Kellerman's avatar
Johann Kellerman committed
170 171 172 173 174 175 176 177 178 179 180 181


def log_bold(msg: str) -> None:
    """Log a message."""
    _LOGGER.info("#" * 60)
    _LOGGER.info(f"{msg:^60}".rstrip())
    _LOGGER.info("#" * 60)


READ_ERRORS = 0


Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
182
async def read_sensors(
Johann Kellerman's avatar
Johann Kellerman committed
183 184 185 186 187 188
    sensors: Sequence[Sensor], msg: str = "", retry_single: bool = False
) -> bool:
    """Read from the Modbus interface."""
    global READ_ERRORS  # pylint:disable=global-statement
    try:
        try:
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
189
            await asyncio.wait_for(SUNSYNK.read_sensors(sensors), OPT.timeout)
Johann Kellerman's avatar
Johann Kellerman committed
190 191 192
            READ_ERRORS = 0
            return True
        except asyncio.TimeoutError:
Johann Kellerman's avatar
remove  
Johann Kellerman committed
193 194 195
            _LOGGER.error("Timeout reading: %s", msg)
        # except KeyError:
        #     _LOGGER.error("Read error%s: Timeout", msg)
Johann Kellerman's avatar
Johann Kellerman committed
196 197 198 199 200 201 202 203 204 205
    except Exception as err:  # pylint:disable=broad-except
        _LOGGER.error("Read Error%s: %s", msg, err)
        READ_ERRORS += 1
        if READ_ERRORS > 3:
            raise Exception(f"Multiple Modbus read errors: {err}") from err

    if retry_single:
        _LOGGER.info("Retrying individual sensors: %s", [s.name for s in SENSORS])
        for sen in sensors:
            await asyncio.sleep(0.02)
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
206
            await read_sensors([sen], msg=sen.name, retry_single=False)
Johann Kellerman's avatar
Johann Kellerman committed
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221

    return False


TERM = (
    "This Add-On will terminate in 30 seconds, "
    "use the Supervisor Watchdog to restart automatically."
)


async def main(loop: AbstractEventLoop) -> None:  # noqa
    """Main async loop."""
    loop.set_debug(OPT.debug > 0)

    try:
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
222
        await SUNSYNK.connect()
Johann Kellerman's avatar
Johann Kellerman committed
223 224 225 226 227 228
    except ConnectionError:
        log_bold(f"Could not connect to {SUNSYNK.port}")
        _LOGGER.critical(TERM)
        await asyncio.sleep(30)
        return

Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
229
    if not await read_sensors([SERIAL]):
Johann Kellerman's avatar
Johann Kellerman committed
230 231 232 233 234 235 236 237
        log_bold(
            "No response on the Modbus interface, try checking the "
            "wiring to the Inverter, the USB-to-RS485 converter, etc"
        )
        _LOGGER.critical(TERM)
        await asyncio.sleep(30)
        return

238
    log_bold(f"Inverter serial number '{SERIAL.value}'")
Johann Kellerman's avatar
Johann Kellerman committed
239

240
    if OPT.sunsynk_id != SERIAL.value and not OPT.sunsynk_id.startswith("_"):
Johann Kellerman's avatar
Johann Kellerman committed
241 242 243
        log_bold("SUNSYNK_ID should be set to the serial number of your Inverter!")
        return

Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
244
    await hass_discover_sensors(str(SERIAL.value))
Johann Kellerman's avatar
Johann Kellerman committed
245 246 247

    # Read all & publish immediately
    await asyncio.sleep(0.01)
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
248
    await read_sensors([f.sensor for f in SENSORS], retry_single=True)
Johann Kellerman's avatar
Johann Kellerman committed
249 250 251 252 253 254
    await publish_sensors(SENSORS, force=True)

    async def poll_sensors() -> None:
        """Poll sensors."""
        fsensors = []
        # 1. collect sensors to read
255
        RROBIN.tick()
Johann Kellerman's avatar
Johann Kellerman committed
256 257 258 259 260
        for fil in SENSORS:
            if fil.should_update():
                fsensors.append(fil)
        if fsensors:
            # 2. read
Johann Kellerman's avatar
0.1.4  
Johann Kellerman committed
261
            if await read_sensors([f.sensor for f in fsensors]):
Johann Kellerman's avatar
Johann Kellerman committed
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284
                # 3. decode & publish
                await publish_sensors(fsensors)

    while True:
        polltask = asyncio.create_task(poll_sensors())
        await asyncio.sleep(1)
        try:
            await polltask
        except asyncio.TimeoutError as exc:
            _LOGGER.error("TimeOut %s", exc)
            continue
        except AttributeError:
            # The read failed. Exit and let the watchdog restart
            return
        if OPT.profiles:
            await profile_poll(SUNSYNK)


if __name__ == "__main__":
    startup()
    LOOP = asyncio.get_event_loop()
    LOOP.run_until_complete(main(LOOP))
    LOOP.close()