initial commit
This commit is contained in:
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