initial commit

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

0
tests/__init__.py Normal file
View File

View File

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

512
tests/test_config.py Normal file
View File

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

96
tests/test_cost.py Normal file
View File

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

74
tests/test_cost_state.py Normal file
View File

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

140
tests/test_discovery.py Normal file
View File

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

125
tests/test_meter.py Normal file
View File

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

167
tests/test_mqtt.py Normal file
View File

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

419
tests/test_pipeline.py Normal file
View File

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

261
tests/test_state.py Normal file
View File

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

151
tests/test_subprocess.py Normal file
View File

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

387
tests/test_web.py Normal file
View File

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