initial commit

This commit is contained in:
2026-03-06 12:25:27 -05:00
commit 4f2556bb42
45 changed files with 8473 additions and 0 deletions

3
hameter/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""HAMeter — Custom rtlamr-to-MQTT bridge for Home Assistant."""
__version__ = "1.0.0"

304
hameter/__main__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

268
hameter/web/static/app.js Normal file
View 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();
}
});

View 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

View 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;
}
}

View 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">&#9671;</span> Dashboard</a></li>
<li><a href="/discovery" class="nav-link {% if request.path == '/discovery' %}active{% endif %}"><span class="nav-icon">&#9678;</span> Discovery</a></li>
<li><a href="/calibration" class="nav-link {% if request.path == '/calibration' %}active{% endif %}"><span class="nav-icon">&#9878;</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">&#8644;</span> MQTT</a></li>
<li><a href="/config/meters" class="nav-link {% if '/config/meters' in request.path %}active{% endif %}"><span class="nav-icon">&#9681;</span> Meters</a></li>
<li><a href="/config/general" class="nav-link {% if '/config/general' in request.path %}active{% endif %}"><span class="nav-icon">&#9881;</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">&#9776;</span> Logs</a></li>
</ul>
</nav>
<main class="main-content">
<header class="top-bar">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">&#9776;</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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// SSE log handler
if (typeof window._onLogUpdate === 'undefined') {
window._onLogUpdate = function(logs) {
if (Array.isArray(logs)) {
logs.forEach(addLogEntry);
}
};
}
</script>
{% endblock %}

View 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">&times;</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">&times;</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 %}

View 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 %}

View 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 %}

View 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 &mdash; I'll use Discovery later</a>
<button type="button" class="btn btn-primary" onclick="saveMeterAndFinish()">Save &amp; Start</button>
</div>
</form>
</div>
<div class="setup-card hidden" id="step-4">
<div class="setup-success">&#10003;</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>