Skip to content

Commit

Permalink
update Switch&Binary sensors
Browse files Browse the repository at this point in the history
  • Loading branch information
kellerza committed Jun 19, 2024
1 parent 6ca785e commit e353fc6
Show file tree
Hide file tree
Showing 17 changed files with 893 additions and 1,837 deletions.
28 changes: 28 additions & 0 deletions hass-addon-sunsynk-multi/Dockerfile.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# FROM ghcr.io/kellerza/hass-addon-sunsynk-multi/armhf:edge
FROM ghcr.io/kellerza/hass-addon-sunsynk-multi/armhf:2185293

# RUN set -x \
# && apk add --no-cache --virtual .build-deps \
# build-base \
# && pip3 install --no-cache-dir --disable-pip-version-check \
# aiohttp==3.8.5 \
# async_modbus==0.2.1 \
# attrs>21 \
# connio==0.2.0 \
# jmespath==1.0.1 \
# mqtt-entity==0.0.4 \
# prettytable==3.8.0 \
# pymodbus[serial]==3.6.4 \
# pysolarmanv5==3.0.2 \
# pyyaml==6.0.1 \
# umodbus==1.0.4 \
# && apk del .build-deps

# Install sunsynk from local source
COPY sunsynk sunsynk
RUN pip3 install ./sunsynk[pymodbus,umodbus,solarman] --no-cache-dir --disable-pip-version-check

COPY rootfs /

#! RUN chmod a+x /etc/services.d/sunsynk/run
#! RUN chmod a+x /etc/services.d/sunsynk/finish
1 change: 1 addition & 0 deletions scripts/copy2local.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ xcopy /Y %cf% %~2\config.yaml
@REM sed -i -E 's/#! (# )?//' %~1\Dockerfile.local
@REM rem sed -i 's/#! //' %~1\Dockerfile.local
@REM xcopy /Y %~1\Dockerfile.local %~2\Dockerfile
xcopy /Y %~1\Dockerfile.local %~2\Dockerfile

EXIT /B 0
4 changes: 3 additions & 1 deletion src/ha_addon_sunsynk_multi/a_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
RWSensor,
SelectRWSensor,
SwitchRWSensor,
SwitchRWSensor0,
TimeRWSensor,
resolve_num,
)
Expand Down Expand Up @@ -157,6 +158,7 @@ def on_change(val: float | int | str | bool) -> None:
"""On change callback."""
_LOGGER.info("Queue update %s=%s", sensor.id, val)
ist.write_queue.update({sensor: val})
self.publish(val)

ent.update(
{
Expand All @@ -177,7 +179,7 @@ def on_change(val: float | int | str | bool) -> None:
)
return self.entity

if isinstance(sensor, SwitchRWSensor):
if isinstance(sensor, (SwitchRWSensor, SwitchRWSensor0)):
self.entity = SwitchEntity(**ent)
return self.entity

Expand Down
23 changes: 12 additions & 11 deletions src/ha_addon_sunsynk_multi/sensor_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ async def callback_sensor(now: int) -> None:
# pylint: disable=too-many-branches
sensors_to_read: set[Sensor] = set()
sensors_to_publish: set[ASensor] = set()

# Flush pending writes
while ist.write_queue:
sensor, value = ist.write_queue.popitem()
if not isinstance(sensor, RWSensor):
continue
await asyncio.sleep(0.1)
await ist.inv.write_sensor(sensor, value)
await asyncio.sleep(0.05)
await ist.read_sensors(sensors=[sensor], msg=sensor.name)
sensors_to_publish.add(ist.ss[sensor.id])

# add all read items
for sec, srun in read_s.items():
if now % sec == 0 or srun.next_run <= now:
Expand All @@ -100,17 +112,6 @@ async def callback_sensor(now: int) -> None:
)
sensors_to_publish.update(ist.ss[s.id] for s in sensors_to_read)

# Flush pending writes
while ist.write_queue:
sensor, value = ist.write_queue.popitem()
if not isinstance(sensor, RWSensor):
continue
await asyncio.sleep(0.1)
await ist.inv.write_sensor(sensor, value)
await asyncio.sleep(0.05)
await ist.read_sensors(sensors=[sensor], msg=sensor.name)
sensors_to_publish.add(ist.ss[sensor.id])

# Publish to MQTT
pub: dict[ASensor, ValType] = {}
# Check significant change reporting
Expand Down
3 changes: 1 addition & 2 deletions src/ha_addon_sunsynk_multi/timer_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ def read_once(self) -> bool:
def significant_change(self, history: list[NumType], last: NumType) -> bool:
"""Check if there is a significant change according to the schedule."""
if self.change_any:
if not history or last != history[-1]:
return True
raise NotImplementedError()
if not history:
return False
avg = sum(history) / len(history)
Expand Down
4 changes: 3 additions & 1 deletion src/sunsynk/definitions/single_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,14 @@
"Load Limit",
options={0: "Allow Export", 1: "Essentials", 2: "Zero Export"},
),
NumberRWSensor(53, "Max Solar power", WATT, 1, min=0, max=RATED_POWER),
NumberRWSensor(245, "Max Sell power", WATT, 1, min=0, max=RATED_POWER),
# If disabled, does not allow the export of any excess solar.
# If enabled, will export any excess, but will also draw a
# constant ~40w at all times for an unknown reason
# 0: "Don't Sell", 1: "Sell solar"
SwitchRWSensor(247, "Solar Export"),
SwitchRWSensor(248, "Use Timer", bitmask=1),
SwitchRWSensor(248, "Use Timer"),
)

PROG1_TIME = TimeRWSensor(250, "Prog1 Time")
Expand Down
4 changes: 2 additions & 2 deletions src/sunsynk/pysunsynk.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ def _new_client(self) -> ModbusBaseClient:
client: Union[AsyncModbusTcpClient, AsyncModbusUdpClient, None] = None

match url.scheme: # python 3.10 minimum
case "serial-tcp":
case "serial-tcp": # RTU-over-TCP
opt = {"framer": ModbusRtuFramer}
client = AsyncModbusTcpClient(host=host, port=port, **opt)
case "tcp":
client = AsyncModbusTcpClient(host=host, port=port, **opt)
case "serial-udp":
case "serial-udp": # RTU-over-UDP
opt = {"framer": ModbusRtuFramer}
client = AsyncModbusUdpClient(host=host, port=port, **opt)
case "udp":
Expand Down
45 changes: 42 additions & 3 deletions src/sunsynk/rwsensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Callable, Generator, Optional, Union

import attrs
from mqtt_entity.utils import BOOL_OFF, BOOL_ON # type: ignore
from mqtt_entity.utils import BOOL_OFF, BOOL_ON

from sunsynk.helpers import NumType, RegType, SSTime, ValType, as_num, hex_str
from sunsynk.sensors import Sensor
Expand Down Expand Up @@ -119,8 +119,8 @@ def reg_to_value(self, regs: RegType) -> ValType:


@attrs.define(slots=True, eq=False)
class SwitchRWSensor(SelectRWSensor):
"""Switch Sensor."""
class SwitchRWSensor0(SelectRWSensor):
"""Switch Sensor. The original implementation."""

on: int = attrs.field(default=1)
"""The register value representing ON."""
Expand All @@ -132,6 +132,45 @@ def __attrs_post_init__(self) -> None:
assert not self.options
assert self.on != self.off
self.options = {self.off: BOOL_OFF, self.on: BOOL_ON}
if self.bitmask:
if self.on is not None:
assert self.on & self.bitmask == self.on
assert self.off & self.bitmask == self.off


@attrs.define(slots=True, eq=False)
class SwitchRWSensor(RWSensor):
"""Switch. Similar to BinarySensor, but writeable."""

on: Optional[int] = attrs.field(default=None)
"""The register value representing ON."""
off: int = attrs.field(default=0)
"""The register value representing OFF."""

def __attrs_post_init__(self) -> None:
"""Ensure correct parameters."""
if self.bitmask:
if self.on is not None:
assert self.on & self.bitmask == self.on
assert self.off & self.bitmask == self.off

def reg_to_value(self, regs: RegType) -> ValType:
"""Reg to value for binary."""
res = super().reg_to_value(regs)
if res is None:
return ""
if self.on is not None:
return res == self.on
return res != self.off

def value_to_reg(self, value: ValType, resolve: ResolveType) -> RegType:
"""Get the reg value from a display value, or the current reg value if out of range."""
value = str(value)
if value == BOOL_ON:
return self.on if self.on else self.masked((0xFF,))
if value != BOOL_OFF:
_LOGGER.warning("%s: ON/OFF expected, got %s", self.name, value)
return (self.off,)


@attrs.define(slots=True, eq=False)
Expand Down
11 changes: 4 additions & 7 deletions src/sunsynk/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,17 @@ class TextSensor(Sensor):
class BinarySensor(Sensor):
"""Binary sensor."""

off: Optional[int] = attrs.field(default=None)
off: int = attrs.field(default=0)
on: Optional[int] = attrs.field(default=None)

def reg_to_value(self, regs: RegType) -> ValType:
"""Reg to value for binary."""
res = super().reg_to_value(regs)
if self.on is not None and self.off is not None:
if res not in [self.on, self.off]:
return None
if res is None:
return None
if self.on is not None:
return res == self.on
if self.off is not None:
return res != self.off
return bool(res)
return res != self.off


@attrs.define(slots=True)
Expand Down
2 changes: 1 addition & 1 deletion src/sunsynk/solarmansunsynk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class SolarmanSunsynk(Sunsynk):
"""Sunsynk class using PySolarmanV5."""

client: PySolarmanV5Async = None
dongle_serial_number: int = attrs.field(default=0)
dongle_serial_number: str = attrs.field(default="")

@dongle_serial_number.validator
def check_serial(self, _: str, value: int) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/sunsynk/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ def update(self, new_regs: dict[int, int]) -> None:
if numeric:
self.history[sen].append(cast(NumType, newv))
else:
if not self.historynn[sen]:
self.historynn[sen].append(None)
if sen not in self.historynn:
self.historynn[sen] = [None]
self.historynn[sen].append(newv)
while len(self.historynn[sen]) > 2:
self.historynn[sen].pop(0)
Expand Down
4 changes: 2 additions & 2 deletions src/tests/ha_addon_sunsynk_multi/test_timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ async def test_schedule() -> None:
"""Test the schedule."""
s = Schedule(key="x", change_any=True)
# no history = change
assert s.significant_change([], 12)
assert s.significant_change([12], 12) is False
with pytest.raises(NotImplementedError):
s.significant_change([], 12)

s = Schedule(key="x", change_by=80)
# lower
Expand Down
7 changes: 3 additions & 4 deletions src/tests/sunsynk/test_rwsensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ def test_bitmask2(caplog: pytest.LogCaptureFixture, state: InverterState) -> Non
assert state[s] is None
state.update({1: 0x1})

assert state[s] == "OFF"
assert state[s] is False
assert "outside" not in caplog.text

state.update({1: 0x14})
assert state[s] == "ON"
assert state[s] is True

val = "OFF"
reg = s.value_to_reg(val, state.get)
Expand Down Expand Up @@ -123,15 +123,14 @@ def test_number_rw2(state: InverterState) -> None:
assert state[s] == 48.5

assert s.value_to_reg(48, state.get) == (4800,)
assert s.value_to_reg(48, {}) == (4800,)

# signed RW.
# https://github.com/kellerza/sunsynk/issues/145
s2 = NumberRWSensor(206, "Grid Trickle Feed", "W", -1, min=-500, max=500)
state.track(s2)
state.update({206: 0xFFBA})
assert state[s2] == -70
assert s2.value_to_reg(-70, {}) == (0xFFBA,)
assert s2.value_to_reg(-70, state.get) == (0xFFBA,)


def test_select_rw(caplog: pytest.LogCaptureFixture, state: InverterState) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/tests/sunsynk/test_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def test_binary_sensor(state: InverterState) -> None:
assert b.reg_to_value((0b1110,)) is False
assert b.reg_to_value((0b11,)) is True
assert b.reg_to_value((0b1111,)) is True
assert b.reg_to_value((0b00,)) is None
assert b.reg_to_value((0b01,)) is None
assert b.reg_to_value((0b00,)) is False
assert b.reg_to_value((0b01,)) is False


def test_sen() -> None:
Expand Down
5 changes: 3 additions & 2 deletions src/tests/sunsynk/test_solarmansunsynk.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Solarman Sunsynk"""
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest
Expand All @@ -9,8 +10,8 @@

@pytest.mark.asyncio
@patch(P_CONNECT, new_callable=AsyncMock)
async def test_uss_sensor(connect) -> None:
ss = SolarmanSunsynk(port="tcp://127.0.0.1:502")
async def test_uss_sensor(connect: Any) -> None:
ss = SolarmanSunsynk(port="tcp://127.0.0.1:502", dongle_serial_number="101")
# await ss.connect()
ss.client = AsyncMock()
rhr = ss.client.read_holding_registers = AsyncMock()
Expand Down
22 changes: 10 additions & 12 deletions www/docs/reference/multi-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,11 @@ The port for RS485 communications, which can be either:
- PORT: tcp://homeassistant.local:502
```
A `serial-tcp://` port is also suported and can be used depending on your gateway.

::: tip
This repository contains a [mbusd](../guide/mbusd) TCP gateway add-on that can be used for this purpose.

If you have any issues connecting directly to a serial port, please try mbusd - also see [this](https://github.com/kellerza/sunsynk/issues/131) issue
:::
If your gateway do not support Modbus TCP to Modbus RTU conversion, you can try using `serial-tcp://` or `serial-udp://` as the port protocol. This will send Modbus RTU framed data over TCP/UDP (RTU-over-TCP).

::: details Solarman driver details

The Solarman driver typically uses `tcp://`, with a port value of 8899. You will need to find the dongle's local IP on your network. You can find the IP on your router, or use a utility like [netscan](https://www.portablefreeware.com/?id=730).
The Solarman driver typically uses `tcp://`, with a port value of **8899**. You will need to find the dongle's local IP on your network. You can find the IP on your router, or use a utility like [netscan](https://www.portablefreeware.com/?id=730).

You probably want to set a fixed IP for the dongle on your router.

Expand All @@ -85,6 +79,12 @@ The port for RS485 communications, which can be either:
- PORT: /dev/ttyUSB0
```

::: tip
This repository contains a [mbusd](../guide/mbusd) add-on, a very reliable Modbus TCP to Modbus RTU gateway.

If you have any issues connecting directly to a serial port, please try mbusd - also see [this](https://github.com/kellerza/sunsynk/issues/131) issue
:::

::: tip
umodbus requires a `serial://` prefix

Expand All @@ -94,17 +94,15 @@ The port for RS485 communications, which can be either:
- PORT: serial:///dev/ttyUSB0
```

:::
:::

- For the first inverter in the list, you can use an empty string
- For the first inverter in the list, you can use an empty string. The serial port selected under `DEBUG_DEVICE` will be used (located at the bottom of you config)*

```yaml
INVERTERS:
- PORT: ""
```

The serial port under `DEBUG_DEVICE` will be used (located at the bottom of you config)*

- umodbus support an RFC2217 compatible port (e.g. `tcp://homeassistant.local:6610`)

## Sensors
Expand Down
Loading

0 comments on commit e353fc6

Please sign in to comment.