Skip to content

Commit b2ed0c4

Browse files
committed
Add pluggable RS-485 / serial device support
- New collector/rs485/ package with drivers for JK BMS, Modbus RTU, and raw hex discovery. - Rs485Device base class + registry for adding future drivers. - Standalone collector/rs485_collector.py daemon that writes to MariaDB/InfluxDB/MQTT. - CLI entrypoint bin/lux-mon-rs485 and discovery helper scripts/discover-rs485.py. - Installer updates: pyserial/pymodbus deps, dialout group, optional lux-mon-rs485.service. - Prefix RS-485 fields to avoid colliding with inverter register names. - Skip non-numeric values (e.g. raw hex dumps) before database writes.
1 parent dbd6e5f commit b2ed0c4

13 files changed

Lines changed: 1214 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ venv/
1313
config.yaml
1414
config.py
1515
.env
16+
.credentials
17+
backup.tar.gz
1618
!config.example.yaml
1719
!config.example.py
1820
!env.example

‎bin/lux-mon-rs485‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/srv/lux-mon/venv/bin/python3
2+
"""CLI entry point for the lux-mon RS-485 collector."""
3+
4+
import argparse
5+
import logging
6+
import sys
7+
from pathlib import Path
8+
9+
# Allow running from a source checkout without being installed.
10+
repo = Path(__file__).resolve().parent.parent
11+
sys.path.insert(0, str(repo))
12+
13+
from collector.rs485_collector import run_rs485_collector
14+
15+
16+
def main():
17+
parser = argparse.ArgumentParser(
18+
prog="lux-mon-rs485",
19+
description="Poll RS-485 / serial devices (JK BMS, Modbus RTU, etc.) and write to lux-mon backends",
20+
)
21+
parser.add_argument("--log-level", default="INFO", help="Logging level (DEBUG/INFO/WARNING/ERROR)")
22+
args = parser.parse_args()
23+
24+
logging.basicConfig(
25+
level=getattr(logging, args.log_level.upper(), logging.INFO),
26+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
27+
)
28+
29+
run_rs485_collector(log_level=args.log_level.upper())
30+
31+
32+
if __name__ == "__main__":
33+
main()

‎collector/rs485/README.md‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# lux-mon RS-485 / serial device support
2+
3+
This package adds pluggable RS-485 and TTL-UART drivers to lux-mon so the
4+
same collector can ingest data from external devices (battery monitors,
5+
meters, weather stations, etc.) and write it to the same MariaDB, InfluxDB,
6+
and MQTT backends.
7+
8+
It is designed to be **additive**: the existing TCP collector is untouched,
9+
and RS-485 collection runs as a separate daemon.
10+
11+
## Available drivers
12+
13+
| Driver | Device / protocol |
14+
|----------------|------------------------------------------------------|
15+
| `jk_bms` | JiKong (JK) BMS over UART/RS-485 |
16+
| `modbus_rtu` | Generic Modbus RTU master |
17+
| `raw` | Listen-only hex dump for discovery / unknown devices |
18+
19+
New drivers can be added by creating a class that inherits from
20+
`Rs485Device` and registering it in `registry.py`.
21+
22+
## Quick start (native install)
23+
24+
1. Install lux-mon. The installer now adds `pyserial`/`pymodbus` and puts
25+
`jmewing` in the `dialout` group.
26+
27+
2. Discover what is connected to your RS-485 adapter:
28+
29+
```bash
30+
lux-mon-discover-rs485 /dev/ttyUSB0
31+
```
32+
33+
3. Enable the RS-485 collector in `/srv/lux-mon/.env`:
34+
35+
```env
36+
LUX_RS485_ENABLED=true
37+
LUX_RS485_PORT=/dev/ttyUSB0
38+
LUX_RS485_BAUD=115200
39+
LUX_RS485_DEVICE_TYPE=jk_bms
40+
LUX_RS485_POLL_INTERVAL=2.0
41+
LUX_RS485_WRITE_INTERVAL=30.0
42+
LUX_RS485_PREFIX=rs485
43+
```
44+
45+
4. Start the collector:
46+
47+
```bash
48+
sudo systemctl enable --now lux-mon-rs485
49+
```
50+
51+
Or run manually for testing:
52+
53+
```bash
54+
cd /srv/lux-mon
55+
source .env
56+
venv/bin/lux-mon-rs485
57+
```
58+
59+
## Data shape
60+
61+
Every driver returns a dictionary of:
62+
63+
```python
64+
{
65+
"field_name": {"value": float, "unit": str},
66+
...
67+
}
68+
```
69+
70+
Before writing, the collector prefixes field names with `LUX_RS485_PREFIX`
71+
(default `rs485_`) so they do not collide with inverter registers. For
72+
example a JK BMS `total_voltage` becomes `rs485_total_voltage` in MariaDB
73+
and `luxmon_register{name="rs485_total_voltage"}` in InfluxDB.
74+
75+
## Adding a new driver
76+
77+
1. Create `collector/rs485/my_device.py` with a class:
78+
79+
```python
80+
from . import Rs485Device, Rs485DeviceConfig
81+
82+
class MyDevice(Rs485Device):
83+
name = "my_device"
84+
label = "My custom device"
85+
86+
def read(self):
87+
return {"temperature": {"value": 25.0, "unit": "°C"}}
88+
```
89+
90+
2. Register it in `collector/rs485/registry.py`:
91+
92+
```python
93+
("my_device", "collector.rs485.my_device", "MyDevice"),
94+
```
95+
96+
3. Optional: add a scale map or protocol-specific options to
97+
`Rs485DeviceConfig.options`.
98+
99+
## Notes
100+
101+
- `modbus_rtu` is a generic master; supply a `scale_map` in code to translate
102+
registers into named sensors.
103+
- `raw` never transmits, it only listens. Use it to confirm bytes are arriving
104+
before choosing a driver.
105+
- Missing dependencies (e.g. `pymodbus`) are logged and skipped instead of
106+
crashing the registry.

‎collector/rs485/__init__.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""RS-485 / serial device drivers for lux-mon.
2+
3+
This package provides pluggable drivers for devices connected to the host via
4+
an RS-485 or TTL serial adapter (for example a CP210x USB-to-UART bridge).
5+
6+
Each driver inherits from :class:`Rs485Device` and implements ``read()`` to
7+
return a snapshot of decoded values.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from abc import ABC, abstractmethod
13+
from dataclasses import dataclass, field
14+
from typing import Any, Dict, Optional
15+
16+
17+
@dataclass
18+
class Rs485DeviceConfig:
19+
"""Common configuration for an RS-485 device."""
20+
21+
port: str = "/dev/ttyUSB0"
22+
baudrate: int = 115200
23+
timeout: float = 1.0
24+
poll_interval: float = 2.0
25+
device_type: str = "jk_bms"
26+
# Generic Modbus settings
27+
slave_id: int = 1
28+
modbus_start: int = 0
29+
modbus_count: int = 40
30+
modbus_function: str = "input" # or "holding"
31+
# Driver-specific options
32+
options: Dict[str, Any] = field(default_factory=dict)
33+
34+
35+
class Rs485Device(ABC):
36+
"""Base class for an RS-485 connected device driver.
37+
38+
A driver owns a serial port and knows how to request/parse data from one
39+
specific device family. The :meth:`read` method should return a dictionary
40+
of decoded values using the same shape as the main inverter driver:
41+
42+
{"field_name": {"value": float, "unit": str}}
43+
"""
44+
45+
name: str = "generic"
46+
label: str = "Generic RS-485 device"
47+
48+
def __init__(self, config: Rs485DeviceConfig):
49+
self.cfg = config
50+
51+
@abstractmethod
52+
def read(self) -> Dict[str, Dict[str, Any]]:
53+
"""Read the device and return decoded values.
54+
55+
Returns a mapping of field name -> {"value": ..., "unit": ...}.
56+
Returns an empty dict if the read failed or no data was available.
57+
"""
58+
raise NotImplementedError
59+
60+
def close(self) -> None:
61+
"""Release any resources."""
62+
pass

0 commit comments

Comments
 (0)