Flatten to a single package directory

The checkout is now astrape/astrape-ems, not astrape/astrape-ems/<package>. The package
contents move to the repository root and pyproject maps the package name to
'.' via package-dir, with the subpackages listed explicitly.

The systemd units move out to /srv/astrape/deploy/systemd, outside every
repository, because they describe this deployment rather than any one
component.

Verified both install modes: editable, which is how the deployment runs, and
a plain wheel install, which is what the container build does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
rpotter6298
2026-08-30 15:05:15 +02:00
parent 91ab976bee
commit d5f3c0f7ac
15 changed files with 6 additions and 27 deletions
View File
View File
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import argparse
from dataclasses import dataclass
from os import environ
from sys import stderr
from time import sleep
from astrape_ems.classes.env import EnvLoader
from astrape_ems.classes.client import SigenPlantClient
from astrape_ems.classes.store import SigenStore
@dataclass(frozen=True)
class SigenDaemonConfig:
poll_seconds: float
@classmethod
def from_env(cls) -> "SigenDaemonConfig":
return cls(
poll_seconds=float(environ.get("SIGEN_POLL_SECONDS", "5")),
)
class SigenDaemon:
"""Polls Sigenergy plant metrics and stores normalized snapshots."""
def __init__(
self,
config: SigenDaemonConfig,
plant_client: SigenPlantClient,
sigen_store: SigenStore,
) -> None:
self.config = config
self.plant_client = plant_client
self.sigen_store = sigen_store
@classmethod
def from_env(cls) -> "SigenDaemon":
return cls(
config=SigenDaemonConfig.from_env(),
plant_client=SigenPlantClient.from_env(),
sigen_store=SigenStore.from_env(),
)
def initialize(self) -> None:
self.sigen_store.initialize()
def run_once(self) -> int:
snapshot = self.plant_client.fetch_snapshot()
return self.sigen_store.save_snapshot(snapshot)
def run_forever(self) -> None:
self.initialize()
while True:
try:
saved_count = self.run_once()
print(f"stored_sigen_plant_snapshots={saved_count}", flush=True)
except Exception as error:
print(f"sigen_poll_error={error}", file=stderr, flush=True)
sleep(self.config.poll_seconds)
def main() -> None:
try:
EnvLoader().load()
daemon = SigenDaemon.from_env()
args = parse_args()
if args.once:
daemon.initialize()
saved_count = daemon.run_once()
print(f"stored_sigen_plant_snapshots={saved_count}", flush=True)
return
daemon.run_forever()
except Exception as error:
print(f"sigen_daemon_startup_error={error}", file=stderr)
raise SystemExit(1) from error
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Poll Sigenergy plant metrics into Astrape's database."
)
parser.add_argument(
"--once",
action="store_true",
help="Initialize storage, save one snapshot, and exit.",
)
return parser.parse_args()
if __name__ == "__main__":
main()
View File
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Debug script to inspect stored Sigenergy plant snapshots."""
from __future__ import annotations
import argparse
from datetime import timezone
import json
from astrape_ems.classes.env import EnvLoader
from astrape_ems.classes.store import SigenStore
def main() -> None:
EnvLoader().load()
args = parse_args()
store = SigenStore.from_env()
if args.view == "raw":
rows = load_raw_snapshots(store, args.limit)
print_raw_snapshots(rows)
return
rows = load_rollup(store, args.view, args.limit)
print_rollup(rows, args.view)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Inspect stored Sigenergy plant snapshots."
)
parser.add_argument(
"--view",
choices=("raw", "1m", "15m", "1h"),
default="raw",
help="View to inspect. Defaults to raw.",
)
parser.add_argument(
"--limit",
type=int,
default=10,
help="Number of most recent rows/buckets to show. Defaults to 10.",
)
return parser.parse_args()
def load_raw_snapshots(store: SigenStore, limit: int) -> list[tuple]:
with store._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
observed_at,
received_at,
solar_power_w,
load_power_w,
battery_soc_pct,
battery_power_w,
grid_import_w,
grid_export_w,
plant_active_power_w,
accumulated_pv_energy_kwh,
daily_consumed_energy_kwh,
raw_values
FROM sigen_plant_snapshots
ORDER BY observed_at DESC
LIMIT %s
""",
(limit,),
)
return cursor.fetchall()
def load_rollup(store: SigenStore, view: str, limit: int) -> list[tuple]:
view_name = {
"1m": "sigen_plant_snapshots_1m",
"15m": "sigen_plant_snapshots_15m",
"1h": "sigen_plant_snapshots_1h",
}[view]
with store._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT
bucket,
sample_count,
avg_solar_power_w,
max_solar_power_w,
avg_load_power_w,
max_load_power_w,
avg_grid_import_w,
max_grid_import_w,
avg_grid_export_w,
max_grid_export_w,
avg_battery_soc_pct
FROM {view_name}
ORDER BY bucket DESC
LIMIT %s
""",
(limit,),
)
return cursor.fetchall()
def print_raw_snapshots(rows: list[tuple]) -> None:
print(f"raw_snapshots={len(rows)}")
for row in rows:
(
observed_at,
received_at,
solar_power_w,
load_power_w,
battery_soc_pct,
battery_power_w,
grid_import_w,
grid_export_w,
plant_active_power_w,
accumulated_pv_energy_kwh,
daily_consumed_energy_kwh,
raw_values,
) = row
print(
f"{_fmt_time(observed_at)} "
f"solar={_fmt_w(solar_power_w)} "
f"load={_fmt_w(load_power_w)} "
f"soc={_fmt_pct(battery_soc_pct)} "
f"battery={_fmt_w(battery_power_w)} "
f"import={_fmt_w(grid_import_w)} "
f"export={_fmt_w(grid_export_w)} "
f"plant={_fmt_w(plant_active_power_w)} "
f"pv_total={_fmt_kwh(accumulated_pv_energy_kwh)} "
f"load_today={_fmt_kwh(daily_consumed_energy_kwh)} "
f"lag_s={(received_at - observed_at).total_seconds():.1f}"
)
if raw_values and any(key.endswith("_error") for key in raw_values):
errors = {
key: value
for key, value in raw_values.items()
if key.endswith("_error")
}
print(f" errors={json.dumps(errors, default=str)}")
def print_rollup(rows: list[tuple], view: str) -> None:
print(f"{view}_buckets={len(rows)}")
for row in rows:
(
bucket,
sample_count,
avg_solar_power_w,
max_solar_power_w,
avg_load_power_w,
max_load_power_w,
avg_grid_import_w,
max_grid_import_w,
avg_grid_export_w,
max_grid_export_w,
avg_battery_soc_pct,
) = row
print(
f"{_fmt_time(bucket)} samples={sample_count:4} "
f"solar_avg={_fmt_w(avg_solar_power_w)} "
f"solar_max={_fmt_w(max_solar_power_w)} "
f"load_avg={_fmt_w(avg_load_power_w)} "
f"load_max={_fmt_w(max_load_power_w)} "
f"import_avg={_fmt_w(avg_grid_import_w)} "
f"import_max={_fmt_w(max_grid_import_w)} "
f"export_avg={_fmt_w(avg_grid_export_w)} "
f"export_max={_fmt_w(max_grid_export_w)} "
f"soc_avg={_fmt_pct(avg_battery_soc_pct)}"
)
def _fmt_time(value) -> str:
return value.astimezone(timezone.utc).isoformat(timespec="seconds")
def _fmt_w(value) -> str:
if value is None:
return "None"
return f"{value:.0f}W"
def _fmt_pct(value) -> str:
if value is None:
return "None"
return f"{value:.1f}%"
def _fmt_kwh(value) -> str:
if value is None:
return "None"
return f"{value:.2f}kWh"
if __name__ == "__main__":
main()
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""Explore a Sigenergy plant or inverter over Modbus TCP."""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from os import environ
from astrape_ems.classes.client import SigenPlantClient
from astrape_ems.classes.env import EnvLoader
from astrape_ems.classes.modbus import (
ModbusReadError,
ModbusReadResult,
RegisterKind,
SigenModbusClient,
)
from astrape_ems.classes.registers import (
DEFAULT_INVERTER_REGISTER_NAMES,
DEFAULT_PLANT_REGISTER_NAMES,
INVERTER_REGISTERS,
PLANT_PARAMETER_REGISTERS,
PLANT_REGISTERS,
SigenRegister,
)
DEFAULT_KINDS: tuple[RegisterKind, ...] = ("holding", "input")
ALL_KINDS: tuple[RegisterKind, ...] = ("holding", "input", "coil", "discrete")
DEFAULT_UNIT_CANDIDATES = (0, 1, 2, 3, 247, 255)
def main() -> None:
EnvLoader().load()
args = parse_args()
if args.command == "units":
results = probe_units(args)
print_results(results, errors=True)
return
if args.command == "catalog":
if args.group in {"plant", "all"}:
print_catalog("Plant Sensors", PLANT_REGISTERS)
if args.group in {"params", "all"}:
print_catalog("Plant Parameters", PLANT_PARAMETER_REGISTERS)
return
if args.command == "snapshot":
snapshot = SigenPlantClient.from_env().fetch_snapshot()
print(json.dumps(asdict(snapshot), indent=2, default=str))
return
with SigenModbusClient(
host=args.host,
port=args.port,
unit_id=args.unit_id,
timeout=args.timeout,
retries=args.retries,
trace=args.trace,
) as client:
if args.command == "probe":
print(
f"Connected to {args.host}:{args.port} "
f"with unit id {args.unit_id}"
)
return
if args.command == "plant":
print_known_registers(client, args.register, PLANT_REGISTERS)
return
if args.command == "inverter":
print_known_registers(client, args.register, INVERTER_REGISTERS)
return
if args.command == "read":
try:
result = client.read(args.kind, args.address, args.count)
print_results([result], errors=True)
except Exception as exc:
print(
f"{args.kind:8} {args.address:5} "
f"+{args.count:<3} ERROR {exc}"
)
return
results: list[ModbusReadResult | ModbusReadError] = []
for kind in args.kind:
results.extend(
client.scan(
kind=kind,
start=args.start,
count=args.count,
chunk_size=args.chunk_size,
)
)
if args.json:
print(json.dumps([asdict(result) for result in results], indent=2))
else:
print_results(results, errors=args.errors)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Minimal Modbus TCP explorer for a Sigenergy plant/inverter."
)
parser.add_argument(
"--host",
default=environ.get("SIGEN_MODBUS_HOST"),
required="SIGEN_MODBUS_HOST" not in environ,
help="Modbus TCP host or IP. Can also be set as SIGEN_MODBUS_HOST.",
)
parser.add_argument(
"--port",
type=int,
default=int(environ.get("SIGEN_MODBUS_PORT", "502")),
help="Modbus TCP port. Defaults to 502.",
)
parser.add_argument(
"--unit-id",
type=int,
default=int(environ.get("SIGEN_MODBUS_UNIT_ID", "1")),
help="Modbus unit/slave id. Defaults to 1.",
)
parser.add_argument(
"--timeout",
type=float,
default=float(environ.get("SIGEN_MODBUS_TIMEOUT", "5")),
help="Socket timeout in seconds. Defaults to 5.",
)
parser.add_argument(
"--retries",
type=int,
default=int(environ.get("SIGEN_MODBUS_RETRIES", "3")),
help="Modbus request retries. Defaults to 3.",
)
parser.add_argument(
"--trace",
action="store_true",
help="Print Modbus TCP packet bytes to stderr.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("probe", help="Open a connection and report success.")
subparsers.add_parser(
"snapshot",
help="Read core plant metrics and print the builder snapshot as JSON.",
)
catalog = subparsers.add_parser(
"catalog",
help="List known Sigenergy plant sensors and settable parameters.",
)
catalog.add_argument(
"group",
choices=("plant", "params", "all"),
nargs="?",
default="all",
help="Catalog group to list. Defaults to all.",
)
units = subparsers.add_parser(
"units",
help="Try small reads against likely unit ids.",
)
units.add_argument(
"--candidate",
action="append",
type=int,
default=None,
help=(
"Unit id candidate to try. Repeat for multiple ids. "
"Defaults to 0, 1, 2, 3, 247, and 255."
),
)
units.add_argument(
"--kind",
action="append",
choices=ALL_KINDS,
default=None,
help=(
"Register table to test. Repeat for multiple kinds. "
"Defaults to holding and input."
),
)
units.add_argument(
"--address",
type=int,
default=30000,
help="Address to test. Defaults to 30000.",
)
units.add_argument(
"--count",
type=int,
default=1,
help="Number of values to request. Defaults to 1.",
)
plant = subparsers.add_parser(
"plant",
help="Read a small set of known Sigenergy plant registers.",
)
plant.add_argument(
"--register",
action="append",
choices=sorted(PLANT_REGISTERS),
default=None,
help="Known plant register to read. Repeat for multiple registers.",
)
inverter = subparsers.add_parser(
"inverter",
help="Read a small set of known Sigenergy inverter registers.",
)
inverter.add_argument(
"--register",
action="append",
choices=sorted(INVERTER_REGISTERS),
default=None,
help="Known inverter register to read. Repeat for multiple registers.",
)
read = subparsers.add_parser(
"read",
help="Read one raw Modbus register range.",
)
read.add_argument(
"kind",
choices=ALL_KINDS,
help="Register table to read.",
)
read.add_argument(
"address",
type=int,
help="Modbus address to read.",
)
read.add_argument(
"count",
type=int,
nargs="?",
default=1,
help="Number of values to read. Defaults to 1.",
)
scan = subparsers.add_parser("scan", help="Scan register ranges in chunks.")
scan.add_argument(
"--kind",
action="append",
choices=ALL_KINDS,
default=None,
help=(
"Register table to scan. Repeat for multiple kinds. "
"Defaults to holding and input."
),
)
scan.add_argument(
"--start",
type=int,
default=0,
help="Starting zero-based Modbus address. Defaults to 0.",
)
scan.add_argument(
"--count",
type=int,
default=100,
help="Number of addresses to scan. Defaults to 100.",
)
scan.add_argument(
"--chunk-size",
type=int,
default=10,
help="Addresses per Modbus request. Defaults to 10.",
)
scan.add_argument(
"--errors",
action="store_true",
help="Show failed chunks as well as successful reads.",
)
scan.add_argument(
"--json",
action="store_true",
help="Print raw result objects as JSON.",
)
args = parser.parse_args()
if args.command == "scan" and args.kind is None:
args.kind = list(DEFAULT_KINDS)
if args.command == "units":
if args.kind is None:
args.kind = list(DEFAULT_KINDS)
if args.candidate is None:
args.candidate = list(DEFAULT_UNIT_CANDIDATES)
if args.command == "plant" and args.register is None:
args.register = list(DEFAULT_PLANT_REGISTER_NAMES)
if args.command == "inverter" and args.register is None:
args.register = list(DEFAULT_INVERTER_REGISTER_NAMES)
return args
def probe_units(args: argparse.Namespace) -> list[ModbusReadResult | ModbusReadError]:
results: list[ModbusReadResult | ModbusReadError] = []
for unit_id in args.candidate:
with SigenModbusClient(
host=args.host,
port=args.port,
unit_id=unit_id,
timeout=args.timeout,
retries=args.retries,
trace=args.trace,
) as client:
for kind in args.kind:
try:
result = client.read(kind, args.address, args.count)
results.append(result)
except Exception as exc:
results.append(
ModbusReadError(
kind=kind,
address=args.address,
count=args.count,
error=f"unit {unit_id}: {exc}",
)
)
return results
def print_known_registers(
client: SigenModbusClient,
register_names: list[str],
registers: dict[str, SigenRegister],
) -> None:
for register_name in register_names:
register = registers[register_name]
try:
result = client.read(register.kind, register.address, register.count)
value = register.decode(result.values)
unit = f" {register.unit}" if register.unit else ""
raw_values = " ".join(str(value) for value in result.values)
print(
f"{register.name:32} {value}{unit:4} "
f"({register.kind} {register.address} +{register.count}: {raw_values})"
)
except Exception as exc:
print(
f"{register.name:32} ERROR "
f"({register.kind} {register.address} +{register.count}: {exc})"
)
def print_catalog(title: str, registers: dict[str, SigenRegister]) -> None:
print(title)
print("-" * len(title))
for register in registers.values():
unit = register.unit or ""
description = register.description or ""
print(
f"{register.name:48} {register.kind:7} "
f"{register.address:5} +{register.count:<2} "
f"{register.data_type:6} gain={register.gain:<7g} "
f"{unit:5} {description}"
)
print()
def print_results(
results: list[ModbusReadResult | ModbusReadError],
errors: bool,
) -> None:
for result in results:
if isinstance(result, ModbusReadError):
if errors:
print(
f"{result.kind:8} {result.address:5} "
f"+{result.count:<3} ERROR {result.error}"
)
continue
values = " ".join(str(value) for value in result.values)
print(f"{result.kind:8} {result.address:5} +{result.count:<3} {values}")
if __name__ == "__main__":
main()