-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathplugin.py
More file actions
6387 lines (5922 loc) · 301 KB
/
Copy pathplugin.py
File metadata and controls
6387 lines (5922 loc) · 301 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
<plugin key="MeshCore" name="MeshCore" author="galadril, GizMoCuz" version="1.0.3" wikilink="" externallink="https://github.com/galadril/Domoticz-MeshCore-Plugin">
<description>
MeshCore LoRa mesh integration for Domoticz.
Requires: pip install -r requirements.txt
</description>
<params>
<param field="Mode1" label="Transport" width="120px">
<options>
<option label="TCP" value="TCP" default="true"/>
<option label="Serial" value="Serial"/>
</options>
</param>
<param field="Address" label="MeshCore Host" width="200px" default="192.168.1.50" visible_when="Mode1=TCP"/>
<param field="Port" label="MeshCore Port" width="80px" default="5000" visible_when="Mode1=TCP"/>
<param field="SerialPort" label="Serial Port" width="200px" visible_when="Mode1=Serial"/>
<param field="Mode2" label="Baud Rate" width="100px" visible_when="Mode1=Serial">
<options>
<option label="115200" value="115200" default="true"/>
<option label="57600" value="57600"/>
<option label="38400" value="38400"/>
<option label="19200" value="19200"/>
<option label="9600" value="9600"/>
</options>
</param>
<param field="Mode4" label="Install Custom Dashboard" width="150px">
<options>
<option label="Yes" value="true" default="true"/>
<option label="No" value="false"/>
</options>
</param>
<param field="Mode3" label="Command Bridge Channel" width="150px"/>
<param field="Mode6" label="Debug Level" width="150px">
<options>
<option label="None" value="0" default="true"/>
<option label="Basic" value="62"/>
<option label="All" value="-1"/>
</options>
</param>
</params>
</plugin>
"""
import DomoticzEx as Domoticz
import asyncio
import calendar
import collections
import copy
import functools
import gc
import json
import math
import os
import queue
import re
import shutil
import sqlite3
import threading
import time
import traceback
import urllib.error
import urllib.request
try:
from meshcore import MeshCore
from meshcore.events import EventType
MESHCORE_AVAILABLE = True
except ImportError:
MESHCORE_AVAILABLE = False
# ── Device scheme (DomoticzEx) ────────────────────────────────────────────────
# DomoticzEx keys devices by a string DeviceID; each DeviceID carries a Units
# dict. There is no 255-unit-per-plugin cap, so the old slot-block math is gone.
#
# DeviceID = MESH_DID ("mesh") → global devices, Unit = UNIT_* below
# DeviceID = "self" → the connected node, Unit = OFF_* below
# DeviceID = <pubkey[:12]> → a remote contact, Unit = OFF_* below
#
# Units must be >= 1 in DomoticzEx, so OFF_* are 1-based.
MESH_DID = "mesh"
SELF_DID = "self"
UNIT_INBOX = 1
UNIT_SEND = 2 # Deprecated send device (superseded by WebSocket channel); kept for stale-cleanup only
UNIT_MSGS_RECV = 3 # Custom counter: messages received today
UNIT_MSGS_SENT_ = 4 # Custom counter: messages sent today
UNIT_DZV_IN = 5 # Text: inbound command payload (JSON, seq-stamped) for dzVents bridge
UNIT_DZV_REPLY = 6 # Text: outbound reply payload written by dzVents
UNIT_DZV_SEND = 7 # Switch (Push On): trigger written by dzVents to dispatch reply
# MeshCore firmware exposes up to 40 channel slots. Domoticz devices are NOT
# created per slot — they live entirely in the dashboard JSON map.
MAX_CHANNEL_SLOTS = 40
# Radio-tuning bounds (used by !set_radio / !set_tx_power validation).
# ISM bands cover 100-2500 MHz; MeshCore typically runs 433/868/915 MHz.
# Wide bandwidth range allows narrow-band experimentation.
RADIO_FREQ_MIN_MHZ = 100.0
RADIO_FREQ_MAX_MHZ = 2500.0
RADIO_BW_MIN_KHZ = 5.0
RADIO_BW_MAX_KHZ = 500.0
RADIO_SF_MIN = 7
RADIO_SF_MAX = 12
RADIO_CR_MIN = 5 # firmware encoding: 5..8 = 4/5..4/8
RADIO_CR_MAX = 8
# Default upper bound on TX power if the device hasn't reported max_tx_power.
RADIO_TX_POWER_DEFAULT_MAX_DBM = 22
# Per-node metric units (1-based — DomoticzEx requires Unit >= 1)
OFF_STATUS = 1 # Switch: online / offline
OFF_BATT_PCT = 2 # Percentage: battery %
OFF_BATT_V = 3 # Custom (V): battery voltage
OFF_RSSI = 4 # Custom (dBm): last RSSI
OFF_SNR = 5 # Custom (dB): last SNR
OFF_NOISE = 6 # Custom (dBm): noise floor
OFF_LASTSEEN = 7 # Text: timestamp of last received message/advert
OFF_TEMP = 8 # Temperature: °C
OFF_HUMID = 9 # Humidity: %
OFF_HOPS = 10 # Custom: path length (hops)
OFF_UPTIME = 11 # Custom (min): node uptime
OFF_AIRTIME = 12 # Custom (%): TX airtime utilization
OFF_MSGS_SENT = 13 # Custom: total messages sent
OFF_MSGS_RECV = 14 # Custom: total messages received
OFF_MSGS = 15 # Text: per-contact DM conversation history
# Cayenne LPP sensor type codes (used in self_telemetry LPP list entries)
LPP_TEMPERATURE = 103
LPP_HUMIDITY = 104
LPP_VOLTAGE = 116 # channel 1 = battery
# Battery voltage range for % calculation (mV)
BAT_VMIN_MV = 3000
BAT_VMAX_MV = 4200
# Node is considered online if last_advert is newer than this (8 h)
ONLINE_THRESHOLD_S = 28800
# Connection timeout for the initial connect (seconds)
CONNECT_TIMEOUT = 12
COMMAND_TIMEOUT = 10
# Reconnect delay after a connection failure / drop (seconds)
RECONNECT_DELAY_S = 30
# Periodic refresh intervals on the persistent connection
STATS_REFRESH_S = 300 # self-node stats (battery, radio, packets)
CONTACTS_REFRESH_S = 60 # contact list refresh (catches new contacts + path changes)
MSG_DRAIN_S = 10 # periodic get_msg() drain — safety net for firmware
# that doesn't emit MESSAGES_WAITING / unsolicited
# push, so the node's message queue never piles up
# Rolling RX log buffer size (per-event detail kept in memory for the dashboard)
RX_LOG_BUFFER = 250
# How often we re-write meshcore_rx_log.json at most (seconds)
RX_LOG_WRITE_S = 2.0
# Seconds after a DM send_msg before we give up waiting for an ACK and
# annotate the sent line with "(no ack)". The firmware's suggested_timeout
# is typically 20–60 s; 90 s gives slow multi-hop paths a generous margin.
DM_ACK_TIMEOUT_S = 90
# After the user changes a setting, ignore device-side self_info echoes of
# manual_add_contacts/telemetry/adv_loc_policy for this many seconds. Some
# firmware briefly returns the prior value while flushing to flash, which
# would otherwise undo the user's change on the very next poll.
# Note: this only guards self_info-sourced settings. The default flood scope
# comes from a separate get_default_flood_scope() round-trip and the device
# returns the just-written value reliably there, so no grace needed for it.
SETTINGS_GRACE_S = 45
# MeshCore firmware encodes "no path / direct or unknown" as path_len=255 (0xFF).
# This is a sentinel value, NOT a real hop count. Exclude it everywhere we
# record or display hop counts so it never appears in hops_records or UI.
HOPS_SENTINEL = 255
# Set to True to append a timestamped trace of the message send/receive
# round-trip to meshcore_debug.log in the plugin directory. Best-effort,
# never raises into callers, size-capped. Off in production.
MSG_FLOW_DEBUG = False
_DBG_PATH = None
_DBG_MAX_BYTES = 2 * 1024 * 1024
def _dbg(msg: str) -> None:
"""Append a timestamped line to meshcore_debug.log. Never raises."""
if not MSG_FLOW_DEBUG:
return
try:
global _DBG_PATH
if _DBG_PATH is None:
_DBG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"meshcore_debug.log")
try:
if os.path.getsize(_DBG_PATH) > _DBG_MAX_BYTES:
os.replace(_DBG_PATH, _DBG_PATH + ".1")
except OSError:
pass
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
with open(_DBG_PATH, "a", encoding="utf-8") as fh:
fh.write("[" + ts + "] " + str(msg) + "\n")
except Exception:
pass
def _bat_pct(mv: int) -> int:
return max(0, min(100, int((mv - BAT_VMIN_MV) / (BAT_VMAX_MV - BAT_VMIN_MV) * 100)))
class BasePlugin:
def __init__(self):
self._queue = queue.Queue() # worker → main thread
self.transport = "TCP" # "TCP" or "Serial"
self.host = ""
self.port = 5000
self.serial_port = ""
self.baud_rate = 115200
# Tracks last successful-connection state to emit transition log lines
self._was_connected = False
self._contact_names = [] # contact names discovered from mc.contacts (non-self)
self.initialized = False
self._self_name = "" # name of the connected node
# pubkey_prefix (12 hex chars) → adv_name, rebuilt from contacts
self._prefix_to_name = {}
# node_name → last Unix timestamp we saw ANY activity from it
self._node_last_activity: dict = {}
# node_name → {"lat": float, "lon": float} from contact adv_lat/adv_lon
self._node_locations: dict = {}
# node_name → contact type int (1=Client/Contact, 2=Repeater, 3=Room Server, 4=Sensor)
self._node_types: dict = {}
# node_name → last_advert unix timestamp (for client-side sorting)
self._node_last_advert: dict = {}
# node_name → contact public_key hex (needed for remove_contact)
self._node_pubkey: dict = {}
# node_name → out_path hex string (e.g. "22a83b") or "" for flood.
# Populated from the contacts list; pruned on contact removal.
self._node_out_path: dict = {}
# node_name → out_path_hash_mode (int, +1 offset matching the
# dashboard convention used elsewhere for device_info path_hash_mode).
self._node_out_path_hash_mode: dict = {}
# node_name → DomoticzEx DeviceID (12-hex pubkey prefix). Populated
# from contact pubkeys and from incoming message pubkey prefixes so a
# device can be created/updated before the contacts poll runs.
self._node_did: dict = {}
# Current value of the connected node's manual_add_contacts setting
# (True = node ignores adverts from unknown contacts; False = auto-add)
self._manual_add_contacts: bool = False
# Other device settings mirrored from self_info — used by the dashboard
self._telemetry_mode_base: int = 0
self._telemetry_mode_loc: int = 0
self._telemetry_mode_env: int = 0
self._advert_loc_policy: int = 0
# Monotonic time (seconds) of the last user-driven setting change.
# Within SETTINGS_GRACE_S after a change we trust the just-set value
# over what self_info reports — some firmware returns the previous
# value briefly while flushing to flash.
self._settings_set_at: float = 0.0
# Default flood scope tag (e.g. "#nl"); empty string = global flood.
# Read via mc.commands.get_default_flood_scope() once per poll cycle.
self._default_flood_scope: str = ""
# Favorite contact names — persisted to <plugin_dir>/meshcore_favorites.json.
# Toggled from the dashboard; favorites sort first within online/offline groups.
self._favorites: set = set()
# Device info (firmware version, build, model) from send_device_query().
# Fetched on connect and refreshed periodically.
self._device_info: dict = {}
# Full SELF_INFO snapshot — radio params (freq, bw, sf, cr, tx_power,
# max_tx_power, multi_acks), our pubkey + advertised lat/lon. Exposed
# via the device map so the dashboard's self-node side panel can show
# and edit them.
self._self_info_full: dict = {}
# Latest result of get_self_telemetry (board-level sensors if any).
self._self_telemetry: dict = {}
# Per-contact query results from remote sync calls (status, telemetry,
# neighbours). Keyed by contact adv_name. Exposed via device map so
# the dashboard's contact info panel can read them.
self._contact_query_results: dict = {}
# Bumped from fast (2s) to steady (10s) once the first contacts batch
# has been dispatched, so we keep onHeartbeat responsive without
# firing every 2 seconds forever.
self._heartbeat_restored: bool = False
# Message counters (reset when Domoticz restarts the plugin)
self._recv_count = 0
self._sent_count = 0
# Channel names already fetched flag (only need once)
self._channels_fetched = False
# Channel index→name map (populated from device), e.g. {0: "General", 1: "MyRoom"}
# Non-empty entries only — used for message routing.
self._channel_names: dict = {}
# Full 8-slot table, including empty slots (idx → name). Exposed via
# the device map so the dashboard can render every slot with controls.
self._channel_slots: dict = {}
# chan_hash (2-hex string, e.g. "a3") → channel_name for every configured
# channel whose CHANNEL_INFO has been fetched. Lets the dashboard resolve
# "Hashes heard on air" rows to a readable name even when the raw RX_LOG
# frames never carried chan_name (which only happens when the library can
# HMAC-verify the ciphertext, which requires the channel secret).
self._chan_hash_to_name: dict = {}
# Persistent-connection worker state
self._worker_thread: threading.Thread | None = None
self._worker_loop: asyncio.AbstractEventLoop | None = None
self._mc = None # live MeshCore instance (worker-owned)
self._stop_event = threading.Event() # set on shutdown (cross-thread)
self._stop_async: asyncio.Event | None = None # created inside worker loop
self._main_task: asyncio.Task | None = None # _run() task, for hard cancel on stop
# Serialise concurrent `!verb` sends and remote queries. The meshcore
# library subscribes to EventType.OK/ERROR globally per send() call,
# so two commands in flight at the same time can have their responses
# cross-attributed (the second waiter gets the first reply). One lock
# → one in-flight command keeps the dispatcher unambiguous.
self._cmd_lock: asyncio.Lock | None = None
# Flag to prevent new connections during shutdown
self._stopping = False
# WebSocket channel state (F1+).
# _ws_ok: None=unknown (first push will detect), True=available, False=absent.
# _sub_feeds: last requested feed from {t:'sub'}; guarded by _rx_log_lock
# (written on the main thread, will be read from the worker thread).
self._ws_ok: bool | None = None
# All fields below are touched from BOTH the worker thread (push
# event callbacks) AND the main thread (_handle_message via
# _dispatch, plus _write_rx_log). Always take self._rx_log_lock
# before reading or mutating any of them.
# Rolling RX_LOG_DATA buffer.
self._rx_log = collections.deque(maxlen=RX_LOG_BUFFER)
self._rx_log_lock = threading.Lock()
# _sub_feeds is initialised here (after the lock) and is always
# accessed under self._rx_log_lock to keep the main/worker access safe.
self._sub_feeds: str = "none"
self._rx_log_dirty = False
self._rx_log_last_write = 0.0
# F3 — rx-log on-demand + deltas.
# _rx_log_seq: monotonic counter incremented on every rxlog/rxlog_delta push.
# _rx_log_total_appended: absolute count of every entry ever appended to
# _rx_log (never decremented, even when the deque evicts old entries).
# _rx_log_pushed_total: value of _rx_log_total_appended at the time of
# the last window/delta push. The entries still in the buffer that
# the client has not yet seen are:
# start = _rx_log_total_appended - len(_rx_log) (oldest still buffered)
# new = list(_rx_log)[_rx_log_pushed_total - start:]
# If _rx_log_pushed_total < start the client missed evicted entries →
# fall back to a full window push.
# All three are guarded by _rx_log_lock.
self._rx_log_seq: int = 0
self._rx_log_total_appended: int = 0
self._rx_log_pushed_total: int = 0
# F7 — device-map delta.
# _device_seq: monotonic counter incremented on every devices/devices_delta push.
# _last_pushed_device_map: snapshot of the deviceMap sent in the last full or
# delta push (None = no baseline; next push will be a full 'devices' message).
# Both are guarded by _rx_log_lock (same lock reused — no new lock needed).
self._device_seq: int = 0
self._last_pushed_device_map: dict | None = None
# Per-feed WebSocket push dirty flags (separate from the file-write
# dirty flags so the two throttling paths don't interfere).
# Set wherever the respective data changes; cleared by _push_dirty_feeds.
self._ws_devices_dirty = False
self._ws_stats_dirty = False
self._ws_heard_dirty = False
self._ws_channels_dirty = False
# Per-feed wall-clock timestamp of the last WebSocket push (monotonic).
self._devices_last_push = 0.0
self._stats_last_push = 0.0
self._heard_last_push = 0.0
self._channels_last_push = 0.0
# Aggregated stats over the rx-log window:
self._payload_type_counts: dict = collections.defaultdict(int)
self._chan_hash_counts: dict = collections.defaultdict(int)
# pubkey_prefix → list of {t, snr, rssi, path_len, kind}
self._signal_history: dict = collections.defaultdict(list)
# raw_hex → list of {t, path, snr} (duplicate flood detection)
self._dup_floods: dict = collections.defaultdict(list)
# 24h × 1h heatmap: hour-of-day → count for past 24h (timestamps trimmed on read)
self._packet_times = collections.deque(maxlen=2000) # raw ts list, trimmed to last 24h
# Recent incoming-message signatures for de-duplication. The same
# message can arrive twice: as an unsolicited push AND via the
# get_msg() drain that start_auto_message_fetching() performs (and
# duplicate-flood copies repeat with the same sender_timestamp/text
# on a different path). 300 entries ≈ plenty at mesh message rates.
self._recent_msg_sigs = collections.deque(maxlen=300)
# Lifetime statistics (persisted to meshcore_stats.json, flushed on
# the rx-log cadence + on stop, reloaded on start). Sender class is
# derived from the contact type: Repeater(2) / Room Server(3) /
# everything else (incl. unknown) = client. Mutations are guarded by
# self._rx_log_lock (same lock as the heard/rx-log writers).
self._stats = {
"adverts_total": 0,
"messages_total": 0,
"client_total": 0,
"repeater_total": 0,
"server_total": 0,
"msg_by_sender": {}, # sender name -> message count
"adv_by_sender": {}, # advert name -> advert count
"msg_by_channel": {}, # resolved channel name -> message count (known channels only)
"hops_records": [], # top-5 [{hops,name,date,channel}], best per name
"today": {"date": "", "messages": 0,
"client": 0, "repeater": 0, "server": 0},
}
self._stats_dirty = False
# Persistent "heard nodes" — adverts from nodes NOT in our contacts.
# full pubkey hex → {pubkey, name, type, lat, lon, snr, rssi,
# path_len, first_heard, last_heard}. Survives restarts via
# meshcore_heard.json (flushed on the rx-log cadence + on stop).
# Updated from RX_LOG ADVERT frames on the worker thread under
# _rx_log_lock; _known_pubkeys is swapped wholesale by _handle_contacts
# so the worker can cheaply skip nodes that are already contacts.
self._heard_nodes: dict = {}
self._heard_dirty = False
self._known_pubkeys: set = set()
# Pubkeys that the user has explicitly deleted from the heard list.
# Full pubkey hex strings. Persisted in meshcore_heard.json under
# "purged": [...] so purged nodes stay dead across restarts.
# Once a purged key reappears as a real contact (i.e. it shows up in
# _known_pubkeys via _handle_contacts) it is removed from this set so
# a subsequent removal can add it back to heard normally.
self._heard_purged: set = set()
# Latest received signal for nodes that ARE contacts, keyed by the
# 12-hex pubkey prefix → {snr, rssi, path_len, t, source}.
# Last-writer-wins across ADVERT (worker, _on_rx_log) and incoming
# messages with a known pubkey (main, _handle_message). Lets contact
# cards show hops/SNR/RSSI even without a Domoticz device, a recent
# message, or a direct path. RSSI is only set when the frame actually
# carried one (adverts do; message events usually don't).
self._contact_sig: dict = {}
# Per-contact clock-skew sample, keyed by pubkey[:12]. Captured ONLY
# when we actually receive a contact's ADVERT over the air, so it is
# a trustworthy paired measurement: {"node_ts": <node's advertised
# RTC>, "our_ts": <our local receive time of THAT advert>}. The
# dashboard flags a wrong RTC from this pair (same approach as heard
# nodes) instead of the old, false-positive-prone comparison of the
# stale contact-list last_advert against an unrelated last_seen.
self._contact_clock: dict = {}
# Pending DM delivery-ACK records.
# Keyed by expected_ack hex code (8 hex chars from MSG_SENT payload).
# Value: {"target": str, "body": str, "out_ts": float,
# "inbox_line": str, "dm_name": str|None}
# Written by the worker thread (_send_message), read and cleared by
# _on_ack (worker) and by onHeartbeat timeout sweep (main). Both
# paths hold _rx_log_lock for the mutation — same discipline as
# _chan_hash_to_name. Dict is bounded: entries are removed on match
# or timeout; at most one entry per in-flight send (send commands are
# serialised by _cmd_lock), so size ≤ 1 in normal operation.
self._pending_acks: dict = {}
# SQLite message store — long-lived connection, opened in onStart.
# All access serialized via _msgdb_lock (separate from _rx_log_lock).
self._msgdb: sqlite3.Connection | None = None
self._msgdb_lock = threading.Lock()
# Monotonic insert counter used for pruning; reset on each onStart.
self._msgdb_insert_count: int = 0
# dzVents command bridge state.
# _dzv_enabled: derived in onStart — True iff a non-empty Command Bridge
# Channel (Mode3) is configured; no separate toggle.
# _dzv_channel: channel name to listen on (from Mode3); empty = disabled.
# _cmd_origins: rid -> {kind, chan, ts} for pending channel replies.
# _dzv_req_id: monotonic counter for correlation ids.
# _dzv_in_seq: monotonic write counter so UNIT_DZV_IN always changes.
self._dzv_enabled: bool = False
self._dzv_channel: str = ""
self._cmd_origins: dict = {}
self._dzv_req_id: int = 0
self._dzv_in_seq: int = 0
# Time-series analytics state.
# Previous packet counter values for delta computation in _ts_packets_add.
self._ts_prev_pkt_recv: int | None = None
self._ts_prev_pkt_sent: int | None = None
self._ts_prev_pkt_flood_rx: int | None = None
self._ts_prev_pkt_flood_tx: int | None = None
self._ts_prev_pkt_dir_rx: int | None = None
self._ts_prev_pkt_dir_tx: int | None = None
# Set of panel names whose cached query results are stale after a new insert.
self._ts_dirty_panels: set = set()
# ── dzVents command bridge helpers ────────────────────────────────────────
def _dzv_next_id(self) -> int:
"""Return the next correlation id, wrapping at 1_000_000."""
self._dzv_req_id = (self._dzv_req_id + 1) % 1_000_000
return self._dzv_req_id
def _dzv_prune_origins(self):
"""Remove stale entries from _cmd_origins (age >300s; cap at 200)."""
cutoff = time.time() - 300
stale = [k for k, v in self._cmd_origins.items() if v.get("ts", 0) < cutoff]
for k in stale:
del self._cmd_origins[k]
if len(self._cmd_origins) > 200:
# Evict the oldest entries by timestamp.
by_age = sorted(self._cmd_origins.items(), key=lambda kv: kv[1].get("ts", 0))
for k, _ in by_age[: len(self._cmd_origins) - 200]:
del self._cmd_origins[k]
def _dzv_channel_match(self, chan_tag: str) -> bool:
"""Return True iff the bridge is enabled, a channel is configured, and
chan_tag matches the configured channel after normalisation.
Normalisation: strip a single leading '#', then .strip().lower() both
sides. So '#Alerts', 'alerts', and 'Alerts' all match a stored 'alerts'.
"""
if not self._dzv_enabled or not self._dzv_channel:
return False
def _norm(s: str) -> str:
return s.lstrip("#").strip().lower()
return _norm(chan_tag) == _norm(self._dzv_channel)
# ── SQLite message store ──────────────────────────────────────────────────
# Max rows to keep in messages table (newest wins on prune).
_MSG_STORE_CAP = 20_000
# Prune at most once every N inserts (cheap amortised cost).
_MSG_STORE_PRUNE_EVERY = 200
# Current schema version stored in the preferences table.
MSG_DB_SCHEMA_VERSION = 4
# ── Elevation cache ───────────────────────────────────────────────────────
# LRU cap: keep at most this many rows in elevation_cache.
_ELEV_PRUNE_CAP = 100_000
# Prune elevation cache at most once every this many seconds (5 min).
_ELEV_PRUNE_INTERVAL = 300
def _msg_store_open(self, db_path: str):
"""Open (or create) the SQLite message store at *db_path*.
Creates the schema if it does not exist. Must only be called once
from onStart on the main thread before the worker starts.
"""
try:
con = sqlite3.connect(db_path, check_same_thread=False)
con.execute("PRAGMA journal_mode=WAL")
con.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chan TEXT NOT NULL,
sender TEXT NOT NULL,
epoch TEXT NOT NULL,
bad INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL,
hops INTEGER,
snr REAL,
rssi INTEGER,
path TEXT,
ack INTEGER,
direction TEXT NOT NULL DEFAULT 'in',
recv_ts TEXT NOT NULL,
peer_key TEXT
)
""")
con.execute("CREATE INDEX IF NOT EXISTS idx_messages_chan_id ON messages(chan, id)")
con.execute("""
CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT
)
""")
con.commit()
self._msgdb = con
self._msgdb_insert_count = 0
self._msg_store_migrate()
# Create indexes after migration so they are always present regardless
# of which path (fresh DB vs. future migration) created the table.
con.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_peerkey"
" ON messages(peer_key, id)"
)
con.commit()
Domoticz.Debug("Message store opened: " + db_path)
except Exception as exc:
Domoticz.Error(f"Message store open failed (non-fatal): {exc!r}")
self._msgdb = None
def _pref_get(self, key: str, default=None):
"""Return the preferences value for *key*, or *default* if absent/error."""
if self._msgdb is None:
return default
try:
with self._msgdb_lock:
cur = self._msgdb.execute(
"SELECT value FROM preferences WHERE key=?", (key,)
)
row = cur.fetchone()
return row[0] if row is not None else default
except Exception as exc:
Domoticz.Error(f"Message store pref_get failed (non-fatal): {exc!r}")
return default
def _pref_set(self, key: str, value: str):
"""Upsert *key*=*value* in the preferences table. Never raises."""
if self._msgdb is None:
return
try:
with self._msgdb_lock:
self._msgdb.execute(
"INSERT INTO preferences(key,value) VALUES(?,?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
self._msgdb.commit()
except Exception as exc:
Domoticz.Error(f"Message store pref_set failed (non-fatal): {exc!r}")
def _msg_store_migrate(self):
"""Apply any pending schema migrations and record the resulting version.
Migration ladder — add one ``elif ver == N:`` block per future version:
ver == 1 — baseline; all columns (including peer_key) already in the
CREATE TABLE statement in _msg_store_open; nothing to ALTER.
The loop is always exercised: even for a fresh DB it runs once (0→1),
confirming the ladder structure is live code, not a dead stub.
"""
try:
stored = self._pref_get("db_version")
ver = int(stored) if stored is not None else 0
from_ver = ver
while ver < self.MSG_DB_SCHEMA_VERSION:
ver += 1
if ver == 1:
pass # baseline — tables already created in _msg_store_open
elif ver == 2:
# Add peer_key column — may be missing on DBs created before
# the column was introduced (ALTER TABLE is a no-op if it
# already exists, so fresh installs are safe too).
try:
self._msgdb.execute(
"ALTER TABLE messages ADD COLUMN peer_key TEXT"
)
self._msgdb.commit()
except Exception:
pass # column already present — safe to ignore
elif ver == 3:
# Elevation sample cache for the LoS tool. Keyed by quantised
# (lat, lon) grid (≈11 m resolution). last_used drives LRU eviction.
cur = self._msgdb.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS elevation_cache (
lat_q INTEGER NOT NULL,
lon_q INTEGER NOT NULL,
elev_m REAL NOT NULL,
last_used INTEGER NOT NULL,
PRIMARY KEY (lat_q, lon_q)
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS ix_elev_last_used"
" ON elevation_cache (last_used)"
)
self._msgdb.commit()
elif ver == 4:
# Time-series tables for historical analytics panels.
cur = self._msgdb.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS ts_radio (
ts INTEGER NOT NULL,
node_key TEXT NOT NULL,
rssi INTEGER,
snr REAL,
noise INTEGER,
path_len INTEGER,
src TEXT
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS ix_ts_radio_ts"
" ON ts_radio (ts)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS ix_ts_radio_node_ts"
" ON ts_radio (node_key, ts)"
)
cur.execute("""
CREATE TABLE IF NOT EXISTS ts_packets_hourly (
hour_ts INTEGER PRIMARY KEY,
rx_count INTEGER NOT NULL DEFAULT 0,
tx_count INTEGER NOT NULL DEFAULT 0,
flood_rx INTEGER NOT NULL DEFAULT 0,
flood_tx INTEGER NOT NULL DEFAULT 0,
direct_rx INTEGER NOT NULL DEFAULT 0,
direct_tx INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS ts_packets_min (
ts INTEGER PRIMARY KEY,
rx_count INTEGER NOT NULL DEFAULT 0,
tx_count INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS ts_relay_keys (
hex_key TEXT PRIMARY KEY,
name TEXT,
last_seen INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS ts_hops (
ts INTEGER NOT NULL,
hops INTEGER NOT NULL
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS ix_ts_hops_ts"
" ON ts_hops (ts)"
)
self._msgdb.commit()
self._pref_set("db_version", str(self.MSG_DB_SCHEMA_VERSION))
if from_ver < self.MSG_DB_SCHEMA_VERSION:
Domoticz.Debug(
f"Message store migrated schema v{from_ver} -> v{self.MSG_DB_SCHEMA_VERSION}"
)
else:
Domoticz.Debug(
f"Message store schema v{self.MSG_DB_SCHEMA_VERSION} (up to date)"
)
except Exception as exc:
Domoticz.Error(f"Message store migration failed (non-fatal): {exc!r}")
@staticmethod
def _norm_peer_key(pk) -> "str | None":
"""Normalise a pubkey prefix to a stable 12-char lowercase hex string.
Rules:
- Strip any non-hex characters ([^0-9a-fA-F]).
- Lowercase.
- Truncate to the first 12 characters.
- Return None if the result is empty.
"""
if not pk:
return None
cleaned = "".join(c for c in str(pk).lower() if c in "0123456789abcdef")
return cleaned[:12] or None
# ── Elevation cache helpers ───────────────────────────────────────────────
@staticmethod
def _elev_quantise(lat: float, lon: float) -> "tuple[int, int]":
"""Quantise (lat, lon) to an integer grid of ~11 m resolution.
Multiplying by 1e4 gives approximately 11 m per step at the equator,
which is finer than the 30 m SRTM source resolution and therefore
lossless for caching purposes.
The rounding deliberately uses ``math.floor(x + 0.5)`` rather than
Python's built-in ``round()`` so that the result matches JavaScript's
``Math.round`` semantics (half-away-from-+inf for positives,
half-toward-zero for negatives). The frontend pre-rounds every
coordinate via ``Math.round(v * 1e4) / 1e4`` before sending; using
Python banker's rounding here would produce a 1 ULP mismatch at
boundary values (e.g. lat 52.00005) and cause needless cache misses.
"""
return (math.floor(lat * 1e4 + 0.5), math.floor(lon * 1e4 + 0.5))
def _elevation_lookup(self, points: "list[tuple[float, float]]") -> "list":
"""Return elevation in metres (float) for each (lat, lon) in *points*.
Results are returned in input order. Any point whose elevation could
not be fetched from either upstream source is represented as None
(rare — should log a warning).
Cache strategy:
- Quantise all points and batch-SELECT from elevation_cache.
- Update last_used for hits.
- Fetch misses from open-elevation (batch ≤100), fall back to
opentopodata on HTTP error.
- INSERT OR REPLACE fetched samples.
This is a *blocking* function — it does synchronous HTTP. Call it via
``loop.run_in_executor(None, self._elevation_lookup, points)`` from the
worker loop.
"""
if not points:
return []
db = self._msgdb
quantised = [self._elev_quantise(lat, lon) for lat, lon in points]
n = len(quantised)
results = [None] * n
# ── Cache lookup ─────────────────────────────────────────────────────
# Map (lat_q, lon_q) → index list (multiple input points may map to
# the same quantised bucket after rounding).
from collections import defaultdict
bucket_to_idxs: "dict[tuple, list[int]]" = defaultdict(list)
for i, q in enumerate(quantised):
bucket_to_idxs[q].append(i)
cached_elev: "dict[tuple, float]" = {}
if db is not None:
unique_qs = list(bucket_to_idxs.keys())
# SQLite 999-param limit: chunk into batches of ≤499 pairs (2 params each).
_CHUNK = 499
for chunk_start in range(0, len(unique_qs), _CHUNK):
chunk = unique_qs[chunk_start: chunk_start + _CHUNK]
if not chunk:
continue
# Build: WHERE (lat_q=? AND lon_q=?) OR (lat_q=? AND lon_q=?) ...
where_parts = " OR ".join(["(lat_q=? AND lon_q=?)"] * len(chunk))
params = []
for lat_q, lon_q in chunk:
params.extend([lat_q, lon_q])
try:
with self._msgdb_lock:
rows = self._msgdb.execute(
f"SELECT lat_q, lon_q, elev_m FROM elevation_cache"
f" WHERE {where_parts}",
params,
).fetchall()
if rows:
now_ts = int(time.time())
upd_params = []
for lat_q, lon_q, elev_m in rows:
cached_elev[(lat_q, lon_q)] = elev_m
upd_params.extend([lat_q, lon_q])
upd_where = " OR ".join(
["(lat_q=? AND lon_q=?)"] * len(rows)
)
self._msgdb.execute(
f"UPDATE elevation_cache SET last_used=?"
f" WHERE {upd_where}",
[now_ts] + upd_params,
)
self._msgdb.commit()
except Exception as exc:
Domoticz.Error(
f"Elevation cache lookup failed (non-fatal): {exc!r}"
)
# Fill hits from cache
for q, idxs in bucket_to_idxs.items():
if q in cached_elev:
for i in idxs:
results[i] = cached_elev[q]
# ── Fetch misses from upstream ────────────────────────────────────────
miss_qs = [q for q in bucket_to_idxs if q not in cached_elev]
if miss_qs:
fetched: "dict[tuple, float]" = {}
_BATCH = 100
def _fetch_open_elevation(batch_qs):
"""POST to open-elevation; returns {(lat_q,lon_q): elev_m} or raises."""
locations = [
{"latitude": lq / 1e4, "longitude": oq / 1e4}
for lq, oq in batch_qs
]
body = json.dumps({"locations": locations}).encode()
req = urllib.request.Request(
"https://api.open-elevation.com/api/v1/lookup",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
raise urllib.error.HTTPError(
req.full_url, resp.status, "non-200", {}, None
)
data = json.loads(resp.read())
out = {}
for i, r in enumerate(data.get("results", [])):
out[batch_qs[i]] = float(r["elevation"])
return out
def _fetch_opentopodata(batch_qs):
"""GET opentopodata; returns {(lat_q,lon_q): elev_m} or raises."""
loc_str = "|".join(
f"{lq / 1e4},{oq / 1e4}" for lq, oq in batch_qs
)
url = (
f"https://api.opentopodata.org/v1/srtm30m"
f"?locations={urllib.request.quote(loc_str, safe=',|.')}"
)
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
raise urllib.error.HTTPError(
req.full_url, resp.status, "non-200", {}, None
)
data = json.loads(resp.read())
out = {}
for i, r in enumerate(data.get("results", [])):
elev = r.get("elevation")
if elev is not None:
out[batch_qs[i]] = float(elev)
return out
for batch_start in range(0, len(miss_qs), _BATCH):
batch = miss_qs[batch_start: batch_start + _BATCH]
try:
batch_result = _fetch_open_elevation(batch)
except Exception as exc:
Domoticz.Debug(
f"Elevation: open-elevation failed ({exc!r}), trying opentopodata"
)
try:
batch_result = _fetch_opentopodata(batch)
except Exception as exc2:
Domoticz.Error(
f"Elevation: both upstream services failed for batch"
f" of {len(batch)} point(s):"
f" open-elevation: {exc!r}; opentopodata: {exc2!r}"
)
batch_result = {}
fetched.update(batch_result)
# Persist fetched samples
if fetched and db is not None:
now_ts = int(time.time())
try:
with self._msgdb_lock:
self._msgdb.executemany(
"INSERT OR REPLACE INTO elevation_cache"
" (lat_q, lon_q, elev_m, last_used) VALUES (?,?,?,?)",
[
(lat_q, lon_q, elev_m, now_ts)
for (lat_q, lon_q), elev_m in fetched.items()
],
)
self._msgdb.commit()
except Exception as exc:
Domoticz.Error(
f"Elevation cache write failed (non-fatal): {exc!r}"
)
# Fill misses
for q, idxs in bucket_to_idxs.items():
if q not in cached_elev and q in fetched:
for i in idxs:
results[i] = fetched[q]
return results
def _elev_prune(self):
"""LRU-evict elevation_cache rows beyond _ELEV_PRUNE_CAP.
Keeps the _ELEV_PRUNE_CAP most-recently-used rows; deletes the rest.
Never raises into callers.
"""
if self._msgdb is None:
return
try:
with self._msgdb_lock:
self._msgdb.execute(
"DELETE FROM elevation_cache"
" WHERE rowid NOT IN ("
" SELECT rowid FROM elevation_cache"
" ORDER BY last_used DESC"
" LIMIT ?"
")",
(self._ELEV_PRUNE_CAP,),
)
self._msgdb.commit()
except Exception as exc:
Domoticz.Error(f"Elevation cache prune failed (non-fatal): {exc!r}")
# ── Time-series analytics helpers ─────────────────────────────────────────
def _ts_ingest(self, src: str, *, node_key: str = None,
rssi=None, snr=None, noise=None, path_len=None):
"""Insert one radio sample into ts_radio.
Inserts immediately — SQLite WAL mode handles concurrent writes at this
frequency without batching. Marks all radio-related analytics panels
dirty so cached query results are invalidated.
Never raises into callers.
"""
if self._msgdb is None:
return
try:
ts = int(time.time())
nk = node_key or "unknown"
rssi_v = int(rssi) if rssi is not None else None
snr_v = float(snr) if snr is not None else None
noise_v = int(noise) if noise is not None else None
pl_v = int(path_len) if path_len is not None else None
with self._msgdb_lock:
self._msgdb.execute(
"INSERT INTO ts_radio (ts, node_key, rssi, snr, noise, path_len, src)"
" VALUES (?,?,?,?,?,?,?)",
(ts, nk, rssi_v, snr_v, noise_v, pl_v, src),
)
self._msgdb.commit()
self._ts_dirty_panels.update({"rssi", "snr", "noise"})
except Exception as exc:
Domoticz.Debug(f"_ts_ingest failed (non-fatal): {exc!r}")
def _ts_packets_add(self, now_ts: int, drx: int, dtx: int,
flood_drx: int = 0, flood_dtx: int = 0,
direct_drx: int = 0, direct_dtx: int = 0):
"""Upsert the current-minute row in ts_packets_min and the current-hour
row in ts_packets_hourly in a single transaction.
All delta arguments should be non-negative counters. Wrap-around-safe
deltas are computed by the caller (_handle_self_stats).
Never raises into callers.