v1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
|
||||
from oms_tui.app import OmsApp, TelegramEvent
|
||||
|
||||
DLL = {
|
||||
"type": "dll",
|
||||
"id": "76348799",
|
||||
"manufacturer": "KAM",
|
||||
"version": "1b",
|
||||
"media": "Cold water meter",
|
||||
"driver": "kamwater",
|
||||
"c_field": "44",
|
||||
"rssi": 97,
|
||||
}
|
||||
JSON = {
|
||||
"type": "json",
|
||||
"id": "76348799",
|
||||
"driver": "kamwater",
|
||||
"media": "cold water",
|
||||
"fields": {"total_m3": 6.408, "status": "DRY"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_appears_and_selects():
|
||||
app = OmsApp(device="none", autostart=False)
|
||||
async with app.run_test() as pilot:
|
||||
app.post_message(TelegramEvent(DLL))
|
||||
await pilot.pause()
|
||||
table = app.query_one("#devices")
|
||||
assert table.row_count == 1
|
||||
|
||||
# nothing selected yet -> global stream mode
|
||||
assert app.show_all is True
|
||||
|
||||
# decrypted JSON updates the model
|
||||
app.post_message(TelegramEvent(JSON))
|
||||
await pilot.pause()
|
||||
dev = app.store.get("76348799")
|
||||
assert dev.decrypted is True
|
||||
assert dev.fields["total_m3"] == 6.408
|
||||
assert dev.count == 1 # one DLL telegram
|
||||
|
||||
# select the device
|
||||
app.action_select()
|
||||
await pilot.pause()
|
||||
assert app.show_all is False
|
||||
assert app.selected_id == "76348799"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_key_via_modal():
|
||||
app = OmsApp(device="none", autostart=False)
|
||||
async with app.run_test() as pilot:
|
||||
app.post_message(TelegramEvent(DLL))
|
||||
await pilot.pause()
|
||||
app.action_select()
|
||||
await pilot.pause()
|
||||
|
||||
app.action_set_key() # opens the modal prompt
|
||||
await pilot.pause()
|
||||
await pilot.press(*"28F64A24988064A079AA2C807D6102AE")
|
||||
await pilot.press("enter") # submit
|
||||
await pilot.pause()
|
||||
|
||||
dev = app.store.get("76348799")
|
||||
assert dev.key == "28F64A24988064A079AA2C807D6102AE"
|
||||
assert dev.status == "key set"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_preloads_key(tmp_path):
|
||||
path = str(tmp_path / "keys.csv")
|
||||
with open(path, "w") as f:
|
||||
f.write("id,aes_key\n76348799,28F64A24988064A079AA2C807D6102AE\n")
|
||||
|
||||
app = OmsApp(device="none", autostart=False)
|
||||
async with app.run_test() as pilot:
|
||||
app._do_import(path, announce=False)
|
||||
await pilot.pause()
|
||||
dev = app.store.get("76348799")
|
||||
assert dev is not None
|
||||
assert dev.key == "28F64A24988064A079AA2C807D6102AE"
|
||||
assert dev.status == "key set"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""End-to-end test through the real wmbusmeters binary (skipped if absent)."""
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from oms_tui.wmbus import WMBusSource
|
||||
|
||||
TELEGRAM = (
|
||||
"C1;1;1;2020-01-23 10:25:13.000;97;148;76348799;"
|
||||
"0x2A442D2C998734761B168D2091D37CAC21E1D68CDAFFCD3DC452BD802913FF7B1706CA9E355D6C2701CC24\n"
|
||||
)
|
||||
KEY = "28F64A24988064A079AA2C807D6102AE"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("wmbusmeters") is None, reason="wmbusmeters not on PATH")
|
||||
def test_pipeline_discovers_and_decrypts():
|
||||
events = []
|
||||
|
||||
async def run():
|
||||
src = WMBusSource(
|
||||
device="stdin:rtlwmbus", stdin_data=(TELEGRAM * 3).encode()
|
||||
)
|
||||
src.set_keys({"76348799": KEY})
|
||||
await src.start(events.append)
|
||||
for _ in range(50): # up to ~5s for the process to finish replaying
|
||||
await asyncio.sleep(0.1)
|
||||
if any(e["type"] == "json" for e in events):
|
||||
break
|
||||
await src.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
ids = {e.get("id") for e in events if e["type"] in ("dll", "json")}
|
||||
assert "76348799" in ids
|
||||
|
||||
dlls = [e for e in events if e["type"] == "dll"]
|
||||
assert any(e["manufacturer"] == "KAM" for e in dlls)
|
||||
|
||||
jsons = [e for e in events if e["type"] == "json"]
|
||||
assert jsons, "no decrypted telegram produced"
|
||||
assert any(e["fields"].get("total_m3") == 6.408 for e in jsons)
|
||||
@@ -0,0 +1,149 @@
|
||||
import os
|
||||
|
||||
from oms_tui.csvio import export_devices, import_rows
|
||||
from oms_tui.models import DeviceStore
|
||||
|
||||
|
||||
def _dll(device_id, mfct="KAM", media="Cold water meter"):
|
||||
return {
|
||||
"type": "dll",
|
||||
"id": device_id,
|
||||
"manufacturer": mfct,
|
||||
"version": "1b",
|
||||
"media": media,
|
||||
"driver": "kamwater",
|
||||
"c_field": "44",
|
||||
"rssi": 97,
|
||||
}
|
||||
|
||||
|
||||
def test_duplicate_dll_burst_counts_once():
|
||||
# Two DLL lines for the SAME telegram (keyed meter + wildcard) arrive
|
||||
# microseconds apart -> counted once.
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("111"), now=1000.0)
|
||||
store.upsert_discovery(_dll("111"), now=1000.001)
|
||||
assert store.get("111").count == 1
|
||||
|
||||
|
||||
def test_real_retransmissions_count_separately():
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("111"), now=1000.0)
|
||||
store.upsert_discovery(_dll("111"), now=1005.0) # seconds later
|
||||
dev = store.get("111")
|
||||
assert dev.count == 2
|
||||
assert dev.manufacturer_name == "Kamstrup"
|
||||
assert dev.status == "listening" # DLL only, encryption not yet known
|
||||
|
||||
|
||||
def test_encrypted_meter_with_plaintext_read_is_locked_not_open():
|
||||
# The exact contradiction the user hit: an encrypted (mode 5) meter that
|
||||
# also exposes some plaintext records read by the wildcard ("scan").
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("61471000"))
|
||||
store.apply_tpl(
|
||||
{"type": "tpl", "ci": "7a", "cfg": "8560", "nb": "6", "sec_mode": 5,
|
||||
"enc": "AES-CBC"},
|
||||
"61471000",
|
||||
)
|
||||
store.apply_json(
|
||||
{"type": "json", "id": "61471000", "name": "scan", "driver": "x",
|
||||
"media": "water", "fields": {"total_m3": 1.0}}
|
||||
)
|
||||
dev = store.get("61471000")
|
||||
assert dev.encrypted is True
|
||||
assert dev.decrypted is True # we do have some values
|
||||
assert dev.key_decrypted is False
|
||||
assert dev.fields_source == "plain"
|
||||
assert dev.status == "locked" # NOT "open"
|
||||
assert dev.encryption == "mode 5 (AES-CBC)"
|
||||
|
||||
|
||||
def test_keyed_decrypt_marks_decrypted():
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("61471000"))
|
||||
store.apply_tpl({"type": "tpl", "sec_mode": 5, "enc": "AES-CBC"}, "61471000")
|
||||
dev = store.get("61471000")
|
||||
dev.key = "00112233445566778899AABBCCDDEEFF"
|
||||
store.apply_json(
|
||||
{"type": "json", "id": "61471000", "name": "m_61471000", "driver": "x",
|
||||
"media": "water", "fields": {"total_m3": 2.0}}
|
||||
)
|
||||
assert dev.key_decrypted is True
|
||||
assert dev.fields_source == "key"
|
||||
assert dev.status == "decrypted"
|
||||
|
||||
|
||||
def test_unencrypted_device_is_open_not_locked():
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("333"))
|
||||
store.apply_json(
|
||||
{"type": "json", "id": "333", "driver": "apator162", "media": "water",
|
||||
"fields": {"total_m3": 1.2}}
|
||||
)
|
||||
dev = store.get("333")
|
||||
assert dev.decrypted is True
|
||||
assert dev.key is None
|
||||
assert dev.status == "open" # not "locked"
|
||||
assert dev.status_icon == "📖"
|
||||
assert dev.status_color == "green"
|
||||
|
||||
|
||||
def test_encryption_from_tpl_and_ell():
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("444"))
|
||||
store.apply_tpl(
|
||||
{"type": "tpl", "ci": "7a", "cfg": "8560", "nb": "6", "sec_mode": 5,
|
||||
"enc": "AES-CBC"},
|
||||
"444",
|
||||
)
|
||||
dev = store.get("444")
|
||||
assert dev.sec_mode == 5
|
||||
assert dev.encryption == "mode 5 (AES-CBC)"
|
||||
assert dev.enc_blocks == "6"
|
||||
|
||||
store2 = DeviceStore()
|
||||
store2.upsert_discovery(_dll("555"))
|
||||
store2.apply_ell(
|
||||
{"type": "ell", "ci": "8d", "sn": "d37cac21", "session": "3", "enc": "AES-CTR"},
|
||||
"555",
|
||||
)
|
||||
assert store2.get("555").encryption == "AES-CTR (ELL)"
|
||||
|
||||
|
||||
def test_json_sets_decrypted_without_counting():
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("222"))
|
||||
store.apply_json(
|
||||
{"type": "json", "id": "222", "driver": "kamwater", "media": "cold water",
|
||||
"fields": {"total_m3": 6.408}}
|
||||
)
|
||||
dev = store.get("222")
|
||||
assert dev.count == 1 # JSON does not double-count
|
||||
assert dev.decrypted is True
|
||||
assert dev.fields["total_m3"] == 6.408
|
||||
|
||||
|
||||
def test_csv_roundtrip_with_keys(tmp_path):
|
||||
store = DeviceStore()
|
||||
store.upsert_discovery(_dll("76348799"))
|
||||
store.get("76348799").key = "28F64A24988064A079AA2C807D6102AE"
|
||||
store.upsert_discovery(_dll("88888888", mfct="TCH", media="Heat"))
|
||||
|
||||
path = os.path.join(tmp_path, "out.csv")
|
||||
assert export_devices(path, store.ordered()) == 2
|
||||
|
||||
rows = import_rows(path)
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
assert by_id["76348799"]["aes_key"] == "28F64A24988064A079AA2C807D6102AE"
|
||||
assert by_id["76348799"]["manufacturer"] == "KAM"
|
||||
assert by_id["88888888"]["aes_key"] == ""
|
||||
|
||||
|
||||
def test_import_minimal_key_file(tmp_path):
|
||||
path = os.path.join(tmp_path, "keys.csv")
|
||||
with open(path, "w") as f:
|
||||
f.write("id,aes_key\n76348799,28F64A24988064A079AA2C807D6102AE\n")
|
||||
rows = import_rows(path)
|
||||
assert rows[0]["id"] == "76348799"
|
||||
assert rows[0]["aes_key"].startswith("28F64A")
|
||||
@@ -0,0 +1,64 @@
|
||||
from oms_tui.parser import parse_line
|
||||
|
||||
DLL = (
|
||||
"(telegram) DLL L=2a C=44 (from meter SND_NR) M=2c2d (KAM) A=76348799 "
|
||||
"VER=1b TYPE=16 (Cold water meter) (driver kamwater) DEV=rtlwmbus[] RSSI=97"
|
||||
)
|
||||
JSON = (
|
||||
'{"_":"telegram","media":"cold water","meter":"kamwater","name":"Vatten",'
|
||||
'"id":"76348799","total_m3":6.408,"target_m3":6.408,"status":"DRY",'
|
||||
'"timestamp":"2020-01-23T10:25:13Z","device":"rtlwmbus[]","rssi_dbm":97}'
|
||||
)
|
||||
|
||||
|
||||
def test_parse_dll():
|
||||
ev = parse_line(DLL)
|
||||
assert ev["type"] == "dll"
|
||||
assert ev["id"] == "76348799"
|
||||
assert ev["manufacturer"] == "KAM"
|
||||
assert ev["version"] == "1b"
|
||||
assert ev["media"] == "Cold water meter"
|
||||
assert ev["driver"] == "kamwater"
|
||||
assert ev["rssi"] == 97
|
||||
|
||||
|
||||
def test_parse_json():
|
||||
ev = parse_line(JSON)
|
||||
assert ev["type"] == "json"
|
||||
assert ev["id"] == "76348799"
|
||||
assert ev["driver"] == "kamwater"
|
||||
assert ev["fields"]["total_m3"] == 6.408
|
||||
# metadata keys are excluded from measurement fields
|
||||
assert "timestamp" not in ev["fields"]
|
||||
assert "rssi_dbm" not in ev["fields"]
|
||||
|
||||
|
||||
def test_parse_ell_encryption():
|
||||
line = (
|
||||
"(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 "
|
||||
"(AES_CTR session=3 time=1755085) CRC=576c"
|
||||
)
|
||||
ev = parse_line(line)
|
||||
assert ev["type"] == "ell"
|
||||
assert ev["enc"] == "AES-CTR"
|
||||
assert ev["session"] == "3"
|
||||
|
||||
|
||||
def test_parse_tpl_security_mode():
|
||||
line = "(telegram) TPL CI=7a ACC=85 STS=00 CFG=8560 (bidirectional AES_CBC_IV nb=6 cntn=0 ra=0 hc=0)"
|
||||
ev = parse_line(line)
|
||||
assert ev["type"] == "tpl"
|
||||
assert ev["sec_mode"] == 5
|
||||
assert ev["enc"] == "AES-CBC"
|
||||
assert ev["nb"] == "6"
|
||||
|
||||
|
||||
def test_parse_tpl_no_security():
|
||||
ev = parse_line("(telegram) TPL CI=78")
|
||||
assert ev["type"] == "tpl"
|
||||
assert ev["sec_mode"] == 0
|
||||
|
||||
|
||||
def test_parse_noise_returns_none():
|
||||
assert parse_line("(config) number of meters: 0") is None
|
||||
assert parse_line("") is None
|
||||
Reference in New Issue
Block a user