initial commit
This commit is contained in:
3
hameter/__init__.py
Normal file
3
hameter/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""HAMeter — Custom rtlamr-to-MQTT bridge for Home Assistant."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
304
hameter/__main__.py
Normal file
304
hameter/__main__.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Entry point for HAMeter: python -m hameter."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from hameter import __version__
|
||||
from hameter.config import (
|
||||
CONFIG_PATH,
|
||||
config_exists,
|
||||
load_config_from_json,
|
||||
load_config_from_yaml,
|
||||
save_config,
|
||||
)
|
||||
from hameter.cost_state import load_cost_state
|
||||
from hameter.discovery import run_discovery_for_web
|
||||
from hameter.pipeline import Pipeline
|
||||
from hameter.state import AppState, CostState, PipelineStatus, WebLogHandler
|
||||
from hameter.web import create_app
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hameter",
|
||||
description="HAMeter — SDR utility meter reader for Home Assistant",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", "-p",
|
||||
type=int,
|
||||
default=9090,
|
||||
help="Web UI port (default: 9090)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", "-v",
|
||||
action="version",
|
||||
version=f"hameter {__version__}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up basic logging early.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
logger = logging.getLogger("hameter")
|
||||
|
||||
# Create shared state.
|
||||
app_state = AppState()
|
||||
|
||||
# Attach web log handler so logs flow to the UI.
|
||||
web_handler = WebLogHandler(app_state)
|
||||
web_handler.setLevel(logging.DEBUG)
|
||||
logging.getLogger().addHandler(web_handler)
|
||||
|
||||
logger.info("HAMeter v%s starting", __version__)
|
||||
|
||||
# Shutdown event.
|
||||
shutdown_event = threading.Event()
|
||||
|
||||
def _signal_handler(signum, _frame):
|
||||
sig_name = signal.Signals(signum).name
|
||||
logger.info("Received %s, initiating shutdown...", sig_name)
|
||||
shutdown_event.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
|
||||
# Try loading existing config.
|
||||
_try_load_config(app_state, logger)
|
||||
|
||||
# Start Flask web server in daemon thread.
|
||||
flask_app = create_app(app_state)
|
||||
flask_thread = threading.Thread(
|
||||
target=lambda: flask_app.run(
|
||||
host="0.0.0.0",
|
||||
port=args.port,
|
||||
threaded=True,
|
||||
use_reloader=False,
|
||||
),
|
||||
name="flask-web",
|
||||
daemon=True,
|
||||
)
|
||||
flask_thread.start()
|
||||
logger.info("Web UI available at http://0.0.0.0:%d", args.port)
|
||||
|
||||
# Main pipeline loop.
|
||||
_pipeline_loop(app_state, shutdown_event, logger)
|
||||
|
||||
logger.info("HAMeter stopped")
|
||||
|
||||
|
||||
def _try_load_config(app_state: AppState, logger: logging.Logger):
|
||||
"""Attempt to load config from JSON, or migrate from YAML, or wait for setup."""
|
||||
if config_exists():
|
||||
try:
|
||||
config = load_config_from_json()
|
||||
app_state.set_config(config)
|
||||
app_state.config_ready.set()
|
||||
app_state.set_status(PipelineStatus.STOPPED, "Config loaded")
|
||||
logger.info("Loaded config from %s", CONFIG_PATH)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Failed to load config: %s", e)
|
||||
app_state.set_status(PipelineStatus.ERROR, str(e))
|
||||
return
|
||||
|
||||
# Check for YAML migration.
|
||||
yaml_paths = ["/config/hameter.yaml", "/app/config/hameter.yaml"]
|
||||
for yp in yaml_paths:
|
||||
if os.path.isfile(yp):
|
||||
try:
|
||||
config = load_config_from_yaml(yp)
|
||||
save_config(config)
|
||||
app_state.set_config(config)
|
||||
app_state.config_ready.set()
|
||||
app_state.set_status(PipelineStatus.STOPPED, "Migrated from YAML")
|
||||
logger.info("Migrated YAML config from %s to %s", yp, CONFIG_PATH)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Failed to migrate YAML config: %s", e)
|
||||
|
||||
# No config found — wait for setup wizard.
|
||||
app_state.set_status(PipelineStatus.UNCONFIGURED)
|
||||
logger.info("No config found. Use the web UI to complete setup.")
|
||||
|
||||
|
||||
def _pipeline_loop(
|
||||
app_state: AppState,
|
||||
shutdown_event: threading.Event,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
"""Main loop: run pipeline, handle restart requests, handle discovery."""
|
||||
while not shutdown_event.is_set():
|
||||
# Wait for config to be ready.
|
||||
if not app_state.config_ready.is_set():
|
||||
logger.info("Waiting for configuration via web UI...")
|
||||
while not shutdown_event.is_set():
|
||||
if app_state.config_ready.wait(timeout=1.0):
|
||||
break
|
||||
if shutdown_event.is_set():
|
||||
return
|
||||
|
||||
# Load/reload config.
|
||||
try:
|
||||
config = load_config_from_json()
|
||||
app_state.set_config(config)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load config: %s", e)
|
||||
app_state.set_status(PipelineStatus.ERROR, str(e))
|
||||
_wait_for_restart_or_shutdown(app_state, shutdown_event)
|
||||
continue
|
||||
|
||||
# Restore persisted cost state.
|
||||
_load_persisted_cost_state(app_state, config, logger)
|
||||
|
||||
# Update log level from config.
|
||||
logging.getLogger().setLevel(
|
||||
getattr(logging, config.general.log_level, logging.INFO)
|
||||
)
|
||||
|
||||
# Check for discovery request before starting pipeline.
|
||||
if app_state.discovery_requested.is_set():
|
||||
_run_web_discovery(app_state, config, shutdown_event, logger)
|
||||
continue
|
||||
|
||||
# If no meters configured, wait.
|
||||
if not config.meters:
|
||||
app_state.set_status(PipelineStatus.STOPPED, "No meters configured")
|
||||
logger.info("No meters configured. Add meters via the web UI.")
|
||||
while not shutdown_event.is_set():
|
||||
if app_state.restart_requested.wait(timeout=1.0):
|
||||
app_state.restart_requested.clear()
|
||||
break
|
||||
if app_state.discovery_requested.is_set():
|
||||
_run_web_discovery(app_state, config, shutdown_event, logger)
|
||||
break
|
||||
continue
|
||||
|
||||
# Run the pipeline.
|
||||
app_state.set_status(PipelineStatus.STARTING)
|
||||
pipeline = Pipeline(config, shutdown_event, app_state)
|
||||
|
||||
logger.info(
|
||||
"Starting pipeline with %d meter(s): %s",
|
||||
len(config.meters),
|
||||
", ".join(f"{m.name} ({m.id})" for m in config.meters),
|
||||
)
|
||||
|
||||
pipeline.run()
|
||||
|
||||
# Check why we exited.
|
||||
if shutdown_event.is_set():
|
||||
return
|
||||
|
||||
if app_state.discovery_requested.is_set():
|
||||
_run_web_discovery(app_state, config, shutdown_event, logger)
|
||||
continue
|
||||
|
||||
if app_state.restart_requested.is_set():
|
||||
app_state.restart_requested.clear()
|
||||
app_state.set_status(PipelineStatus.RESTARTING, "Reloading configuration...")
|
||||
logger.info("Pipeline restart requested, reloading config...")
|
||||
continue
|
||||
|
||||
# Pipeline exited on its own (error or subprocess failure).
|
||||
if not shutdown_event.is_set():
|
||||
app_state.set_status(PipelineStatus.ERROR, "Pipeline exited unexpectedly")
|
||||
_wait_for_restart_or_shutdown(app_state, shutdown_event)
|
||||
|
||||
|
||||
def _load_persisted_cost_state(
|
||||
app_state: AppState,
|
||||
config,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
"""Load persisted cost state and restore into AppState."""
|
||||
saved = load_cost_state()
|
||||
if not saved:
|
||||
return
|
||||
|
||||
meters_with_cost = {m.id for m in config.meters if m.cost_factors}
|
||||
restored = 0
|
||||
for mid_str, cs_data in saved.items():
|
||||
try:
|
||||
mid = int(mid_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Skipping cost state with invalid meter ID: %s", mid_str)
|
||||
continue
|
||||
if mid not in meters_with_cost:
|
||||
continue
|
||||
try:
|
||||
cs = CostState(
|
||||
cumulative_cost=float(cs_data.get("cumulative_cost", 0.0)),
|
||||
last_calibrated_reading=(
|
||||
float(cs_data["last_calibrated_reading"])
|
||||
if cs_data.get("last_calibrated_reading") is not None
|
||||
else None
|
||||
),
|
||||
billing_period_start=str(cs_data.get("billing_period_start", "")),
|
||||
last_updated=str(cs_data.get("last_updated", "")),
|
||||
fixed_charges_applied=float(cs_data.get("fixed_charges_applied", 0.0)),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
logger.warning("Skipping corrupt cost state for meter %s: %s", mid_str, e)
|
||||
continue
|
||||
app_state.update_cost_state(mid, cs)
|
||||
restored += 1
|
||||
|
||||
if restored:
|
||||
logger.info("Restored cost state for %d meter(s)", restored)
|
||||
|
||||
# Clean up orphaned cost states for meters no longer in config.
|
||||
all_meter_ids = {m.id for m in config.meters}
|
||||
current_cost_states = app_state.get_cost_states()
|
||||
for mid in list(current_cost_states.keys()):
|
||||
if mid not in all_meter_ids:
|
||||
app_state.remove_cost_state(mid)
|
||||
logger.info("Removed orphaned cost state for meter %d", mid)
|
||||
|
||||
|
||||
def _wait_for_restart_or_shutdown(
|
||||
app_state: AppState,
|
||||
shutdown_event: threading.Event,
|
||||
):
|
||||
"""Block until a restart is requested or shutdown."""
|
||||
while not shutdown_event.is_set():
|
||||
if app_state.restart_requested.wait(timeout=1.0):
|
||||
app_state.restart_requested.clear()
|
||||
break
|
||||
|
||||
|
||||
def _run_web_discovery(
|
||||
app_state: AppState,
|
||||
config,
|
||||
shutdown_event: threading.Event,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
"""Run discovery mode triggered from web UI."""
|
||||
app_state.discovery_requested.clear()
|
||||
app_state.set_status(PipelineStatus.DISCOVERY)
|
||||
app_state.clear_discovery_results()
|
||||
|
||||
duration = app_state.discovery_duration
|
||||
logger.info("Starting web-triggered discovery for %d seconds", duration)
|
||||
|
||||
run_discovery_for_web(
|
||||
config=config.general,
|
||||
shutdown_event=shutdown_event,
|
||||
app_state=app_state,
|
||||
duration=duration,
|
||||
stop_event=app_state.stop_discovery,
|
||||
)
|
||||
|
||||
app_state.stop_discovery.clear()
|
||||
app_state.set_status(PipelineStatus.STOPPED, "Discovery complete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
399
hameter/config.py
Normal file
399
hameter/config.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""Configuration loading, validation, and persistence for HAMeter.
|
||||
|
||||
All configuration is managed through the web UI and stored as JSON
|
||||
at /data/config.json. A YAML config file can be imported as a
|
||||
one-time migration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROTOCOLS = {"scm", "scm+", "idm", "netidm", "r900", "r900bcd"}
|
||||
VALID_RATE_TYPES = {"per_unit", "fixed"}
|
||||
|
||||
# Default icons/unit per common meter type.
|
||||
_METER_DEFAULTS = {
|
||||
"energy": {"icon": "mdi:flash", "unit": "kWh"},
|
||||
"gas": {"icon": "mdi:fire", "unit": "ft\u00b3"},
|
||||
"water": {"icon": "mdi:water", "unit": "gal"},
|
||||
}
|
||||
|
||||
CONFIG_PATH = "/data/config.json"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dataclasses
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@dataclass
|
||||
class MqttConfig:
|
||||
host: str
|
||||
port: int = 1883
|
||||
user: str = ""
|
||||
password: str = ""
|
||||
base_topic: str = "hameter"
|
||||
ha_autodiscovery: bool = True
|
||||
ha_autodiscovery_topic: str = "homeassistant"
|
||||
client_id: str = "hameter"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateComponent:
|
||||
name: str
|
||||
rate: float
|
||||
type: str = "per_unit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeterConfig:
|
||||
id: int
|
||||
protocol: str
|
||||
name: str
|
||||
unit_of_measurement: str
|
||||
icon: str = "mdi:gauge"
|
||||
device_class: str = ""
|
||||
state_class: str = "total_increasing"
|
||||
multiplier: float = 1.0
|
||||
cost_factors: list[RateComponent] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneralConfig:
|
||||
sleep_for: int = 0
|
||||
device_id: str = "0"
|
||||
rtl_tcp_host: str = "127.0.0.1"
|
||||
rtl_tcp_port: int = 1234
|
||||
log_level: str = "INFO"
|
||||
rtlamr_extra_args: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HaMeterConfig:
|
||||
general: GeneralConfig
|
||||
mqtt: MqttConfig
|
||||
meters: list[MeterConfig]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Config file operations
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def config_exists(path: Optional[str] = None) -> bool:
|
||||
"""Check if a config file exists at the standard path."""
|
||||
return os.path.isfile(path or CONFIG_PATH)
|
||||
|
||||
|
||||
def load_config_from_json(path: Optional[str] = None) -> HaMeterConfig:
|
||||
"""Load configuration from a JSON file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file does not exist.
|
||||
ValueError: For validation errors.
|
||||
"""
|
||||
path = path or CONFIG_PATH
|
||||
with open(path) as f:
|
||||
raw = json.load(f)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Config file is not a valid JSON object: {path}")
|
||||
|
||||
return _build_config_from_dict(raw)
|
||||
|
||||
|
||||
def load_config_from_yaml(path: str) -> HaMeterConfig:
|
||||
"""Load from YAML file (migration path from older versions).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If YAML file does not exist.
|
||||
ValueError: For validation errors.
|
||||
"""
|
||||
with open(path) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"YAML file is not a valid mapping: {path}")
|
||||
|
||||
return _build_config_from_dict(raw)
|
||||
|
||||
|
||||
def save_config(config: HaMeterConfig, path: Optional[str] = None):
|
||||
"""Atomically write config to JSON file.
|
||||
|
||||
Writes to a temp file in the same directory, then os.replace()
|
||||
for atomic rename. This prevents corruption on power loss.
|
||||
"""
|
||||
path = path or CONFIG_PATH
|
||||
data = config_to_dict(config)
|
||||
|
||||
dir_path = os.path.dirname(path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=dir_path or ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def config_to_dict(config: HaMeterConfig) -> dict:
|
||||
"""Serialize HaMeterConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"general": {
|
||||
"sleep_for": config.general.sleep_for,
|
||||
"device_id": config.general.device_id,
|
||||
"rtl_tcp_host": config.general.rtl_tcp_host,
|
||||
"rtl_tcp_port": config.general.rtl_tcp_port,
|
||||
"log_level": config.general.log_level,
|
||||
"rtlamr_extra_args": config.general.rtlamr_extra_args,
|
||||
},
|
||||
"mqtt": {
|
||||
"host": config.mqtt.host,
|
||||
"port": config.mqtt.port,
|
||||
"user": config.mqtt.user,
|
||||
"password": config.mqtt.password,
|
||||
"base_topic": config.mqtt.base_topic,
|
||||
"ha_autodiscovery": config.mqtt.ha_autodiscovery,
|
||||
"ha_autodiscovery_topic": config.mqtt.ha_autodiscovery_topic,
|
||||
"client_id": config.mqtt.client_id,
|
||||
},
|
||||
"meters": [
|
||||
{
|
||||
"id": m.id,
|
||||
"protocol": m.protocol,
|
||||
"name": m.name,
|
||||
"unit_of_measurement": m.unit_of_measurement,
|
||||
"icon": m.icon,
|
||||
"device_class": m.device_class,
|
||||
"state_class": m.state_class,
|
||||
"multiplier": m.multiplier,
|
||||
"cost_factors": [
|
||||
{"name": cf.name, "rate": cf.rate, "type": cf.type}
|
||||
for cf in m.cost_factors
|
||||
],
|
||||
}
|
||||
for m in config.meters
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validation helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def validate_mqtt_config(data: dict) -> tuple[bool, str]:
|
||||
"""Validate MQTT config fields, return (ok, error_message)."""
|
||||
if not data.get("host", "").strip():
|
||||
return False, "MQTT host is required"
|
||||
port = data.get("port", 1883)
|
||||
try:
|
||||
port = int(port)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
return False, f"Invalid port: {port}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_meter_config(data: dict) -> tuple[bool, str]:
|
||||
"""Validate a single meter config dict, return (ok, error_message)."""
|
||||
if not data.get("id"):
|
||||
return False, "Meter ID is required"
|
||||
try:
|
||||
int(data["id"])
|
||||
except (ValueError, TypeError):
|
||||
return False, f"Meter ID must be a number: {data['id']}"
|
||||
|
||||
protocol = data.get("protocol", "").lower()
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return False, (
|
||||
f"Invalid protocol: {protocol}. "
|
||||
f"Valid: {', '.join(sorted(VALID_PROTOCOLS))}"
|
||||
)
|
||||
|
||||
if not data.get("name", "").strip():
|
||||
return False, "Meter name is required"
|
||||
|
||||
multiplier = data.get("multiplier", 1.0)
|
||||
try:
|
||||
multiplier = float(multiplier)
|
||||
except (ValueError, TypeError):
|
||||
return False, f"Multiplier must be a number: {data.get('multiplier')}"
|
||||
if multiplier <= 0:
|
||||
return False, f"Multiplier must be positive, got {multiplier}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_rate_component(data: dict) -> tuple[bool, str]:
|
||||
"""Validate a single rate component dict, return (ok, error_message)."""
|
||||
if not data.get("name", "").strip():
|
||||
return False, "Rate component name is required"
|
||||
try:
|
||||
float(data.get("rate", ""))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"Rate must be a number: {data.get('rate')}"
|
||||
comp_type = data.get("type", "per_unit")
|
||||
if comp_type not in VALID_RATE_TYPES:
|
||||
return False, f"Invalid rate type: {comp_type}. Must be 'per_unit' or 'fixed'"
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_meter_defaults(device_class: str) -> dict:
|
||||
"""Get smart defaults (icon, unit) for a device class."""
|
||||
return dict(_METER_DEFAULTS.get(device_class, {}))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internal builders
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_config_from_dict(raw: dict) -> HaMeterConfig:
|
||||
"""Build HaMeterConfig from a raw dict (from JSON or YAML)."""
|
||||
general = _build_general_from_dict(raw)
|
||||
mqtt = _build_mqtt_from_dict(raw)
|
||||
meters = _build_meters_from_dict(raw)
|
||||
return HaMeterConfig(general=general, mqtt=mqtt, meters=meters)
|
||||
|
||||
|
||||
def _build_general_from_dict(raw: dict) -> GeneralConfig:
|
||||
"""Build GeneralConfig from raw dict."""
|
||||
g = raw.get("general", {})
|
||||
extra_args = g.get("rtlamr_extra_args", [])
|
||||
if isinstance(extra_args, str):
|
||||
extra_args = extra_args.split()
|
||||
|
||||
rtl_tcp_port = int(g.get("rtl_tcp_port", 1234))
|
||||
if not (1 <= rtl_tcp_port <= 65535):
|
||||
raise ValueError(
|
||||
f"rtl_tcp_port must be 1-65535, got {rtl_tcp_port}"
|
||||
)
|
||||
|
||||
log_level = str(g.get("log_level", "INFO")).upper()
|
||||
if log_level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
||||
raise ValueError(
|
||||
f"Invalid log_level '{log_level}'. "
|
||||
f"Valid: DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
||||
)
|
||||
|
||||
device_id = str(g.get("device_id", "0"))
|
||||
try:
|
||||
if int(device_id) < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(
|
||||
f"device_id must be a non-negative integer, got '{device_id}'"
|
||||
)
|
||||
|
||||
return GeneralConfig(
|
||||
sleep_for=int(g.get("sleep_for", 0)),
|
||||
device_id=device_id,
|
||||
rtl_tcp_host=g.get("rtl_tcp_host", "127.0.0.1"),
|
||||
rtl_tcp_port=rtl_tcp_port,
|
||||
log_level=log_level,
|
||||
rtlamr_extra_args=list(extra_args),
|
||||
)
|
||||
|
||||
|
||||
def _build_mqtt_from_dict(raw: dict) -> MqttConfig:
|
||||
"""Build MqttConfig from raw dict."""
|
||||
m = raw.get("mqtt") or {}
|
||||
|
||||
host = m.get("host", "")
|
||||
if not host:
|
||||
raise ValueError(
|
||||
"MQTT host not set. Configure it via the web UI."
|
||||
)
|
||||
|
||||
port = int(m.get("port", 1883))
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError(f"MQTT port must be 1-65535, got {port}")
|
||||
|
||||
return MqttConfig(
|
||||
host=host,
|
||||
port=port,
|
||||
user=m.get("user", ""),
|
||||
password=m.get("password", ""),
|
||||
base_topic=m.get("base_topic", "hameter"),
|
||||
ha_autodiscovery=m.get("ha_autodiscovery", True),
|
||||
ha_autodiscovery_topic=m.get("ha_autodiscovery_topic", "homeassistant"),
|
||||
client_id=m.get("client_id", "hameter"),
|
||||
)
|
||||
|
||||
|
||||
def _build_meters_from_dict(raw: dict) -> list[MeterConfig]:
|
||||
"""Build meter list from raw dict."""
|
||||
meters_raw = raw.get("meters")
|
||||
if not meters_raw:
|
||||
return []
|
||||
|
||||
meters = []
|
||||
seen_ids: set[int] = set()
|
||||
for i, m in enumerate(meters_raw):
|
||||
meter_id = m.get("id")
|
||||
if meter_id is None:
|
||||
raise ValueError(f"Meter #{i + 1} missing required 'id'")
|
||||
|
||||
mid_int = int(meter_id)
|
||||
if mid_int in seen_ids:
|
||||
raise ValueError(
|
||||
f"Meter #{i + 1} has duplicate id {mid_int}"
|
||||
)
|
||||
seen_ids.add(mid_int)
|
||||
|
||||
protocol = m.get("protocol", "").lower()
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
raise ValueError(
|
||||
f"Meter #{i + 1} (id={meter_id}) has invalid protocol "
|
||||
f"'{protocol}'. Valid: {', '.join(sorted(VALID_PROTOCOLS))}"
|
||||
)
|
||||
|
||||
name = m.get("name", "")
|
||||
if not name:
|
||||
raise ValueError(
|
||||
f"Meter #{i + 1} (id={meter_id}) missing required 'name'"
|
||||
)
|
||||
|
||||
# Apply smart defaults based on device_class.
|
||||
device_class = m.get("device_class", "")
|
||||
defaults = _METER_DEFAULTS.get(device_class, {})
|
||||
|
||||
cost_factors = [
|
||||
RateComponent(
|
||||
name=cf.get("name", ""),
|
||||
rate=float(cf.get("rate", 0.0)),
|
||||
type=cf.get("type", "per_unit"),
|
||||
)
|
||||
for cf in m.get("cost_factors", [])
|
||||
]
|
||||
|
||||
meters.append(MeterConfig(
|
||||
id=int(meter_id),
|
||||
protocol=protocol,
|
||||
name=name,
|
||||
unit_of_measurement=m.get("unit_of_measurement", "") or defaults.get("unit", ""),
|
||||
icon=m.get("icon", "") or defaults.get("icon", "mdi:gauge"),
|
||||
device_class=device_class,
|
||||
state_class=m.get("state_class", "total_increasing"),
|
||||
multiplier=float(m.get("multiplier", 1.0)),
|
||||
cost_factors=cost_factors,
|
||||
))
|
||||
|
||||
return meters
|
||||
60
hameter/cost.py
Normal file
60
hameter/cost.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Cost calculation for meter rate components."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hameter.config import RateComponent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostResult:
|
||||
"""Result of a cost calculation for a single reading delta."""
|
||||
|
||||
delta: float
|
||||
per_unit_cost: float
|
||||
component_costs: list[dict]
|
||||
total_incremental_cost: float
|
||||
|
||||
|
||||
def calculate_incremental_cost(
|
||||
delta: float,
|
||||
cost_factors: list[RateComponent],
|
||||
) -> CostResult:
|
||||
"""Calculate the incremental cost for a usage delta.
|
||||
|
||||
Only per_unit rate components contribute to incremental cost.
|
||||
Fixed charges are NOT included — they are handled separately
|
||||
via the billing period reset / manual add workflow.
|
||||
|
||||
Args:
|
||||
delta: Usage delta in calibrated units (e.g., kWh).
|
||||
cost_factors: List of rate components from meter config.
|
||||
|
||||
Returns:
|
||||
CostResult with per-component breakdown and total.
|
||||
"""
|
||||
component_costs = []
|
||||
per_unit_total = 0.0
|
||||
|
||||
for cf in cost_factors:
|
||||
if cf.type == "per_unit":
|
||||
cost = round(delta * cf.rate, 4)
|
||||
per_unit_total += cost
|
||||
else:
|
||||
cost = 0.0
|
||||
|
||||
component_costs.append({
|
||||
"name": cf.name,
|
||||
"rate": cf.rate,
|
||||
"type": cf.type,
|
||||
"cost": cost,
|
||||
})
|
||||
|
||||
return CostResult(
|
||||
delta=delta,
|
||||
per_unit_cost=round(per_unit_total, 4),
|
||||
component_costs=component_costs,
|
||||
total_incremental_cost=round(per_unit_total, 4),
|
||||
)
|
||||
56
hameter/cost_state.py
Normal file
56
hameter/cost_state.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Persistent cost state management.
|
||||
|
||||
Cost state is stored at /data/cost_state.json, separate from config.json.
|
||||
This file is updated each time a cost calculation occurs, enabling
|
||||
persistence across restarts without bloating the config file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COST_STATE_PATH = "/data/cost_state.json"
|
||||
|
||||
|
||||
def load_cost_state(path: Optional[str] = None) -> dict:
|
||||
"""Load persisted cost state from disk.
|
||||
|
||||
Returns:
|
||||
Dict keyed by meter_id (str) with cost state values.
|
||||
"""
|
||||
path = path or COST_STATE_PATH
|
||||
if not os.path.isfile(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load cost state: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def save_cost_state(states: dict, path: Optional[str] = None):
|
||||
"""Atomically save cost state to disk."""
|
||||
path = path or COST_STATE_PATH
|
||||
dir_path = os.path.dirname(path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=dir_path or ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(states, f, indent=2)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
189
hameter/discovery.py
Normal file
189
hameter/discovery.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Discovery mode: listen for all nearby meter transmissions."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from hameter.config import GeneralConfig
|
||||
from hameter.meter import parse_rtlamr_line
|
||||
from hameter.state import PipelineStatus
|
||||
from hameter.subprocess_manager import SubprocessManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_discovery(
|
||||
config: GeneralConfig,
|
||||
shutdown_event: threading.Event,
|
||||
duration: int = 120,
|
||||
):
|
||||
"""Run in discovery mode: capture all meter transmissions and summarize.
|
||||
|
||||
Args:
|
||||
config: General configuration (device_id, rtl_tcp settings).
|
||||
shutdown_event: Threading event to signal early shutdown.
|
||||
duration: How many seconds to listen before stopping.
|
||||
"""
|
||||
proc_mgr = SubprocessManager(config, shutdown_event)
|
||||
|
||||
if not proc_mgr.start_discovery_mode():
|
||||
logger.error("Failed to start SDR in discovery mode")
|
||||
return
|
||||
|
||||
seen: dict[int, dict] = {}
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("DISCOVERY MODE")
|
||||
logger.info("Listening for %d seconds. Press Ctrl+C to stop early.", duration)
|
||||
logger.info("All nearby meter transmissions will be logged.")
|
||||
logger.info("=" * 60)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
while not shutdown_event.is_set() and (time.monotonic() - start) < duration:
|
||||
line = proc_mgr.get_line(timeout=1.0)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
reading = parse_rtlamr_line(line, meters={})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not reading:
|
||||
continue
|
||||
|
||||
mid = reading.meter_id
|
||||
|
||||
if mid not in seen:
|
||||
seen[mid] = {
|
||||
"protocol": reading.protocol,
|
||||
"count": 0,
|
||||
"first_seen": reading.timestamp,
|
||||
"last_consumption": 0,
|
||||
}
|
||||
logger.info(
|
||||
" NEW METER: ID=%-12d Protocol=%-6s Consumption=%d",
|
||||
mid,
|
||||
reading.protocol,
|
||||
reading.raw_consumption,
|
||||
)
|
||||
|
||||
seen[mid]["count"] += 1
|
||||
seen[mid]["last_consumption"] = reading.raw_consumption
|
||||
seen[mid]["last_seen"] = reading.timestamp
|
||||
|
||||
finally:
|
||||
proc_mgr.stop()
|
||||
|
||||
# Print summary.
|
||||
logger.info("")
|
||||
logger.info("=" * 60)
|
||||
logger.info("DISCOVERY SUMMARY — %d unique meters found", len(seen))
|
||||
logger.info("=" * 60)
|
||||
logger.info(
|
||||
"%-12s %-8s %-6s %-15s",
|
||||
"Meter ID", "Protocol", "Count", "Last Reading",
|
||||
)
|
||||
logger.info("-" * 50)
|
||||
|
||||
for mid, info in sorted(seen.items(), key=lambda x: -x[1]["count"]):
|
||||
logger.info(
|
||||
"%-12d %-8s %-6d %-15d",
|
||||
mid,
|
||||
info["protocol"],
|
||||
info["count"],
|
||||
info["last_consumption"],
|
||||
)
|
||||
|
||||
logger.info("")
|
||||
logger.info(
|
||||
"To add a meter, use the web UI at http://localhost:9090/config/meters"
|
||||
)
|
||||
|
||||
|
||||
def run_discovery_for_web(
|
||||
config: GeneralConfig,
|
||||
shutdown_event: threading.Event,
|
||||
app_state,
|
||||
duration: int = 120,
|
||||
stop_event: Optional[threading.Event] = None,
|
||||
):
|
||||
"""Run discovery mode, reporting results to AppState for the web UI.
|
||||
|
||||
Args:
|
||||
config: General configuration.
|
||||
shutdown_event: Global shutdown signal.
|
||||
app_state: Shared AppState to report discoveries to.
|
||||
duration: How many seconds to listen.
|
||||
stop_event: Optional event to stop discovery early (from web UI).
|
||||
"""
|
||||
proc_mgr = SubprocessManager(config, shutdown_event)
|
||||
|
||||
if not proc_mgr.start_discovery_mode():
|
||||
logger.error("Failed to start SDR in discovery mode")
|
||||
app_state.set_status(PipelineStatus.ERROR, "Failed to start SDR for discovery")
|
||||
return
|
||||
|
||||
logger.info("Discovery mode started, listening for %d seconds", duration)
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
while (
|
||||
not shutdown_event.is_set()
|
||||
and (time.monotonic() - start) < duration
|
||||
and not (stop_event and stop_event.is_set())
|
||||
):
|
||||
line = proc_mgr.get_line(timeout=1.0)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
reading = parse_rtlamr_line(line, meters={})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not reading:
|
||||
continue
|
||||
|
||||
mid = reading.meter_id
|
||||
existing = app_state.get_discovery_results()
|
||||
|
||||
if mid in existing:
|
||||
prev = existing[mid]
|
||||
info = {
|
||||
"protocol": prev["protocol"],
|
||||
"count": prev["count"] + 1,
|
||||
"first_seen": prev["first_seen"],
|
||||
"last_consumption": reading.raw_consumption,
|
||||
"last_seen": reading.timestamp,
|
||||
}
|
||||
else:
|
||||
info = {
|
||||
"protocol": reading.protocol,
|
||||
"count": 1,
|
||||
"first_seen": reading.timestamp,
|
||||
"last_consumption": reading.raw_consumption,
|
||||
"last_seen": reading.timestamp,
|
||||
}
|
||||
logger.info(
|
||||
"Discovery: new meter ID=%d Protocol=%s Consumption=%d",
|
||||
mid,
|
||||
reading.protocol,
|
||||
reading.raw_consumption,
|
||||
)
|
||||
|
||||
app_state.record_discovery(mid, info)
|
||||
|
||||
finally:
|
||||
proc_mgr.stop()
|
||||
|
||||
results = app_state.get_discovery_results()
|
||||
logger.info("Discovery complete: %d unique meters found", len(results))
|
||||
92
hameter/meter.py
Normal file
92
hameter/meter.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Meter data model and rtlamr output parsing."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from hameter.config import MeterConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map rtlamr message Type to the field containing the meter ID.
|
||||
PROTOCOL_ID_FIELDS: dict[str, str] = {
|
||||
"SCM": "ID",
|
||||
"SCM+": "EndpointID",
|
||||
"IDM": "ERTSerialNumber",
|
||||
"NetIDM": "ERTSerialNumber",
|
||||
"R900": "ID",
|
||||
"R900BCD": "ID",
|
||||
}
|
||||
|
||||
# Map rtlamr message Type to the field containing the consumption value.
|
||||
PROTOCOL_CONSUMPTION_FIELDS: dict[str, str] = {
|
||||
"SCM": "Consumption",
|
||||
"SCM+": "Consumption",
|
||||
"IDM": "LastConsumptionCount",
|
||||
"NetIDM": "LastConsumptionCount",
|
||||
"R900": "Consumption",
|
||||
"R900BCD": "Consumption",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeterReading:
|
||||
meter_id: int
|
||||
protocol: str
|
||||
raw_consumption: int
|
||||
calibrated_consumption: float
|
||||
timestamp: str
|
||||
raw_message: dict
|
||||
|
||||
|
||||
def parse_rtlamr_line(
|
||||
line: str,
|
||||
meters: dict[int, MeterConfig],
|
||||
) -> Optional[MeterReading]:
|
||||
"""Parse a single JSON line from rtlamr stdout.
|
||||
|
||||
Args:
|
||||
line: Raw JSON string from rtlamr.
|
||||
meters: Dict mapping meter ID to config. If empty, accept all meters
|
||||
(discovery mode).
|
||||
|
||||
Returns:
|
||||
MeterReading if the line matches a configured meter (or any meter in
|
||||
discovery mode), otherwise None.
|
||||
"""
|
||||
data = json.loads(line)
|
||||
msg_type = data.get("Type", "")
|
||||
message = data.get("Message", {})
|
||||
|
||||
id_field = PROTOCOL_ID_FIELDS.get(msg_type)
|
||||
consumption_field = PROTOCOL_CONSUMPTION_FIELDS.get(msg_type)
|
||||
|
||||
if not id_field or not consumption_field:
|
||||
return None
|
||||
|
||||
meter_id = int(message.get(id_field, 0))
|
||||
if meter_id == 0:
|
||||
return None
|
||||
|
||||
raw_consumption = int(message.get(consumption_field, 0))
|
||||
|
||||
# Filter: if meters dict is populated, only accept configured meters.
|
||||
if meters and meter_id not in meters:
|
||||
return None
|
||||
|
||||
meter_cfg = meters.get(meter_id)
|
||||
multiplier = meter_cfg.multiplier if meter_cfg else 1.0
|
||||
calibrated = raw_consumption * multiplier
|
||||
|
||||
timestamp = data.get("Time") or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return MeterReading(
|
||||
meter_id=meter_id,
|
||||
protocol=msg_type,
|
||||
raw_consumption=raw_consumption,
|
||||
calibrated_consumption=round(calibrated, 4),
|
||||
timestamp=timestamp,
|
||||
raw_message=message,
|
||||
)
|
||||
252
hameter/mqtt_client.py
Normal file
252
hameter/mqtt_client.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""MQTT client with Home Assistant auto-discovery support."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
from hameter import __version__
|
||||
from hameter.config import MeterConfig, MqttConfig
|
||||
from hameter.meter import MeterReading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HaMeterMQTT:
|
||||
"""Manages MQTT connection, HA discovery, and meter state publishing."""
|
||||
|
||||
def __init__(self, config: MqttConfig, meters: list[MeterConfig]):
|
||||
self._config = config
|
||||
self._meters = meters
|
||||
self._meters_by_id: dict[int, MeterConfig] = {m.id: m for m in meters}
|
||||
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=config.client_id,
|
||||
)
|
||||
|
||||
# Last Will: broker publishes "offline" if we disconnect unexpectedly.
|
||||
self._client.will_set(
|
||||
topic=f"{config.base_topic}/status",
|
||||
payload="offline",
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
if config.user:
|
||||
self._client.username_pw_set(config.user, config.password)
|
||||
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._client.on_message = self._on_message
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the MQTT broker and start the network loop.
|
||||
|
||||
Raises:
|
||||
OSError: If the broker is unreachable.
|
||||
"""
|
||||
logger.info("Connecting to MQTT broker at %s:%d", self._config.host, self._config.port)
|
||||
try:
|
||||
self._client.connect(self._config.host, self._config.port, keepalive=60)
|
||||
except OSError as e:
|
||||
logger.error("Failed to connect to MQTT broker: %s", e)
|
||||
raise
|
||||
self._client.loop_start()
|
||||
|
||||
def disconnect(self):
|
||||
"""Publish offline status and cleanly disconnect."""
|
||||
try:
|
||||
self._client.publish(
|
||||
f"{self._config.base_topic}/status", "offline", qos=1, retain=True,
|
||||
)
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning("Error during MQTT disconnect: %s", e)
|
||||
|
||||
def publish_online(self):
|
||||
"""Publish online availability status."""
|
||||
self._client.publish(
|
||||
f"{self._config.base_topic}/status", "online", qos=1, retain=True,
|
||||
)
|
||||
|
||||
def publish_reading(self, reading: MeterReading):
|
||||
"""Publish a meter reading to the state topic."""
|
||||
state_payload = {
|
||||
"reading": reading.calibrated_consumption,
|
||||
"raw_reading": reading.raw_consumption,
|
||||
"timestamp": reading.timestamp,
|
||||
"protocol": reading.protocol,
|
||||
}
|
||||
self._client.publish(
|
||||
f"{self._config.base_topic}/{reading.meter_id}/state",
|
||||
json.dumps(state_payload),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
meter_cfg = self._meters_by_id.get(reading.meter_id)
|
||||
unit = meter_cfg.unit_of_measurement if meter_cfg else ""
|
||||
logger.info(
|
||||
"Published: meter=%d raw=%d calibrated=%.4f %s",
|
||||
reading.meter_id,
|
||||
reading.raw_consumption,
|
||||
reading.calibrated_consumption,
|
||||
unit,
|
||||
)
|
||||
|
||||
def publish_cost(self, meter_id: int, cumulative_cost: float):
|
||||
"""Publish cumulative cost for a meter."""
|
||||
payload = {"cost": round(cumulative_cost, 2)}
|
||||
self._client.publish(
|
||||
f"{self._config.base_topic}/{meter_id}/cost",
|
||||
json.dumps(payload),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
logger.debug("Published cost: meter=%d cost=$%.2f", meter_id, cumulative_cost)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# HA Auto-Discovery
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _publish_discovery(self):
|
||||
"""Publish HA MQTT auto-discovery config for each meter."""
|
||||
if not self._config.ha_autodiscovery:
|
||||
return
|
||||
|
||||
for meter in self._meters:
|
||||
device_id = f"hameter_{meter.id}"
|
||||
base = self._config.base_topic
|
||||
disco = self._config.ha_autodiscovery_topic
|
||||
|
||||
device_block = {
|
||||
"identifiers": [device_id],
|
||||
"name": meter.name,
|
||||
"manufacturer": "HAMeter",
|
||||
"model": f"ERT {meter.protocol.upper()}",
|
||||
"sw_version": __version__,
|
||||
"serial_number": str(meter.id),
|
||||
}
|
||||
|
||||
availability_block = {
|
||||
"availability_topic": f"{base}/status",
|
||||
"payload_available": "online",
|
||||
"payload_not_available": "offline",
|
||||
}
|
||||
|
||||
# --- Calibrated reading sensor ---
|
||||
reading_config = {
|
||||
"name": f"{meter.name} Reading",
|
||||
"unique_id": f"{device_id}_reading",
|
||||
"state_topic": f"{base}/{meter.id}/state",
|
||||
"value_template": "{{ value_json.reading }}",
|
||||
"unit_of_measurement": meter.unit_of_measurement,
|
||||
"state_class": meter.state_class,
|
||||
"icon": meter.icon,
|
||||
"device": device_block,
|
||||
**availability_block,
|
||||
}
|
||||
if meter.device_class:
|
||||
reading_config["device_class"] = meter.device_class
|
||||
|
||||
self._client.publish(
|
||||
f"{disco}/sensor/{device_id}/reading/config",
|
||||
json.dumps(reading_config),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
# --- Last Seen sensor ---
|
||||
lastseen_config = {
|
||||
"name": f"{meter.name} Last Seen",
|
||||
"unique_id": f"{device_id}_last_seen",
|
||||
"state_topic": f"{base}/{meter.id}/state",
|
||||
"value_template": "{{ value_json.timestamp }}",
|
||||
"device_class": "timestamp",
|
||||
"icon": "mdi:clock-outline",
|
||||
"device": {"identifiers": [device_id], "name": meter.name},
|
||||
**availability_block,
|
||||
}
|
||||
self._client.publish(
|
||||
f"{disco}/sensor/{device_id}/last_seen/config",
|
||||
json.dumps(lastseen_config),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
# --- Raw reading sensor (diagnostic) ---
|
||||
raw_config = {
|
||||
"name": f"{meter.name} Raw Reading",
|
||||
"unique_id": f"{device_id}_raw",
|
||||
"state_topic": f"{base}/{meter.id}/state",
|
||||
"value_template": "{{ value_json.raw_reading }}",
|
||||
"icon": "mdi:counter",
|
||||
"entity_category": "diagnostic",
|
||||
"device": {"identifiers": [device_id], "name": meter.name},
|
||||
**availability_block,
|
||||
}
|
||||
self._client.publish(
|
||||
f"{disco}/sensor/{device_id}/raw/config",
|
||||
json.dumps(raw_config),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
# --- Cost sensor (only for meters with cost_factors) ---
|
||||
if meter.cost_factors:
|
||||
cost_config = {
|
||||
"name": f"{meter.name} Cost",
|
||||
"unique_id": f"{device_id}_cost",
|
||||
"state_topic": f"{base}/{meter.id}/cost",
|
||||
"value_template": "{{ value_json.cost }}",
|
||||
"unit_of_measurement": "$",
|
||||
"device_class": "monetary",
|
||||
"state_class": "total",
|
||||
"icon": "mdi:currency-usd",
|
||||
"device": {"identifiers": [device_id], "name": meter.name},
|
||||
**availability_block,
|
||||
}
|
||||
self._client.publish(
|
||||
f"{disco}/sensor/{device_id}/cost/config",
|
||||
json.dumps(cost_config),
|
||||
qos=1,
|
||||
retain=True,
|
||||
)
|
||||
|
||||
logger.info("Published HA discovery for meter %d (%s)", meter.id, meter.name)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Callbacks
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _on_connect(self, client, userdata, connect_flags, reason_code, properties):
|
||||
if reason_code == 0:
|
||||
logger.info("Connected to MQTT broker")
|
||||
self.publish_online()
|
||||
# Subscribe to HA status so we re-publish discovery on HA restart.
|
||||
client.subscribe(
|
||||
f"{self._config.ha_autodiscovery_topic}/status", qos=1,
|
||||
)
|
||||
self._publish_discovery()
|
||||
else:
|
||||
logger.error("MQTT connection failed: %s", reason_code)
|
||||
|
||||
def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
|
||||
if reason_code == 0:
|
||||
logger.info("Disconnected from MQTT broker (clean)")
|
||||
else:
|
||||
logger.warning("Lost MQTT connection (rc=%s), will auto-reconnect", reason_code)
|
||||
|
||||
def _on_message(self, client, userdata, message):
|
||||
# Re-publish discovery when HA comes online.
|
||||
try:
|
||||
payload = message.payload.decode()
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
return
|
||||
if message.topic.endswith("/status") and payload == "online":
|
||||
logger.info("Home Assistant came online, re-publishing discovery")
|
||||
self._publish_discovery()
|
||||
198
hameter/pipeline.py
Normal file
198
hameter/pipeline.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Main pipeline: ties subprocess management, parsing, and MQTT together."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
from typing import Optional
|
||||
|
||||
from hameter.config import HaMeterConfig, MeterConfig
|
||||
from hameter.cost import calculate_incremental_cost
|
||||
from hameter.cost_state import save_cost_state
|
||||
from hameter.meter import MeterReading, parse_rtlamr_line
|
||||
from hameter.mqtt_client import HaMeterMQTT
|
||||
from hameter.state import CostState, PipelineStatus
|
||||
from hameter.subprocess_manager import SubprocessManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Orchestrates the full meter-reading pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: HaMeterConfig,
|
||||
shutdown_event: threading.Event,
|
||||
app_state=None,
|
||||
):
|
||||
self._config = config
|
||||
self._shutdown = shutdown_event
|
||||
self._state = app_state
|
||||
self._meters_by_id = {m.id: m for m in config.meters}
|
||||
self._meter_ids = list(self._meters_by_id.keys())
|
||||
self._protocols = list({m.protocol for m in config.meters})
|
||||
|
||||
self._proc_mgr = SubprocessManager(config.general, shutdown_event)
|
||||
self._mqtt = HaMeterMQTT(config.mqtt, config.meters)
|
||||
|
||||
def run(self):
|
||||
"""Block until shutdown or restart request."""
|
||||
try:
|
||||
try:
|
||||
self._mqtt.connect()
|
||||
except OSError as e:
|
||||
if self._state:
|
||||
self._state.set_status(
|
||||
PipelineStatus.ERROR, f"MQTT connection failed: {e}"
|
||||
)
|
||||
return
|
||||
if self._state:
|
||||
self._state.set_status(PipelineStatus.STARTING, "Connecting to MQTT...")
|
||||
if not self._start_with_retries():
|
||||
if self._state:
|
||||
self._state.set_status(
|
||||
PipelineStatus.ERROR, "Failed to start subprocesses"
|
||||
)
|
||||
logger.error("Failed to start subprocesses after retries, exiting")
|
||||
return
|
||||
if self._state:
|
||||
self._state.set_status(PipelineStatus.RUNNING)
|
||||
self._main_loop()
|
||||
finally:
|
||||
self._shutdown_all()
|
||||
|
||||
def _start_with_retries(self, max_attempts: int = 5) -> bool:
|
||||
"""Try to start subprocesses up to max_attempts times."""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if self._shutdown.is_set():
|
||||
return False
|
||||
if self._should_stop():
|
||||
return False
|
||||
logger.info("Starting subprocesses (attempt %d/%d)", attempt, max_attempts)
|
||||
if self._proc_mgr.start(self._meter_ids, self._protocols):
|
||||
return True
|
||||
if attempt < max_attempts:
|
||||
logger.warning("Startup failed, retrying...")
|
||||
if self._shutdown.wait(timeout=5):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _main_loop(self):
|
||||
"""Read lines from rtlamr, parse, and publish."""
|
||||
logger.info("Pipeline running — listening for meter readings")
|
||||
|
||||
while not self._shutdown.is_set():
|
||||
# Check for restart/discovery request from web UI.
|
||||
if self._should_stop():
|
||||
logger.info("Received request to stop pipeline")
|
||||
break
|
||||
|
||||
# Health check.
|
||||
if not self._proc_mgr.is_healthy():
|
||||
logger.warning("Subprocess died, attempting restart")
|
||||
if not self._proc_mgr.restart(self._meter_ids, self._protocols):
|
||||
logger.error("Restart failed, exiting main loop")
|
||||
break
|
||||
continue
|
||||
|
||||
# Read next line (non-blocking, 1s timeout).
|
||||
line = self._proc_mgr.get_line(timeout=1.0)
|
||||
if line is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
reading = parse_rtlamr_line(line, self._meters_by_id)
|
||||
if reading:
|
||||
self._mqtt.publish_reading(reading)
|
||||
if self._state:
|
||||
self._state.record_reading(reading)
|
||||
meter_cfg = self._meters_by_id.get(reading.meter_id)
|
||||
if meter_cfg and meter_cfg.cost_factors:
|
||||
self._process_cost(reading, meter_cfg)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Non-JSON line from rtlamr: %.200s", line)
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
logger.warning("Error processing line: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Unexpected error processing line")
|
||||
|
||||
def _process_cost(self, reading: MeterReading, meter_cfg: MeterConfig):
|
||||
"""Calculate and record incremental cost for a reading."""
|
||||
cost_state = self._state.get_cost_state(reading.meter_id)
|
||||
|
||||
if cost_state is None:
|
||||
# First reading for this meter — initialize baseline, no cost yet.
|
||||
new_state = CostState(
|
||||
last_calibrated_reading=reading.calibrated_consumption,
|
||||
last_updated=reading.timestamp,
|
||||
billing_period_start=reading.timestamp,
|
||||
)
|
||||
self._state.update_cost_state(reading.meter_id, new_state)
|
||||
return
|
||||
|
||||
if cost_state.last_calibrated_reading is None:
|
||||
# After a billing period reset — this reading sets the baseline.
|
||||
new_state = CostState(
|
||||
cumulative_cost=cost_state.cumulative_cost,
|
||||
last_calibrated_reading=reading.calibrated_consumption,
|
||||
billing_period_start=cost_state.billing_period_start,
|
||||
last_updated=reading.timestamp,
|
||||
fixed_charges_applied=cost_state.fixed_charges_applied,
|
||||
)
|
||||
self._state.update_cost_state(reading.meter_id, new_state)
|
||||
return
|
||||
|
||||
delta = reading.calibrated_consumption - cost_state.last_calibrated_reading
|
||||
if delta <= 0:
|
||||
# No new consumption (duplicate reading or meter rollover).
|
||||
return
|
||||
|
||||
result = calculate_incremental_cost(delta, meter_cfg.cost_factors)
|
||||
|
||||
new_cumulative = round(
|
||||
cost_state.cumulative_cost + result.total_incremental_cost, 4
|
||||
)
|
||||
new_state = CostState(
|
||||
cumulative_cost=new_cumulative,
|
||||
last_calibrated_reading=reading.calibrated_consumption,
|
||||
billing_period_start=cost_state.billing_period_start,
|
||||
last_updated=reading.timestamp,
|
||||
fixed_charges_applied=cost_state.fixed_charges_applied,
|
||||
)
|
||||
|
||||
self._state.update_cost_state(reading.meter_id, new_state)
|
||||
self._mqtt.publish_cost(reading.meter_id, new_cumulative)
|
||||
self._save_all_cost_states()
|
||||
|
||||
logger.debug(
|
||||
"Cost update: meter=%d delta=%.4f incremental=$%.4f cumulative=$%.4f",
|
||||
reading.meter_id, delta,
|
||||
result.total_incremental_cost,
|
||||
cost_state.cumulative_cost,
|
||||
)
|
||||
|
||||
def _save_all_cost_states(self):
|
||||
"""Persist cost state for all meters to disk."""
|
||||
states = self._state.get_cost_states()
|
||||
serialized = {str(mid): asdict(cs) for mid, cs in states.items()}
|
||||
try:
|
||||
save_cost_state(serialized)
|
||||
except Exception:
|
||||
logger.exception("Failed to persist cost state")
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
"""Check if the web UI has requested a restart or discovery."""
|
||||
if self._state is None:
|
||||
return False
|
||||
return (
|
||||
self._state.restart_requested.is_set()
|
||||
or self._state.discovery_requested.is_set()
|
||||
)
|
||||
|
||||
def _shutdown_all(self):
|
||||
"""Clean shutdown of subprocesses and MQTT."""
|
||||
logger.info("Shutting down pipeline...")
|
||||
self._proc_mgr.stop()
|
||||
self._mqtt.disconnect()
|
||||
logger.info("Pipeline shutdown complete")
|
||||
275
hameter/state.py
Normal file
275
hameter/state.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Thread-safe shared state between web server and pipeline."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from hameter.meter import MeterReading
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostState:
|
||||
"""Tracks cumulative cost for a single meter across a billing period."""
|
||||
|
||||
cumulative_cost: float = 0.0
|
||||
last_calibrated_reading: Optional[float] = None
|
||||
billing_period_start: str = ""
|
||||
last_updated: str = ""
|
||||
fixed_charges_applied: float = 0.0
|
||||
|
||||
|
||||
class PipelineStatus(Enum):
|
||||
UNCONFIGURED = "unconfigured"
|
||||
STOPPED = "stopped"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
RESTARTING = "restarting"
|
||||
DISCOVERY = "discovery"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class AppState:
|
||||
"""Thread-safe shared state for the HAMeter application.
|
||||
|
||||
Accessed by:
|
||||
- Main thread: Pipeline reads/writes pipeline_status, last_readings
|
||||
- Flask thread: Web routes read status, trigger restart/discovery
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Pipeline state
|
||||
self._status: PipelineStatus = PipelineStatus.UNCONFIGURED
|
||||
self._status_message: str = ""
|
||||
|
||||
# Config object (set once loaded)
|
||||
self._config = None
|
||||
|
||||
# Meter readings (most recent per meter ID)
|
||||
self._last_readings: dict[int, MeterReading] = {}
|
||||
self._reading_counts: dict[int, int] = {}
|
||||
|
||||
# Cost tracking per meter
|
||||
self._cost_states: dict[int, CostState] = {}
|
||||
|
||||
# Discovery results
|
||||
self._discovery_results: dict[int, dict] = {}
|
||||
|
||||
# Log ring buffer for web UI streaming
|
||||
self._log_buffer: deque[dict] = deque(maxlen=1000)
|
||||
|
||||
# SSE subscribers (list of threading.Event per subscriber)
|
||||
self._sse_events: list[threading.Event] = []
|
||||
|
||||
# Signals from web -> pipeline
|
||||
self._restart_requested = threading.Event()
|
||||
self._discovery_requested = threading.Event()
|
||||
self._discovery_duration: int = 120
|
||||
self._stop_discovery = threading.Event()
|
||||
|
||||
# Pipeline startup gate: set once config is valid
|
||||
self._config_ready = threading.Event()
|
||||
|
||||
# --- Status ---
|
||||
|
||||
@property
|
||||
def status(self) -> PipelineStatus:
|
||||
with self._lock:
|
||||
return self._status
|
||||
|
||||
def set_status(self, status: PipelineStatus, message: str = ""):
|
||||
with self._lock:
|
||||
self._status = status
|
||||
self._status_message = message
|
||||
self._notify_sse()
|
||||
|
||||
@property
|
||||
def status_message(self) -> str:
|
||||
with self._lock:
|
||||
return self._status_message
|
||||
|
||||
# --- Config ---
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
with self._lock:
|
||||
return self._config
|
||||
|
||||
def set_config(self, config):
|
||||
with self._lock:
|
||||
self._config = config
|
||||
|
||||
# --- Readings ---
|
||||
|
||||
def record_reading(self, reading: MeterReading):
|
||||
with self._lock:
|
||||
self._last_readings[reading.meter_id] = reading
|
||||
self._reading_counts[reading.meter_id] = (
|
||||
self._reading_counts.get(reading.meter_id, 0) + 1
|
||||
)
|
||||
self._notify_sse()
|
||||
|
||||
def get_last_readings(self) -> dict[int, MeterReading]:
|
||||
with self._lock:
|
||||
return dict(self._last_readings)
|
||||
|
||||
def get_reading_counts(self) -> dict[int, int]:
|
||||
with self._lock:
|
||||
return dict(self._reading_counts)
|
||||
|
||||
def clear_readings(self, meter_id: Optional[int] = None):
|
||||
"""Clear cached readings. If meter_id given, clear only that meter."""
|
||||
with self._lock:
|
||||
if meter_id is not None:
|
||||
self._last_readings.pop(meter_id, None)
|
||||
self._reading_counts.pop(meter_id, None)
|
||||
else:
|
||||
self._last_readings.clear()
|
||||
self._reading_counts.clear()
|
||||
self._notify_sse()
|
||||
|
||||
# --- Cost state ---
|
||||
|
||||
def get_cost_states(self) -> dict[int, CostState]:
|
||||
with self._lock:
|
||||
return dict(self._cost_states)
|
||||
|
||||
def get_cost_state(self, meter_id: int) -> Optional[CostState]:
|
||||
with self._lock:
|
||||
return self._cost_states.get(meter_id)
|
||||
|
||||
def update_cost_state(self, meter_id: int, cost_state: CostState):
|
||||
with self._lock:
|
||||
self._cost_states[meter_id] = cost_state
|
||||
self._notify_sse()
|
||||
|
||||
def reset_cost_state(self, meter_id: int, timestamp: str):
|
||||
"""Reset cost tracking for a new billing period."""
|
||||
with self._lock:
|
||||
self._cost_states[meter_id] = CostState(
|
||||
cumulative_cost=0.0,
|
||||
last_calibrated_reading=None,
|
||||
billing_period_start=timestamp,
|
||||
last_updated=timestamp,
|
||||
fixed_charges_applied=0.0,
|
||||
)
|
||||
self._notify_sse()
|
||||
|
||||
def add_fixed_charges(self, meter_id: int, amount: float, timestamp: str):
|
||||
"""Add fixed charges to the cumulative cost for a meter."""
|
||||
with self._lock:
|
||||
cs = self._cost_states.get(meter_id)
|
||||
if cs:
|
||||
cs.cumulative_cost = round(cs.cumulative_cost + amount, 4)
|
||||
cs.fixed_charges_applied = round(
|
||||
cs.fixed_charges_applied + amount, 4
|
||||
)
|
||||
cs.last_updated = timestamp
|
||||
self._notify_sse()
|
||||
|
||||
def remove_cost_state(self, meter_id: int):
|
||||
"""Remove cost state for a meter (e.g. when cost_factors are cleared)."""
|
||||
with self._lock:
|
||||
self._cost_states.pop(meter_id, None)
|
||||
|
||||
# --- Discovery ---
|
||||
|
||||
def record_discovery(self, meter_id: int, info: dict):
|
||||
with self._lock:
|
||||
self._discovery_results[meter_id] = info
|
||||
self._notify_sse()
|
||||
|
||||
def get_discovery_results(self) -> dict[int, dict]:
|
||||
with self._lock:
|
||||
return dict(self._discovery_results)
|
||||
|
||||
def clear_discovery_results(self):
|
||||
with self._lock:
|
||||
self._discovery_results.clear()
|
||||
|
||||
# --- Log buffer ---
|
||||
|
||||
def add_log(self, record: dict):
|
||||
with self._lock:
|
||||
self._log_buffer.append(record)
|
||||
self._notify_sse()
|
||||
|
||||
def get_recent_logs(self, count: int = 200) -> list[dict]:
|
||||
with self._lock:
|
||||
items = list(self._log_buffer)
|
||||
return items[-count:]
|
||||
|
||||
# --- SSE notification ---
|
||||
|
||||
def subscribe_sse(self) -> threading.Event:
|
||||
event = threading.Event()
|
||||
with self._lock:
|
||||
self._sse_events.append(event)
|
||||
return event
|
||||
|
||||
def unsubscribe_sse(self, event: threading.Event):
|
||||
with self._lock:
|
||||
try:
|
||||
self._sse_events.remove(event)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _notify_sse(self):
|
||||
with self._lock:
|
||||
events = list(self._sse_events)
|
||||
for event in events:
|
||||
event.set()
|
||||
|
||||
# --- Signals ---
|
||||
|
||||
@property
|
||||
def restart_requested(self) -> threading.Event:
|
||||
return self._restart_requested
|
||||
|
||||
@property
|
||||
def discovery_requested(self) -> threading.Event:
|
||||
return self._discovery_requested
|
||||
|
||||
@property
|
||||
def discovery_duration(self) -> int:
|
||||
with self._lock:
|
||||
return self._discovery_duration
|
||||
|
||||
@discovery_duration.setter
|
||||
def discovery_duration(self, value: int):
|
||||
with self._lock:
|
||||
self._discovery_duration = value
|
||||
|
||||
@property
|
||||
def stop_discovery(self) -> threading.Event:
|
||||
return self._stop_discovery
|
||||
|
||||
@property
|
||||
def config_ready(self) -> threading.Event:
|
||||
return self._config_ready
|
||||
|
||||
|
||||
class WebLogHandler(logging.Handler):
|
||||
"""Captures log records into AppState for web UI streaming."""
|
||||
|
||||
def __init__(self, app_state: AppState):
|
||||
super().__init__()
|
||||
self._state = app_state
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
self._state.add_log({
|
||||
"timestamp": time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S", time.localtime(record.created)
|
||||
),
|
||||
"level": record.levelname,
|
||||
"name": record.name,
|
||||
"message": record.getMessage(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
320
hameter/subprocess_manager.py
Normal file
320
hameter/subprocess_manager.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Subprocess lifecycle management for rtl_tcp and rtlamr."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from hameter.config import GeneralConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for rtl_tcp to print "listening..." before giving up.
|
||||
RTL_TCP_STARTUP_TIMEOUT = 10
|
||||
# How long to wait for rtlamr to produce its first output.
|
||||
RTLAMR_STARTUP_TIMEOUT = 30
|
||||
# Max consecutive restart failures before increasing backoff.
|
||||
MAX_FAST_RETRIES = 3
|
||||
FAST_RETRY_DELAY = 5
|
||||
SLOW_RETRY_DELAY = 30
|
||||
|
||||
|
||||
class SubprocessManager:
|
||||
"""Manages the rtl_tcp and rtlamr subprocess lifecycle.
|
||||
|
||||
Architecture:
|
||||
- rtl_tcp runs as a TCP server providing SDR samples.
|
||||
- rtlamr connects to rtl_tcp and outputs JSON lines to stdout.
|
||||
- A dedicated reader thread puts stdout lines into a queue.
|
||||
- Both processes are started in new sessions (process groups) for
|
||||
reliable cleanup via os.killpg().
|
||||
"""
|
||||
|
||||
def __init__(self, config: GeneralConfig, shutdown_event: threading.Event):
|
||||
self._config = config
|
||||
self._shutdown = shutdown_event
|
||||
self._rtl_tcp_proc: Optional[subprocess.Popen] = None
|
||||
self._rtlamr_proc: Optional[subprocess.Popen] = None
|
||||
self._output_queue: queue.Queue[str] = queue.Queue()
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def start(
|
||||
self,
|
||||
meter_ids: list[int],
|
||||
protocols: list[str],
|
||||
) -> bool:
|
||||
"""Start rtl_tcp and rtlamr. Returns True on success."""
|
||||
if not self._start_rtl_tcp():
|
||||
return False
|
||||
if not self._start_rtlamr(meter_ids, protocols):
|
||||
self._kill_process(self._rtl_tcp_proc, "rtl_tcp")
|
||||
self._rtl_tcp_proc = None
|
||||
return False
|
||||
self._start_reader_thread()
|
||||
self._consecutive_failures = 0
|
||||
return True
|
||||
|
||||
def start_discovery_mode(self) -> bool:
|
||||
"""Start in discovery mode: no meter ID filter, all protocols."""
|
||||
if not self._start_rtl_tcp():
|
||||
return False
|
||||
if not self._start_rtlamr(meter_ids=[], protocols=["all"]):
|
||||
self._kill_process(self._rtl_tcp_proc, "rtl_tcp")
|
||||
self._rtl_tcp_proc = None
|
||||
return False
|
||||
self._start_reader_thread()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""Stop all subprocesses and the reader thread."""
|
||||
self._kill_process(self._rtlamr_proc, "rtlamr")
|
||||
self._rtlamr_proc = None
|
||||
self._kill_process(self._rtl_tcp_proc, "rtl_tcp")
|
||||
self._rtl_tcp_proc = None
|
||||
if self._reader_thread and self._reader_thread.is_alive():
|
||||
self._reader_thread.join(timeout=5)
|
||||
if self._reader_thread.is_alive():
|
||||
logger.warning("Reader thread did not exit within timeout")
|
||||
self._reader_thread = None
|
||||
# Drain the output queue to prevent memory buildup.
|
||||
while not self._output_queue.empty():
|
||||
try:
|
||||
self._output_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def restart(
|
||||
self,
|
||||
meter_ids: list[int],
|
||||
protocols: list[str],
|
||||
) -> bool:
|
||||
"""Stop everything, wait, then restart."""
|
||||
self.stop()
|
||||
self._consecutive_failures += 1
|
||||
|
||||
if self._consecutive_failures >= MAX_FAST_RETRIES:
|
||||
delay = SLOW_RETRY_DELAY
|
||||
else:
|
||||
delay = FAST_RETRY_DELAY
|
||||
|
||||
logger.info(
|
||||
"Waiting %ds before restart (attempt %d)...",
|
||||
delay,
|
||||
self._consecutive_failures,
|
||||
)
|
||||
# Wait but check for shutdown periodically.
|
||||
if self._shutdown.wait(timeout=delay):
|
||||
return False # Shutdown requested during wait.
|
||||
|
||||
return self.start(meter_ids, protocols)
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if both subprocesses are still running."""
|
||||
return (
|
||||
self._rtl_tcp_proc is not None
|
||||
and self._rtl_tcp_proc.poll() is None
|
||||
and self._rtlamr_proc is not None
|
||||
and self._rtlamr_proc.poll() is None
|
||||
)
|
||||
|
||||
def get_line(self, timeout: float = 1.0) -> Optional[str]:
|
||||
"""Get the next line from rtlamr stdout (non-blocking)."""
|
||||
try:
|
||||
return self._output_queue.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _start_rtl_tcp(self) -> bool:
|
||||
"""Start the rtl_tcp server and wait for readiness."""
|
||||
cmd = [
|
||||
"rtl_tcp",
|
||||
"-a", self._config.rtl_tcp_host,
|
||||
"-p", str(self._config.rtl_tcp_port),
|
||||
"-d", self._config.device_id,
|
||||
]
|
||||
logger.info("Starting rtl_tcp: %s", " ".join(cmd))
|
||||
|
||||
try:
|
||||
self._rtl_tcp_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("rtl_tcp binary not found. Is rtl-sdr installed?")
|
||||
return False
|
||||
|
||||
# Wait for the "listening..." line that indicates readiness.
|
||||
# If we can't detect the marker, fall back to a simple delay
|
||||
# and check that the process is still alive.
|
||||
if self._wait_for_output(
|
||||
self._rtl_tcp_proc,
|
||||
"listening",
|
||||
RTL_TCP_STARTUP_TIMEOUT,
|
||||
"rtl_tcp",
|
||||
):
|
||||
return True
|
||||
|
||||
# Fallback: if process is still running, assume it started OK.
|
||||
# rtl_tcp may buffer its output or print to a different fd.
|
||||
if self._rtl_tcp_proc.poll() is None:
|
||||
logger.warning(
|
||||
"rtl_tcp did not print expected marker, but process is alive — continuing"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _start_rtlamr(
|
||||
self,
|
||||
meter_ids: list[int],
|
||||
protocols: list[str],
|
||||
) -> bool:
|
||||
"""Start rtlamr connected to the running rtl_tcp server."""
|
||||
msg_types = ",".join(sorted(set(p.lower() for p in protocols)))
|
||||
|
||||
cmd = [
|
||||
"rtlamr",
|
||||
"-format=json",
|
||||
f"-server={self._config.rtl_tcp_host}:{self._config.rtl_tcp_port}",
|
||||
f"-msgtype={msg_types}",
|
||||
"-unique",
|
||||
]
|
||||
|
||||
if meter_ids:
|
||||
cmd.append(f"-filterid={','.join(str(m) for m in meter_ids)}")
|
||||
|
||||
cmd.extend(self._config.rtlamr_extra_args)
|
||||
|
||||
logger.info("Starting rtlamr: %s", " ".join(cmd))
|
||||
|
||||
try:
|
||||
self._rtlamr_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("rtlamr binary not found. Is rtlamr installed?")
|
||||
return False
|
||||
|
||||
# Give rtlamr a moment to connect to rtl_tcp and start.
|
||||
time.sleep(2)
|
||||
if self._rtlamr_proc.poll() is not None:
|
||||
stderr = ""
|
||||
if self._rtlamr_proc.stderr:
|
||||
stderr = self._rtlamr_proc.stderr.read()
|
||||
logger.error("rtlamr exited immediately: %s", stderr)
|
||||
return False
|
||||
|
||||
logger.info("rtlamr started (PID %d)", self._rtlamr_proc.pid)
|
||||
return True
|
||||
|
||||
def _start_reader_thread(self):
|
||||
"""Start a background thread to read rtlamr stdout into the queue."""
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._stdout_reader,
|
||||
name="rtlamr-reader",
|
||||
daemon=True,
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _stdout_reader(self):
|
||||
"""Read rtlamr stdout line-by-line into the output queue."""
|
||||
try:
|
||||
for line in self._rtlamr_proc.stdout:
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
self._output_queue.put(stripped)
|
||||
if self._shutdown.is_set():
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
pass # Pipe closed during shutdown.
|
||||
finally:
|
||||
logger.debug("stdout reader thread exiting")
|
||||
|
||||
def _wait_for_output(
|
||||
self,
|
||||
proc: subprocess.Popen,
|
||||
marker: str,
|
||||
timeout: float,
|
||||
name: str,
|
||||
) -> bool:
|
||||
"""Wait for a specific marker string in a process's stdout.
|
||||
|
||||
Uses a background thread to avoid blocking on readline().
|
||||
"""
|
||||
found = threading.Event()
|
||||
lines_read: list[str] = []
|
||||
|
||||
def _reader():
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
lines_read.append(stripped)
|
||||
logger.debug("%s: %s", name, stripped)
|
||||
if marker.lower() in stripped.lower():
|
||||
found.set()
|
||||
return
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
reader = threading.Thread(target=_reader, daemon=True)
|
||||
reader.start()
|
||||
reader.join(timeout=timeout)
|
||||
|
||||
if found.is_set():
|
||||
logger.info("%s is ready", name)
|
||||
return True
|
||||
|
||||
if proc.poll() is not None:
|
||||
logger.error(
|
||||
"%s exited during startup. Output: %s",
|
||||
name,
|
||||
"; ".join(lines_read[-5:]) if lines_read else "(none)",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s: marker '%s' not seen within %ds. Output so far: %s",
|
||||
name, marker, int(timeout),
|
||||
"; ".join(lines_read[-5:]) if lines_read else "(none)",
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _kill_process(proc: Optional[subprocess.Popen], name: str):
|
||||
"""Reliably terminate a subprocess and its process group."""
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
logger.info("Sending SIGTERM to %s (pgid %d)", name, pgid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
logger.info("%s terminated cleanly", name)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"%s did not exit after SIGTERM, sending SIGKILL", name
|
||||
)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
proc.wait(timeout=3)
|
||||
except ProcessLookupError:
|
||||
pass # Already dead.
|
||||
except Exception as e:
|
||||
logger.error("Error killing %s: %s", name, e)
|
||||
18
hameter/web/__init__.py
Normal file
18
hameter/web/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Flask app factory for HAMeter web UI."""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from hameter.state import AppState
|
||||
|
||||
|
||||
def create_app(app_state: AppState) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(24))
|
||||
app.config["APP_STATE"] = app_state
|
||||
|
||||
from hameter.web.routes import bp
|
||||
app.register_blueprint(bp)
|
||||
|
||||
return app
|
||||
1051
hameter/web/routes.py
Normal file
1051
hameter/web/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
268
hameter/web/static/app.js
Normal file
268
hameter/web/static/app.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/* HAMeter Web UI - JavaScript */
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// SSE (Server-Sent Events) connection
|
||||
// ----------------------------------------------------------------
|
||||
let eventSource = null;
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/api/events');
|
||||
|
||||
eventSource.addEventListener('status', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
updateStatus(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('readings', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
updateReadings(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('costs', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
updateCosts(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('discovery', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
if (typeof window._onDiscoveryUpdate === 'function') {
|
||||
window._onDiscoveryUpdate(data);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('logs', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
if (typeof window._onLogUpdate === 'function') {
|
||||
window._onLogUpdate(data);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = function() {
|
||||
// Auto-reconnect is built into EventSource
|
||||
updateStatusDot('error');
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Status updates
|
||||
// ----------------------------------------------------------------
|
||||
function updateStatus(data) {
|
||||
updateStatusDot(data.status);
|
||||
|
||||
// Update sidebar status
|
||||
const statusText = document.querySelector('.status-text');
|
||||
if (statusText) {
|
||||
const labels = {
|
||||
'unconfigured': 'Not Configured',
|
||||
'stopped': 'Stopped',
|
||||
'starting': 'Starting...',
|
||||
'running': 'Running',
|
||||
'restarting': 'Restarting...',
|
||||
'discovery': 'Discovery Mode',
|
||||
'error': 'Error',
|
||||
};
|
||||
statusText.textContent = labels[data.status] || data.status;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusDot(status) {
|
||||
const dot = document.querySelector('.status-dot');
|
||||
if (dot) {
|
||||
dot.className = 'status-dot ' + status;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Readings updates (dashboard)
|
||||
// ----------------------------------------------------------------
|
||||
function updateReadings(readings) {
|
||||
for (const [meterId, data] of Object.entries(readings)) {
|
||||
const readingEl = document.querySelector('#reading-' + meterId + ' .reading-value');
|
||||
if (readingEl) {
|
||||
readingEl.textContent = formatNumber(data.calibrated_consumption);
|
||||
}
|
||||
|
||||
const rawEl = document.getElementById('raw-' + meterId);
|
||||
if (rawEl) {
|
||||
rawEl.textContent = formatNumber(data.raw_consumption);
|
||||
}
|
||||
|
||||
const lastSeenEl = document.getElementById('lastseen-' + meterId);
|
||||
if (lastSeenEl) {
|
||||
lastSeenEl.textContent = data.timestamp || '--';
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('count-' + meterId);
|
||||
if (countEl) {
|
||||
countEl.textContent = data.count || 0;
|
||||
}
|
||||
|
||||
// Update cost display if present
|
||||
const costEl = document.getElementById('cost-' + meterId);
|
||||
if (costEl && data.cumulative_cost !== undefined) {
|
||||
costEl.textContent = '$' + Number(data.cumulative_cost).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Cost updates (dashboard)
|
||||
// ----------------------------------------------------------------
|
||||
function updateCosts(costs) {
|
||||
for (const [meterId, data] of Object.entries(costs)) {
|
||||
const costEl = document.getElementById('cost-' + meterId);
|
||||
if (costEl) {
|
||||
costEl.textContent = '$' + Number(data.cumulative_cost).toFixed(2);
|
||||
}
|
||||
|
||||
const billingStartEl = document.getElementById('billing-start-' + meterId);
|
||||
if (billingStartEl) {
|
||||
billingStartEl.textContent = data.billing_period_start || '--';
|
||||
}
|
||||
|
||||
const fixedEl = document.getElementById('fixed-charges-' + meterId);
|
||||
if (fixedEl) {
|
||||
fixedEl.textContent = '$' + Number(data.fixed_charges_applied).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n === null || n === undefined) return '--';
|
||||
return Number(n).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 4,
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Toast notifications
|
||||
// ----------------------------------------------------------------
|
||||
function showToast(message, type) {
|
||||
type = type || 'info';
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(function() {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(function() { toast.remove(); }, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Restart banner
|
||||
// ----------------------------------------------------------------
|
||||
function showRestartBanner() {
|
||||
const banner = document.getElementById('restart-banner');
|
||||
if (banner) {
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
// Create one dynamically if not on a page with one
|
||||
showToast('Pipeline restart required to apply changes', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pipeline control
|
||||
// ----------------------------------------------------------------
|
||||
function restartPipeline() {
|
||||
if (!confirm('Restart the pipeline? Meter monitoring will briefly pause.')) return;
|
||||
|
||||
fetch('/api/pipeline/restart', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
showToast('Pipeline restarting...', 'info');
|
||||
// Hide restart banner if visible
|
||||
const banner = document.getElementById('restart-banner');
|
||||
if (banner) banner.classList.add('hidden');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
showToast('Failed to restart: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Cost actions
|
||||
// ----------------------------------------------------------------
|
||||
function resetBillingPeriod(meterId) {
|
||||
if (!confirm('Reset the billing period for this meter? Cost will be set to $0.00.')) return;
|
||||
|
||||
fetch('/api/costs/' + meterId + '/reset', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
showToast('Billing period reset', 'success');
|
||||
var costEl = document.getElementById('cost-' + meterId);
|
||||
if (costEl) costEl.textContent = '$0.00';
|
||||
} else {
|
||||
showToast(data.error || 'Reset failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
showToast('Failed: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function addFixedCharges(meterId) {
|
||||
if (!confirm('Add fixed charges to this meter?')) return;
|
||||
|
||||
fetch('/api/costs/' + meterId + '/add-fixed', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
showToast('Added $' + Number(data.fixed_added).toFixed(2) + ' fixed charges', 'success');
|
||||
var costEl = document.getElementById('cost-' + meterId);
|
||||
if (costEl) costEl.textContent = '$' + Number(data.cumulative_cost).toFixed(2);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to add charges', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
showToast('Failed: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Sidebar toggle (mobile)
|
||||
// ----------------------------------------------------------------
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('open');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Password toggle
|
||||
// ----------------------------------------------------------------
|
||||
function togglePassword(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Init
|
||||
// ----------------------------------------------------------------
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only connect SSE on pages with the sidebar (not setup page)
|
||||
if (document.querySelector('.sidebar')) {
|
||||
connectSSE();
|
||||
}
|
||||
});
|
||||
46
hameter/web/static/favicon.svg
Normal file
46
hameter/web/static/favicon.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
|
||||
<!-- Background: black rounded square -->
|
||||
<rect width="96" height="96" rx="18" ry="18" fill="#000000"/>
|
||||
|
||||
<!-- Radio waves - left side -->
|
||||
<path d="M 22 30 A 18 18 0 0 0 22 46" fill="none" stroke="#5cbf2a" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M 16 27 A 24 24 0 0 0 16 49" fill="none" stroke="#5cbf2a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
|
||||
<!-- Radio waves - right side -->
|
||||
<path d="M 74 30 A 18 18 0 0 1 74 46" fill="none" stroke="#5cbf2a" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M 80 27 A 24 24 0 0 1 80 49" fill="none" stroke="#5cbf2a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
|
||||
<!-- Meter body - circular -->
|
||||
<circle cx="48" cy="48" r="24" fill="#5cbf2a"/>
|
||||
|
||||
<!-- Meter face - dark inner circle -->
|
||||
<circle cx="48" cy="46" r="18" fill="#1a2a1a"/>
|
||||
|
||||
<!-- Digital readout screen border -->
|
||||
<rect x="30" y="38" width="36" height="14" rx="2" ry="2" fill="#1a2a1a" stroke="#5cbf2a" stroke-width="1.5"/>
|
||||
|
||||
<!-- Digital readout digits (5 zeros) -->
|
||||
<!-- Digit 1 -->
|
||||
<rect x="33" y="40.5" width="4.5" height="9" rx="1" ry="1" fill="none" stroke="#5cbf2a" stroke-width="1" opacity="0.9"/>
|
||||
<!-- Digit 2 -->
|
||||
<rect x="39.5" y="40.5" width="4.5" height="9" rx="1" ry="1" fill="none" stroke="#5cbf2a" stroke-width="1" opacity="0.9"/>
|
||||
<!-- Digit 3 -->
|
||||
<rect x="46" y="40.5" width="4.5" height="9" rx="1" ry="1" fill="none" stroke="#5cbf2a" stroke-width="1" opacity="0.9"/>
|
||||
<!-- Digit 4 -->
|
||||
<rect x="52.5" y="40.5" width="4.5" height="9" rx="1" ry="1" fill="none" stroke="#5cbf2a" stroke-width="1" opacity="0.9"/>
|
||||
<!-- Digit 5 -->
|
||||
<rect x="59" y="40.5" width="4.5" height="9" rx="1" ry="1" fill="none" stroke="#5cbf2a" stroke-width="1" opacity="0.9"/>
|
||||
|
||||
<!-- Small indicator blocks below readout -->
|
||||
<rect x="35" y="55" width="6" height="3" rx="0.5" fill="#5cbf2a" opacity="0.5"/>
|
||||
<rect x="45" y="55" width="6" height="3" rx="0.5" fill="#5cbf2a" opacity="0.5"/>
|
||||
<rect x="55" y="55" width="6" height="3" rx="0.5" fill="#5cbf2a" opacity="0.5"/>
|
||||
|
||||
<!-- Base/bottom of meter -->
|
||||
<path d="M 32 68 Q 32 72 36 74 L 60 74 Q 64 72 64 68 Z" fill="#5cbf2a"/>
|
||||
<circle cx="48" cy="71" r="2" fill="#1a2a1a"/>
|
||||
|
||||
<!-- Bottom screws -->
|
||||
<circle cx="38" cy="78" r="1.5" fill="#3a3a3a"/>
|
||||
<circle cx="58" cy="78" r="1.5" fill="#3a3a3a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
759
hameter/web/static/style.css
Normal file
759
hameter/web/static/style.css
Normal file
@@ -0,0 +1,759 @@
|
||||
/* HAMeter Web UI Styles */
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #1e293b;
|
||||
--bg-input: #0f172a;
|
||||
--bg-sidebar: #0f172a;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--danger: #dc2626;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.running { background: var(--success); }
|
||||
.status-dot.starting, .status-dot.restarting { background: var(--warning); }
|
||||
.status-dot.error { background: var(--error); }
|
||||
.status-dot.stopped, .status-dot.unconfigured { background: var(--text-muted); }
|
||||
.status-dot.discovery { background: var(--accent); }
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.nav-separator {
|
||||
padding: 1rem 1.25rem 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.top-bar .page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-secondary { background: var(--bg-card); color: var(--text-primary); border-color: var(--border); }
|
||||
.btn-secondary:hover { background: var(--border); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
.btn-link { background: none; border: none; color: var(--accent); text-decoration: underline; }
|
||||
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; }
|
||||
.btn-lg { padding: 0.75rem 2rem; font-size: 1rem; }
|
||||
.btn-icon { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
||||
.btn-group { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* Forms */
|
||||
.config-form-container {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="password"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group input[readonly] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group .required { color: var(--error); }
|
||||
|
||||
.form-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-inline label { margin-bottom: 0; }
|
||||
.form-inline input { width: auto; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group input { flex: 1; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-protocol {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.badge-cost {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--success, #22c55e);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Meter cards */
|
||||
.meter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meter-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.meter-card-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meter-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.meter-reading {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.reading-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reading-unit {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.meter-details {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label { color: var(--text-muted); }
|
||||
.detail-value { color: var(--text-secondary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Flash / toast messages */
|
||||
.flash {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flash-success { background: rgba(34, 197, 94, 0.15); border: 1px solid var(--success); color: var(--success); }
|
||||
.flash-error { background: rgba(239, 68, 68, 0.15); border: 1px solid var(--error); color: var(--error); }
|
||||
.flash-warning { background: rgba(245, 158, 11, 0.15); border: 1px solid var(--warning); color: var(--warning); }
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success { background: var(--success); color: #fff; }
|
||||
.toast-error { background: var(--error); color: #fff; }
|
||||
.toast-info { background: var(--accent); color: #fff; }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Restart banner */
|
||||
.restart-banner {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid var(--warning);
|
||||
color: var(--warning);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Test result */
|
||||
.test-result {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.test-success { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.test-error { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state p { margin-bottom: 1rem; }
|
||||
|
||||
/* Log viewer */
|
||||
.log-viewer {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-line { padding: 1px 0; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-ts { color: var(--text-muted); }
|
||||
.log-level { font-weight: 600; }
|
||||
.log-name { color: var(--text-muted); }
|
||||
.log-debug .log-level { color: var(--text-muted); }
|
||||
.log-info .log-level { color: var(--text-primary); }
|
||||
.log-warning .log-level { color: var(--warning); }
|
||||
.log-error .log-level { color: var(--error); }
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.log-controls select,
|
||||
.log-controls input[type="text"] {
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Discovery page */
|
||||
.discovery-info {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.discovery-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.discovery-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Calibration */
|
||||
.calibration-info {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.calibration-result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.result-item { text-align: center; }
|
||||
.result-label { display: block; font-size: 0.78rem; color: var(--text-muted); margin-bottom: 0.25rem; }
|
||||
.result-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Setup page */
|
||||
.setup-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.setup-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-card h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.setup-card p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.setup-card .form-group {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.setup-card .form-actions {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-logo {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.setup-success {
|
||||
font-size: 3rem;
|
||||
color: var(--success);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setup-error {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden { display: none !important; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
code {
|
||||
background: var(--bg-input);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Cost factor editor */
|
||||
.cost-factor-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cost-factor-row .cf-name {
|
||||
flex: 2;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cost-factor-row .cf-rate {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cost-factor-row .cf-type {
|
||||
width: 100px;
|
||||
padding: 0.4rem 0.4rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Cost display on dashboard */
|
||||
.meter-cost {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--success);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cost-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cost-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.discovery-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
60
hameter/web/templates/base.html
Normal file
60
hameter/web/templates/base.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}HAMeter{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('main.static', filename='style.css') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('main.static', filename='favicon.svg') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">HAMeter</h1>
|
||||
<div class="status-indicator" id="pipeline-status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}"><span class="nav-icon">◇</span> Dashboard</a></li>
|
||||
<li><a href="/discovery" class="nav-link {% if request.path == '/discovery' %}active{% endif %}"><span class="nav-icon">◎</span> Discovery</a></li>
|
||||
<li><a href="/calibration" class="nav-link {% if request.path == '/calibration' %}active{% endif %}"><span class="nav-icon">⚖</span> Calibration</a></li>
|
||||
<li class="nav-separator">Configuration</li>
|
||||
<li><a href="/config/mqtt" class="nav-link {% if '/config/mqtt' in request.path %}active{% endif %}"><span class="nav-icon">⇄</span> MQTT</a></li>
|
||||
<li><a href="/config/meters" class="nav-link {% if '/config/meters' in request.path %}active{% endif %}"><span class="nav-icon">◑</span> Meters</a></li>
|
||||
<li><a href="/config/general" class="nav-link {% if '/config/general' in request.path %}active{% endif %}"><span class="nav-icon">⚙</span> General</a></li>
|
||||
<li class="nav-separator">System</li>
|
||||
<li><a href="/logs" class="nav-link {% if request.path == '/logs' %}active{% endif %}"><span class="nav-icon">☰</span> Logs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<h2 class="page-title">{% block page_title %}{% endblock %}</h2>
|
||||
<div class="top-bar-actions">
|
||||
{% block top_actions %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-area">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="{{ url_for('main.static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
146
hameter/web/templates/calibration.html
Normal file
146
hameter/web/templates/calibration.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Calibration - HAMeter{% endblock %}
|
||||
{% block page_title %}Calibration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="config-form-container">
|
||||
{% if not meters %}
|
||||
<div class="empty-state">
|
||||
<p>No meters configured. <a href="/config/meters/add">Add a meter</a> first.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="calibration-info">
|
||||
<p>Calibrate the multiplier that converts raw meter readings to actual units (kWh, gallons, etc.).</p>
|
||||
<p><strong>How:</strong> Read the display on your physical meter, enter it below along with the current raw reading from HAMeter, and the multiplier will be calculated automatically.</p>
|
||||
</div>
|
||||
|
||||
<form id="calibration-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="cal-meter">Select Meter</label>
|
||||
<select id="cal-meter" onchange="onMeterSelect()">
|
||||
{% for meter in meters %}
|
||||
<option value="{{ meter.id }}"
|
||||
data-unit="{{ meter.unit_of_measurement }}"
|
||||
data-multiplier="{{ meter.multiplier }}"
|
||||
data-raw="{{ readings.get(meter.id, '')|attr('raw_consumption') if readings.get(meter.id) else '' }}">
|
||||
{{ meter.name }} ({{ meter.id }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-raw">Current Raw Reading (from HAMeter)</label>
|
||||
<input type="number" id="cal-raw" step="any">
|
||||
<small>This is the raw, uncalibrated value. You can find it on the Dashboard.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cal-physical">Current Physical Meter Reading</label>
|
||||
<input type="number" id="cal-physical" step="any">
|
||||
<small>Read this from the display on your physical meter. <span id="cal-unit-hint"></span></small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="calculate()">Calculate</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="calibration-result hidden" id="cal-result">
|
||||
<h3>Result</h3>
|
||||
<div class="result-grid">
|
||||
<div class="result-item">
|
||||
<span class="result-label">New Multiplier</span>
|
||||
<span class="result-value" id="cal-new-multiplier">--</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span class="result-label">Current Multiplier</span>
|
||||
<span class="result-value" id="cal-current-multiplier">--</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span class="result-label">Preview</span>
|
||||
<span class="result-value" id="cal-preview">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="applyMultiplier()">Apply Multiplier</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedMeterId = null;
|
||||
let calculatedMultiplier = null;
|
||||
|
||||
function onMeterSelect() {
|
||||
const sel = document.getElementById('cal-meter');
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
selectedMeterId = parseInt(sel.value);
|
||||
const raw = opt.dataset.raw;
|
||||
if (raw) document.getElementById('cal-raw').value = raw;
|
||||
document.getElementById('cal-current-multiplier').textContent = opt.dataset.multiplier;
|
||||
const unit = opt.dataset.unit;
|
||||
document.getElementById('cal-unit-hint').textContent = unit ? '(in ' + unit + ')' : '';
|
||||
}
|
||||
|
||||
async function calculate() {
|
||||
const raw = parseFloat(document.getElementById('cal-raw').value);
|
||||
const physical = parseFloat(document.getElementById('cal-physical').value);
|
||||
if (!raw || !physical) {
|
||||
showToast('Enter both values', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/calibration/calculate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({raw_reading: raw, physical_reading: physical}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
showToast(data.error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
calculatedMultiplier = data.multiplier;
|
||||
document.getElementById('cal-new-multiplier').textContent = data.multiplier;
|
||||
document.getElementById('cal-preview').textContent = data.preview;
|
||||
document.getElementById('cal-result').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function applyMultiplier() {
|
||||
if (!selectedMeterId || !calculatedMultiplier) return;
|
||||
const resp = await fetch('/api/calibration/apply', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({meter_id: selectedMeterId, multiplier: calculatedMultiplier}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
showToast('Multiplier applied', 'success');
|
||||
if (data.restart_required) showRestartBanner();
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select meter from query param or default to first
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const meterId = params.get('meter');
|
||||
if (meterId) {
|
||||
const sel = document.getElementById('cal-meter');
|
||||
if (sel) {
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === meterId) {
|
||||
sel.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onMeterSelect();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
hameter/web/templates/dashboard.html
Normal file
77
hameter/web/templates/dashboard.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - HAMeter{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
|
||||
{% block top_actions %}
|
||||
<button class="btn btn-secondary btn-sm" onclick="restartPipeline()">Restart Pipeline</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
{% if meters %}
|
||||
<div class="meter-grid" id="meter-grid">
|
||||
{% for meter in meters %}
|
||||
<div class="meter-card" id="meter-card-{{ meter.id }}" data-meter-id="{{ meter.id }}">
|
||||
<div class="meter-card-header">
|
||||
<h3>{{ meter.name }}</h3>
|
||||
<span class="badge badge-protocol">{{ meter.protocol|upper }}</span>
|
||||
</div>
|
||||
<div class="meter-card-body">
|
||||
<div class="meter-reading" id="reading-{{ meter.id }}">
|
||||
<span class="reading-value">--</span>
|
||||
<span class="reading-unit">{{ meter.unit_of_measurement }}</span>
|
||||
</div>
|
||||
{% if meter.cost_factors %}
|
||||
<div class="meter-cost" id="cost-section-{{ meter.id }}">
|
||||
<div class="cost-value" id="cost-{{ meter.id }}">$--</div>
|
||||
<div class="cost-label">Estimated Cost</div>
|
||||
<div class="cost-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Billing Start</span>
|
||||
<span class="detail-value" id="billing-start-{{ meter.id }}">--</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Fixed Charges</span>
|
||||
<span class="detail-value" id="fixed-charges-{{ meter.id }}">$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cost-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="addFixedCharges({{ meter.id }})">Add Fixed Charges</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="resetBillingPeriod({{ meter.id }})">Reset Period</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="meter-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Raw Reading</span>
|
||||
<span class="detail-value" id="raw-{{ meter.id }}">--</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Last Seen</span>
|
||||
<span class="detail-value" id="lastseen-{{ meter.id }}">--</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Readings</span>
|
||||
<span class="detail-value" id="count-{{ meter.id }}">0</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Meter ID</span>
|
||||
<span class="detail-value">{{ meter.id }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Multiplier</span>
|
||||
<span class="detail-value">{{ meter.multiplier }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No meters configured.</p>
|
||||
<p><a href="/discovery" class="btn btn-primary">Run Discovery</a> or <a href="/config/meters/add" class="btn btn-secondary">Add Meter Manually</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
119
hameter/web/templates/discovery.html
Normal file
119
hameter/web/templates/discovery.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Discovery - HAMeter{% endblock %}
|
||||
{% block page_title %}Discovery{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="discovery-page">
|
||||
<div class="discovery-info">
|
||||
<p>Discovery mode listens for all nearby meter transmissions. This will <strong>temporarily stop</strong> meter monitoring while active.</p>
|
||||
</div>
|
||||
|
||||
<div class="discovery-controls">
|
||||
<div class="form-group form-inline">
|
||||
<label for="duration">Duration (seconds)</label>
|
||||
<input type="number" id="duration" value="120" min="10" max="600">
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="btn-start" onclick="startDiscovery()">Start Discovery</button>
|
||||
<button class="btn btn-danger hidden" id="btn-stop" onclick="stopDiscovery()">Stop</button>
|
||||
</div>
|
||||
<div class="discovery-status hidden" id="discovery-status">
|
||||
<span class="spinner-sm"></span>
|
||||
<span id="discovery-timer">Listening...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="discovery-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Meter ID</th>
|
||||
<th>Protocol</th>
|
||||
<th>Count</th>
|
||||
<th>Last Reading</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="discovery-tbody">
|
||||
<tr class="empty-row"><td colspan="5">No meters found yet. Start discovery to scan.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let discoveryTimer = null;
|
||||
let discoveryStart = null;
|
||||
let discoveryDuration = 120;
|
||||
const configuredIds = new Set({{ configured_ids|tojson }});
|
||||
|
||||
function startDiscovery() {
|
||||
discoveryDuration = parseInt(document.getElementById('duration').value) || 120;
|
||||
fetch('/api/discovery/start', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({duration: discoveryDuration}),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.ok) {
|
||||
discoveryStart = Date.now();
|
||||
document.getElementById('btn-start').classList.add('hidden');
|
||||
document.getElementById('btn-stop').classList.remove('hidden');
|
||||
document.getElementById('discovery-status').classList.remove('hidden');
|
||||
document.getElementById('discovery-tbody').innerHTML =
|
||||
'<tr class="empty-row"><td colspan="5">Listening for meters...</td></tr>';
|
||||
discoveryTimer = setInterval(updateTimer, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopDiscovery() {
|
||||
fetch('/api/discovery/stop', {method: 'POST'});
|
||||
endDiscovery();
|
||||
}
|
||||
|
||||
function endDiscovery() {
|
||||
clearInterval(discoveryTimer);
|
||||
document.getElementById('btn-start').classList.remove('hidden');
|
||||
document.getElementById('btn-stop').classList.add('hidden');
|
||||
document.getElementById('discovery-status').classList.add('hidden');
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const elapsed = Math.floor((Date.now() - discoveryStart) / 1000);
|
||||
const remaining = discoveryDuration - elapsed;
|
||||
if (remaining <= 0) {
|
||||
endDiscovery();
|
||||
return;
|
||||
}
|
||||
document.getElementById('discovery-timer').textContent =
|
||||
'Listening... ' + remaining + 's remaining';
|
||||
}
|
||||
|
||||
// SSE handler updates discovery results
|
||||
if (typeof window._onDiscoveryUpdate === 'undefined') {
|
||||
window._onDiscoveryUpdate = function(results) {
|
||||
const tbody = document.getElementById('discovery-tbody');
|
||||
if (!tbody) return;
|
||||
if (!results || Object.keys(results).length === 0) return;
|
||||
|
||||
let html = '';
|
||||
// Sort by count descending
|
||||
const entries = Object.entries(results).sort((a, b) => (b[1].count || 0) - (a[1].count || 0));
|
||||
for (const [mid, info] of entries) {
|
||||
const meterId = parseInt(mid);
|
||||
const configured = configuredIds.has(meterId);
|
||||
html += '<tr>' +
|
||||
'<td><code>' + mid + '</code></td>' +
|
||||
'<td><span class="badge badge-protocol">' + (info.protocol || '').toUpperCase() + '</span></td>' +
|
||||
'<td>' + (info.count || 0) + '</td>' +
|
||||
'<td>' + (info.last_consumption || '--') + '</td>' +
|
||||
'<td>' + (configured
|
||||
? '<span class="text-muted">Configured</span>'
|
||||
: '<a href="/config/meters/add?id=' + mid + '&protocol=' + (info.protocol || '') + '" class="btn btn-sm btn-primary">Add</a>'
|
||||
) + '</td></tr>';
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
66
hameter/web/templates/general.html
Normal file
66
hameter/web/templates/general.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}General Settings - HAMeter{% endblock %}
|
||||
{% block page_title %}General Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="config-form-container">
|
||||
<form id="general-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="log_level">Log Level</label>
|
||||
<select id="log_level">
|
||||
<option value="DEBUG" {{ 'selected' if general.log_level == 'DEBUG' }}>DEBUG</option>
|
||||
<option value="INFO" {{ 'selected' if general.log_level == 'INFO' }}>INFO</option>
|
||||
<option value="WARNING" {{ 'selected' if general.log_level == 'WARNING' }}>WARNING</option>
|
||||
<option value="ERROR" {{ 'selected' if general.log_level == 'ERROR' }}>ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device_id">SDR Device Index</label>
|
||||
<input type="text" id="device_id" value="{{ general.device_id }}">
|
||||
<small>Usually "0" unless you have multiple RTL-SDR dongles.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rtl_tcp_host">rtl_tcp Host</label>
|
||||
<input type="text" id="rtl_tcp_host" value="{{ general.rtl_tcp_host }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rtl_tcp_port">rtl_tcp Port</label>
|
||||
<input type="number" id="rtl_tcp_port" value="{{ general.rtl_tcp_port }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rtlamr_extra_args">rtlamr Extra Arguments</label>
|
||||
<input type="text" id="rtlamr_extra_args" value="{{ general.rtlamr_extra_args|join(' ') }}" placeholder="Space-separated arguments">
|
||||
<small>Additional command-line arguments passed to rtlamr.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveGeneral()">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function saveGeneral() {
|
||||
const data = {
|
||||
log_level: document.getElementById('log_level').value,
|
||||
device_id: document.getElementById('device_id').value,
|
||||
rtl_tcp_host: document.getElementById('rtl_tcp_host').value,
|
||||
rtl_tcp_port: parseInt(document.getElementById('rtl_tcp_port').value),
|
||||
rtlamr_extra_args: document.getElementById('rtlamr_extra_args').value,
|
||||
};
|
||||
|
||||
const resp = await fetch('/api/config/general', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.ok) {
|
||||
showToast('Settings saved', 'success');
|
||||
if (res.restart_required) showRestartBanner();
|
||||
} else {
|
||||
showToast(res.error || 'Save failed', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
84
hameter/web/templates/logs.html
Normal file
84
hameter/web/templates/logs.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs - HAMeter{% endblock %}
|
||||
{% block page_title %}Logs{% endblock %}
|
||||
|
||||
{% block top_actions %}
|
||||
<div class="log-controls">
|
||||
<select id="log-level-filter" onchange="filterLogs()">
|
||||
<option value="">All Levels</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<input type="text" id="log-search" placeholder="Filter..." oninput="filterLogs()">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="log-autoscroll" checked> Auto-scroll
|
||||
</label>
|
||||
<button class="btn btn-sm btn-secondary" onclick="clearLogs()">Clear</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="log-viewer" id="log-viewer"></div>
|
||||
|
||||
<script>
|
||||
const logViewer = document.getElementById('log-viewer');
|
||||
let allLogs = [];
|
||||
|
||||
// Load initial logs
|
||||
fetch('/api/logs?count=500')
|
||||
.then(r => r.json())
|
||||
.then(logs => {
|
||||
allLogs = logs;
|
||||
renderLogs();
|
||||
});
|
||||
|
||||
function addLogEntry(entry) {
|
||||
allLogs.push(entry);
|
||||
if (allLogs.length > 2000) allLogs = allLogs.slice(-1000);
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const level = document.getElementById('log-level-filter').value;
|
||||
const search = document.getElementById('log-search').value.toLowerCase();
|
||||
|
||||
const filtered = allLogs.filter(log => {
|
||||
if (level && log.level !== level) return false;
|
||||
if (search && !log.message.toLowerCase().includes(search) && !log.name.toLowerCase().includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
logViewer.innerHTML = filtered.map(log => {
|
||||
const cls = 'log-' + (log.level || 'INFO').toLowerCase();
|
||||
return '<div class="log-line ' + cls + '">' +
|
||||
'<span class="log-ts">' + (log.timestamp || '') + '</span> ' +
|
||||
'<span class="log-level">[' + (log.level || '?') + ']</span> ' +
|
||||
'<span class="log-name">' + (log.name || '') + ':</span> ' +
|
||||
'<span class="log-msg">' + escapeHtml(log.message || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (document.getElementById('log-autoscroll').checked) {
|
||||
logViewer.scrollTop = logViewer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function filterLogs() { renderLogs(); }
|
||||
function clearLogs() { allLogs = []; renderLogs(); }
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// SSE log handler
|
||||
if (typeof window._onLogUpdate === 'undefined') {
|
||||
window._onLogUpdate = function(logs) {
|
||||
if (Array.isArray(logs)) {
|
||||
logs.forEach(addLogEntry);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
160
hameter/web/templates/meter_form.html
Normal file
160
hameter/web/templates/meter_form.html
Normal file
@@ -0,0 +1,160 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit' if editing else 'Add' }} Meter - HAMeter{% endblock %}
|
||||
{% block page_title %}{{ 'Edit' if editing else 'Add' }} Meter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="config-form-container">
|
||||
<form id="meter-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="meter-id">Meter ID (ERT Serial Number) <span class="required">*</span></label>
|
||||
<input type="number" id="meter-id" value="{{ meter.id if meter else prefill_id|default('', true) }}" {{ 'readonly' if editing }} required>
|
||||
{% if editing %}<small>Meter ID cannot be changed after creation.</small>{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-protocol">Protocol <span class="required">*</span></label>
|
||||
<select id="meter-protocol">
|
||||
{% for p in protocols %}
|
||||
<option value="{{ p }}" {{ 'selected' if (meter and meter.protocol == p) or (not meter and prefill_protocol|default('', true) == p) }}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-name">Name <span class="required">*</span></label>
|
||||
<input type="text" id="meter-name" value="{{ meter.name if meter else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-device-class">Device Class</label>
|
||||
<select id="meter-device-class" onchange="onDeviceClassChange()">
|
||||
<option value="" {{ 'selected' if not meter or not meter.device_class }}>None</option>
|
||||
<option value="energy" {{ 'selected' if meter and meter.device_class == 'energy' }}>Energy (Electric)</option>
|
||||
<option value="gas" {{ 'selected' if meter and meter.device_class == 'gas' }}>Gas</option>
|
||||
<option value="water" {{ 'selected' if meter and meter.device_class == 'water' }}>Water</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-unit">Unit of Measurement</label>
|
||||
<input type="text" id="meter-unit" value="{{ meter.unit_of_measurement if meter else '' }}" placeholder="Auto-set by device class">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-icon">Icon</label>
|
||||
<input type="text" id="meter-icon" value="{{ meter.icon if meter else '' }}" placeholder="e.g. mdi:flash">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-state-class">State Class</label>
|
||||
<select id="meter-state-class">
|
||||
<option value="total_increasing" {{ 'selected' if not meter or meter.state_class == 'total_increasing' }}>total_increasing</option>
|
||||
<option value="measurement" {{ 'selected' if meter and meter.state_class == 'measurement' }}>measurement</option>
|
||||
<option value="total" {{ 'selected' if meter and meter.state_class == 'total' }}>total</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-multiplier">Calibration Multiplier</label>
|
||||
<input type="number" id="meter-multiplier" step="any" value="{{ meter.multiplier if meter else '1.0' }}">
|
||||
<small>Use <a href="/calibration">Calibration</a> to calculate this value.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Rate Components (Cost Factors)</label>
|
||||
<small>Add rate components from your utility bill to track costs.</small>
|
||||
<div id="cost-factors-list">
|
||||
{% if meter and meter.cost_factors %}
|
||||
{% for cf in meter.cost_factors %}
|
||||
<div class="cost-factor-row" data-index="{{ loop.index0 }}">
|
||||
<input type="text" class="cf-name" value="{{ cf.name }}" placeholder="Name (e.g. Generation)">
|
||||
<input type="number" class="cf-rate" step="any" value="{{ cf.rate }}" placeholder="Rate">
|
||||
<select class="cf-type">
|
||||
<option value="per_unit" {{ 'selected' if cf.type == 'per_unit' }}>Per Unit</option>
|
||||
<option value="fixed" {{ 'selected' if cf.type == 'fixed' }}>Fixed</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-icon" onclick="removeCostFactor(this)" title="Remove">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addCostFactor()" style="margin-top: 0.5rem;">+ Add Rate Component</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/config/meters" class="btn btn-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMeter()">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function onDeviceClassChange() {
|
||||
const dc = document.getElementById('meter-device-class').value;
|
||||
if (!dc) return;
|
||||
fetch('/api/meter_defaults/' + dc)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.unit) document.getElementById('meter-unit').value = data.unit;
|
||||
if (data.icon) document.getElementById('meter-icon').value = data.icon;
|
||||
});
|
||||
}
|
||||
|
||||
function addCostFactor(name, rate, type) {
|
||||
const list = document.getElementById('cost-factors-list');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cost-factor-row';
|
||||
row.innerHTML =
|
||||
'<input type="text" class="cf-name" value="' + (name || '') + '" placeholder="Name (e.g. Generation)">' +
|
||||
'<input type="number" class="cf-rate" step="any" value="' + (rate || '') + '" placeholder="Rate">' +
|
||||
'<select class="cf-type">' +
|
||||
'<option value="per_unit"' + (type === 'fixed' ? '' : ' selected') + '>Per Unit</option>' +
|
||||
'<option value="fixed"' + (type === 'fixed' ? ' selected' : '') + '>Fixed</option>' +
|
||||
'</select>' +
|
||||
'<button type="button" class="btn btn-icon" onclick="removeCostFactor(this)" title="Remove">×</button>';
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function removeCostFactor(btn) {
|
||||
btn.closest('.cost-factor-row').remove();
|
||||
}
|
||||
|
||||
function collectCostFactors() {
|
||||
const rows = document.querySelectorAll('.cost-factor-row');
|
||||
const factors = [];
|
||||
rows.forEach(function(row) {
|
||||
const name = row.querySelector('.cf-name').value.trim();
|
||||
const rate = parseFloat(row.querySelector('.cf-rate').value);
|
||||
const type = row.querySelector('.cf-type').value;
|
||||
if (name && !isNaN(rate)) {
|
||||
factors.push({ name: name, rate: rate, type: type });
|
||||
}
|
||||
});
|
||||
return factors;
|
||||
}
|
||||
|
||||
async function saveMeter() {
|
||||
const data = {
|
||||
id: parseInt(document.getElementById('meter-id').value),
|
||||
protocol: document.getElementById('meter-protocol').value,
|
||||
name: document.getElementById('meter-name').value,
|
||||
device_class: document.getElementById('meter-device-class').value,
|
||||
unit_of_measurement: document.getElementById('meter-unit').value,
|
||||
icon: document.getElementById('meter-icon').value,
|
||||
state_class: document.getElementById('meter-state-class').value,
|
||||
multiplier: parseFloat(document.getElementById('meter-multiplier').value) || 1.0,
|
||||
cost_factors: collectCostFactors(),
|
||||
};
|
||||
|
||||
const editing = {{ 'true' if editing else 'false' }};
|
||||
const url = editing ? '/api/config/meters/' + data.id : '/api/config/meters';
|
||||
const method = editing ? 'PUT' : 'POST';
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.ok) {
|
||||
showToast('Meter saved', 'success');
|
||||
setTimeout(() => window.location.href = '/config/meters', 500);
|
||||
} else {
|
||||
showToast(res.error || 'Save failed', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
70
hameter/web/templates/meters.html
Normal file
70
hameter/web/templates/meters.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meters - HAMeter{% endblock %}
|
||||
{% block page_title %}Meters{% endblock %}
|
||||
|
||||
{% block top_actions %}
|
||||
<a href="/config/meters/add" class="btn btn-primary btn-sm">Add Meter</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="restart-banner hidden" id="restart-banner">
|
||||
Pipeline restart required to apply changes.
|
||||
<button class="btn btn-primary btn-sm" onclick="restartPipeline()">Restart Now</button>
|
||||
</div>
|
||||
|
||||
{% if meters %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th>Protocol</th>
|
||||
<th>Type</th>
|
||||
<th>Unit</th>
|
||||
<th>Multiplier</th>
|
||||
<th>Cost Tracking</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for meter in meters %}
|
||||
<tr>
|
||||
<td>{{ meter.name }}</td>
|
||||
<td><code>{{ meter.id }}</code></td>
|
||||
<td><span class="badge badge-protocol">{{ meter.protocol|upper }}</span></td>
|
||||
<td>{{ meter.device_class or '--' }}</td>
|
||||
<td>{{ meter.unit_of_measurement }}</td>
|
||||
<td>{{ meter.multiplier }}</td>
|
||||
<td>{% if meter.cost_factors %}<span class="badge badge-cost">{{ meter.cost_factors|length }} rate{{ 's' if meter.cost_factors|length != 1 }}</span>{% else %}--{% endif %}</td>
|
||||
<td class="actions-cell">
|
||||
<a href="/config/meters/{{ meter.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>
|
||||
<a href="/calibration?meter={{ meter.id }}" class="btn btn-sm btn-secondary">Calibrate</a>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteMeter({{ meter.id }}, '{{ meter.name }}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No meters configured.</p>
|
||||
<p><a href="/config/meters/add" class="btn btn-primary">Add Meter</a> or <a href="/discovery" class="btn btn-secondary">Run Discovery</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function deleteMeter(id, name) {
|
||||
if (!confirm('Delete meter "' + name + '" (ID: ' + id + ')?')) return;
|
||||
const resp = await fetch('/api/config/meters/' + id, {method: 'DELETE'});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
showToast('Meter deleted', 'success');
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showToast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
113
hameter/web/templates/mqtt.html
Normal file
113
hameter/web/templates/mqtt.html
Normal file
@@ -0,0 +1,113 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}MQTT Settings - HAMeter{% endblock %}
|
||||
{% block page_title %}MQTT Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="config-form-container">
|
||||
<form id="mqtt-config-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="host">Host <span class="required">*</span></label>
|
||||
<input type="text" id="host" name="host" value="{{ mqtt.host }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="port">Port</label>
|
||||
<input type="number" id="port" name="port" value="{{ mqtt.port }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="user">Username</label>
|
||||
<input type="text" id="user" name="user" value="{{ mqtt.user }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" id="password" name="password" value="{{ '***' if mqtt.password else '' }}">
|
||||
<button type="button" class="btn btn-icon" onclick="togglePassword('password')">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="base_topic">Base Topic</label>
|
||||
<input type="text" id="base_topic" name="base_topic" value="{{ mqtt.base_topic }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="ha_autodiscovery" name="ha_autodiscovery" {{ 'checked' if mqtt.ha_autodiscovery }}>
|
||||
HA Auto-Discovery
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="ha-topic-group">
|
||||
<label for="ha_autodiscovery_topic">HA Discovery Topic</label>
|
||||
<input type="text" id="ha_autodiscovery_topic" name="ha_autodiscovery_topic" value="{{ mqtt.ha_autodiscovery_topic }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="client_id">Client ID</label>
|
||||
<input type="text" id="client_id" name="client_id" value="{{ mqtt.client_id }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testMqttConnection()">Test Connection</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMqttConfig()">Save</button>
|
||||
</div>
|
||||
<div class="test-result hidden" id="mqtt-test-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testMqttConnection() {
|
||||
const result = document.getElementById('mqtt-test-result');
|
||||
result.classList.remove('hidden');
|
||||
result.textContent = 'Testing...';
|
||||
result.className = 'test-result';
|
||||
|
||||
const data = {
|
||||
host: document.getElementById('host').value,
|
||||
port: parseInt(document.getElementById('port').value),
|
||||
user: document.getElementById('user').value,
|
||||
password: document.getElementById('password').value,
|
||||
};
|
||||
// Don't send masked password for test
|
||||
if (data.password === '***') data.password = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/mqtt/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const res = await resp.json();
|
||||
result.textContent = res.message;
|
||||
result.className = 'test-result ' + (res.ok ? 'test-success' : 'test-error');
|
||||
} catch (e) {
|
||||
result.textContent = 'Error: ' + e.message;
|
||||
result.className = 'test-result test-error';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMqttConfig() {
|
||||
const data = {
|
||||
host: document.getElementById('host').value,
|
||||
port: parseInt(document.getElementById('port').value),
|
||||
user: document.getElementById('user').value,
|
||||
password: document.getElementById('password').value,
|
||||
base_topic: document.getElementById('base_topic').value,
|
||||
ha_autodiscovery: document.getElementById('ha_autodiscovery').checked,
|
||||
ha_autodiscovery_topic: document.getElementById('ha_autodiscovery_topic').value,
|
||||
client_id: document.getElementById('client_id').value,
|
||||
};
|
||||
|
||||
const resp = await fetch('/api/config/mqtt', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.ok) {
|
||||
showToast('MQTT settings saved', 'success');
|
||||
if (res.restart_required) {
|
||||
showRestartBanner();
|
||||
}
|
||||
} else {
|
||||
showToast(res.error || 'Save failed', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
210
hameter/web/templates/setup.html
Normal file
210
hameter/web/templates/setup.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HAMeter Setup</title>
|
||||
<link rel="stylesheet" href="{{ url_for('main.static', filename='style.css') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('main.static', filename='favicon.svg') }}">
|
||||
</head>
|
||||
<body class="setup-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-card" id="step-1">
|
||||
<div class="setup-logo">HAMeter</div>
|
||||
<h2>Welcome</h2>
|
||||
<p>HAMeter reads utility meters via SDR and publishes readings to Home Assistant over MQTT.</p>
|
||||
<p>Let's configure your connection.</p>
|
||||
<button class="btn btn-primary btn-lg" onclick="showStep(2)">Get Started</button>
|
||||
</div>
|
||||
|
||||
<div class="setup-card hidden" id="step-2">
|
||||
<h2>MQTT Broker</h2>
|
||||
<p>Enter your MQTT broker connection details.</p>
|
||||
<form id="mqtt-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="mqtt-host">Host <span class="required">*</span></label>
|
||||
<input type="text" id="mqtt-host" placeholder="192.168.1.74" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mqtt-port">Port</label>
|
||||
<input type="number" id="mqtt-port" value="1883">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mqtt-user">Username</label>
|
||||
<input type="text" id="mqtt-user" placeholder="Optional">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mqtt-password">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="Optional">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="testMqtt()">Test Connection</button>
|
||||
<button type="button" class="btn btn-primary" onclick="mqttNext()">Next</button>
|
||||
</div>
|
||||
<div class="test-result hidden" id="mqtt-test-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="setup-card hidden" id="step-3">
|
||||
<h2>Add a Meter</h2>
|
||||
<p>If you know your meter's radio ID and protocol, enter them below. Otherwise, you can use Discovery after setup.</p>
|
||||
<form id="meter-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="meter-id">Meter ID (ERT Serial Number)</label>
|
||||
<input type="number" id="meter-id" placeholder="e.g. 23040293">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-protocol">Protocol</label>
|
||||
<select id="meter-protocol">
|
||||
{% for p in protocols %}
|
||||
<option value="{{ p }}">{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-name">Name</label>
|
||||
<input type="text" id="meter-name" placeholder="e.g. Electric Meter">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="meter-device-class">Type</label>
|
||||
<select id="meter-device-class" onchange="updateMeterDefaults()">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="energy">Energy (Electric)</option>
|
||||
<option value="gas">Gas</option>
|
||||
<option value="water">Water</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="#" class="btn btn-link" onclick="skipMeter()">Skip — I'll use Discovery later</a>
|
||||
<button type="button" class="btn btn-primary" onclick="saveMeterAndFinish()">Save & Start</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="setup-card hidden" id="step-4">
|
||||
<div class="setup-success">✓</div>
|
||||
<h2>Setup Complete</h2>
|
||||
<p>HAMeter is starting up. Redirecting to dashboard...</p>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="setup-error hidden" id="setup-error">
|
||||
<p class="error-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showStep(n) {
|
||||
document.querySelectorAll('.setup-card').forEach(el => el.classList.add('hidden'));
|
||||
document.getElementById('step-' + n).classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function testMqtt() {
|
||||
const result = document.getElementById('mqtt-test-result');
|
||||
result.classList.remove('hidden');
|
||||
result.textContent = 'Testing...';
|
||||
result.className = 'test-result';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/mqtt/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
host: document.getElementById('mqtt-host').value,
|
||||
port: parseInt(document.getElementById('mqtt-port').value),
|
||||
user: document.getElementById('mqtt-user').value,
|
||||
password: document.getElementById('mqtt-password').value,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
result.textContent = data.message;
|
||||
result.className = 'test-result ' + (data.ok ? 'test-success' : 'test-error');
|
||||
} catch (e) {
|
||||
result.textContent = 'Request failed: ' + e.message;
|
||||
result.className = 'test-result test-error';
|
||||
}
|
||||
}
|
||||
|
||||
function mqttNext() {
|
||||
const host = document.getElementById('mqtt-host').value.trim();
|
||||
if (!host) {
|
||||
showError('MQTT host is required');
|
||||
return;
|
||||
}
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
function updateMeterDefaults() {
|
||||
const dc = document.getElementById('meter-device-class').value;
|
||||
if (!dc) return;
|
||||
fetch('/api/meter_defaults/' + dc)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Auto-fill name if empty
|
||||
const nameEl = document.getElementById('meter-name');
|
||||
if (!nameEl.value) {
|
||||
const names = {energy: 'Electric Meter', gas: 'Gas Meter', water: 'Water Meter'};
|
||||
nameEl.value = names[dc] || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveMeterAndFinish() {
|
||||
await doSetup(true);
|
||||
}
|
||||
|
||||
async function skipMeter() {
|
||||
await doSetup(false);
|
||||
}
|
||||
|
||||
async function doSetup(includeMeter) {
|
||||
const payload = {
|
||||
mqtt: {
|
||||
host: document.getElementById('mqtt-host').value.trim(),
|
||||
port: parseInt(document.getElementById('mqtt-port').value),
|
||||
user: document.getElementById('mqtt-user').value,
|
||||
password: document.getElementById('mqtt-password').value,
|
||||
},
|
||||
};
|
||||
|
||||
if (includeMeter) {
|
||||
const meterId = document.getElementById('meter-id').value;
|
||||
if (!meterId) {
|
||||
showError('Meter ID is required');
|
||||
return;
|
||||
}
|
||||
payload.meter = {
|
||||
id: parseInt(meterId),
|
||||
protocol: document.getElementById('meter-protocol').value,
|
||||
name: document.getElementById('meter-name').value || 'Meter 1',
|
||||
device_class: document.getElementById('meter-device-class').value,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
showStep(4);
|
||||
setTimeout(() => window.location.href = '/dashboard', 3000);
|
||||
} else {
|
||||
showError(data.error || 'Setup failed');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('Request failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('setup-error');
|
||||
el.classList.remove('hidden');
|
||||
el.querySelector('.error-text').textContent = msg;
|
||||
setTimeout(() => el.classList.add('hidden'), 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user