bugfixes
This commit is contained in:
@@ -88,7 +88,9 @@ Keys:
|
|||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
Details include manufacturer, media/type, driver, version, RSSI, first/last
|
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
|
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**
|
latest measurement values with a timestamp. Unencrypted meters show as **open**
|
||||||
(📖, readable without a key); encrypted ones are **locked** (🔒) until you add a
|
(📖, readable without a key); encrypted ones are **locked** (🔒) until you add a
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,7 @@ from rich.console import Group
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.screen import ModalScreen
|
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"
|
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:
|
def stream_text(ts_str: str, ev: dict) -> Text:
|
||||||
if ev.get("type") == "json":
|
if ev.get("type") == "json":
|
||||||
fields = ev.get("fields", {})
|
fields = ev.get("fields", {})
|
||||||
@@ -109,6 +123,8 @@ class OmsApp(App[None]):
|
|||||||
("a", "show_all", "Show all / stream"),
|
("a", "show_all", "Show all / stream"),
|
||||||
("enter", "select", "Select device"),
|
("enter", "select", "Select device"),
|
||||||
("q", "quit", "Quit"),
|
("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 = [
|
COLUMNS = [
|
||||||
@@ -337,6 +353,17 @@ class OmsApp(App[None]):
|
|||||||
meta.add_row("link (C)", dev.c_field or "?")
|
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("rssi", "?" if dev.rssi is None else f"{dev.rssi} dBm")
|
||||||
meta.add_row("telegrams", str(dev.count))
|
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(
|
meta.add_row(
|
||||||
"first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen))
|
"first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ FIELDNAMES = [
|
|||||||
"last_seen",
|
"last_seen",
|
||||||
"count",
|
"count",
|
||||||
"rssi",
|
"rssi",
|
||||||
|
"interval_s",
|
||||||
|
"interval_samples",
|
||||||
"encryption",
|
"encryption",
|
||||||
"security_mode",
|
"security_mode",
|
||||||
"status",
|
"status",
|
||||||
@@ -50,6 +52,11 @@ def export_devices(path: str, devices: Iterable[Device]) -> int:
|
|||||||
"last_seen": _fmt_time(d.last_seen),
|
"last_seen": _fmt_time(d.last_seen),
|
||||||
"count": d.count,
|
"count": d.count,
|
||||||
"rssi": "" if d.rssi is None else d.rssi,
|
"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,
|
"encryption": d.encryption,
|
||||||
"security_mode": d.sec_mode or "",
|
"security_mode": d.sec_mode or "",
|
||||||
"status": d.status,
|
"status": d.status,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -42,6 +43,37 @@ class Device:
|
|||||||
default_factory=lambda: deque(maxlen=500)
|
default_factory=lambda: deque(maxlen=500)
|
||||||
)
|
)
|
||||||
_last_count_at: float = 0.0 # for de-duplicating multi-meter DLL bursts
|
_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
|
@property
|
||||||
def encryption(self) -> str:
|
def encryption(self) -> str:
|
||||||
@@ -113,6 +145,20 @@ class DeviceStore:
|
|||||||
def ordered(self) -> List[Device]:
|
def ordered(self) -> List[Device]:
|
||||||
return [self.devices[i] for i in self.order]
|
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]:
|
def _get_or_create(self, device_id: str, now: float) -> Tuple[Device, bool]:
|
||||||
dev = self.devices.get(device_id)
|
dev = self.devices.get(device_id)
|
||||||
if dev is None:
|
if dev is None:
|
||||||
@@ -134,11 +180,7 @@ class DeviceStore:
|
|||||||
if ev.get("rssi") is not None:
|
if ev.get("rssi") is not None:
|
||||||
dev.rssi = ev["rssi"]
|
dev.rssi = ev["rssi"]
|
||||||
dev.last_seen = now
|
dev.last_seen = now
|
||||||
# Count once per telegram, ignoring the duplicate DLL burst from a
|
self._note_telegram(dev, now)
|
||||||
# second matching meter.
|
|
||||||
if is_new or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW:
|
|
||||||
dev.count += 1
|
|
||||||
dev._last_count_at = now
|
|
||||||
return dev, is_new
|
return dev, is_new
|
||||||
|
|
||||||
def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]:
|
def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]:
|
||||||
@@ -192,4 +234,8 @@ class DeviceStore:
|
|||||||
if from_key:
|
if from_key:
|
||||||
dev.key_decrypted = True
|
dev.key_decrypted = True
|
||||||
dev.last_seen = now
|
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
|
return dev, is_new
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ _DLL_RE = re.compile(
|
|||||||
r"M=(?P<mhex>[0-9a-fA-F]+) \((?P<mfct>[A-Z?]+)\) "
|
r"M=(?P<mhex>[0-9a-fA-F]+) \((?P<mfct>[A-Z?]+)\) "
|
||||||
r"A=(?P<id>\w+) "
|
r"A=(?P<id>\w+) "
|
||||||
r"VER=(?P<ver>[0-9a-fA-F]+) "
|
r"VER=(?P<ver>[0-9a-fA-F]+) "
|
||||||
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>[^)]*)\) "
|
# media can itself contain parentheses, e.g. "Radio converter (meter side)";
|
||||||
|
# match lazily up to the " (driver ...)" that always follows.
|
||||||
|
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>.*?)\) "
|
||||||
r"\(driver (?P<driver>\w+)\)"
|
r"\(driver (?P<driver>\w+)\)"
|
||||||
r"(?:.*?RSSI=(?P<rssi>-?\d+))?"
|
r"(?:.*?RSSI=(?P<rssi>-?\d+))?"
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -48,6 +48,15 @@ async def test_device_appears_and_selects():
|
|||||||
assert app.selected_id == "76348799"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_set_key_via_modal():
|
async def test_set_key_via_modal():
|
||||||
app = OmsApp(device="none", autostart=False)
|
app = OmsApp(device="none", autostart=False)
|
||||||
|
|||||||
@@ -74,6 +74,63 @@ def test_keyed_decrypt_marks_decrypted():
|
|||||||
assert dev.status == "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():
|
def test_unencrypted_device_is_open_not_locked():
|
||||||
store = DeviceStore()
|
store = DeviceStore()
|
||||||
store.upsert_discovery(_dll("333"))
|
store.upsert_discovery(_dll("333"))
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ def test_parse_json():
|
|||||||
assert "rssi_dbm" not in ev["fields"]
|
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():
|
def test_parse_ell_encryption():
|
||||||
line = (
|
line = (
|
||||||
"(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 "
|
"(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 "
|
||||||
|
|||||||
+33
-25
@@ -1,25 +1,33 @@
|
|||||||
id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,status,aes_key
|
id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,interval_s,interval_samples,encryption,security_mode,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,
|
37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:35:10,2026-07-24 13:27:32,0,,,0,none,,open,
|
||||||
61379612,,,warm water,qwaterv2,,2026-07-24 12:13:38,2026-07-24 12:19:30,0,,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,
|
||||||
61471660,QDS,Qundis,water,qwaterv2,1d,2026-07-24 12:13:39,2026-07-24 12:19:22,5,124,open,
|
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,
|
||||||
10179914,ITW,Itron,water,itron,00,2026-07-24 12:14:11,2026-07-24 12:19:11,2,46,open,
|
61379612,,,warm water,qwaterv2,,2026-07-24 12:36:03,2026-07-24 13:26:46,0,,,0,none,,open,
|
||||||
23252389,ITW,Itron,Water meter,itron,00,2026-07-24 12:14:38,2026-07-24 12:19:38,2,130,locked,
|
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,
|
||||||
37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:14:39,2026-07-24 12:20:15,0,,open,
|
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,
|
||||||
09237338,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:14:52,2026-07-24 12:19:52,2,61,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,
|
||||||
68544330,,,heat,qheatv2,,2026-07-24 12:14:56,2026-07-24 12:14:56,0,,open,
|
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,
|
||||||
23252290,ITW,Itron,Water meter,itron,00,2026-07-24 12:15:16,2026-07-24 12:15:16,1,34,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,
|
||||||
00258334,ITW,Itron,water,itron,00,2026-07-24 12:16:02,2026-07-24 12:16:02,1,120,open,
|
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,
|
||||||
08936799,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:09,2026-07-24 12:16:09,1,73,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,
|
||||||
23252289,ITW,Itron,Water meter,itron,00,2026-07-24 12:16:17,2026-07-24 12:16:17,1,74,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,
|
||||||
09155592,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:31,2026-07-24 12:16:31,1,57,locked,
|
68544330,,,heat,qheatv2,,2026-07-24 12:37:20,2026-07-24 13:22:13,0,,,0,none,,open,
|
||||||
02713603,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:05,2026-07-24 12:17:05,1,60,locked,
|
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,
|
||||||
00340954,ITW,Itron,water,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,85,open,
|
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,
|
||||||
23252254,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,46,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,
|
||||||
23252292,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:09,2026-07-24 12:17:09,1,67,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,
|
||||||
00227616,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:21,2026-07-24 12:17:21,1,37,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,
|
||||||
23252281,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:33,2026-07-24 12:17:33,1,123,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,
|
||||||
23252286,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:54,2026-07-24 12:17:54,1,100,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,
|
||||||
00093643,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:07,2026-07-24 12:18:07,1,51,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,
|
||||||
00124993,ITW,Itron,water,itron,00,2026-07-24 12:18:16,2026-07-24 12:18:16,1,34,open,
|
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,
|
||||||
00065762,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:31,2026-07-24 12:18:31,1,51,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,
|
||||||
00257608,ITW,Itron,water,itron,00,2026-07-24 12:19:08,2026-07-24 12:19:08,1,39,open,
|
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,
|
||||||
|
|||||||
|
Reference in New Issue
Block a user