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

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
dist/
build/
# Virtual environment
.venv/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Application runtime
*.log
/.claude
SDR_Meter_Reading_Project.docx

236
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,236 @@
# HAMeter Deployment Guide — Unraid
## Prerequisites
- Unraid server (Dell R730) with Community Applications plugin installed
- Nooelec RTL-SDR v5 dongle
- Home Assistant running as Docker container on Unraid (spaceinvaderone/ha_inabox)
---
## Step 1: Install Mosquitto MQTT Broker
1. In Unraid web UI, go to **Apps** tab
2. Search for **"mosquitto"**
3. Install **eclipse-mosquitto** (the official image)
4. Default settings are fine for initial setup:
- Port: **1883** (MQTT) and **9001** (WebSocket, optional)
- Config path: `/mnt/user/appdata/mosquitto`
5. Start the container
6. Your Unraid server IP (`192.168.1.74`) is the MQTT broker address
### Verify Mosquitto is Running
From Unraid terminal or SSH:
```bash
docker logs mosquitto
```
You should see a line like: `mosquitto version X.X.X running`
---
## Step 2: Connect MQTT to Home Assistant
1. In Home Assistant, go to **Settings → Devices & Services**
2. Click **+ Add Integration** (bottom right), search for **MQTT**
3. Enter:
- **Broker**: `192.168.1.74`
- **Port**: `1883`
- **Username/Password**: Leave blank (unless you configured auth on Mosquitto)
4. Click **Submit**
5. Verify connection succeeds
---
## Step 3: Connect SDR Dongle to R730
1. Plug the Nooelec RTL-SDR into a **USB 2.0 port** on the R730 (avoid USB 3.0 — causes I2C errors)
2. Attach the telescopic whip antenna collapsed to ~8 cm (quarter-wave for 900 MHz)
3. Verify Unraid sees the device — from Unraid terminal:
```bash
lsusb | grep -i rtl
```
You should see: `Realtek Semiconductor Corp. RTL2838 DVB-T` or similar
### Blacklist the Kernel DVB Driver
The Linux DVB driver will claim the dongle and prevent rtl_tcp from using it:
1. SSH into Unraid
2. Create the blacklist file (persists across reboots):
```bash
mkdir -p /boot/config/modprobe.d
echo "blacklist dvb_usb_rtl28xxu" >> /boot/config/modprobe.d/rtlsdr.conf
```
3. If the module is currently loaded, unload it:
```bash
rmmod dvb_usb_rtl28xxu 2>/dev/null
```
---
## Step 4: Build the HAMeter Docker Image
On the Unraid server (or any machine with Docker), clone the project and build:
```bash
# Copy project files to Unraid (e.g., via SMB share or scp)
# Then on Unraid terminal:
cd /mnt/user/appdata/hameter
docker build -t hameter:latest .
```
---
## Step 5: Deploy HAMeter Container
### Option A: Via Unraid Docker UI
1. Go to **Docker → Add Container**
2. Configure:
| Setting | Value |
|---------|-------|
| **Name** | `hameter` |
| **Repository** | `hameter:latest` |
| **Network** | Host |
| **Extra Parameters** | `--device=/dev/bus/usb` |
| **Restart Policy** | Unless Stopped |
3. Add path mapping:
| Container Path | Host Path | Mode |
|----------------|-----------|------|
| `/config/hameter.yaml` | `/mnt/user/appdata/hameter/config/hameter.yaml` | Read Only |
### Option B: Via Command Line
```bash
docker run -d \
--name hameter \
--restart unless-stopped \
--network host \
--device=/dev/bus/usb \
-v /mnt/user/appdata/hameter/config/hameter.yaml:/config/hameter.yaml:ro \
-e TZ=America/New_York \
hameter:latest
```
---
## Step 6: Deploy Configuration
1. Copy the config file to the appdata path:
```bash
mkdir -p /mnt/user/appdata/hameter/config
cp config/hameter.yaml /mnt/user/appdata/hameter/config/hameter.yaml
```
2. The config is pre-set with your Unraid IP (`192.168.1.74`) and electric meter ID (`23040293`)
3. Start the container
---
## Step 7: Verify It's Working
1. Check HAMeter logs:
```bash
docker logs -f hameter
```
You should see:
- `Connected to MQTT broker`
- `Published HA discovery for meter 23040293`
- `Published: meter=23040293 raw=XXXXX calibrated=XX.XXXX kWh`
2. In Home Assistant, go to **Settings → Devices & Services → MQTT**
- A new device **"Electric Meter"** should appear with 3 entities:
- **Electric Meter Reading** — calibrated kWh value
- **Electric Meter Raw Reading** — raw register value (diagnostic)
- **Electric Meter Last Seen** — timestamp of last transmission
3. Check **Developer Tools → States** and filter for `sensor.electric_meter`
---
## Step 8: Add to Energy Dashboard
1. Go to **HA → Settings → Dashboards → Energy**
2. Under **Electricity Grid**, click **Add Consumption**
3. Select `sensor.electric_meter_reading`
4. Optionally set a **cost per kWh** for cost tracking
---
## Calibration
The raw SCM consumption value (e.g., 516,030) does not directly equal kWh.
HAMeter has a built-in `multiplier` per meter in the config.
### How to Calibrate
1. Read your physical meter display (e.g., 59,669 kWh)
2. Check the raw reading in HA: **Developer Tools → States → `sensor.electric_meter_raw_reading`**
3. Calculate: `multiplier = meter_display_kWh / raw_value`
- Example: `59,669 / 516,030 ≈ 0.1156`
4. Update `multiplier: 0.1156` in `config/hameter.yaml`
5. Restart the container: `docker restart hameter`
The calibrated reading sensor will now show actual kWh.
The raw reading sensor (diagnostic) always shows the unconverted value
for recalibration later.
---
## Discovering Gas and Water Meter IDs
HAMeter has a built-in discovery mode:
```bash
docker run --rm \
--device=/dev/bus/usb \
-v /mnt/user/appdata/hameter/config/hameter.yaml:/config/hameter.yaml:ro \
hameter:latest --discover --discover-duration 120
```
This will:
1. Listen for all nearby meter transmissions for 120 seconds
2. Log each new meter ID, protocol, and consumption value as they appear
3. Print a summary table of all meters found
Match the IDs to your physical meters, then add them to the config file.
---
## Troubleshooting
### "usb_open error -3"
The DVB kernel driver is claiming the device:
```bash
rmmod dvb_usb_rtl28xxu
```
### "rtl_tcp binary not found"
The Docker image didn't build correctly. Rebuild:
```bash
docker build --no-cache -t hameter:latest .
```
### No meter readings appearing
- Check antenna is attached and collapsed to ~8 cm
- Verify SDR is on USB 2.0 port (not 3.0)
- Check container has USB device access: `docker exec hameter ls /dev/bus/usb/`
- Try discovery mode to confirm the SDR is receiving signals
- Check logs: `docker logs hameter`
### MQTT connection refused
- Verify Mosquitto container is running: `docker ps | grep mosquitto`
- Check IP/port in `hameter.yaml` matches Mosquitto
- Test connectivity: `docker exec hameter python -c "import socket; s=socket.socket(); s.connect(('192.168.1.74',1883)); print('OK')"`
### Sensors not appearing in HA
- Verify MQTT integration is configured in HA
- Check MQTT messages are arriving: In HA, go to **Developer Tools → MQTT → Listen** and subscribe to `hameter/#`
- Restart HA after first discovery publish
### Container keeps restarting
- Check logs for the specific error: `docker logs --tail 50 hameter`
- Common cause: USB device not passed through or kernel driver conflict

61
Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# =============================================================
# Stage 1: Build rtlamr from source (Go)
# =============================================================
FROM golang:1.24-bookworm AS go-builder
RUN go install github.com/bemasher/rtlamr@latest
# =============================================================
# Stage 2: Build rtl-sdr tools from source (C)
# =============================================================
FROM debian:bookworm-slim AS sdr-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake pkg-config git ca-certificates libusb-1.0-0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN git clone https://github.com/osmocom/rtl-sdr.git /tmp/rtl-sdr \
&& cd /tmp/rtl-sdr \
&& mkdir build && cd build \
&& cmake .. -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON \
&& make -j"$(nproc)" \
&& make install
# =============================================================
# Stage 3: Python runtime
# =============================================================
FROM python:3.13-slim-bookworm
LABEL maintainer="HAMeter"
LABEL description="SDR utility meter reader for Home Assistant"
# Runtime dependency for rtl-sdr
RUN apt-get update && apt-get install -y --no-install-recommends \
libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled binaries from builder stages
COPY --from=go-builder /go/bin/rtlamr /usr/local/bin/rtlamr
COPY --from=sdr-builder /usr/local/bin/rtl_tcp /usr/local/bin/rtl_tcp
COPY --from=sdr-builder /usr/local/lib/librtlsdr* /usr/local/lib/
RUN ldconfig
# Install Python dependencies
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# Copy application code
COPY hameter/ /app/hameter/
WORKDIR /app
# Web UI port
EXPOSE 9090
# Persistent config storage
VOLUME /data
# Docker sends SIGTERM on stop; we handle it gracefully
STOPSIGNAL SIGTERM
ENTRYPOINT ["python", "-m", "hameter"]

295
README.md Normal file
View File

@@ -0,0 +1,295 @@
# HAMeter
Read utility meters (electric, gas, water) via SDR and publish to Home Assistant over MQTT.
HAMeter uses an RTL-SDR dongle to receive wireless transmissions from ERT-equipped utility meters, decodes them with [rtlamr](https://github.com/bemasher/rtlamr), and publishes readings to Home Assistant via MQTT auto-discovery.
## Features
- Reads SCM, SCM+, IDM, and R900 (Neptune) meter protocols
- Built-in calibration multiplier per meter
- Home Assistant MQTT auto-discovery — sensors appear automatically
- Three HA sensors per meter: calibrated reading, raw reading, last seen
- Discovery mode to find nearby meter IDs
- Fully configurable via Docker environment variables — no config files needed
- Smart defaults based on meter type (energy, gas, water)
- Supports up to 9 meters simultaneously
## Requirements
- RTL-SDR dongle (tested with Nooelec RTL-SDR v5)
- 915 MHz antenna (or telescopic whip at ~8 cm for 900 MHz band)
- Docker host with USB access (tested on Unraid)
- MQTT broker (e.g., Mosquitto)
- Home Assistant with MQTT integration
## Quick Start
### 1. Install Mosquitto MQTT Broker
Install via your Docker host's app store or:
```bash
docker run -d --name mosquitto -p 1883:1883 eclipse-mosquitto
```
### 2. Add MQTT Integration to Home Assistant
**Settings → Devices & Services → Add Integration → MQTT**
- Broker: your Docker host IP
- Port: 1883
### 3. Connect the SDR Dongle
Plug the RTL-SDR into a **USB 2.0 port** (USB 3.0 can cause interference).
Blacklist the kernel DVB driver so it doesn't claim the device:
```bash
# On Unraid (persists across reboots):
mkdir -p /boot/config/modprobe.d
echo "blacklist dvb_usb_rtl28xxu" >> /boot/config/modprobe.d/rtlsdr.conf
rmmod dvb_usb_rtl28xxu 2>/dev/null
# On other Linux:
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/rtlsdr.conf
sudo rmmod dvb_usb_rtl28xxu 2>/dev/null
```
### 4. Run HAMeter
```bash
docker run -d \
--name hameter \
--restart unless-stopped \
--device=/dev/bus/usb \
-e MQTT_HOST=192.168.1.74 \
-e METER_1_ID=23040293 \
-e METER_1_PROTOCOL=scm \
-e METER_1_NAME="Electric Meter" \
-e METER_1_DEVICE_CLASS=energy \
-e METER_1_MULTIPLIER=1.0 \
hameter:latest
```
Your meter should appear in Home Assistant within a minute.
## Configuration
All configuration is done via environment variables. No config file needed.
### Required Variables
| Variable | Description |
|----------|-------------|
| `MQTT_HOST` | MQTT broker IP address |
| `METER_1_ID` | Meter radio serial number (ERT ID, not the billing number) |
| `METER_1_PROTOCOL` | Protocol: `scm`, `scm+`, `idm`, `r900`, `r900bcd`, `netidm` |
### Optional Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MQTT_PORT` | `1883` | MQTT broker port |
| `MQTT_USER` | *(empty)* | MQTT username |
| `MQTT_PASSWORD` | *(empty)* | MQTT password |
| `MQTT_BASE_TOPIC` | `hameter` | Base MQTT topic |
| `METER_1_NAME` | `Electric Meter` | Friendly name in HA |
| `METER_1_MULTIPLIER` | `1.0` | Calibration multiplier (see below) |
| `METER_1_DEVICE_CLASS` | `energy` | HA device class — sets smart defaults |
| `METER_1_UNIT` | *(auto)* | Unit of measurement |
| `METER_1_ICON` | *(auto)* | HA icon |
| `METER_1_STATE_CLASS` | `total_increasing` | HA state class |
| `SDR_DEVICE_ID` | `0` | RTL-SDR device index |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
### Smart Defaults by Device Class
When you set `METER_1_DEVICE_CLASS`, the icon and unit are set automatically:
| Device Class | Icon | Unit |
|-------------|------|------|
| `energy` | `mdi:flash` | `kWh` |
| `gas` | `mdi:fire` | `ft³` |
| `water` | `mdi:water` | `gal` |
You can override any of these with the explicit `METER_1_ICON` or `METER_1_UNIT` variables.
### Multiple Meters
Add meters using `METER_2_*`, `METER_3_*`, etc. (up to `METER_9_*`):
```bash
-e METER_1_ID=23040293 \
-e METER_1_PROTOCOL=scm \
-e METER_1_NAME="Electric Meter" \
-e METER_1_DEVICE_CLASS=energy \
-e METER_2_ID=55512345 \
-e METER_2_PROTOCOL=r900 \
-e METER_2_NAME="Water Meter" \
-e METER_2_DEVICE_CLASS=water \
```
Gaps are fine (e.g., meter 1 and 3 without 2).
## Finding Your Meter ID
Your meter has two numbers:
- **Billing number** — printed on the faceplate (e.g., `2698881`). **Don't use this.**
- **Radio serial number (ERT ID)** — transmitted over RF (e.g., `23040293`). **Use this one.**
To find the ERT ID, run HAMeter in discovery mode:
```bash
docker run --rm \
--device=/dev/bus/usb \
-e MQTT_HOST=localhost \
-e METER_1_ID=0 \
-e METER_1_PROTOCOL=scm \
hameter:latest --discover --discover-duration 120
```
This listens for all nearby meter transmissions for 2 minutes and prints a summary:
```
DISCOVERY SUMMARY — 12 unique meters found
============================================================
Meter ID Protocol Count Last Reading
--------------------------------------------------
23040293 SCM 8 519161
55512345 R900 3 12345678
...
```
Cross-reference the IDs with your physical meters to identify which is yours. Your meter will likely be the one with the highest count (since it's closest).
## Calibration
The raw value from the meter's radio does not directly equal the reading on the meter display. You need a calibration multiplier to convert raw values to actual units (kWh, gallons, etc.).
### How to Calibrate
**Step 1: Read your physical meter**
Go to the meter and write down the display reading. For example: `59,669 kWh`
**Step 2: Get the raw reading from HA**
In Home Assistant, go to **Developer Tools → States** and find your meter's raw reading sensor. For example: `sensor.electric_meter_raw_reading` shows `516,030`
Take both readings at roughly the same time so they correspond.
**Step 3: Calculate the multiplier**
Divide the physical reading by the raw reading:
```
multiplier = physical_reading / raw_reading
multiplier = 59669 / 516030
multiplier = 0.1156
```
**Step 4: Set the multiplier**
Set `METER_1_MULTIPLIER=0.1156` in your Docker environment variables and restart the container.
### Verifying Calibration
After setting the multiplier, the **Reading** sensor in HA should closely match your physical meter display. If it drifts over time, repeat the process to recalculate.
The **Raw Reading** sensor (shown as a diagnostic entity in HA) always shows the unconverted value, so you can recalibrate at any time without removing the multiplier first.
### Why Is Calibration Needed?
ERT meters transmit a raw register value, not the displayed reading. The relationship between the two depends on the meter's internal configuration (Kh factor, register multiplier, number of dials). The multiplier accounts for all of these.
## Home Assistant Sensors
Each configured meter creates three sensors in HA:
| Sensor | Example Entity | Description |
|--------|---------------|-------------|
| **Reading** | `sensor.electric_meter_reading` | Calibrated value (e.g., `59,669 kWh`) |
| **Raw Reading** | `sensor.electric_meter_raw_reading` | Unconverted register value (diagnostic) |
| **Last Seen** | `sensor.electric_meter_last_seen` | Timestamp of last received transmission |
All three are grouped under a single device in HA with the manufacturer shown as "HAMeter".
### Energy Dashboard
To add your electric meter to the HA Energy Dashboard:
1. Go to **Settings → Dashboards → Energy**
2. Under **Electricity Grid**, click **Add Consumption**
3. Select `sensor.electric_meter_reading`
4. Optionally set a cost per kWh
## Building from Source
```bash
git clone https://github.com/your-username/HAMeter.git
cd HAMeter
docker build -t hameter:latest .
```
The Dockerfile is a multi-stage build:
1. **Go** — compiles `rtlamr` from source
2. **C** — compiles `rtl-sdr` (librtlsdr + rtl_tcp) from source
3. **Python** — runtime with `paho-mqtt` and `pyyaml`
## Unraid Setup
### Via Docker UI
1. Go to **Docker → Add Container**
2. Set **Repository** to `hameter:latest`
3. Set **Network** to Host
4. Set **Extra Parameters** to `--device=/dev/bus/usb`
5. Add environment variables for `MQTT_HOST`, `METER_1_ID`, `METER_1_PROTOCOL`, etc.
6. Start the container
### USB Passthrough
The SDR dongle must be accessible inside the container. Use `--device=/dev/bus/usb` to pass the entire USB bus (simplest and most reliable across reboots).
## Troubleshooting
### No readings appearing
- Verify the SDR dongle is on a **USB 2.0 port** (USB 3.0 causes interference)
- Check that the antenna is attached
- Run discovery mode to confirm the SDR is receiving signals
- Check logs: `docker logs hameter`
### "usb_open error -3"
The kernel DVB driver is claiming the device:
```bash
rmmod dvb_usb_rtl28xxu
```
### MQTT connection refused
- Verify Mosquitto is running: `docker ps | grep mosquitto`
- Check `MQTT_HOST` and `MQTT_PORT` are correct
- If using Docker bridge networking, use the host IP (not `127.0.0.1`)
### Sensors not appearing in HA
- Verify the MQTT integration is configured in HA
- In HA, go to **Developer Tools → MQTT → Listen** and subscribe to `hameter/#` to check if messages are arriving
- Restart HA if sensors don't appear after the first discovery publish
## Supported Meters
HAMeter supports any ERT meter that transmits on the 900 MHz ISM band:
| Protocol | Typical Manufacturer | Meter Type |
|----------|---------------------|------------|
| SCM | Itron | Electric, Gas |
| SCM+ | Itron | Electric, Gas |
| IDM | Itron | Electric (with interval data) |
| R900 | Neptune | Water |
| R900BCD | Neptune | Water |
| NetIDM | Itron | Electric (net metering) |
## License
MIT

59
examples/hameter.yaml Normal file
View File

@@ -0,0 +1,59 @@
# HAMeter configuration
# Mount this file into the container at /config/hameter.yaml
general:
# 0 = continuous (recommended for always-on server)
sleep_for: 0
# RTL-SDR device index (0 = first/only device)
device_id: "0"
# rtl_tcp listens inside the container (don't change unless using remote SDR)
rtl_tcp_host: "127.0.0.1"
rtl_tcp_port: 1234
# DEBUG, INFO, WARNING, ERROR
log_level: INFO
mqtt:
# Unraid server IP (where Mosquitto runs)
host: "192.168.1.74"
port: 1883
user: ""
password: ""
base_topic: "hameter"
ha_autodiscovery: true
ha_autodiscovery_topic: "homeassistant"
client_id: "hameter"
meters:
# Electric Meter — Itron Centron C1SR
# ERT Radio Serial: 23040293 (NOT the billing number 2698881)
- id: 23040293
protocol: scm
name: "Electric Meter"
unit_of_measurement: "kWh"
icon: "mdi:flash"
device_class: energy
state_class: total_increasing
# Calibration: physical_meter_reading / raw_scm_value
# From PoC: 59669 / 516030 ≈ 0.1156
# TODO: take a simultaneous reading to confirm this multiplier
multiplier: 0.1156
# Gas Meter — uncomment after running --discover to find the ID
# - id: REPLACE_WITH_GAS_METER_ID
# protocol: scm
# name: "Gas Meter"
# unit_of_measurement: "ft³"
# icon: "mdi:fire"
# device_class: gas
# state_class: total_increasing
# multiplier: 1.0
# Water Meter — likely Neptune R900 protocol
# - id: REPLACE_WITH_WATER_METER_ID
# protocol: r900
# name: "Water Meter"
# unit_of_measurement: "gal"
# icon: "mdi:water"
# device_class: water
# state_class: total_increasing
# multiplier: 1.0

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>

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
paho-mqtt==2.1.0
pyyaml==6.0.2
flask==3.1.0

53
rtlamr2mqtt.yaml Normal file
View File

@@ -0,0 +1,53 @@
# rtlamr2mqtt configuration for HAMeter
# Reference: https://github.com/allangood/rtlamr2mqtt
general:
# Seconds between reading cycles (300 = 5 minutes)
sleep_for: 300
# RTL-SDR device index (0 = first/only device)
device_id: "0"
# Set to true to see all meter transmissions (useful for finding gas/water meter IDs)
# Change to false once all meter IDs are identified
tickle_rtl_tcp: false
mqtt:
# Unraid server IP (where Mosquitto runs)
host: "192.168.1.74"
port: 1883
# Leave blank if Mosquitto is configured without auth (default)
# Add credentials if you enable authentication on Mosquitto
user: ""
password: ""
ha_autodiscovery: true
ha_autodiscovery_topic: homeassistant
meters:
# Electric Meter - Itron Centron C1SR
# ERT Radio Serial: 23040293 (NOT the billing number 2698881)
- id: 23040293
protocol: scm
name: Electric Meter
unit_of_measurement: kWh
icon: mdi:flash
device_class: energy
state_class: total_increasing
# --- Future meters (uncomment and fill in IDs after identification) ---
# Gas Meter - inspect physically to find ERT serial number
# - id: REPLACE_WITH_GAS_METER_ID
# protocol: scm
# name: Gas Meter
# unit_of_measurement: ft³
# icon: mdi:fire
# device_class: gas
# state_class: total_increasing
# Water Meter - likely Neptune R900 protocol
# - id: REPLACE_WITH_WATER_METER_ID
# protocol: r900
# name: Water Meter
# unit_of_measurement: gal
# icon: mdi:water
# device_class: water
# state_class: total_increasing

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,6 @@
{"Time":"2026-03-01T15:18:58Z","Offset":0,"Length":100,"Type":"SCM","Message":{"ID":23040293,"Type":7,"TamperPhy":0,"TamperEnc":0,"Consumption":516012,"ChecksumVal":12345}}
{"Time":"2026-03-01T15:20:13Z","Offset":0,"Length":100,"Type":"SCM","Message":{"ID":23040293,"Type":7,"TamperPhy":0,"TamperEnc":0,"Consumption":516030,"ChecksumVal":12346}}
{"Time":"2026-03-01T15:18:58Z","Offset":0,"Length":200,"Type":"IDM","Message":{"ERTType":7,"ERTSerialNumber":23040293,"TransmitTimeOffset":42,"ConsumptionIntervalCount":47,"DifferentialConsumptionIntervals":[100,105,98],"PowerOutageFlags":"0x00","LastConsumptionCount":2124513}}
{"Time":"2026-03-01T15:19:00Z","Offset":0,"Length":100,"Type":"SCM+","Message":{"FrameSync":5795,"ProtocolID":30,"EndpointType":7,"EndpointID":98765432,"Consumption":350000,"Tamper":0,"PacketCRC":48059}}
{"Time":"2026-03-01T15:19:05Z","Offset":0,"Length":100,"Type":"R900","Message":{"ID":55512345,"Unkn1":1,"NoUse":false,"BackFlow":false,"Leak":false,"LeakNow":false,"Consumption":12345678}}
{"Time":"2026-03-01T15:21:00Z","Offset":0,"Length":100,"Type":"SCM","Message":{"ID":99999999,"Type":7,"TamperPhy":0,"TamperEnc":0,"Consumption":100000,"ChecksumVal":99999}}

512
tests/test_config.py Normal file
View File

@@ -0,0 +1,512 @@
"""Tests for hameter.config."""
import json
import os
from pathlib import Path
import pytest
import yaml
from hameter.config import (
HaMeterConfig,
GeneralConfig,
MeterConfig,
MqttConfig,
RateComponent,
config_exists,
config_to_dict,
load_config_from_json,
load_config_from_yaml,
save_config,
validate_meter_config,
validate_mqtt_config,
validate_rate_component,
get_meter_defaults,
)
def _write_json_config(tmp_path: Path, data: dict) -> str:
"""Write a JSON config dict to a temp file and return the path."""
p = tmp_path / "config.json"
p.write_text(json.dumps(data))
return str(p)
def _write_yaml_config(tmp_path: Path, data: dict) -> str:
"""Write a YAML config dict to a temp file and return the path."""
p = tmp_path / "hameter.yaml"
p.write_text(yaml.dump(data))
return str(p)
VALID_CONFIG = {
"general": {
"sleep_for": 0,
"device_id": "0",
"log_level": "DEBUG",
},
"mqtt": {
"host": "192.168.1.74",
"port": 1883,
"base_topic": "hameter",
},
"meters": [
{
"id": 23040293,
"protocol": "scm",
"name": "Electric Meter",
"unit_of_measurement": "kWh",
"multiplier": 0.1156,
"device_class": "energy",
},
],
}
class TestLoadConfigFromJson:
"""Tests for JSON-based configuration."""
def test_valid_config(self, tmp_path):
path = _write_json_config(tmp_path, VALID_CONFIG)
cfg = load_config_from_json(path)
assert cfg.general.sleep_for == 0
assert cfg.general.device_id == "0"
assert cfg.general.log_level == "DEBUG"
assert cfg.mqtt.host == "192.168.1.74"
assert cfg.mqtt.port == 1883
assert cfg.mqtt.base_topic == "hameter"
assert len(cfg.meters) == 1
m = cfg.meters[0]
assert m.id == 23040293
assert m.protocol == "scm"
assert m.name == "Electric Meter"
assert m.multiplier == 0.1156
assert m.device_class == "energy"
def test_defaults_applied(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [
{"id": 123, "protocol": "scm", "name": "Test"},
],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.general.sleep_for == 0
assert cfg.general.device_id == "0"
assert cfg.general.log_level == "INFO"
assert cfg.general.rtl_tcp_host == "127.0.0.1"
assert cfg.general.rtl_tcp_port == 1234
assert cfg.mqtt.port == 1883
assert cfg.mqtt.base_topic == "hameter"
assert cfg.mqtt.ha_autodiscovery is True
m = cfg.meters[0]
assert m.multiplier == 1.0
assert m.state_class == "total_increasing"
assert m.icon == "mdi:gauge"
def test_missing_mqtt_host_raises(self, tmp_path):
data = {
"mqtt": {"port": 1883},
"meters": [{"id": 1, "protocol": "scm", "name": "X"}],
}
path = _write_json_config(tmp_path, data)
with pytest.raises(ValueError, match="MQTT host"):
load_config_from_json(path)
def test_empty_meters_is_valid(self, tmp_path):
data = {"mqtt": {"host": "10.0.0.1"}, "meters": []}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters == []
def test_no_meters_key_is_valid(self, tmp_path):
data = {"mqtt": {"host": "10.0.0.1"}}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters == []
def test_invalid_protocol_raises(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [{"id": 1, "protocol": "invalid", "name": "X"}],
}
path = _write_json_config(tmp_path, data)
with pytest.raises(ValueError, match="invalid protocol"):
load_config_from_json(path)
def test_multiple_meters(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [
{"id": 111, "protocol": "scm", "name": "Electric", "multiplier": 0.5},
{"id": 222, "protocol": "r900", "name": "Water", "multiplier": 1.0},
],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert len(cfg.meters) == 2
assert cfg.meters[0].id == 111
assert cfg.meters[0].multiplier == 0.5
assert cfg.meters[1].id == 222
assert cfg.meters[1].protocol == "r900"
def test_smart_defaults_energy(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [
{"id": 1, "protocol": "scm", "name": "E", "device_class": "energy"},
],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters[0].icon == "mdi:flash"
assert cfg.meters[0].unit_of_measurement == "kWh"
def test_smart_defaults_gas(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [
{"id": 1, "protocol": "scm", "name": "G", "device_class": "gas"},
],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters[0].icon == "mdi:fire"
assert cfg.meters[0].unit_of_measurement == "ft\u00b3"
def test_smart_defaults_water(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [
{"id": 1, "protocol": "r900", "name": "W", "device_class": "water"},
],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters[0].icon == "mdi:water"
assert cfg.meters[0].unit_of_measurement == "gal"
def test_file_not_found_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
load_config_from_json(str(tmp_path / "nonexistent.json"))
class TestLoadConfigFromYaml:
"""Tests for YAML migration path."""
def test_valid_yaml(self, tmp_path):
path = _write_yaml_config(tmp_path, VALID_CONFIG)
cfg = load_config_from_yaml(path)
assert cfg.mqtt.host == "192.168.1.74"
assert len(cfg.meters) == 1
assert cfg.meters[0].id == 23040293
class TestSaveConfig:
"""Tests for atomic config saving."""
def test_save_and_reload(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=123, protocol="scm", name="Test",
unit_of_measurement="kWh",
),
],
)
path = str(tmp_path / "config.json")
save_config(config, path)
loaded = load_config_from_json(path)
assert loaded.mqtt.host == "10.0.0.1"
assert loaded.meters[0].id == 123
def test_creates_parent_directory(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[],
)
path = str(tmp_path / "subdir" / "config.json")
save_config(config, path)
assert os.path.isfile(path)
def test_no_temp_file_left_on_success(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[],
)
path = str(tmp_path / "config.json")
save_config(config, path)
# Only the config file should exist, no .tmp files
files = list(tmp_path.iterdir())
assert len(files) == 1
assert files[0].name == "config.json"
class TestConfigToDict:
"""Tests for serialization."""
def test_round_trip(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(log_level="DEBUG", device_id="1"),
mqtt=MqttConfig(host="10.0.0.1", port=1884),
meters=[
MeterConfig(
id=999, protocol="r900", name="Water",
unit_of_measurement="gal", multiplier=2.5,
),
],
)
d = config_to_dict(config)
assert d["general"]["log_level"] == "DEBUG"
assert d["mqtt"]["host"] == "10.0.0.1"
assert d["meters"][0]["id"] == 999
assert d["meters"][0]["multiplier"] == 2.5
# Write and reload
path = str(tmp_path / "config.json")
with open(path, "w") as f:
json.dump(d, f)
loaded = load_config_from_json(path)
assert loaded.general.log_level == "DEBUG"
assert loaded.meters[0].multiplier == 2.5
class TestConfigExists:
"""Tests for config_exists."""
def test_exists(self, tmp_path):
path = str(tmp_path / "config.json")
Path(path).write_text("{}")
assert config_exists(path)
def test_not_exists(self, tmp_path):
assert not config_exists(str(tmp_path / "missing.json"))
class TestValidateMqttConfig:
"""Tests for validate_mqtt_config."""
def test_valid(self):
ok, err = validate_mqtt_config({"host": "10.0.0.1", "port": 1883})
assert ok
assert err == ""
def test_missing_host(self):
ok, err = validate_mqtt_config({"port": 1883})
assert not ok
assert "host" in err.lower()
def test_empty_host(self):
ok, err = validate_mqtt_config({"host": "", "port": 1883})
assert not ok
def test_invalid_port(self):
ok, err = validate_mqtt_config({"host": "x", "port": 99999})
assert not ok
assert "port" in err.lower()
def test_string_port(self):
ok, err = validate_mqtt_config({"host": "x", "port": "abc"})
assert not ok
class TestValidateMeterConfig:
"""Tests for validate_meter_config."""
def test_valid(self):
ok, err = validate_meter_config(
{"id": 123, "protocol": "scm", "name": "Test"}
)
assert ok
def test_missing_id(self):
ok, err = validate_meter_config(
{"protocol": "scm", "name": "Test"}
)
assert not ok
def test_invalid_protocol(self):
ok, err = validate_meter_config(
{"id": 1, "protocol": "bad", "name": "Test"}
)
assert not ok
assert "protocol" in err.lower()
def test_missing_name(self):
ok, err = validate_meter_config(
{"id": 1, "protocol": "scm"}
)
assert not ok
assert "name" in err.lower()
def test_invalid_multiplier(self):
ok, err = validate_meter_config(
{"id": 1, "protocol": "scm", "name": "X", "multiplier": "bad"}
)
assert not ok
assert "multiplier" in err.lower()
class TestCostFactors:
"""Tests for cost_factors serialization and deserialization."""
def test_round_trip_with_cost_factors(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=123, protocol="scm", name="Electric",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="Supply", rate=0.14742, type="per_unit"),
RateComponent(name="Customer Charge", rate=9.65, type="fixed"),
],
),
],
)
path = str(tmp_path / "config.json")
save_config(config, path)
loaded = load_config_from_json(path)
assert len(loaded.meters[0].cost_factors) == 2
cf0 = loaded.meters[0].cost_factors[0]
assert cf0.name == "Supply"
assert cf0.rate == 0.14742
assert cf0.type == "per_unit"
cf1 = loaded.meters[0].cost_factors[1]
assert cf1.name == "Customer Charge"
assert cf1.rate == 9.65
assert cf1.type == "fixed"
def test_no_cost_factors_defaults_empty(self, tmp_path):
data = {
"mqtt": {"host": "10.0.0.1"},
"meters": [{"id": 1, "protocol": "scm", "name": "Test"}],
}
path = _write_json_config(tmp_path, data)
cfg = load_config_from_json(path)
assert cfg.meters[0].cost_factors == []
def test_config_to_dict_includes_cost_factors(self):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=1, protocol="scm", name="Test",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="Rate", rate=0.10, type="per_unit"),
],
),
],
)
d = config_to_dict(config)
assert len(d["meters"][0]["cost_factors"]) == 1
assert d["meters"][0]["cost_factors"][0] == {
"name": "Rate", "rate": 0.10, "type": "per_unit",
}
def test_config_to_dict_empty_cost_factors(self):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=1, protocol="scm", name="Test",
unit_of_measurement="kWh",
),
],
)
d = config_to_dict(config)
assert d["meters"][0]["cost_factors"] == []
def test_negative_rate(self, tmp_path):
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=1, protocol="scm", name="Test",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="Credit", rate=-0.00077, type="per_unit"),
],
),
],
)
path = str(tmp_path / "config.json")
save_config(config, path)
loaded = load_config_from_json(path)
assert loaded.meters[0].cost_factors[0].rate == -0.00077
class TestValidateRateComponent:
"""Tests for validate_rate_component."""
def test_valid_per_unit(self):
ok, err = validate_rate_component(
{"name": "Supply", "rate": 0.14742, "type": "per_unit"}
)
assert ok
def test_valid_fixed(self):
ok, err = validate_rate_component(
{"name": "Customer Charge", "rate": 9.65, "type": "fixed"}
)
assert ok
def test_missing_name(self):
ok, err = validate_rate_component({"rate": 0.10})
assert not ok
assert "name" in err.lower()
def test_invalid_rate(self):
ok, err = validate_rate_component({"name": "X", "rate": "bad"})
assert not ok
assert "rate" in err.lower()
def test_invalid_type(self):
ok, err = validate_rate_component(
{"name": "X", "rate": 0.10, "type": "tiered"}
)
assert not ok
assert "type" in err.lower()
def test_defaults_to_per_unit(self):
ok, err = validate_rate_component({"name": "X", "rate": 0.10})
assert ok
class TestGetMeterDefaults:
"""Tests for get_meter_defaults."""
def test_energy(self):
d = get_meter_defaults("energy")
assert d["icon"] == "mdi:flash"
assert d["unit"] == "kWh"
def test_gas(self):
d = get_meter_defaults("gas")
assert d["icon"] == "mdi:fire"
def test_water(self):
d = get_meter_defaults("water")
assert d["icon"] == "mdi:water"
assert d["unit"] == "gal"
def test_unknown(self):
d = get_meter_defaults("unknown")
assert d == {}

96
tests/test_cost.py Normal file
View File

@@ -0,0 +1,96 @@
"""Tests for hameter.cost."""
from hameter.config import RateComponent
from hameter.cost import calculate_incremental_cost
class TestCalculateIncrementalCost:
"""Tests for the cost calculation engine."""
def test_single_per_unit_rate(self):
factors = [RateComponent(name="Supply", rate=0.14742, type="per_unit")]
result = calculate_incremental_cost(100.0, factors)
assert result.delta == 100.0
assert abs(result.total_incremental_cost - 14.742) < 0.001
assert len(result.component_costs) == 1
assert result.component_costs[0]["name"] == "Supply"
assert abs(result.component_costs[0]["cost"] - 14.742) < 0.001
def test_multiple_per_unit_rates(self):
factors = [
RateComponent(name="Supply", rate=0.14742, type="per_unit"),
RateComponent(name="Distribution", rate=0.09443, type="per_unit"),
RateComponent(name="Transmission", rate=0.04673, type="per_unit"),
]
result = calculate_incremental_cost(961.0, factors)
expected = 961.0 * (0.14742 + 0.09443 + 0.04673)
assert abs(result.total_incremental_cost - expected) < 0.01
def test_negative_rate_credit(self):
factors = [
RateComponent(name="Supply", rate=0.10, type="per_unit"),
RateComponent(name="Credit", rate=-0.02, type="per_unit"),
]
result = calculate_incremental_cost(100.0, factors)
# 100 * 0.10 + 100 * (-0.02) = 10.0 - 2.0 = 8.0
assert abs(result.total_incremental_cost - 8.0) < 0.001
def test_fixed_charges_excluded(self):
factors = [
RateComponent(name="Supply", rate=0.10, type="per_unit"),
RateComponent(name="Customer Charge", rate=9.65, type="fixed"),
]
result = calculate_incremental_cost(100.0, factors)
# Only per_unit: 100 * 0.10 = 10.0
assert abs(result.total_incremental_cost - 10.0) < 0.001
# Fixed component should have cost=0
fixed = [c for c in result.component_costs if c["type"] == "fixed"]
assert len(fixed) == 1
assert fixed[0]["cost"] == 0.0
def test_zero_delta(self):
factors = [RateComponent(name="Supply", rate=0.10, type="per_unit")]
result = calculate_incremental_cost(0.0, factors)
assert result.total_incremental_cost == 0.0
assert result.delta == 0.0
def test_empty_cost_factors(self):
result = calculate_incremental_cost(100.0, [])
assert result.total_incremental_cost == 0.0
assert result.component_costs == []
def test_full_bill_example(self):
"""Match the user's electric bill: 961 kWh, $313.10 total."""
factors = [
RateComponent(name="Generation", rate=0.14742, type="per_unit"),
RateComponent(name="Dist 1", rate=0.09443, type="per_unit"),
RateComponent(name="Dist 2", rate=0.01037, type="per_unit"),
RateComponent(name="Transition", rate=-0.00077, type="per_unit"),
RateComponent(name="Transmission", rate=0.04673, type="per_unit"),
RateComponent(name="Net Meter Recovery", rate=0.00625, type="per_unit"),
RateComponent(name="Revenue Decoupling", rate=-0.00044, type="per_unit"),
RateComponent(name="Distributed Solar", rate=0.00583, type="per_unit"),
RateComponent(name="Renewable Energy", rate=0.00050, type="per_unit"),
RateComponent(name="Energy Efficiency", rate=0.02506, type="per_unit"),
RateComponent(name="EV Program", rate=0.00238, type="per_unit"),
RateComponent(name="Customer Charge", rate=9.65, type="fixed"),
]
result = calculate_incremental_cost(961.0, factors)
# Per-unit total (excluding fixed and the tiered distribution nuance)
# Sum of per-unit rates: 0.32776
per_unit_sum = sum(cf.rate for cf in factors if cf.type == "per_unit")
expected = 961.0 * per_unit_sum
assert abs(result.total_incremental_cost - expected) < 0.01
def test_component_costs_preserve_all_fields(self):
factors = [
RateComponent(name="Supply", rate=0.10, type="per_unit"),
RateComponent(name="Fixed", rate=5.0, type="fixed"),
]
result = calculate_incremental_cost(50.0, factors)
assert len(result.component_costs) == 2
for c in result.component_costs:
assert "name" in c
assert "rate" in c
assert "type" in c
assert "cost" in c

74
tests/test_cost_state.py Normal file
View File

@@ -0,0 +1,74 @@
"""Tests for hameter.cost_state persistence."""
import json
from hameter.cost_state import load_cost_state, save_cost_state
class TestCostStatePersistence:
"""Tests for load/save of cost state."""
def test_save_and_load(self, tmp_path):
path = str(tmp_path / "cost_state.json")
states = {
"123": {
"cumulative_cost": 156.78,
"last_calibrated_reading": 59830.5,
"billing_period_start": "2026-03-01T00:00:00Z",
"last_updated": "2026-03-05T14:30:00Z",
"fixed_charges_applied": 9.65,
}
}
save_cost_state(states, path)
loaded = load_cost_state(path)
assert loaded["123"]["cumulative_cost"] == 156.78
assert loaded["123"]["last_calibrated_reading"] == 59830.5
assert loaded["123"]["fixed_charges_applied"] == 9.65
def test_load_nonexistent_returns_empty(self, tmp_path):
path = str(tmp_path / "nonexistent.json")
result = load_cost_state(path)
assert result == {}
def test_load_corrupt_returns_empty(self, tmp_path):
path = str(tmp_path / "cost_state.json")
with open(path, "w") as f:
f.write("not json")
result = load_cost_state(path)
assert result == {}
def test_creates_parent_directory(self, tmp_path):
path = str(tmp_path / "subdir" / "cost_state.json")
save_cost_state({"1": {"cumulative_cost": 0}}, path)
loaded = load_cost_state(path)
assert "1" in loaded
def test_no_temp_file_left(self, tmp_path):
path = str(tmp_path / "cost_state.json")
save_cost_state({"1": {"cumulative_cost": 10.0}}, path)
files = list(tmp_path.iterdir())
assert len(files) == 1
assert files[0].name == "cost_state.json"
def test_multiple_meters(self, tmp_path):
path = str(tmp_path / "cost_state.json")
states = {
"111": {"cumulative_cost": 50.0, "last_calibrated_reading": 1000.0},
"222": {"cumulative_cost": 25.0, "last_calibrated_reading": 500.0},
}
save_cost_state(states, path)
loaded = load_cost_state(path)
assert len(loaded) == 2
assert loaded["111"]["cumulative_cost"] == 50.0
assert loaded["222"]["cumulative_cost"] == 25.0
def test_atomic_write_format(self, tmp_path):
path = str(tmp_path / "cost_state.json")
states = {"123": {"cumulative_cost": 42.0}}
save_cost_state(states, path)
with open(path) as f:
raw = f.read()
# Should be pretty-printed JSON with trailing newline
assert raw.endswith("\n")
parsed = json.loads(raw)
assert parsed == states

140
tests/test_discovery.py Normal file
View File

@@ -0,0 +1,140 @@
"""Unit tests for the hameter.discovery module."""
import threading
from unittest.mock import MagicMock, patch
import pytest
from hameter.discovery import run_discovery_for_web
from hameter.config import GeneralConfig
from hameter.state import AppState, PipelineStatus
VALID_LINE_1 = '{"Time":"2026-03-05T10:00:00Z","Type":"SCM","Message":{"ID":12345,"Consumption":50000}}'
VALID_LINE_2 = '{"Time":"2026-03-05T10:00:05Z","Type":"SCM","Message":{"ID":67890,"Consumption":12000}}'
VALID_LINE_3 = '{"Time":"2026-03-05T10:00:10Z","Type":"SCM","Message":{"ID":12345,"Consumption":50100}}'
@pytest.fixture
def config():
return GeneralConfig()
@pytest.fixture
def shutdown_event():
return threading.Event()
@pytest.fixture
def app_state():
return AppState()
@patch("hameter.discovery.SubprocessManager")
def test_run_discovery_for_web_records_results(
mock_spm_cls, config, shutdown_event, app_state
):
"""Parsed meter readings are recorded via app_state."""
mock_proc = MagicMock()
mock_spm_cls.return_value = mock_proc
mock_proc.start_discovery_mode.return_value = True
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
lines = [VALID_LINE_1, VALID_LINE_2, VALID_LINE_3]
if calls[0] <= len(lines):
return lines[calls[0] - 1]
shutdown_event.set()
return None
mock_proc.get_line.side_effect = get_line
run_discovery_for_web(
config, shutdown_event, app_state,
duration=300, stop_event=None,
)
results = app_state.get_discovery_results()
assert 12345 in results
assert 67890 in results
assert results[12345]["count"] >= 2
mock_proc.stop.assert_called_once()
@patch("hameter.discovery.SubprocessManager")
def test_run_discovery_for_web_start_failure(
mock_spm_cls, config, shutdown_event, app_state
):
"""Start failure sets ERROR status."""
mock_proc = MagicMock()
mock_spm_cls.return_value = mock_proc
mock_proc.start_discovery_mode.return_value = False
run_discovery_for_web(
config, shutdown_event, app_state,
duration=120, stop_event=None,
)
assert app_state.status == PipelineStatus.ERROR
mock_proc.get_line.assert_not_called()
@patch("hameter.discovery.SubprocessManager")
def test_run_discovery_for_web_respects_stop_event(
mock_spm_cls, config, shutdown_event, app_state
):
"""stop_event causes early exit."""
mock_proc = MagicMock()
mock_spm_cls.return_value = mock_proc
mock_proc.start_discovery_mode.return_value = True
stop_event = threading.Event()
stop_event.set()
mock_proc.get_line.return_value = VALID_LINE_1
run_discovery_for_web(
config, shutdown_event, app_state,
duration=300, stop_event=stop_event,
)
mock_proc.stop.assert_called_once()
@patch("hameter.discovery.SubprocessManager")
def test_run_discovery_for_web_handles_invalid_json(
mock_spm_cls, config, shutdown_event, app_state
):
"""Invalid lines are skipped without crashing."""
mock_proc = MagicMock()
mock_spm_cls.return_value = mock_proc
mock_proc.start_discovery_mode.return_value = True
calls = [0]
lines = [
"not json",
"{malformed{{",
"",
VALID_LINE_1,
]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] <= len(lines):
return lines[calls[0] - 1]
shutdown_event.set()
return None
mock_proc.get_line.side_effect = get_line
run_discovery_for_web(
config, shutdown_event, app_state,
duration=300, stop_event=None,
)
results = app_state.get_discovery_results()
assert 12345 in results
assert len(results) == 1
mock_proc.stop.assert_called_once()

125
tests/test_meter.py Normal file
View File

@@ -0,0 +1,125 @@
"""Tests for hameter.meter."""
import json
from pathlib import Path
import pytest
from hameter.config import MeterConfig
from hameter.meter import parse_rtlamr_line
FIXTURES_DIR = Path(__file__).parent / "fixtures"
@pytest.fixture
def sample_lines() -> list[str]:
"""Load sample rtlamr JSON lines from the fixture file."""
path = FIXTURES_DIR / "sample_rtlamr_output.jsonl"
return [line.strip() for line in path.read_text().splitlines() if line.strip()]
@pytest.fixture
def electric_meter() -> MeterConfig:
return MeterConfig(
id=23040293,
protocol="scm",
name="Electric Meter",
unit_of_measurement="kWh",
device_class="energy",
multiplier=0.1156,
)
@pytest.fixture
def meters_by_id(electric_meter) -> dict[int, MeterConfig]:
return {electric_meter.id: electric_meter}
class TestParseRtlamrLine:
def test_scm_matching_meter(self, sample_lines, meters_by_id):
"""SCM line for configured meter should parse and apply multiplier."""
# First line is SCM for meter 23040293 with Consumption=516012
reading = parse_rtlamr_line(sample_lines[0], meters_by_id)
assert reading is not None
assert reading.meter_id == 23040293
assert reading.protocol == "SCM"
assert reading.raw_consumption == 516012
assert reading.calibrated_consumption == round(516012 * 0.1156, 4)
def test_scm_second_reading(self, sample_lines, meters_by_id):
"""Second SCM line should show updated consumption."""
reading = parse_rtlamr_line(sample_lines[1], meters_by_id)
assert reading is not None
assert reading.raw_consumption == 516030
assert reading.calibrated_consumption == round(516030 * 0.1156, 4)
def test_idm_matching_meter(self, sample_lines, meters_by_id):
"""IDM line for same meter should parse using ERTSerialNumber."""
reading = parse_rtlamr_line(sample_lines[2], meters_by_id)
assert reading is not None
assert reading.meter_id == 23040293
assert reading.protocol == "IDM"
assert reading.raw_consumption == 2124513
def test_scm_plus_unconfigured_meter(self, sample_lines, meters_by_id):
"""SCM+ line for a meter NOT in our config should return None."""
reading = parse_rtlamr_line(sample_lines[3], meters_by_id)
assert reading is None
def test_r900_unconfigured_meter(self, sample_lines, meters_by_id):
"""R900 line for a meter NOT in our config should return None."""
reading = parse_rtlamr_line(sample_lines[4], meters_by_id)
assert reading is None
def test_scm_unknown_meter_filtered(self, sample_lines, meters_by_id):
"""SCM line for meter 99999999 should be filtered out."""
reading = parse_rtlamr_line(sample_lines[5], meters_by_id)
assert reading is None
def test_discovery_mode_accepts_all(self, sample_lines):
"""With empty meters dict (discovery mode), all lines should parse."""
results = []
for line in sample_lines:
r = parse_rtlamr_line(line, meters={})
if r:
results.append(r)
# All 6 lines should produce readings in discovery mode.
assert len(results) == 6
# Check different protocols parsed correctly.
protocols = {r.protocol for r in results}
assert protocols == {"SCM", "IDM", "SCM+", "R900"}
def test_multiplier_default_in_discovery(self, sample_lines):
"""In discovery mode, multiplier should default to 1.0."""
reading = parse_rtlamr_line(sample_lines[0], meters={})
assert reading is not None
# raw == calibrated when multiplier is 1.0
assert reading.calibrated_consumption == float(reading.raw_consumption)
def test_invalid_json_raises(self):
"""Non-JSON input should raise JSONDecodeError."""
with pytest.raises(json.JSONDecodeError):
parse_rtlamr_line("not json", meters={})
def test_unknown_type_returns_none(self):
"""Unknown message Type should return None."""
line = json.dumps({"Type": "UNKNOWN", "Message": {"ID": 1, "Consumption": 100}})
assert parse_rtlamr_line(line, meters={}) is None
def test_timestamp_from_rtlamr(self, sample_lines, meters_by_id):
"""Timestamp should come from the rtlamr Time field."""
reading = parse_rtlamr_line(sample_lines[0], meters_by_id)
assert reading is not None
assert reading.timestamp == "2026-03-01T15:18:58Z"
def test_r900_fields(self, sample_lines):
"""R900 reading should extract ID and Consumption correctly."""
# Line index 4 is R900
reading = parse_rtlamr_line(sample_lines[4], meters={})
assert reading is not None
assert reading.meter_id == 55512345
assert reading.raw_consumption == 12345678
assert reading.protocol == "R900"

167
tests/test_mqtt.py Normal file
View File

@@ -0,0 +1,167 @@
"""Unit tests for the HaMeterMQTT class."""
import json
import unittest
from unittest.mock import MagicMock, patch
from hameter.mqtt_client import HaMeterMQTT
from hameter.config import MqttConfig, MeterConfig, RateComponent
from hameter.meter import MeterReading
def _mqtt_config(**kw):
defaults = dict(
host="broker.test", port=1883, user="", password="",
base_topic="hameter", ha_autodiscovery=True,
ha_autodiscovery_topic="homeassistant", client_id="hameter",
)
defaults.update(kw)
return MqttConfig(**defaults)
def _meter(**kw):
defaults = dict(
id=100, protocol="scm", name="Electric",
unit_of_measurement="kWh", cost_factors=[],
)
defaults.update(kw)
return MeterConfig(**defaults)
def _reading(**kw):
defaults = dict(
meter_id=100, protocol="SCM", raw_consumption=50000,
calibrated_consumption=500.0, timestamp="2026-03-05T12:00:00Z",
raw_message={"ID": 100, "Consumption": 50000},
)
defaults.update(kw)
return MeterReading(**defaults)
@patch("hameter.mqtt_client.mqtt.Client")
class TestHaMeterMQTT(unittest.TestCase):
def test_connect_with_credentials(self, MockClient):
mock_inst = MockClient.return_value
cfg = _mqtt_config(user="u", password="p")
HaMeterMQTT(cfg, [_meter()])
mock_inst.username_pw_set.assert_called_once_with("u", "p")
def test_connect_without_credentials(self, MockClient):
mock_inst = MockClient.return_value
cfg = _mqtt_config(user="", password="")
HaMeterMQTT(cfg, [_meter()])
mock_inst.username_pw_set.assert_not_called()
def test_connect_calls_broker(self, MockClient):
mock_inst = MockClient.return_value
m = HaMeterMQTT(_mqtt_config(), [_meter()])
m.connect()
mock_inst.connect.assert_called_once_with("broker.test", 1883, keepalive=60)
mock_inst.loop_start.assert_called_once()
def test_publish_reading(self, MockClient):
mock_inst = MockClient.return_value
m = HaMeterMQTT(_mqtt_config(), [_meter(id=100)])
r = _reading()
m.publish_reading(r)
# Find the state publish call
calls = mock_inst.publish.call_args_list
state_calls = [c for c in calls if "100/state" in str(c)]
assert len(state_calls) == 1
payload = json.loads(state_calls[0][0][1])
assert payload["reading"] == 500.0
assert payload["raw_reading"] == 50000
def test_publish_cost(self, MockClient):
mock_inst = MockClient.return_value
m = HaMeterMQTT(_mqtt_config(), [_meter(id=200)])
m.publish_cost(200, 42.75)
calls = mock_inst.publish.call_args_list
cost_calls = [c for c in calls if "200/cost" in str(c)]
assert len(cost_calls) == 1
payload = json.loads(cost_calls[0][0][1])
assert payload["cost"] == 42.75
def test_disconnect_publishes_offline(self, MockClient):
mock_inst = MockClient.return_value
m = HaMeterMQTT(_mqtt_config(), [_meter()])
m.disconnect()
calls = mock_inst.publish.call_args_list
offline_calls = [c for c in calls if "status" in str(c[0][0])]
assert len(offline_calls) >= 1
assert offline_calls[-1][0][1] == "offline"
mock_inst.loop_stop.assert_called_once()
mock_inst.disconnect.assert_called_once()
def test_on_connect_success(self, MockClient):
mock_inst = MockClient.return_value
HaMeterMQTT(_mqtt_config(), [_meter()])
on_connect = mock_inst.on_connect
mock_inst.publish.reset_mock()
mock_inst.subscribe.reset_mock()
on_connect(mock_inst, None, MagicMock(), 0, None)
publish_calls = mock_inst.publish.call_args_list
online = [c for c in publish_calls if c[0][1] == "online"]
assert len(online) >= 1
mock_inst.subscribe.assert_called_once()
def test_on_connect_failure(self, MockClient):
mock_inst = MockClient.return_value
HaMeterMQTT(_mqtt_config(), [_meter()])
on_connect = mock_inst.on_connect
mock_inst.publish.reset_mock()
mock_inst.subscribe.reset_mock()
on_connect(mock_inst, None, MagicMock(), 5, None)
mock_inst.publish.assert_not_called()
mock_inst.subscribe.assert_not_called()
def test_on_disconnect_clean(self, MockClient):
mock_inst = MockClient.return_value
HaMeterMQTT(_mqtt_config(), [_meter()])
on_disc = mock_inst.on_disconnect
on_disc(mock_inst, None, MagicMock(), 0, None)
def test_on_message_ha_online(self, MockClient):
mock_inst = MockClient.return_value
HaMeterMQTT(_mqtt_config(), [_meter()])
on_msg = mock_inst.on_message
mock_inst.publish.reset_mock()
msg = MagicMock()
msg.topic = "homeassistant/status"
msg.payload = b"online"
on_msg(mock_inst, None, msg)
calls = mock_inst.publish.call_args_list
disco = [c for c in calls if "homeassistant/sensor/" in str(c)]
assert len(disco) >= 3
def test_discovery_without_cost_factors(self, MockClient):
mock_inst = MockClient.return_value
HaMeterMQTT(_mqtt_config(), [_meter(cost_factors=[])])
mock_inst.publish.reset_mock()
# Access the instance through the mock (HaMeterMQTT stores it as self._client)
# We need the HaMeterMQTT instance to call _publish_discovery
m = HaMeterMQTT(_mqtt_config(), [_meter(cost_factors=[])])
mock_inst.publish.reset_mock()
m._publish_discovery()
calls = mock_inst.publish.call_args_list
disco = [c for c in calls if "homeassistant/sensor/" in str(c)]
assert len(disco) == 3
topics = " ".join(str(c) for c in disco)
assert "cost/config" not in topics
def test_discovery_with_cost_factors(self, MockClient):
mock_inst = MockClient.return_value
meter = _meter(cost_factors=[
RateComponent(name="Supply", rate=0.10, type="per_unit"),
])
m = HaMeterMQTT(_mqtt_config(), [meter])
mock_inst.publish.reset_mock()
m._publish_discovery()
calls = mock_inst.publish.call_args_list
disco = [c for c in calls if "homeassistant/sensor/" in str(c)]
assert len(disco) == 4
cost_calls = [c for c in disco if "cost/config" in str(c)]
assert len(cost_calls) == 1
payload = json.loads(cost_calls[0][0][1])
assert payload["device_class"] == "monetary"

419
tests/test_pipeline.py Normal file
View File

@@ -0,0 +1,419 @@
"""Unit tests for the Pipeline class."""
import threading
from unittest.mock import MagicMock, patch
import pytest
from hameter.pipeline import Pipeline
from hameter.meter import MeterReading
from hameter.config import (
HaMeterConfig,
GeneralConfig,
MqttConfig,
MeterConfig,
RateComponent,
)
from hameter.cost import CostResult
from hameter.state import AppState, CostState
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(meters=None):
if meters is None:
meters = [
MeterConfig(
id=12345,
protocol="scm",
name="Electric Meter",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="energy", rate=0.12, type="per_unit"),
],
),
]
return HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="localhost"),
meters=meters,
)
def _make_reading(meter_id=12345, raw=100000, calibrated=1000.0):
return MeterReading(
meter_id=meter_id,
protocol="SCM",
raw_consumption=raw,
calibrated_consumption=calibrated,
timestamp="2026-03-05T12:00:00Z",
raw_message={"ID": meter_id, "Consumption": raw},
)
_P = "hameter.pipeline"
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@patch(f"{_P}.HaMeterMQTT")
@patch(f"{_P}.SubprocessManager")
class TestPipeline:
def test_pipeline_starts_and_shuts_down(self, MockSubMgr, MockMQTT):
"""Pipeline connects MQTT, starts subprocesses, and shuts down."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
proc.get_line.side_effect = lambda timeout=1.0: (shutdown.set(), None)[1]
Pipeline(config, shutdown, app_state).run()
mqtt.connect.assert_called_once()
proc.start.assert_called_once()
proc.stop.assert_called_once()
mqtt.disconnect.assert_called_once()
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_processes_reading(self, mock_parse, MockSubMgr, MockMQTT):
"""A valid reading is parsed and published via MQTT."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
reading = _make_reading()
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"Type":"SCM","Message":{"ID":12345}}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
Pipeline(config, shutdown, app_state).run()
mock_parse.assert_called_once()
mqtt.publish_reading.assert_called_once_with(reading)
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_cost_first_reading_sets_baseline(
self, mock_parse, mock_save, MockSubMgr, MockMQTT
):
"""First reading sets baseline, no cost published."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
reading = _make_reading(calibrated=1000.0)
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
Pipeline(config, shutdown, app_state).run()
mqtt.publish_cost.assert_not_called()
cs = app_state.get_cost_state(12345)
assert cs is not None
assert cs.last_calibrated_reading == 1000.0
@patch(f"{_P}.calculate_incremental_cost")
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_cost_with_valid_delta(
self, mock_parse, mock_save, mock_calc, MockSubMgr, MockMQTT
):
"""With a baseline, a positive delta triggers cost calculation."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
# Pre-set cost state with a baseline
app_state.update_cost_state(12345, CostState(
cumulative_cost=1.20,
last_calibrated_reading=1000.0,
billing_period_start="2026-03-01T00:00:00Z",
))
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
reading = _make_reading(calibrated=1010.0) # delta = 10.0
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
mock_calc.return_value = CostResult(
delta=10.0,
per_unit_cost=1.20,
component_costs=[],
total_incremental_cost=1.20,
)
Pipeline(config, shutdown, app_state).run()
mock_calc.assert_called_once()
mqtt.publish_cost.assert_called_once()
cs = app_state.get_cost_state(12345)
assert cs.cumulative_cost == pytest.approx(2.40)
mock_save.assert_called()
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_cost_skips_no_cost_factors(
self, mock_parse, mock_save, MockSubMgr, MockMQTT
):
"""Meter without cost_factors: no cost processing."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config(meters=[
MeterConfig(id=99999, protocol="scm", name="Water",
unit_of_measurement="gal", cost_factors=[]),
])
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
reading = _make_reading(meter_id=99999, calibrated=500.0)
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
Pipeline(config, shutdown, app_state).run()
mqtt.publish_reading.assert_called_once()
mqtt.publish_cost.assert_not_called()
mock_save.assert_not_called()
@patch(f"{_P}.calculate_incremental_cost")
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_cost_skips_negative_delta(
self, mock_parse, mock_save, mock_calc, MockSubMgr, MockMQTT
):
"""Delta <= 0 skips cost calculation."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
app_state.update_cost_state(12345, CostState(
cumulative_cost=5.0,
last_calibrated_reading=1000.0,
))
reading = _make_reading(calibrated=1000.0) # delta = 0
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
Pipeline(config, shutdown, app_state).run()
mock_calc.assert_not_called()
mqtt.publish_cost.assert_not_called()
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_cost_after_billing_reset(
self, mock_parse, mock_save, MockSubMgr, MockMQTT
):
"""After billing reset (last_calibrated_reading=None), sets baseline only."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
app_state.update_cost_state(12345, CostState(
cumulative_cost=0.0,
last_calibrated_reading=None,
billing_period_start="2026-03-01T00:00:00Z",
))
reading = _make_reading(calibrated=1050.0)
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
Pipeline(config, shutdown, app_state).run()
mqtt.publish_cost.assert_not_called()
cs = app_state.get_cost_state(12345)
assert cs.last_calibrated_reading == 1050.0
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_handles_restart_request(
self, mock_parse, MockSubMgr, MockMQTT
):
"""Restart request causes main loop to exit."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
app_state.restart_requested.set()
return None
proc.get_line.side_effect = get_line
Pipeline(config, shutdown, app_state).run()
mock_parse.assert_not_called()
proc.stop.assert_called_once()
mqtt.disconnect.assert_called_once()
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_handles_unhealthy_subprocess(
self, mock_parse, MockSubMgr, MockMQTT
):
"""Unhealthy subprocess triggers a restart attempt."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.restart.return_value = True
health = iter([False, True, True])
proc.is_healthy.side_effect = lambda: next(health, True)
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] >= 2:
shutdown.set()
return None
proc.get_line.side_effect = get_line
Pipeline(config, shutdown, app_state).run()
proc.restart.assert_called()
@patch(f"{_P}.calculate_incremental_cost")
@patch(f"{_P}.save_cost_state")
@patch(f"{_P}.parse_rtlamr_line")
def test_pipeline_saves_cost_state(
self, mock_parse, mock_save, mock_calc, MockSubMgr, MockMQTT
):
"""save_cost_state is called after a cost update."""
shutdown = threading.Event()
app_state = AppState()
config = _make_config()
app_state.update_cost_state(12345, CostState(
cumulative_cost=0.0,
last_calibrated_reading=900.0,
))
reading = _make_reading(calibrated=950.0)
proc = MockSubMgr.return_value
mqtt = MockMQTT.return_value
proc.start.return_value = True
proc.is_healthy.return_value = True
calls = [0]
def get_line(timeout=1.0):
calls[0] += 1
if calls[0] == 1:
return '{"data":"x"}'
shutdown.set()
return None
proc.get_line.side_effect = get_line
mock_parse.return_value = reading
mock_calc.return_value = CostResult(
delta=50.0, per_unit_cost=6.0,
component_costs=[], total_incremental_cost=6.0,
)
Pipeline(config, shutdown, app_state).run()
mock_save.assert_called()

261
tests/test_state.py Normal file
View File

@@ -0,0 +1,261 @@
"""Tests for hameter.state."""
import threading
import pytest
from hameter.meter import MeterReading
from hameter.state import AppState, CostState, PipelineStatus, WebLogHandler
class TestAppState:
"""Tests for the AppState shared state object."""
def test_initial_status(self):
state = AppState()
assert state.status == PipelineStatus.UNCONFIGURED
def test_set_status(self):
state = AppState()
state.set_status(PipelineStatus.RUNNING)
assert state.status == PipelineStatus.RUNNING
assert state.status_message == ""
def test_set_status_with_message(self):
state = AppState()
state.set_status(PipelineStatus.ERROR, "Something broke")
assert state.status == PipelineStatus.ERROR
assert state.status_message == "Something broke"
def test_record_reading(self):
state = AppState()
reading = MeterReading(
meter_id=123,
protocol="scm",
raw_consumption=1000,
calibrated_consumption=100.0,
timestamp="2026-01-01 00:00:00",
raw_message={},
)
state.record_reading(reading)
readings = state.get_last_readings()
assert 123 in readings
assert readings[123].raw_consumption == 1000
counts = state.get_reading_counts()
assert counts[123] == 1
def test_multiple_readings_same_meter(self):
state = AppState()
for i in range(5):
reading = MeterReading(
meter_id=123,
protocol="scm",
raw_consumption=1000 + i,
calibrated_consumption=100.0 + i,
timestamp=f"2026-01-01 00:00:0{i}",
raw_message={},
)
state.record_reading(reading)
readings = state.get_last_readings()
assert readings[123].raw_consumption == 1004 # Last one
counts = state.get_reading_counts()
assert counts[123] == 5
def test_discovery_results(self):
state = AppState()
state.record_discovery(111, {"protocol": "scm", "count": 1})
state.record_discovery(222, {"protocol": "r900", "count": 3})
results = state.get_discovery_results()
assert len(results) == 2
assert results[111]["protocol"] == "scm"
assert results[222]["count"] == 3
def test_clear_discovery_results(self):
state = AppState()
state.record_discovery(111, {"protocol": "scm", "count": 1})
state.clear_discovery_results()
assert state.get_discovery_results() == {}
def test_log_buffer(self):
state = AppState()
state.add_log({"message": "hello"})
state.add_log({"message": "world"})
logs = state.get_recent_logs(10)
assert len(logs) == 2
assert logs[0]["message"] == "hello"
assert logs[1]["message"] == "world"
def test_log_buffer_max_size(self):
state = AppState()
for i in range(1500):
state.add_log({"message": f"log {i}"})
logs = state.get_recent_logs(2000)
assert len(logs) == 1000 # maxlen
def test_log_buffer_recent_count(self):
state = AppState()
for i in range(50):
state.add_log({"message": f"log {i}"})
logs = state.get_recent_logs(10)
assert len(logs) == 10
assert logs[-1]["message"] == "log 49"
def test_sse_subscribe_notify(self):
state = AppState()
event = state.subscribe_sse()
assert not event.is_set()
state.set_status(PipelineStatus.RUNNING)
assert event.is_set()
def test_sse_unsubscribe(self):
state = AppState()
event = state.subscribe_sse()
state.unsubscribe_sse(event)
# Should not raise even if unsubscribing twice
state.unsubscribe_sse(event)
def test_config_ready_event(self):
state = AppState()
assert not state.config_ready.is_set()
state.config_ready.set()
assert state.config_ready.is_set()
def test_restart_requested_event(self):
state = AppState()
assert not state.restart_requested.is_set()
state.restart_requested.set()
assert state.restart_requested.is_set()
state.restart_requested.clear()
assert not state.restart_requested.is_set()
def test_discovery_duration(self):
state = AppState()
assert state.discovery_duration == 120
state.discovery_duration = 60
assert state.discovery_duration == 60
def test_thread_safety(self):
"""Multiple threads recording readings simultaneously."""
state = AppState()
errors = []
def record(meter_id):
try:
for i in range(100):
reading = MeterReading(
meter_id=meter_id,
protocol="scm",
raw_consumption=i,
calibrated_consumption=float(i),
timestamp="2026-01-01",
raw_message={},
)
state.record_reading(reading)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=record, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
counts = state.get_reading_counts()
for i in range(10):
assert counts[i] == 100
class TestCostState:
"""Tests for cost state tracking in AppState."""
def test_initial_cost_states_empty(self):
state = AppState()
assert state.get_cost_states() == {}
assert state.get_cost_state(123) is None
def test_update_cost_state(self):
state = AppState()
cs = CostState(cumulative_cost=50.0, last_calibrated_reading=1000.0)
state.update_cost_state(123, cs)
result = state.get_cost_state(123)
assert result is not None
assert result.cumulative_cost == 50.0
assert result.last_calibrated_reading == 1000.0
def test_get_cost_states_multiple(self):
state = AppState()
state.update_cost_state(111, CostState(cumulative_cost=10.0))
state.update_cost_state(222, CostState(cumulative_cost=20.0))
states = state.get_cost_states()
assert len(states) == 2
assert states[111].cumulative_cost == 10.0
assert states[222].cumulative_cost == 20.0
def test_reset_cost_state(self):
state = AppState()
cs = CostState(
cumulative_cost=100.0,
last_calibrated_reading=5000.0,
fixed_charges_applied=9.65,
)
state.update_cost_state(123, cs)
state.reset_cost_state(123, "2026-04-01T00:00:00Z")
result = state.get_cost_state(123)
assert result.cumulative_cost == 0.0
assert result.last_calibrated_reading is None
assert result.billing_period_start == "2026-04-01T00:00:00Z"
assert result.fixed_charges_applied == 0.0
def test_add_fixed_charges(self):
state = AppState()
cs = CostState(cumulative_cost=50.0, last_calibrated_reading=1000.0)
state.update_cost_state(123, cs)
state.add_fixed_charges(123, 9.65, "2026-03-05T00:00:00Z")
result = state.get_cost_state(123)
assert result.cumulative_cost == 59.65
assert result.fixed_charges_applied == 9.65
def test_add_fixed_charges_accumulates(self):
state = AppState()
cs = CostState(cumulative_cost=50.0)
state.update_cost_state(123, cs)
state.add_fixed_charges(123, 5.0, "2026-03-01")
state.add_fixed_charges(123, 3.0, "2026-03-02")
result = state.get_cost_state(123)
assert result.cumulative_cost == 58.0
assert result.fixed_charges_applied == 8.0
def test_add_fixed_charges_no_cost_state(self):
state = AppState()
# Should not raise even if no cost state exists
state.add_fixed_charges(999, 10.0, "2026-03-01")
assert state.get_cost_state(999) is None
def test_cost_state_notifies_sse(self):
state = AppState()
event = state.subscribe_sse()
state.update_cost_state(123, CostState(cumulative_cost=10.0))
assert event.is_set()
class TestWebLogHandler:
"""Tests for the WebLogHandler."""
def test_emits_to_app_state(self):
state = AppState()
handler = WebLogHandler(state)
import logging
logger = logging.getLogger("test.webloghandler")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.info("Test message")
logger.removeHandler(handler)
logs = state.get_recent_logs()
assert len(logs) >= 1
last = logs[-1]
assert last["level"] == "INFO"
assert last["message"] == "Test message"
assert "timestamp" in last

151
tests/test_subprocess.py Normal file
View File

@@ -0,0 +1,151 @@
"""Unit tests for the SubprocessManager class."""
import queue
import threading
import unittest
from unittest.mock import MagicMock, patch
from hameter.subprocess_manager import SubprocessManager
from hameter.config import GeneralConfig
def _config():
return GeneralConfig(
device_id="0",
rtl_tcp_host="127.0.0.1",
rtl_tcp_port=1234,
rtlamr_extra_args=[],
)
def _mock_proc(pid=1000, alive=True, stdout_lines=None):
"""Create a mock subprocess.Popen."""
proc = MagicMock()
proc.pid = pid
proc.poll.return_value = None if alive else 1
proc.wait.return_value = 0
# SubprocessManager uses text=True, so lines are strings (not bytes)
if stdout_lines is not None:
proc.stdout = iter(stdout_lines)
else:
proc.stdout = iter([])
proc.stderr = MagicMock()
proc.stderr.read.return_value = ""
return proc
class TestSubprocessManager(unittest.TestCase):
def setUp(self):
self.shutdown = threading.Event()
self.mgr = SubprocessManager(_config(), self.shutdown)
@patch("time.sleep")
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_start_success(self, mock_popen, mock_sleep):
"""start() returns True when both processes start OK."""
rtl = _mock_proc(pid=100, stdout_lines=["listening...\n"])
rtlamr = _mock_proc(pid=200)
mock_popen.side_effect = [rtl, rtlamr]
result = self.mgr.start([12345], ["scm"])
self.assertTrue(result)
self.assertEqual(mock_popen.call_count, 2)
@patch("time.sleep")
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_start_rtl_tcp_not_found(self, mock_popen, mock_sleep):
"""start() returns False when rtl_tcp binary not found."""
mock_popen.side_effect = FileNotFoundError("not found")
result = self.mgr.start([12345], ["scm"])
self.assertFalse(result)
@patch("time.sleep")
@patch("hameter.subprocess_manager.os.killpg")
@patch("hameter.subprocess_manager.os.getpgid", side_effect=lambda p: p)
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_stop_kills_processes(self, mock_popen, mock_getpgid, mock_killpg, mock_sleep):
"""stop() kills both subprocess groups."""
rtl = _mock_proc(pid=100, stdout_lines=["listening...\n"])
rtlamr = _mock_proc(pid=200)
mock_popen.side_effect = [rtl, rtlamr]
self.mgr.start([12345], ["scm"])
self.mgr.stop()
# killpg should be called for both processes
self.assertGreaterEqual(mock_killpg.call_count, 2)
@patch("time.sleep")
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_is_healthy_both_running(self, mock_popen, mock_sleep):
"""is_healthy() True when both poll() return None."""
rtl = _mock_proc(pid=100, stdout_lines=["listening...\n"])
rtlamr = _mock_proc(pid=200)
mock_popen.side_effect = [rtl, rtlamr]
self.mgr.start([12345], ["scm"])
self.assertTrue(self.mgr.is_healthy())
@patch("time.sleep")
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_is_healthy_dead_process(self, mock_popen, mock_sleep):
"""is_healthy() False when one process exits."""
rtl = _mock_proc(pid=100, stdout_lines=["listening...\n"])
rtlamr = _mock_proc(pid=200)
mock_popen.side_effect = [rtl, rtlamr]
self.mgr.start([12345], ["scm"])
rtlamr.poll.return_value = 1
self.assertFalse(self.mgr.is_healthy())
def test_get_line_from_queue(self):
"""get_line() returns queued data."""
self.mgr._output_queue.put("test line")
self.assertEqual(self.mgr.get_line(timeout=1.0), "test line")
def test_get_line_timeout(self):
"""get_line() returns None on empty queue."""
self.assertIsNone(self.mgr.get_line(timeout=0.05))
@patch("hameter.subprocess_manager.os.killpg")
@patch("hameter.subprocess_manager.os.getpgid", side_effect=lambda p: p)
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_restart_with_backoff(self, mock_popen, mock_getpgid, mock_killpg):
"""restart() stops, waits, then starts again."""
procs = [
_mock_proc(pid=100, stdout_lines=["listening...\n"]),
_mock_proc(pid=200),
_mock_proc(pid=300, stdout_lines=["listening...\n"]),
_mock_proc(pid=400),
]
mock_popen.side_effect = procs
with patch("time.sleep"):
self.mgr.start([12345], ["scm"])
result = self.mgr.restart([12345], ["scm"])
self.assertTrue(result)
self.assertEqual(mock_popen.call_count, 4)
@patch("time.sleep")
@patch("hameter.subprocess_manager.subprocess.Popen")
def test_start_discovery_mode(self, mock_popen, mock_sleep):
"""start_discovery_mode() uses 'all' protocols, no filter."""
rtl = _mock_proc(pid=100, stdout_lines=["listening...\n"])
rtlamr = _mock_proc(pid=200)
mock_popen.side_effect = [rtl, rtlamr]
result = self.mgr.start_discovery_mode()
self.assertTrue(result)
rtlamr_call = mock_popen.call_args_list[1]
cmd = rtlamr_call[0][0]
cmd_str = " ".join(cmd)
self.assertIn("all", cmd_str.lower())
self.assertNotIn("filterid", cmd_str.lower())

387
tests/test_web.py Normal file
View File

@@ -0,0 +1,387 @@
"""Tests for the web UI routes using Flask test client."""
import json
import pytest
from hameter.config import (
GeneralConfig,
HaMeterConfig,
MeterConfig,
MqttConfig,
RateComponent,
save_config,
)
from hameter.state import AppState, CostState, PipelineStatus
from hameter.web import create_app
@pytest.fixture
def app_state():
return AppState()
@pytest.fixture
def client(app_state):
app = create_app(app_state)
app.config["TESTING"] = True
with app.test_client() as client:
yield client
@pytest.fixture
def configured_state(app_state, tmp_path, monkeypatch):
"""AppState with a config loaded."""
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=123, protocol="scm", name="Electric",
unit_of_measurement="kWh",
),
],
)
save_config(config, str(tmp_path / "config.json"))
app_state.set_config(config)
app_state.config_ready.set()
app_state.set_status(PipelineStatus.RUNNING)
return app_state
class TestRedirects:
def test_root_redirects_to_setup_when_unconfigured(self, client, app_state):
response = client.get("/")
assert response.status_code == 302
assert "/setup" in response.headers["Location"]
def test_root_redirects_to_dashboard_when_configured(self, client, configured_state):
response = client.get("/")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
def test_setup_redirects_to_dashboard_when_configured(self, client, configured_state):
response = client.get("/setup")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
class TestApiStatus:
def test_status_returns_current_state(self, client, app_state):
app_state.set_status(PipelineStatus.RUNNING, "All good")
response = client.get("/api/status")
data = response.get_json()
assert data["status"] == "running"
assert data["message"] == "All good"
def test_status_unconfigured(self, client, app_state):
response = client.get("/api/status")
data = response.get_json()
assert data["status"] == "unconfigured"
class TestApiSetup:
def test_setup_saves_config(self, client, app_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
response = client.post("/api/setup", json={
"mqtt": {"host": "10.0.0.1", "port": 1883},
"meter": {"id": 123, "protocol": "scm", "name": "Test"},
})
assert response.status_code == 200
data = response.get_json()
assert data["ok"]
assert app_state.config_ready.is_set()
def test_setup_without_meter(self, client, app_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
response = client.post("/api/setup", json={
"mqtt": {"host": "10.0.0.1"},
})
assert response.status_code == 200
assert app_state.config is not None
assert app_state.config.meters == []
def test_setup_rejects_missing_host(self, client):
response = client.post("/api/setup", json={
"mqtt": {"port": 1883},
})
assert response.status_code == 400
class TestApiConfigMeters:
def test_list_meters(self, client, configured_state):
response = client.get("/api/config/meters")
data = response.get_json()
assert len(data) == 1
assert data[0]["id"] == 123
def test_add_meter(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.post("/api/config/meters", json={
"id": 456,
"protocol": "r900",
"name": "Water",
})
assert response.status_code == 200
assert response.get_json()["ok"]
assert len(configured_state.config.meters) == 2
def test_add_duplicate_meter_rejected(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.post("/api/config/meters", json={
"id": 123, # Already exists
"protocol": "scm",
"name": "Dup",
})
assert response.status_code == 409
def test_delete_meter(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.delete("/api/config/meters/123")
assert response.status_code == 200
assert len(configured_state.config.meters) == 0
def test_delete_nonexistent_meter(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.delete("/api/config/meters/999")
assert response.status_code == 404
def test_update_meter(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.put("/api/config/meters/123", json={
"protocol": "scm",
"name": "Updated Electric",
"multiplier": 0.5,
})
assert response.status_code == 200
assert configured_state.config.meters[0].name == "Updated Electric"
assert configured_state.config.meters[0].multiplier == 0.5
class TestApiPipeline:
def test_restart_sets_event(self, client, app_state):
response = client.post("/api/pipeline/restart")
assert response.status_code == 200
assert app_state.restart_requested.is_set()
class TestApiDiscovery:
def test_start_sets_event(self, client, app_state):
response = client.post("/api/discovery/start", json={"duration": 60})
assert response.status_code == 200
assert app_state.discovery_requested.is_set()
assert app_state.discovery_duration == 60
def test_stop_sets_event(self, client, app_state):
response = client.post("/api/discovery/stop")
assert response.status_code == 200
assert app_state.stop_discovery.is_set()
def test_results_empty(self, client, app_state):
response = client.get("/api/discovery/results")
data = response.get_json()
assert data == []
class TestApiCalibration:
def test_calculate(self, client):
response = client.post("/api/calibration/calculate", json={
"raw_reading": 516030,
"physical_reading": 59669,
})
data = response.get_json()
assert "multiplier" in data
assert abs(data["multiplier"] - 0.115633) < 0.001
def test_calculate_zero_raw(self, client):
response = client.post("/api/calibration/calculate", json={
"raw_reading": 0,
"physical_reading": 100,
})
assert response.status_code == 400
class TestApiLogs:
def test_get_logs(self, client, app_state):
app_state.add_log({"level": "INFO", "message": "test"})
response = client.get("/api/logs")
data = response.get_json()
assert len(data) >= 1
class TestApiMeterDefaults:
def test_energy_defaults(self, client):
response = client.get("/api/meter_defaults/energy")
data = response.get_json()
assert data["icon"] == "mdi:flash"
assert data["unit"] == "kWh"
def test_unknown_defaults(self, client):
response = client.get("/api/meter_defaults/unknown")
data = response.get_json()
assert data == {}
class TestApiCosts:
@pytest.fixture
def cost_state(self, app_state, tmp_path, monkeypatch):
"""AppState with a meter that has cost_factors and a cost state."""
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
monkeypatch.setattr("hameter.cost_state.COST_STATE_PATH", str(tmp_path / "cost_state.json"))
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=123, protocol="scm", name="Electric",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="Supply", rate=0.10, type="per_unit"),
RateComponent(name="Customer Charge", rate=9.65, type="fixed"),
],
),
],
)
save_config(config, str(tmp_path / "config.json"))
app_state.set_config(config)
app_state.config_ready.set()
app_state.set_status(PipelineStatus.RUNNING)
app_state.update_cost_state(123, CostState(
cumulative_cost=50.0,
last_calibrated_reading=1000.0,
billing_period_start="2026-03-01T00:00:00Z",
))
return app_state
def test_get_costs(self, client, cost_state):
response = client.get("/api/costs")
data = response.get_json()
assert "123" in data
assert data["123"]["cumulative_cost"] == 50.0
def test_reset_cost(self, client, cost_state):
response = client.post("/api/costs/123/reset")
assert response.status_code == 200
data = response.get_json()
assert data["ok"]
assert data["billing_period_start"]
cs = cost_state.get_cost_state(123)
assert cs.cumulative_cost == 0.0
assert cs.last_calibrated_reading is None
def test_add_fixed_charges(self, client, cost_state):
response = client.post("/api/costs/123/add-fixed")
assert response.status_code == 200
data = response.get_json()
assert data["ok"]
assert data["fixed_added"] == 9.65
assert abs(data["cumulative_cost"] - 59.65) < 0.01
def test_add_fixed_charges_no_meter(self, client, cost_state):
response = client.post("/api/costs/999/add-fixed")
assert response.status_code == 404
def test_get_costs_empty(self, client, app_state):
response = client.get("/api/costs")
data = response.get_json()
assert data == {}
class TestApiMetersWithCostFactors:
def test_list_meters_includes_cost_factors(self, client, app_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
config = HaMeterConfig(
general=GeneralConfig(),
mqtt=MqttConfig(host="10.0.0.1"),
meters=[
MeterConfig(
id=123, protocol="scm", name="Electric",
unit_of_measurement="kWh",
cost_factors=[
RateComponent(name="Supply", rate=0.10, type="per_unit"),
],
),
],
)
save_config(config, str(tmp_path / "config.json"))
app_state.set_config(config)
app_state.config_ready.set()
response = client.get("/api/config/meters")
data = response.get_json()
assert len(data) == 1
assert len(data[0]["cost_factors"]) == 1
assert data[0]["cost_factors"][0]["name"] == "Supply"
assert data[0]["cost_factors"][0]["rate"] == 0.10
def test_add_meter_with_cost_factors(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.post("/api/config/meters", json={
"id": 456,
"protocol": "r900",
"name": "Water",
"cost_factors": [
{"name": "Supply", "rate": 0.05, "type": "per_unit"},
],
})
assert response.status_code == 200
meter = configured_state.config.meters[1]
assert len(meter.cost_factors) == 1
assert meter.cost_factors[0].name == "Supply"
def test_update_meter_with_cost_factors(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.put("/api/config/meters/123", json={
"protocol": "scm",
"name": "Electric",
"cost_factors": [
{"name": "Generation", "rate": 0.14742, "type": "per_unit"},
{"name": "Customer Charge", "rate": 9.65, "type": "fixed"},
],
})
assert response.status_code == 200
meter = configured_state.config.meters[0]
assert len(meter.cost_factors) == 2
assert meter.cost_factors[0].name == "Generation"
assert meter.cost_factors[1].type == "fixed"
def test_add_meter_with_invalid_cost_factor(self, client, configured_state, tmp_path, monkeypatch):
monkeypatch.setattr("hameter.config.CONFIG_PATH", str(tmp_path / "config.json"))
save_config(configured_state.config, str(tmp_path / "config.json"))
response = client.post("/api/config/meters", json={
"id": 456,
"protocol": "r900",
"name": "Water",
"cost_factors": [
{"name": "", "rate": 0.05}, # Empty name
],
})
assert response.status_code == 400