|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import glob |
| 6 | +import json |
6 | 7 | import os |
7 | 8 | import shutil |
8 | 9 | import subprocess |
9 | 10 | from datetime import datetime, timedelta, timezone |
10 | 11 | from pathlib import Path |
11 | | -from typing import Any |
| 12 | +from typing import Any, List, Optional |
12 | 13 | from zoneinfo import ZoneInfo |
13 | 14 |
|
14 | 15 | import asyncio |
@@ -605,6 +606,197 @@ async def _start_ws_broadcaster() -> None: |
605 | 606 | asyncio.create_task(_ws_broadcaster_task()) |
606 | 607 |
|
607 | 608 |
|
| 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 | + |
608 | 800 | # ── static dashboard ──────────────────────────────────────────────────────── |
609 | 801 |
|
610 | 802 | STATIC_DIR = Path(__file__).parent / "static" |
|
0 commit comments