Metadata-Version: 2.4
Name: aiosaj
Version: 0.2.0
Summary: Async library to read data from SAJ solar inverters (Ethernet and WiFi modules)
Author: kellerza
Author-email: fredericvl <frederic.van.linthoudt@gmail.com>
Maintainer: Rafael Sarrechia
License-Expression: MIT
Project-URL: Homepage, https://github.com/Rsarrechia/aiosaj
Project-URL: Repository, https://github.com/Rsarrechia/aiosaj
Project-URL: Issues, https://github.com/Rsarrechia/aiosaj/issues
Project-URL: Original project, https://github.com/fredericvl/pysaj
Keywords: saj,solar,inverter,photovoltaic,home-assistant
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp>=3.8
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff==0.17.1; extra == "lint"
Dynamic: license-file

# aiosaj

[![PyPI](https://img.shields.io/pypi/v/aiosaj)](https://pypi.org/project/aiosaj/)
[![Python](https://img.shields.io/pypi/pyversions/aiosaj)](https://pypi.org/project/aiosaj/)
[![tests](https://github.com/Rsarrechia/aiosaj/actions/workflows/tests.yml/badge.svg)](https://github.com/Rsarrechia/aiosaj/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

Async library to read SAJ solar inverters over their Ethernet (`real_time_data.xml`) and
WiFi (`status/status.php`) interfaces, written for Home Assistant.

aiosaj continues [fredericvl/pysaj](https://github.com/fredericvl/pysaj), which was based
on the pysma component written by @kellerza. It is published under a new name and
imported as `aiosaj`, so it can be installed next to the original `pysaj`.

```
pip install aiosaj
```

```python
import asyncio

from aiosaj import SAJ, Sensors


async def main():
    sensors = Sensors(wifi=True)
    saj = SAJ("192.168.1.100", wifi=True)
    if await saj.read(sensors):
        print(sensors["current_power"].value, sensors["total_yield"].value)


asyncio.run(main())
```

Migrating from `pysaj`: install `aiosaj` and change `import pysaj` / `from pysaj import ...`
to `aiosaj`. The API is otherwise the same as pysaj 0.1.x; see [CHANGELOG.md](CHANGELOG.md)
for what differs from upstream 0.0.16.

## Sensors

`Sensors(wifi=True)` reads the WiFi module's `status/status.php`; `Sensors(wifi=False)`
reads the ethernet interface's `real_time_data.xml`. A sensor is only reported as
`enabled` once the inverter has actually returned a value for it, so channels a
particular inverter does not have (a third PV string, unused string currents) never
show up.

| Sensor | Unit | Interface |
| --- | --- | --- |
| `current_power` | W | both |
| `today_yield`, `total_yield` | kWh | both |
| `today_time`, `total_time` | h | both |
| `total_co2_reduced` | kg | both |
| `temperature` | °C | both |
| `state` | | both |
| `today_max_current` | W | ethernet |
| `pv1_voltage` … `pv3_voltage` | V | WiFi ✓ · LAN pending¹ |
| `pv1_current` … `pv3_current` | A | WiFi ✓ · LAN pending¹ |
| `pv1_string1_current` … `pv3_string4_current` | A | WiFi ✓ · LAN pending¹ |
| `grid_frequency` | Hz | WiFi ✓ · LAN pending¹ |
| `line1_voltage` … `line3_voltage` | V | WiFi ✓ · LAN pending¹ |
| `line1_current` … `line3_current` | A | WiFi ✓ · LAN pending¹ |
| `bus_voltage` | V | WiFi ✓ · LAN pending¹ |

¹ These values are **not WiFi-exclusive** — the inverter measures them regardless of
how you connect. They are currently wired up and verified only for the WiFi interface,
where field positions, scaling and units come from the module's own `status.html`
(its `cf` array) and `/i18n/en/status.xml` (its `u<N>` entries). The ethernet/LAN
interface exposes the same values in `real_time_data.xml`, under different tag names.

Two independently posted `real_time_data.xml` dumps (see [Sources](#sources)) agree on
these, so they are the tags LAN support should use:

| Sensor | LAN tag |
| --- | --- |
| `pv1_voltage` … `pv3_voltage` | `v-pv1`, `v-pv2`, `v-pv3` |
| `pv1_string1_current` … `pv3_string4_current` | `i-pv11` … `i-pv34` |
| `bus_voltage` | `v-bus` |
| `line1_voltage` … `line3_voltage` | `Vac_l1`, `Vac_l2`, `Vac_l3` |
| `line1_current` … `line3_current` | `Iac_l1`, `Iac_l2`, `Iac_l3` |
| `grid_frequency` | `Freq1`, and `Freq2` / `Freq3` per further phase |

`pv1_current` … `pv3_current` have no LAN counterpart: the XML reports string currents
only. The LAN interface also carries per-phase power (`pac1`, `pac2`, `pac3`) that the
WiFi record does not.

Those tag names come from public dumps rather than from an inverter this library has
been run against, so they are deliberately not wired up yet - a sensor reading a guessed
tag that turns out to be wrong would be worse than one that stays disabled. If you have
a LAN-connected SAJ inverter, `curl http://<inverter>/real_time_data.xml` and open an
issue with the output; confirming the tags is all that is left to do.

## Sources

The LAN tag names in ¹ above are taken from these publicly posted
`real_time_data.xml` dumps, which agree with each other:

* <https://forum.domoticz.com/viewtopic.php?t=30154>
* <https://community.openhab.org/t/read-networkbinding-xml-file-from-saj-suntrino-inverter/105690>

## Validation

The WiFi module returns malformed records - truncated bodies, wait markers, zeroed
registers while it is starting up. Its own web UI throws these away
(`if(35!=s.length){return;}`); this library does the same, because a partial record
otherwise yields plausible looking numbers read from the wrong field positions.

A record is only applied when:

* it is a single line whose field count matches a known layout (35, or 23 for older
  firmware),
* the daily counters do not exceed their lifetime counters,
* each reading falls inside that sensor's plausible range.

`65535` is the module's not-available sentinel and never becomes a reading.

Readings taken while the inverter's run state is not `Normal` (waking up, waiting,
error) are not trusted: live values become `None` and the counters keep their last
known value.

Lifetime counters (`total_yield`, `total_time`, `total_co2_reduced`) may never
decrease. Home Assistant reads them as `total_increasing`, so a single low sample is
taken as a meter reset and the whole counter is then booked as fresh production.
Implausibly large forward jumps are held until several consecutive readings agree, so
a corrupt high value cannot become the new baseline either.
