From 50a9ee8c587ea18dc47013b0611ebd823a875d5a Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 24 Jul 2026 13:57:13 +0200 Subject: [PATCH] bugfixes --- README.md | 4 +- .../oms_tui/__pycache__/app.cpython-313.pyc | Bin 34892 -> 36325 bytes .../oms_tui/__pycache__/csvio.cpython-313.pyc | Bin 3094 -> 3694 bytes .../__pycache__/models.cpython-313.pyc | Bin 9901 -> 12522 bytes .../__pycache__/parser.cpython-313.pyc | Bin 5378 -> 5376 bytes oms-tui/oms_tui/app.py | 27 ++++++++ oms-tui/oms_tui/csvio.py | 7 +++ oms-tui/oms_tui/models.py | 56 +++++++++++++++-- oms-tui/oms_tui/parser.py | 4 +- .../test_app.cpython-313-pytest-9.0.3.pyc | Bin 11780 -> 13170 bytes ...st_models_csv.cpython-313-pytest-9.0.3.pyc | Bin 25292 -> 34887 bytes .../test_parser.cpython-313-pytest-9.0.3.pyc | Bin 11661 -> 14766 bytes oms-tui/tests/test_app.py | 9 +++ oms-tui/tests/test_models_csv.py | 57 +++++++++++++++++ oms-tui/tests/test_parser.py | 16 +++++ oms_devices.csv | 58 ++++++++++-------- 16 files changed, 206 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 01375c4..5c2b038 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Keys: | `q` | quit | Details include manufacturer, media/type, driver, version, RSSI, first/last -seen, telegram count, the encryption in use (OMS security mode 5 = AES-CBC, +seen, telegram count, the estimated **send interval** (median of the gaps +between telegrams, so missed transmissions don't skew it — needs ≥2 telegrams), +the encryption in use (OMS security mode 5 = AES-CBC, mode 7 = AES-CTR, or ELL AES-CTR), TPL/ELL layer info, and — once decoded — the latest measurement values with a timestamp. Unencrypted meters show as **open** (📖, readable without a key); encrypted ones are **locked** (🔒) until you add a diff --git a/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc index abfcdcc2b9aac9700e7fbaf0a72221f30277c74d..3d0bca34f7bf8be279f48e3218350d2007858c2c 100644 GIT binary patch delta 8532 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 diff --git a/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc index e2f787a1e4a272d7548faed76660f48c154136c6..dff6c73d3b8b94dea52056b724a72c613a971858 100644 GIT binary patch delta 1370 zcmbOx@lJ;CGcPX}0|NuYUc2P1HJlUq{xfL`Oy*}yvf#?hD@iRXOU#Kcz9oQQC*~I9 zq!!=eO3h0ysw^nU%+I^UTb!C)T9jE*8K0YC~FWOh>eQl8G|_{ z_cM!G#4`qShO!0;p-O{z@r=P-p{zk7AT}zFXAI_^e34mk_rr=Ou>S>ybKI^Ozw=nmXmL?*z<_{6@eUdi?O^&YO)He(&Tnl zIb#6^28JR*5CICZB4H4V9Ylb_sYn#W5(5$9AVPwHfk9JZvJb0u8YhSkiq|4g3>JYR ztB4mQ28x^_P}CRkgSe>X-x7qUqSWG&%-qD1)LX1Y`K5U&MR5CWaTKQ}gOgE__~iYp zW(x8Q3=FsUQY#Abi%Q~CQp+-vQ;Unt85kH;85kIfS1?W%Wizh7BPh|~c!Ni%-@nuU z1CxlL&UUZA*KZK2viy_rrM z61S&qO#2`Tk`ZGNRlFf5e_hV-A_!T|WV$1(w7~GXve`vtvmK0=WvxC)gVf0}h^nt( zyslw&QN!wkJV>Nofk9Mpf#PzVg*qP;K>|t)qACjlmq#s(x~^)v!RUiBNKgggDOC_( z4dSWNnWgiqS1^507ZDWt!Yszi_d$<=mv4e#r(3tpNJ(nBbW)AiiCJ8A}$sbtlc{u!vm>3usZZVb@iBGm>Q(|LhU|`S` zp1h9Ddh$CqNqK$-28JR~XcU25Ujzz`A|a5NFo+PD?9XndAOkXhFSVi|zo;ZWCABOw zIkmXRkb!|gk%57s_#wmOrR>J_w*@6S9B=Rl_4{}FKai51uQF5RhKlNPzlDA`G_|g4 z+FaDMxgjezKYV8R4LSMw88b6($ScgRoLPBCNqdFkdY_d(mz6BJiHAa-?bUIRIhNVK)Jt)K~dZ1Y~dond_;iDMSgQUXA6^rILO-| ze-(jJkDn%ckqSseb@Fd+T^?;vK(G|$mlqf5O*Y}t7YBz7cV;d)WWeHf3=9m`ldE{n zGfGeP;+5hCX(@698KE;dpI1!W0b~SoW?qRVOA$iOVe%YaGd5L_GKtAIcr|j>K}>M8 zfWo;5Wa>(WB2a=T0^7-9lbfGXnv-f*JaB>c9* diff --git a/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc index db4053ac081a0be1d0353788e4624d6d24ce1fab..319658b8c5bb3ae6f79e3c0c3357b5fdc13e085c 100644 GIT binary patch delta 4677 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 a%nV|Z7Z`-Tv*j`}N`GR=Wn^cBI2Zt49dV`r delta 2320 zcmaErxYn2NGcPX}0|Ns?w@q@Exco#u3C3v?)fMVlyji?hi&zyHf*FHZz1h6ji`cz5 zia5Z0Hg8TZt|Bfk?jmk4o+2JE-XdNvz9K#ahG6z!4sU)hfg%9~h8T7U1|^1IPAP_9 zE>k9u4GavX5Q?ElFqpeY2rSPd#SqL3lP?lZXV&Daa`DWYcXPYDk1^ch zi_b|cE{RXhFU>27Pb}FypQn#eU?t-%mg3Z$v|>gE1_p&9v&mL`N{o7wWBJk0AVXCSkZ(NB}9h>wARp-2=&*n=#CY0%+UVziv>&o9GhIyr~`8xM+Po`4ji?c`1Y zJ8%$9j^hxU{7@i6-4kSrJBaWA5nc=o3@aInyg_Uf?FE7=j2@Gx3Ys9aKNj>8^8+dL z2N5W$-G$T`gC^$*nMi@+qsWATfkBh$7F$tjNoi4Dkr0ThG5LkiX-!LzRx40IaugKh z7o--IR2Dgc#K2wzJ0k$Z4WIl=SXvHbP7&BdGmxMFi~z+%aqDJd5j95U2duo4FNh{E znoc$pQ?IXLU|^Wa5W`T!7|c+_6wFw}9L!Y263iFO42s+$)?of%7BGt~SRj}c%wi7~ z3}yqfID&aklo0F_SC$4Pm|~D8 z8yIeA>2$F4@Z3;P>R{>RDuN{lLs^hnnoPHtb5iq)GC|>iVv4bZqI5PWi9k%b!!I9CAgblk+9QTp=bDWq=}qIX$&Rlc~r7#O8!JFFrG+7?d*=6pFxsUI>!o zNAi?jdTL4DWG+bsM$^f2cqAvgNG5}QsL2Fz5XfMdnT(lmvnHRCRAeli{6^AjvXJaE z8<46Zkl95br-F6fVoNM2NX<*pWC1&GnCvJetjeLw%fL{~l*0p46QP^~E)7Ba2on<~6$VYt$$p9j!bPBj zsL5Lt4~mEy5K%aJuA(A)ks63?HQ7%_MpKg&?3-Imsbxi=)LB#w@)lcWaeQ8Cc`+;0 zQ=t5>pk&Ne3yQp-lOvT*8-HVC;1QaTd|gKGqKw`}F}*8BE|*1JZt(E;dv02yB-4e|tYNPy2R zro5yOO%9l4^EH(CJ3uCZqCo)?TNgA`8Rt*_rm;>MVkjsWp%|nJ99b%x_iHLLO=i+* zS40U;kcAQ;0u*6IprW=YVe%>+MOjUDaIiu=1rOJvMv&Txli%t{F`7;0&{b#K2y)D& z$&pI>lT&o()g!_Rq!5(hG?|JbL7rjp%g;-Na*AX@B^C=f1%gX2u##T_{skqO`FV*s z(U9yA3sqjl17X$eCR6 z@oA+crA4Xn@kOA_Toga~uYP(x*tsBe;2H&@%?zZT!zMRBr8Fniu4n-R0|Tf&EC$s~ zAD9^#8E-OpJ!MdO%)ot@LH0g_#(f6Us|==38FW6dF)(p|XYyfW6#u{gB0n=TFmZo? b2&6GFh)G^x5cm6`GPHkWZi7{<6i=Yn^W9{ZZ;VecT4px=V%v{_|4eXz!CLb5E F1OU-B9aR7T diff --git a/oms-tui/oms_tui/app.py b/oms-tui/oms_tui/app.py index 8160006..5bed94e 100644 --- a/oms-tui/oms_tui/app.py +++ b/oms-tui/oms_tui/app.py @@ -18,6 +18,7 @@ from rich.console import Group from rich.table import Table from rich.text import Text from textual.app import App, ComposeResult +from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll from textual.message import Message from textual.screen import ModalScreen @@ -41,6 +42,19 @@ def rel_time(ts: float) -> str: return f"{d // 3600}h {(d % 3600) // 60}m ago" +def fmt_duration(seconds: Optional[float]) -> str: + if seconds is None: + return "?" + s = int(round(seconds)) + if s < 60: + return f"{s}s" + if s < 3600: + return f"{s // 60}m {s % 60:02d}s" + if s < 86400: + return f"{s // 3600}h {(s % 3600) // 60:02d}m" + return f"{s // 86400}d {(s % 86400) // 3600:02d}h" + + def stream_text(ts_str: str, ev: dict) -> Text: if ev.get("type") == "json": fields = ev.get("fields", {}) @@ -109,6 +123,8 @@ class OmsApp(App[None]): ("a", "show_all", "Show all / stream"), ("enter", "select", "Select device"), ("q", "quit", "Quit"), + # priority so Ctrl+C quits even while a modal Input is focused + Binding("ctrl+c", "quit", "Quit", priority=True, show=False), ] COLUMNS = [ @@ -337,6 +353,17 @@ class OmsApp(App[None]): meta.add_row("link (C)", dev.c_field or "?") meta.add_row("rssi", "?" if dev.rssi is None else f"{dev.rssi} dBm") meta.add_row("telegrams", str(dev.count)) + if dev.interval_samples >= 1: + est = fmt_duration(dev.interval_estimate) + last = fmt_duration(dev.interval_last) + detail = f"~{est}" + extra = f"last {last}, {dev.interval_samples} sample" + extra += "s" if dev.interval_samples != 1 else "" + interval_text = Text(detail, style="bold") + interval_text.append(f" ({extra})", style="dim") + meta.add_row("send interval", interval_text) + else: + meta.add_row("send interval", Text("— (need ≥2 telegrams)", style="dim")) meta.add_row( "first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen)) ) diff --git a/oms-tui/oms_tui/csvio.py b/oms-tui/oms_tui/csvio.py index bfa0c0c..807e87f 100644 --- a/oms-tui/oms_tui/csvio.py +++ b/oms-tui/oms_tui/csvio.py @@ -19,6 +19,8 @@ FIELDNAMES = [ "last_seen", "count", "rssi", + "interval_s", + "interval_samples", "encryption", "security_mode", "status", @@ -50,6 +52,11 @@ def export_devices(path: str, devices: Iterable[Device]) -> int: "last_seen": _fmt_time(d.last_seen), "count": d.count, "rssi": "" if d.rssi is None else d.rssi, + "interval_s": ( + "" if d.interval_estimate is None + else round(d.interval_estimate, 1) + ), + "interval_samples": d.interval_samples, "encryption": d.encryption, "security_mode": d.sec_mode or "", "status": d.status, diff --git a/oms-tui/oms_tui/models.py b/oms-tui/oms_tui/models.py index 7bf01ae..4517a66 100644 --- a/oms-tui/oms_tui/models.py +++ b/oms-tui/oms_tui/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import statistics import time from collections import deque from dataclasses import dataclass, field @@ -42,6 +43,37 @@ class Device: default_factory=lambda: deque(maxlen=500) ) _last_count_at: float = 0.0 # for de-duplicating multi-meter DLL bursts + # arrival times of counted telegrams, for estimating the send interval + rx_times: Deque[float] = field(default_factory=lambda: deque(maxlen=64)) + + @property + def intervals(self) -> List[float]: + t = list(self.rx_times) + return [b - a for a, b in zip(t, t[1:])] + + @property + def interval_samples(self) -> int: + return max(0, len(self.rx_times) - 1) + + @property + def interval_estimate(self) -> Optional[float]: + """Best estimate of the transmit period: median of observed gaps. + + Median is robust to missed telegrams (which show up as gaps that are + multiples of the true period) and to the odd retransmission burst. + """ + gaps = self.intervals + return statistics.median(gaps) if gaps else None + + @property + def interval_last(self) -> Optional[float]: + gaps = self.intervals + return gaps[-1] if gaps else None + + @property + def interval_min(self) -> Optional[float]: + gaps = self.intervals + return min(gaps) if gaps else None @property def encryption(self) -> str: @@ -113,6 +145,20 @@ class DeviceStore: def ordered(self) -> List[Device]: return [self.devices[i] for i in self.order] + @staticmethod + def _note_telegram(dev: Device, now: float) -> None: + """Count one telegram and record its arrival, de-duplicating the + multi-line burst a single telegram produces (DLL[+DLL]+ELL+TPL+JSON, + all within microseconds) so counts and the interval stay accurate. + + Driven by *any* telegram-level event, so a DLL line we fail to parse + can't silently zero out the count and send-interval. + """ + if dev._last_count_at == 0.0 or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW: + dev.count += 1 + dev.rx_times.append(now) + dev._last_count_at = now + def _get_or_create(self, device_id: str, now: float) -> Tuple[Device, bool]: dev = self.devices.get(device_id) if dev is None: @@ -134,11 +180,7 @@ class DeviceStore: if ev.get("rssi") is not None: dev.rssi = ev["rssi"] dev.last_seen = now - # Count once per telegram, ignoring the duplicate DLL burst from a - # second matching meter. - if is_new or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW: - dev.count += 1 - dev._last_count_at = now + self._note_telegram(dev, now) return dev, is_new def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]: @@ -192,4 +234,8 @@ class DeviceStore: if from_key: dev.key_decrypted = True dev.last_seen = now + # Also drive the counter from JSON, so telegrams still count even if + # their DLL line failed to parse. The dedup window collapses the DLL + # and its JSON (same telegram) into one. + self._note_telegram(dev, now) return dev, is_new diff --git a/oms-tui/oms_tui/parser.py b/oms-tui/oms_tui/parser.py index ea6fb8f..fa53a05 100644 --- a/oms-tui/oms_tui/parser.py +++ b/oms-tui/oms_tui/parser.py @@ -27,7 +27,9 @@ _DLL_RE = re.compile( r"M=(?P[0-9a-fA-F]+) \((?P[A-Z?]+)\) " r"A=(?P\w+) " r"VER=(?P[0-9a-fA-F]+) " - r"TYPE=(?P[0-9a-fA-F]+) \((?P[^)]*)\) " + # media can itself contain parentheses, e.g. "Radio converter (meter side)"; + # match lazily up to the " (driver ...)" that always follows. + r"TYPE=(?P[0-9a-fA-F]+) \((?P.*?)\) " r"\(driver (?P\w+)\)" r"(?:.*?RSSI=(?P-?\d+))?" ) diff --git a/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc index 946074f80c37f82a2f7539c03be550c0206bdc60..d63539a44433fc20c4116608d231716602c08cf2 100644 GIT binary patch delta 1266 zcmZpP`IN@@nU|M~fq{V`$v!!2GS@^t2}Y-l>fDSIyR-#Gr5H>>QVa|XMS|&!nqr&H z82R`aS8jF{uw`P@p4==H$fz^ z@l|~V!{~A9-(Z) zWXb{(XJ82Ch~|T^7#M;CCI@nqkpg1JLk4H&^ZwrFvfvC&+UH!{mJvP~}J7H4I$Wh`)>{99c~ zgoDAI(N7jkvU4!_edJ*9 z^s^UfgVec!2zLgcpeLW?*0_W@lhvP=J7<0FWtyC8@%Atfn4n5}2cKnM2__ zJA;5+e_eOoEe_?I9Ma!e!QB43j=Dd^=?n}EP4ccvj0+iE?AR7DyDBj*6?Nrc-o@z3 z$hKSDm4o@95Th##+d*MxDOV2WBf8wKN{mM=7+u*|j##p}Dlr~qWOU_VKFR_n*%)1w z7{!lru(`^!ALV0o734X}&jVshv%4y7-Xh+|#;7ydSuWHO6gfqPpil;-y&@wJ3lxM! zE}$^xOH3}w%+HH2PA!SgPOU5o21z@B2uF}Kb9QPaC>otW(O4t_ic1cpxYPkD#2KL+ zhzM0E3YhFDFT)+jz`$SwitM9|lN*GcHm{OT=Va8GETpNb5)Lv;0YpTC2xSnV4kAE# zt0)S@ik=*;DJui=&@Ha`l>GS2+=Bd~5^$0$g1C9|T{(3&uv=p%AJmldMI<}0Q++|E zfZThF+o7N`J}ti}H?hQ|2%H8HNfMkId666sPLxFflXbM@SaTQ{7y>r?Yeg~f3fM*vY5P0$C%M<@+%!>UvLoHgMyDGH?b)D7JFiGWnOY-eo-LEdQcKB0);~n z$f%VJpFtTGlHfUPa`RJ4b5iY!${83K7{TE*IaIfvpOsPZGcyB|=m#cNM#T>TlaK0J Kvy?F~FaQ8dgeE!w delta 509 zcmeyA))K?_nU|M~fq{YHi(zusV$O+t5{z6M)wvm2(it^HH|H_(@iS^}t`)FlV$_*&N>x4sDHcQBsvoUHX&I}9;xA+p1 zOEUBG;)_#D;r4|<#*@F}~fD|xir&bo3PQI_FE-t~qz@W)dqy>_51rgdH z!fkS*ia38U2Ll6x0u&VaPwr8XVU1#7U@+diR%JaWqxR%jJ=LTjka`6W5dtEVL4-Pp z&;SvkAR-K8hwv@t;*z3UEcpegdAC^0i!w`6i&vlb zFfcHQ*BV`Y8VA_EUWywt}%D%;(SX`Nx zoS9z~05S~Z|00knD;Yk6BC^OIq>{rXH$SB`C)KW~n1O+Tk%57sc=_a|M)i}OjCELw H7#J7;qJ(-@ diff --git a/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc index f822e312e59af15c267136ea3d751fe8722233c9..0a8c983d07e63b495c8e98dd1e54ed899f8a3930 100644 GIT binary patch delta 5584 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 Ls8poMz`y_iwl70= diff --git a/oms-tui/tests/test_app.py b/oms-tui/tests/test_app.py index c204fd8..7a36809 100644 --- a/oms-tui/tests/test_app.py +++ b/oms-tui/tests/test_app.py @@ -48,6 +48,15 @@ async def test_device_appears_and_selects(): assert app.selected_id == "76348799" +@pytest.mark.asyncio +async def test_ctrl_c_quits(): + app = OmsApp(device="none", autostart=False) + async with app.run_test() as pilot: + await pilot.press("ctrl+c") + await pilot.pause() + assert app._exit is True + + @pytest.mark.asyncio async def test_set_key_via_modal(): app = OmsApp(device="none", autostart=False) diff --git a/oms-tui/tests/test_models_csv.py b/oms-tui/tests/test_models_csv.py index 9aed0fe..2ec8456 100644 --- a/oms-tui/tests/test_models_csv.py +++ b/oms-tui/tests/test_models_csv.py @@ -74,6 +74,63 @@ def test_keyed_decrypt_marks_decrypted(): assert dev.status == "decrypted" +def test_send_interval_estimate(): + store = DeviceStore() + # telegrams at t=0,16,32,64 -> gaps 16,16,32 (one missed) -> median 16 + for t in (1000.0, 1016.0, 1032.0, 1064.0): + store.upsert_discovery(_dll("777"), now=t) + dev = store.get("777") + assert dev.interval_samples == 3 + assert dev.interval_estimate == 16 + assert dev.interval_last == 32 + assert dev.interval_min == 16 + + +def test_json_only_still_counts_and_intervals(): + # Regression: a device whose DLL line fails to parse still gets counted + # (and its interval inferred) from the JSON telegrams alone. + store = DeviceStore() + for t in (2000.0, 2016.0, 2032.0): + store.apply_json( + {"type": "json", "id": "37287397", "name": "scan", "driver": "qheatv2", + "media": "radio converter (meter side)", "fields": {"total_kwh": 0}}, + now=t, + ) + dev = store.get("37287397") + assert dev.count == 3 + assert dev.interval_samples == 2 + assert dev.interval_estimate == 16 + + +def test_dll_and_its_json_count_once(): + # The DLL and its own JSON (same telegram, ~same instant) must not double. + store = DeviceStore() + store.upsert_discovery(_dll("123"), now=3000.0) + store.apply_json( + {"type": "json", "id": "123", "name": "scan", "fields": {"x": 1}}, + now=3000.05, + ) + assert store.get("123").count == 1 + + +def test_single_telegram_has_no_interval(): + store = DeviceStore() + store.upsert_discovery(_dll("888"), now=1000.0) + dev = store.get("888") + assert dev.interval_samples == 0 + assert dev.interval_estimate is None + + +def test_duplicate_burst_does_not_pollute_interval(): + store = DeviceStore() + store.upsert_discovery(_dll("999"), now=1000.0) + store.upsert_discovery(_dll("999"), now=1000.002) # duplicate DLL burst + store.upsert_discovery(_dll("999"), now=1016.0) + dev = store.get("999") + assert dev.interval_samples == 1 + assert dev.interval_estimate == 16 # not ~0 from the burst + + def test_unencrypted_device_is_open_not_locked(): store = DeviceStore() store.upsert_discovery(_dll("333")) diff --git a/oms-tui/tests/test_parser.py b/oms-tui/tests/test_parser.py index 6f13684..5f358ce 100644 --- a/oms-tui/tests/test_parser.py +++ b/oms-tui/tests/test_parser.py @@ -33,6 +33,22 @@ def test_parse_json(): assert "rssi_dbm" not in ev["fields"] +def test_parse_dll_with_nested_parens_in_media(): + # "Radio converter (meter side)" has parentheses inside the media name. + line = ( + "(telegram) DLL L=3c C=44 (from meter SND_NR) M=4493 (QDS) A=37027095 " + "VER=23 TYPE=37 (Radio converter (meter side)) (driver qheatv2) DEV= RSSI=0" + ) + ev = parse_line(line) + assert ev is not None + assert ev["type"] == "dll" + assert ev["id"] == "37027095" + assert ev["manufacturer"] == "QDS" + assert ev["media"] == "Radio converter (meter side)" + assert ev["driver"] == "qheatv2" + assert ev["rssi"] == 0 + + def test_parse_ell_encryption(): line = ( "(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 " diff --git a/oms_devices.csv b/oms_devices.csv index cd3f747..c4070ec 100644 --- a/oms_devices.csv +++ b/oms_devices.csv @@ -1,25 +1,33 @@ -id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,status,aes_key -23252256,ITW,Itron,Water meter,itron,00,2026-07-24 12:13:32,2026-07-24 12:18:31,2,122,locked, -61379612,,,warm water,qwaterv2,,2026-07-24 12:13:38,2026-07-24 12:19:30,0,,open, -61471660,QDS,Qundis,water,qwaterv2,1d,2026-07-24 12:13:39,2026-07-24 12:19:22,5,124,open, -10179914,ITW,Itron,water,itron,00,2026-07-24 12:14:11,2026-07-24 12:19:11,2,46,open, -23252389,ITW,Itron,Water meter,itron,00,2026-07-24 12:14:38,2026-07-24 12:19:38,2,130,locked, -37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:14:39,2026-07-24 12:20:15,0,,open, -09237338,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:14:52,2026-07-24 12:19:52,2,61,locked, -68544330,,,heat,qheatv2,,2026-07-24 12:14:56,2026-07-24 12:14:56,0,,open, -23252290,ITW,Itron,Water meter,itron,00,2026-07-24 12:15:16,2026-07-24 12:15:16,1,34,locked, -00258334,ITW,Itron,water,itron,00,2026-07-24 12:16:02,2026-07-24 12:16:02,1,120,open, -08936799,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:09,2026-07-24 12:16:09,1,73,locked, -23252289,ITW,Itron,Water meter,itron,00,2026-07-24 12:16:17,2026-07-24 12:16:17,1,74,locked, -09155592,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:31,2026-07-24 12:16:31,1,57,locked, -02713603,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:05,2026-07-24 12:17:05,1,60,locked, -00340954,ITW,Itron,water,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,85,open, -23252254,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,46,locked, -23252292,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:09,2026-07-24 12:17:09,1,67,locked, -00227616,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:21,2026-07-24 12:17:21,1,37,locked, -23252281,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:33,2026-07-24 12:17:33,1,123,locked, -23252286,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:54,2026-07-24 12:17:54,1,100,locked, -00093643,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:07,2026-07-24 12:18:07,1,51,locked, -00124993,ITW,Itron,water,itron,00,2026-07-24 12:18:16,2026-07-24 12:18:16,1,34,open, -00065762,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:31,2026-07-24 12:18:31,1,51,locked, -00257608,ITW,Itron,water,itron,00,2026-07-24 12:19:08,2026-07-24 12:19:08,1,39,open, +id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,interval_s,interval_samples,encryption,security_mode,status,aes_key +37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:35:10,2026-07-24 13:27:32,0,,,0,none,,open, +23252290,ITW,Itron,Water meter,itron,00,2026-07-24 12:35:17,2026-07-24 13:15:16,8,52,300.1,7,mode 5 (AES-CBC),5,locked, +00258334,ITW,Itron,water,itron,00,2026-07-24 12:36:02,2026-07-24 13:26:03,11,122,300.0,10,mode 5 (AES-CBC),5,locked, +61379612,,,warm water,qwaterv2,,2026-07-24 12:36:03,2026-07-24 13:26:46,0,,,0,none,,open, +08936799,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:36:08,2026-07-24 13:21:08,10,83,300.2,9,mode 5 (AES-CBC),5,locked, +23252289,ITW,Itron,Water meter,itron,00,2026-07-24 12:36:17,2026-07-24 13:21:17,7,104,300.6,6,mode 5 (AES-CBC),5,locked, +61471660,QDS,Qundis,water,qwaterv2,1d,2026-07-24 12:36:21,2026-07-24 13:27:45,35,118,109.5,34,mode 5 (AES-CBC),5,locked, +09155592,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:36:32,2026-07-24 13:21:32,10,51,300.2,9,mode 5 (AES-CBC),5,locked, +23252244,ITW,Itron,Water meter,itron,00,2026-07-24 12:36:49,2026-07-24 13:26:50,11,126,300.1,10,mode 5 (AES-CBC),5,locked, +02713603,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:37:05,2026-07-24 13:17:06,9,60,300.1,8,mode 5 (AES-CBC),5,locked, +00340954,ITW,Itron,water,itron,00,2026-07-24 12:37:08,2026-07-24 13:17:08,9,83,300.0,8,mode 5 (AES-CBC),5,locked, +23252292,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:09,2026-07-24 13:22:08,10,118,300.1,9,mode 5 (AES-CBC),5,locked, +68544330,,,heat,qheatv2,,2026-07-24 12:37:20,2026-07-24 13:22:13,0,,,0,none,,open, +23252281,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:32,2026-07-24 13:27:32,11,101,300.1,10,mode 5 (AES-CBC),5,locked, +23252286,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:53,2026-07-24 13:27:53,11,51,300.0,10,mode 5 (AES-CBC),5,locked, +00093643,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:38:06,2026-07-24 13:23:05,10,97,300.1,9,mode 5 (AES-CBC),5,locked, +00124993,ITW,Itron,water,itron,00,2026-07-24 12:38:15,2026-07-24 13:23:15,10,78,300.0,9,mode 5 (AES-CBC),5,locked, +00065762,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:38:30,2026-07-24 13:23:30,10,55,300.0,9,mode 5 (AES-CBC),5,locked, +00257608,ITW,Itron,water,itron,00,2026-07-24 12:39:08,2026-07-24 13:24:08,6,33,599.6,5,mode 5 (AES-CBC),5,locked, +10179914,ITW,Itron,water,itron,00,2026-07-24 12:39:11,2026-07-24 13:24:11,10,59,299.8,9,mode 5 (AES-CBC),5,locked, +23252389,ITW,Itron,Water meter,itron,00,2026-07-24 12:39:38,2026-07-24 13:24:38,9,122,300.1,8,mode 5 (AES-CBC),5,locked, +09237338,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:39:51,2026-07-24 13:14:51,8,30,299.9,7,mode 5 (AES-CBC),5,locked, +23252380,ITW,Itron,Water meter,itron,00,2026-07-24 12:40:47,2026-07-24 13:25:47,4,60,599.9,3,mode 5 (AES-CBC),5,locked, +02548773,ITW,Itron,cold water,itron,00,2026-07-24 12:42:01,2026-07-24 13:22:00,9,49,299.8,8,mode 5 (AES-CBC),5,locked, +23252254,ITW,Itron,Water meter,itron,00,2026-07-24 12:42:08,2026-07-24 13:22:09,8,77,300.2,7,mode 5 (AES-CBC),5,locked, +09710983,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:42:51,2026-07-24 13:07:51,4,34,300.0,3,mode 5 (AES-CBC),5,locked, +23252256,ITW,Itron,Water meter,itron,00,2026-07-24 12:43:31,2026-07-24 13:23:31,9,132,300.0,8,mode 5 (AES-CBC),5,locked, +23252283,ITW,Itron,Water meter,itron,00,2026-07-24 13:00:48,2026-07-24 13:15:48,4,67,299.9,3,mode 5 (AES-CBC),5,locked, +00227417,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:05:16,2026-07-24 13:10:16,2,36,300.0,1,mode 5 (AES-CBC),5,locked, +00227616,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:07:22,2026-07-24 13:17:22,3,29,300.0,2,mode 5 (AES-CBC),5,locked, +00287826,ITW,Itron,water,itron,00,2026-07-24 13:10:50,2026-07-24 13:15:51,2,37,300.3,1,mode 5 (AES-CBC),5,locked, +09710957,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:11:07,2026-07-24 13:11:07,1,16,,0,mode 5 (AES-CBC),5,locked,