Skip to content

Commit ed773a6

Browse files
author
Jeremy Ewing
committed
feat: automation/rule engine for time/sensor-based inverter writes
- Add HOLDING_REGISTERS map and build_write_request() to protocol.py - New collector/automation.py rule engine with time windows, sensor conditions, static or range-table actions, dry-run mode, and safe Modbus write helpers - Wire automation evaluation into collector writer loop - Add /api/automation/* endpoints for rules, registers, dry-run test, enable toggle, and action log - Add Automations page to the built-in dashboard - Add automation_enabled + automation_rules settings defaults Live tested: dry-run rule evaluated against every snapshot; direct Modbus write to h66 echoed back successfully.
1 parent 0e914b6 commit ed773a6

7 files changed

Lines changed: 1099 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,45 @@ The API server runs on port 8080 and provides:
139139
| `GET /api/settings` | All runtime settings |
140140
| `GET /api/settings/{name}` | Single setting value |
141141
| `PUT /api/settings/{name}` | Update a setting (JSON body: `{"value": "..."}`) |
142+
| `GET /api/automation/rules` | List automation rules and global enable flag |
143+
| `POST /api/automation/rules` | Replace the full rule set (JSON array) |
144+
| `POST /api/automation/test` | Dry-run evaluate rules against the latest snapshot |
145+
| `GET /api/automation/log` | Recent automation actions / dry-runs |
146+
147+
The built-in dashboard (`/`) includes an **Automations** page where you can enable the engine, add rules with time windows and sensor conditions, and test them in dry-run mode before allowing live inverter writes.
148+
149+
### Automation rules
150+
151+
Rules are stored as JSON in the `automation_rules` setting and evaluated after every snapshot. A rule can set a holding register to a static value or choose a value from a sensor-range table (e.g., different charge amps for different battery-voltage bands). Each rule supports:
152+
153+
- `time_window`: `{"start": "21:00", "end": "06:00"}` (wraps past midnight)
154+
- `conditions`: list of `{"sensor": "battery_voltage", "min": 0, "max": 54}` checks
155+
- `action`: `{"register": "ac_charge_power", "value": 85}` or a `ranges` table
156+
- `dry_run`: when `true`, the rule logs what it *would* write but never sends a Modbus command
157+
158+
Only registers listed in `collector/protocol.py:HOLDING_REGISTERS` can be written, and every value is clamped to the register's documented min/max. Example rule matching SolarAssistant's night grid-charge behavior:
159+
160+
```json
161+
{
162+
"id": "night_grid_charge",
163+
"name": "Grid charge current (night)",
164+
"enabled": true,
165+
"dry_run": true,
166+
"time_window": {"start": "21:00", "end": "06:00"},
167+
"conditions": [],
168+
"action": {
169+
"register": "ac_charge_power",
170+
"range_sensor": "battery_voltage",
171+
"ranges": [
172+
{"min": 0, "max": 54.0, "value": 85},
173+
{"min": 55.0, "max": 56.0, "value": 45},
174+
{"min": 57.0, "max": 58.0, "value": 5}
175+
]
176+
}
177+
}
178+
```
179+
180+
Enable the engine globally with the `automation_enabled` setting (or the dashboard toggle). Set `dry_run: false` on a rule only when you are ready for live inverter writes.
142181

143182
The API also serves a built-in web dashboard at `/` with real-time gauges, power flow, historic charts, battery details, energy totals, and runtime settings.
144183

‎api/__init__.py‎

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
from __future__ import annotations
44

55
import glob
6+
import json
67
import os
78
import shutil
89
import subprocess
910
from datetime import datetime, timedelta, timezone
1011
from pathlib import Path
11-
from typing import Any
12+
from typing import Any, List, Optional
1213
from zoneinfo import ZoneInfo
1314

1415
import asyncio
@@ -605,6 +606,197 @@ async def _start_ws_broadcaster() -> None:
605606
asyncio.create_task(_ws_broadcaster_task())
606607

607608

609+
def _load_db_setting(name: str) -> Optional[str]:
610+
"""Load a single setting value from MariaDB, or None if unavailable."""
611+
try:
612+
conn = _get_conn()
613+
with conn.cursor() as cur:
614+
cur.execute("SELECT value FROM lux_settings WHERE name = %s", (name,))
615+
row = cur.fetchone()
616+
return row[0] if row else None
617+
except Exception:
618+
return None
619+
finally:
620+
conn.close()
621+
622+
623+
# ── automation / rules engine ───────────────────────────────────────────────
624+
625+
from collector.protocol import HOLDING_REGISTERS, HOLDING_BY_NAME
626+
from collector.automation import AutomationEngine, SETTING_KEY as AUTOMATION_SETTING, ENABLED_KEY as AUTOMATION_ENABLED
627+
628+
629+
class AutomationRuleBody(BaseModel):
630+
id: Optional[str] = None
631+
name: str
632+
enabled: bool = True
633+
dry_run: bool = False
634+
time_window: Optional[dict] = None
635+
conditions: List[dict] = []
636+
action: dict
637+
638+
639+
class AutomationTestBody(BaseModel):
640+
snapshot: Optional[dict] = None
641+
timezone: Optional[str] = None
642+
643+
644+
def _load_automation_engine() -> AutomationEngine:
645+
return AutomationEngine(
646+
db_host=DB_CONFIG["host"],
647+
db_port=DB_CONFIG["port"],
648+
db_user=DB_CONFIG["user"],
649+
db_password=DB_CONFIG["password"],
650+
db_name=DB_CONFIG["database"],
651+
)
652+
653+
654+
@app.get("/api/automation/registers")
655+
def api_automation_registers():
656+
"""Return the list of writable holding registers for automation actions."""
657+
return {
658+
"registers": [
659+
{
660+
"name": info["name"],
661+
"address": reg,
662+
"unit": info["unit"],
663+
"scale": info["scale"],
664+
"min": info.get("min"),
665+
"max": info.get("max"),
666+
"desc": info["desc"],
667+
}
668+
for reg, info in sorted(HOLDING_REGISTERS.items())
669+
]
670+
}
671+
672+
673+
@app.get("/api/automation/rules")
674+
def api_automation_rules():
675+
"""Return the current automation rules and global enable flag."""
676+
engine = _load_automation_engine()
677+
from collector.settings import get
678+
679+
conn = _get_conn()
680+
try:
681+
enabled_raw = get(conn, AUTOMATION_ENABLED)
682+
rules_raw = get(conn, AUTOMATION_SETTING)
683+
finally:
684+
conn.close()
685+
686+
return {
687+
"enabled": str(enabled_raw).lower() in ("true", "1", "yes", "on") if enabled_raw else False,
688+
"rules": json.loads(rules_raw or "[]"),
689+
}
690+
691+
692+
@app.post("/api/automation/rules")
693+
def api_automation_rules_save(body: List[dict]):
694+
"""Replace the entire automation rule set."""
695+
from collector.settings import set_
696+
697+
conn = _get_conn()
698+
try:
699+
set_(conn, AUTOMATION_SETTING, json.dumps(body))
700+
return {"saved": True, "count": len(body)}
701+
finally:
702+
conn.close()
703+
704+
705+
@app.delete("/api/automation/rules/{rule_id}")
706+
def api_automation_rule_delete(rule_id: str):
707+
"""Delete a single automation rule by id."""
708+
from collector.settings import get, set_
709+
710+
conn = _get_conn()
711+
try:
712+
rules_raw = get(conn, AUTOMATION_SETTING) or "[]"
713+
rules = json.loads(rules_raw)
714+
new_rules = [r for r in rules if str(r.get("id")) != rule_id]
715+
set_(conn, AUTOMATION_SETTING, json.dumps(new_rules))
716+
return {"deleted": True, "removed": len(rules) - len(new_rules)}
717+
finally:
718+
conn.close()
719+
720+
721+
@app.post("/api/automation/enable")
722+
def api_automation_enable(enabled: bool = True):
723+
"""Enable or disable the automation engine globally."""
724+
from collector.settings import set_
725+
726+
conn = _get_conn()
727+
try:
728+
set_(conn, AUTOMATION_ENABLED, "true" if enabled else "false")
729+
return {"enabled": enabled}
730+
finally:
731+
conn.close()
732+
733+
734+
@app.post("/api/automation/test")
735+
def api_automation_test(body: AutomationTestBody):
736+
"""Dry-run evaluate rules against a snapshot (or the latest DB snapshot)."""
737+
engine = _load_automation_engine()
738+
739+
snapshot = body.snapshot
740+
if snapshot is None:
741+
snap = _fetch_latest_snapshot()
742+
if snap is None:
743+
raise HTTPException(404, "No snapshot available")
744+
snapshot = snap["registers"]
745+
746+
tz = body.timezone or _load_db_setting("timezone") or "America/Chicago"
747+
748+
# Force every rule into dry_run mode for this test.
749+
rules = engine.load_rules()
750+
results = []
751+
from zoneinfo import ZoneInfo
752+
from datetime import datetime
753+
754+
now = datetime.now(ZoneInfo(tz))
755+
for rule in rules:
756+
rule.dry_run = True
757+
target = rule.evaluate(snapshot, now)
758+
if target is None:
759+
results.append({"rule_id": rule.id, "name": rule.name, "matched": False})
760+
continue
761+
meta = HOLDING_REGISTERS[HOLDING_BY_NAME[rule.action.register_name]]
762+
raw = int(round(target / meta["scale"]))
763+
results.append({
764+
"rule_id": rule.id,
765+
"name": rule.name,
766+
"matched": True,
767+
"register": meta["name"],
768+
"value": target,
769+
"raw": raw,
770+
})
771+
return {"enabled": engine.is_enabled(), "timezone": tz, "results": results}
772+
773+
774+
@app.get("/api/automation/log")
775+
def api_automation_log(limit: int = Query(50, ge=1, le=500)):
776+
"""Return recent automation actions / dry-runs."""
777+
engine = _load_automation_engine()
778+
conn = _get_conn()
779+
try:
780+
with conn.cursor() as cur:
781+
cur.execute(
782+
f"""
783+
SELECT id, ts, rule_id, rule_name, register_name,
784+
raw_value, scaled_value, dry_run, success, message
785+
FROM {engine.log_table}
786+
ORDER BY ts DESC LIMIT %s
787+
""",
788+
(limit,),
789+
)
790+
columns = [
791+
"id", "timestamp", "rule_id", "rule_name", "register_name",
792+
"raw_value", "scaled_value", "dry_run", "success", "message",
793+
]
794+
rows = cur.fetchall()
795+
return {"log": [_row_to_dict(row, columns) for row in rows], "count": len(rows)}
796+
finally:
797+
conn.close()
798+
799+
608800
# ── static dashboard ────────────────────────────────────────────────────────
609801

610802
STATIC_DIR = Path(__file__).parent / "static"

0 commit comments

Comments
 (0)