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