homewatch 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Davis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: homewatch
3
+ Version: 0.1.0
4
+ Summary: Home counter-surveillance detector for Linux: Remote ID drones, Bluetooth trackers, unknown devices, RF.
5
+ Author: Ryan Davis
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sloppytopp/homewatch
8
+ Project-URL: Issues, https://github.com/sloppytopp/homewatch/issues
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Environment :: Console
11
+ Classifier: Topic :: System :: Networking :: Monitoring
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: bleak>=0.21
16
+ Dynamic: license-file
17
+
18
+ # homewatch
19
+ Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
20
+ Run: `./homewatch.sh setup` once, then `./homewatch.sh run` -> http://127.0.0.1:8777
21
+
22
+ ![dashboard with made-up demo data](https://raw.githubusercontent.com/sloppytopp/homewatch/main/docs/screenshot.png)
23
+
24
+ ## Install
25
+ `pip install homewatch` (or from source below). System packages are still needed:
26
+ `sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
27
+ camera/drone vendor detection - Homewatch warns at startup if it is missing), then
28
+ `python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
29
+ Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
30
+
31
+ ## Commands
32
+ | Command | What |
33
+ |---|---|
34
+ | `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
35
+ | `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
36
+ | `scan` | one-shot scan |
37
+ | `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
38
+ | `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
39
+ | `find <BLE addr>` | hot/cold locator |
40
+ | `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
41
+ | `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
42
+ | `selftest` | unit tests |
43
+
44
+ ## Read this first
45
+ - **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
46
+ Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
47
+ - **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
48
+ drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
49
+ **never written to disk**; events are pruned after 30 days.
50
+ - **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
51
+ enforcement or the FAA.
52
+ - **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
53
+ - **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
54
+ enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
55
+ - **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
56
+ Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
57
+ `X-Homewatch` header.
58
+ - Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/homewatch/config.json` (mode 600), never in the code.
@@ -0,0 +1,41 @@
1
+ # homewatch
2
+ Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
3
+ Run: `./homewatch.sh setup` once, then `./homewatch.sh run` -> http://127.0.0.1:8777
4
+
5
+ ![dashboard with made-up demo data](https://raw.githubusercontent.com/sloppytopp/homewatch/main/docs/screenshot.png)
6
+
7
+ ## Install
8
+ `pip install homewatch` (or from source below). System packages are still needed:
9
+ `sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
10
+ camera/drone vendor detection - Homewatch warns at startup if it is missing), then
11
+ `python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
12
+ Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
13
+
14
+ ## Commands
15
+ | Command | What |
16
+ |---|---|
17
+ | `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
18
+ | `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
19
+ | `scan` | one-shot scan |
20
+ | `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
21
+ | `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
22
+ | `find <BLE addr>` | hot/cold locator |
23
+ | `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
24
+ | `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
25
+ | `selftest` | unit tests |
26
+
27
+ ## Read this first
28
+ - **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
29
+ Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
30
+ - **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
31
+ drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
32
+ **never written to disk**; events are pruned after 30 days.
33
+ - **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
34
+ enforcement or the FAA.
35
+ - **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
36
+ - **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
37
+ enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
38
+ - **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
39
+ Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
40
+ `X-Homewatch` header.
41
+ - Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/homewatch/config.json` (mode 600), never in the code.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ main()
@@ -0,0 +1,314 @@
1
+ import argparse
2
+ import sys
3
+ import threading
4
+ import time
5
+
6
+ from .core import DOMAINS, DB, Engine, load_config, save_config, new_token
7
+
8
+ ICON = {"ok": "OK ", "watch": "WATCH", "alert": "ALERT", "off": "off "}
9
+
10
+
11
+ def cmd_run(a):
12
+ from . import det_ble, det_host, det_lan, det_sdr, det_wifi, web
13
+ eng = Engine()
14
+ det_ble.BLEDetector(eng).start()
15
+ if not a.no_wifi:
16
+ det_wifi.WifiDetector(eng).start()
17
+ det_lan.LanDetector(eng).start()
18
+ det_host.HostDetector(eng, own_port=a.port).start()
19
+ det_sdr.SdrDetector(eng).start()
20
+ from . import oui
21
+ if oui.table_size() == 0:
22
+ print("WARNING: no MAC vendor database found - camera/drone vendor detection is OFF.\n"
23
+ " Install it: sudo apt install ieee-data nmap", flush=True)
24
+ eng.db.prune()
25
+ cfg = load_config()
26
+ if not cfg.get("my_ssids"): # learn which Wi-Fi is yours (first run) - never hardcoded
27
+ import subprocess
28
+ out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], capture_output=True, text=True).stdout
29
+ mine = sorted({l.split(":", 1)[1] for l in out.splitlines() if l.startswith("yes:") and l.split(":", 1)[1]})
30
+ if mine:
31
+ cfg["my_ssids"] = mine
32
+ save_config(cfg)
33
+ print(f"Learned your Wi-Fi name(s): {', '.join(mine)} (edit my_ssids in {__import__('homewatch.core', fromlist=['x']).CONFIG_PATH})")
34
+ token = None
35
+ if a.lan:
36
+ token = cfg.setdefault("dashboard_key", new_token())
37
+ save_config(cfg)
38
+ web.serve(eng, "0.0.0.0" if a.lan else "127.0.0.1", a.port, token)
39
+ print(f"Homewatch running. Dashboard: http://127.0.0.1:{a.port}")
40
+ if a.lan:
41
+ import subprocess
42
+ ip = subprocess.run("hostname -I", shell=True, capture_output=True, text=True).stdout.split()[0]
43
+ print(f"Phone (same Wi-Fi): http://{ip}:{a.port}/?k={token} <- keep this link private")
44
+ if a.lan:
45
+ print("NOTE: --lan is plain HTTP. Anyone sniffing your Wi-Fi could see the link/token. Use only on a network you trust.")
46
+ print(f"Phone alerts: {'ON' if cfg.get('ntfy', {}).get('enabled') else 'off (see: homewatch ntfy setup)'}\n"
47
+ "Type b + Enter when the sensor beeps. Ctrl-C to stop.", flush=True)
48
+
49
+ def keys():
50
+ for line in sys.stdin:
51
+ if line.strip().lower() in ("b", "beep"):
52
+ eng.beep("keyboard")
53
+ print(" -> beep logged", flush=True)
54
+ if sys.stdin.isatty():
55
+ threading.Thread(target=keys, daemon=True).start()
56
+ try:
57
+ while True:
58
+ time.sleep(60)
59
+ line = " | ".join(f"{d}:{eng.state[d]['level']}" for d in DOMAINS)
60
+ print(f"[{time.strftime('%H:%M')}] {line}", flush=True)
61
+ except KeyboardInterrupt:
62
+ eng.stop.set()
63
+
64
+
65
+ def cmd_beep(a):
66
+ db = DB()
67
+ ts = time.time() - a.ago * 60
68
+ db.exec("INSERT INTO beeps(ts,note) VALUES(?,?)", (ts, " ".join(a.note)))
69
+ print(f"Beep logged at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))}")
70
+
71
+
72
+ def cmd_report(a):
73
+ db = DB()
74
+ since = time.time() - a.hours * 3600
75
+ beeps = db.query("SELECT * FROM beeps WHERE ts>? ORDER BY ts", (since,))
76
+ if not beeps:
77
+ print("No beeps logged in that window. Log them with: homewatch beep (or 'b' in `run`)")
78
+ hits = 0
79
+ for b in beeps:
80
+ near = db.query("SELECT * FROM events WHERE ts BETWEEN ? AND ? AND level IN ('alert','watch') "
81
+ "AND kind!='beep' ORDER BY ts", (b["ts"] - a.window * 60, b["ts"] + a.window * 60))
82
+ stamp = time.strftime("%a %m-%d %H:%M:%S", time.localtime(b["ts"]))
83
+ print(f"\nBEEP {stamp} {b['note'] or ''}")
84
+ if near:
85
+ hits += 1
86
+ for e in near:
87
+ print(f" {time.strftime('%H:%M:%S', time.localtime(e['ts']))} {e['level']:5} {e['domain']:7} {e['msg']}")
88
+ else:
89
+ print(f" nothing flagged within +/-{a.window} min")
90
+ if beeps:
91
+ print(f"\n== {hits}/{len(beeps)} beeps had a detection nearby. ==")
92
+ hrs = sorted(time.localtime(b['ts']).tm_hour for b in beeps)
93
+ print("Beep hours:", ", ".join(f"{h:02d}:xx" for h in hrs))
94
+ if hits == 0:
95
+ print("No drone, tracker, camera or RF event lines up with any beep -> sensor/animal/environment is the likely cause.")
96
+ if beeps:
97
+ span = max(1.0, (time.time() - min(since, beeps[0]["ts"])) / 60)
98
+ wins = max(1, int(span / (2 * a.window)))
99
+ busy = db.query("SELECT COUNT(DISTINCT CAST(ts/? AS INT)) n FROM events WHERE level IN ('alert','watch') "
100
+ "AND kind!='beep' AND ts>?", (a.window * 120, since))[0]["n"]
101
+ print(f"Baseline: detections were present in {busy} of ~{wins} {2*a.window:.0f}-minute windows overall. "
102
+ f"If that is close to {hits}/{len(beeps)}, the detections are background and prove nothing about the beeps.")
103
+ allev = db.query("SELECT level,COUNT(*) n FROM events WHERE ts>? GROUP BY level", (since,))
104
+ print("Events in window:", {e["level"]: e["n"] for e in allev})
105
+
106
+
107
+ def cmd_devices(a):
108
+ rows = DB().query("SELECT * FROM devices ORDER BY trusted, klass DESC, ip")
109
+ for d in rows:
110
+ t = "trusted " if d["trusted"] else "UNREVIEWED"
111
+ print(f"{t} {d['ip']:15} {d['mac']} {d['klass']:8} {d['vendor'][:34]:34} ports:{d['ports'] or '-'}")
112
+ print("\nMark yours as trusted: homewatch trust all | homewatch trust <mac>")
113
+
114
+
115
+ def cmd_trust(a):
116
+ db = DB()
117
+ if a.mac == "all":
118
+ db.exec("UPDATE devices SET trusted=1")
119
+ else:
120
+ db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (a.mac.lower(),))
121
+ print("ok")
122
+
123
+
124
+ def cmd_ignore(a):
125
+ db = DB()
126
+ cur = set(db.kv_get("ble_ignore", []))
127
+ cur.add(a.addr.upper())
128
+ db.kv_set("ble_ignore", sorted(cur))
129
+ print("Ignoring BLE device", a.addr, "(note: AirTags rotate addresses; this only mutes this one address)")
130
+
131
+
132
+ def cmd_find(a):
133
+ import asyncio
134
+ from bleak import BleakScanner
135
+ target = a.addr.upper()
136
+ print(f"Hot/cold finder for {target}. Walk around; stronger bar = closer. Ctrl-C to stop.")
137
+
138
+ def cb(dev, adv):
139
+ if dev.address.upper() == target:
140
+ n = max(0, min(40, int((adv.rssi + 100) * 40 / 60)))
141
+ print(f"{adv.rssi:4} dBm |{'#' * n}", flush=True)
142
+
143
+ async def go():
144
+ async with BleakScanner(cb):
145
+ while True:
146
+ await asyncio.sleep(1)
147
+ try:
148
+ asyncio.run(go())
149
+ except KeyboardInterrupt:
150
+ pass
151
+
152
+
153
+ def cmd_home(a):
154
+ cfg = load_config()
155
+ if a.lat is not None:
156
+ cfg["home"] = {"lat": a.lat, "lon": a.lon}
157
+ save_config(cfg)
158
+ print("Home location:", cfg.get("home") or "not set",
159
+ "\nTip: for exact position, long-press your house in Google/Apple Maps and copy the two numbers, then:"
160
+ "\n homewatch home <lat> <lon>")
161
+
162
+
163
+ def cmd_ntfy(a):
164
+ import urllib.request
165
+ cfg = load_config()
166
+ n = cfg.setdefault("ntfy", {})
167
+ if a.action == "setup":
168
+ n["topic"] = n.get("topic") or "homewatch-" + new_token()
169
+ n.setdefault("enabled", False)
170
+ save_config(cfg)
171
+ print(f"Topic created: {n['topic']}\n"
172
+ "1. Install the free 'ntfy' app (iOS App Store / Android Play Store).\n"
173
+ f"2. In the app tap + and subscribe to topic: {n['topic']} (server: ntfy.sh)\n"
174
+ "3. Then run: homewatch ntfy test (sends one test message)\n"
175
+ "4. Then run: homewatch ntfy on\n"
176
+ "Note: the topic name is the only 'password', so keep it private. Pushes are generic - never MACs or coordinates.")
177
+ return
178
+ if not n.get("topic"):
179
+ sys.exit("Run first: homewatch ntfy setup")
180
+ if a.action in ("on", "off"):
181
+ n["enabled"] = a.action == "on"
182
+ save_config(cfg)
183
+ print("Phone alerts", "ON" if n["enabled"] else "OFF")
184
+ elif a.action == "test":
185
+ req = urllib.request.Request(f"https://ntfy.sh/{n['topic']}", data=b"Homewatch test: phone alerts work.",
186
+ headers={"Title": "Homewatch"}, method="POST")
187
+ urllib.request.urlopen(req, timeout=10).read()
188
+ print("Test message sent. Check your phone.")
189
+
190
+
191
+ def cmd_setup(a):
192
+ """First-run walkthrough: your Wi-Fi, home position, and which devices on the network are yours."""
193
+ import socket
194
+ import subprocess
195
+ from . import det_lan
196
+ cfg = load_config()
197
+ print("Homewatch setup - takes about a minute.\n")
198
+ out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], capture_output=True, text=True).stdout
199
+ mine = sorted({l.split(":", 1)[1] for l in out.splitlines() if l.startswith("yes:") and l.split(":", 1)[1]})
200
+ if mine:
201
+ ok = input(f"1. Is your home Wi-Fi '{', '.join(mine)}'? [Y/n] ").strip().lower() != "n"
202
+ if ok:
203
+ cfg["my_ssids"] = mine
204
+ if "home" not in cfg:
205
+ print("\n2. Home position (optional, for the drone map). In Google/Apple Maps long-press your house and copy the")
206
+ print(" two numbers. West longitude is negative (e.g. 40.7128 -74.0060). Press Enter to skip.")
207
+ raw = input(" lat lon: ").split()
208
+ if len(raw) == 2:
209
+ try:
210
+ cfg["home"] = {"lat": float(raw[0]), "lon": float(raw[1])}
211
+ except ValueError:
212
+ print(" (couldn't read that - skipped)")
213
+ save_config(cfg)
214
+ print("\n3. Scanning your network for devices...")
215
+ eng = Engine(quiet=True)
216
+ det_lan.LanDetector(eng).scan()
217
+ pend = eng.db.query("SELECT * FROM devices WHERE trusted=0 ORDER BY ip")
218
+ if not pend:
219
+ print(" Nothing to review.")
220
+ for d in pend:
221
+ try:
222
+ name = socket.gethostbyaddr(d["ip"])[0]
223
+ except OSError:
224
+ name = ""
225
+ what = f"{d['ip']:15} {d['mac']} {d['vendor']} {name}".rstrip()
226
+ ans = input(f" Is this yours? {what} [y/N/a=all remaining] ").strip().lower()
227
+ if ans == "a":
228
+ eng.db.exec("UPDATE devices SET trusted=1 WHERE trusted=0")
229
+ break
230
+ if ans == "y":
231
+ eng.db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (d["mac"],))
232
+ left = eng.db.query("SELECT COUNT(*) n FROM devices WHERE trusted=0")[0]["n"]
233
+ print(f"\nDone. {left} device(s) left unreviewed - they will show amber until you identify them "
234
+ "(your router's client list can help).\nStart with: homewatch run")
235
+
236
+
237
+ def cmd_scan(a):
238
+ """One-shot scan of everything, print answers, exit."""
239
+ from . import det_ble, det_host, det_lan, det_wifi
240
+ eng = Engine(quiet=True)
241
+ ble = det_ble.BLEDetector(eng)
242
+ ble.start()
243
+ print("Scanning ~25s (Bluetooth + Wi-Fi + LAN + this computer)...", flush=True)
244
+ wd = det_wifi.WifiDetector(eng)
245
+ try:
246
+ wd.evaluate(wd.scan())
247
+ except Exception as e:
248
+ eng.status("drone", "watch", f"Wi-Fi: {e}", "wifi")
249
+ try:
250
+ det_lan.LanDetector(eng).scan()
251
+ except Exception as e:
252
+ eng.status("network", "watch", f"LAN: {e}", "lan")
253
+ try:
254
+ det_host.HostDetector(eng).check()
255
+ except Exception as e:
256
+ eng.status("host", "watch", str(e))
257
+ from . import det_sdr
258
+ det_sdr.SdrDetector(eng).start()
259
+ time.sleep(20)
260
+ ble._evaluate()
261
+ eng.stop.set()
262
+ for d, t in DOMAINS.items():
263
+ s = eng.state[d]
264
+ print(f"{ICON[s['level']]} {t}\n {s['msg']}")
265
+
266
+
267
+ def cmd_selftest(a):
268
+ import os
269
+ import unittest
270
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
271
+ if not os.path.isdir(os.path.join(root, "tests")):
272
+ sys.exit("selftest needs a source checkout (tests/ not found).")
273
+ os.chdir(root)
274
+ sys.exit(unittest.main(module=None, argv=["x", "discover", "-s", "tests", "-t", "."]))
275
+
276
+
277
+ def main():
278
+ p = argparse.ArgumentParser(prog="homewatch", description="Home counter-surveillance detector")
279
+ sp = p.add_subparsers(dest="cmd", required=True)
280
+ r = sp.add_parser("run", help="run all detectors + dashboard")
281
+ r.add_argument("--lan", action="store_true", help="serve dashboard to phones on your LAN")
282
+ r.add_argument("--port", type=int, default=8777)
283
+ r.add_argument("--no-wifi", action="store_true")
284
+ r.set_defaults(f=cmd_run)
285
+ b = sp.add_parser("beep", help="log that the sensor just beeped")
286
+ b.add_argument("--ago", type=float, default=0, help="minutes ago")
287
+ b.add_argument("note", nargs="*")
288
+ b.set_defaults(f=cmd_beep)
289
+ rp = sp.add_parser("report", help="line up beeps against detections")
290
+ rp.add_argument("--hours", type=float, default=72)
291
+ rp.add_argument("--window", type=float, default=5, help="minutes either side of a beep")
292
+ rp.set_defaults(f=cmd_report)
293
+ sp.add_parser("devices", help="LAN inventory").set_defaults(f=cmd_devices)
294
+ t = sp.add_parser("trust", help="mark LAN device(s) as yours")
295
+ t.add_argument("mac")
296
+ t.set_defaults(f=cmd_trust)
297
+ i = sp.add_parser("ignore", help="mute a BLE address (your own tracker)")
298
+ i.add_argument("addr")
299
+ i.set_defaults(f=cmd_ignore)
300
+ f = sp.add_parser("find", help="hot/cold locator for a BLE device")
301
+ f.add_argument("addr")
302
+ f.set_defaults(f=cmd_find)
303
+ h = sp.add_parser("home", help="show/set home lat lon for the drone map")
304
+ h.add_argument("lat", type=float, nargs="?")
305
+ h.add_argument("lon", type=float, nargs="?")
306
+ h.set_defaults(f=cmd_home)
307
+ nt = sp.add_parser("ntfy", help="phone alerts via ntfy.sh")
308
+ nt.add_argument("action", choices=["setup", "test", "on", "off"])
309
+ nt.set_defaults(f=cmd_ntfy)
310
+ sp.add_parser("setup", help="first-run walkthrough").set_defaults(f=cmd_setup)
311
+ sp.add_parser("scan", help="one-shot scan, print answers").set_defaults(f=cmd_scan)
312
+ sp.add_parser("selftest").set_defaults(f=cmd_selftest)
313
+ a = p.parse_args()
314
+ a.f(a)
@@ -0,0 +1,174 @@
1
+ """Shared engine: sqlite log, live status per question, alerts."""
2
+ import json
3
+ import os
4
+ import secrets
5
+ import urllib.request
6
+ import shutil
7
+ import sqlite3
8
+ import subprocess
9
+ import threading
10
+ import time
11
+
12
+ DATA_DIR = os.environ.get("HOMEWATCH_DIR") or os.path.expanduser("~/.local/share/homewatch")
13
+ DOMAINS = {
14
+ "drone": "Drone near the house?",
15
+ "tracker": "Active tracker present?",
16
+ "network": "Hidden camera / unknown device?",
17
+ "rf": "Elevated RF / EMF? (RTL-SDR)",
18
+ "host": "This computer's own camera/mic/remote access",
19
+ }
20
+ LEVELS = ("off", "ok", "watch", "alert")
21
+
22
+ CONFIG_PATH = os.path.join(DATA_DIR, "config.json")
23
+ PUSH_TEXT = { # phone pushes are deliberately generic: no MACs, no coordinates
24
+ "drone": "A drone signal was detected near the house.",
25
+ "tracker": "A tracker has stayed close to the house.",
26
+ "network": "Something new or camera-like showed up on your network.",
27
+ "rf": "Unusual radio activity was detected.",
28
+ "host": "Your computer's camera, mic or remote access triggered.",
29
+ }
30
+
31
+
32
+ def load_config():
33
+ try:
34
+ with open(CONFIG_PATH) as f:
35
+ return json.load(f)
36
+ except (OSError, ValueError):
37
+ return {}
38
+
39
+
40
+ def save_config(cfg):
41
+ os.makedirs(DATA_DIR, exist_ok=True)
42
+ with open(CONFIG_PATH, "w") as f:
43
+ json.dump(cfg, f, indent=2)
44
+ os.chmod(CONFIG_PATH, 0o600)
45
+
46
+
47
+ def new_token():
48
+ return secrets.token_urlsafe(9)
49
+
50
+
51
+ SCHEMA = """
52
+ CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY, ts REAL, domain TEXT,
53
+ level TEXT, kind TEXT, key TEXT, msg TEXT, detail TEXT);
54
+ CREATE INDEX IF NOT EXISTS ev_ts ON events(ts);
55
+ CREATE TABLE IF NOT EXISTS beeps(id INTEGER PRIMARY KEY, ts REAL, note TEXT);
56
+ CREATE TABLE IF NOT EXISTS devices(mac TEXT PRIMARY KEY, first_seen REAL, last_seen REAL,
57
+ ip TEXT, vendor TEXT, klass TEXT, ports TEXT, trusted INTEGER DEFAULT 0);
58
+ CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT);
59
+ """
60
+
61
+
62
+ class DB:
63
+ def __init__(self, path=None):
64
+ os.makedirs(DATA_DIR, exist_ok=True)
65
+ self.path = path or os.path.join(DATA_DIR, "homewatch.db")
66
+ self.c = sqlite3.connect(self.path, check_same_thread=False)
67
+ self.c.row_factory = sqlite3.Row
68
+ self.lock = threading.Lock()
69
+ with self.lock:
70
+ self.c.executescript(SCHEMA)
71
+
72
+ def exec(self, sql, args=()):
73
+ with self.lock:
74
+ cur = self.c.execute(sql, args)
75
+ self.c.commit()
76
+ return cur
77
+
78
+ def query(self, sql, args=()):
79
+ with self.lock:
80
+ return [dict(r) for r in self.c.execute(sql, args).fetchall()]
81
+
82
+ def prune(self, days=30):
83
+ """Retention: don't keep sighting history forever."""
84
+ self.exec("DELETE FROM events WHERE ts < ?", (time.time() - days * 86400,))
85
+ self.exec("DELETE FROM beeps WHERE ts < ?", (time.time() - 365 * 86400,))
86
+
87
+ def kv_get(self, k, default=None):
88
+ r = self.query("SELECT v FROM kv WHERE k=?", (k,))
89
+ return json.loads(r[0]["v"]) if r else default
90
+
91
+ def kv_set(self, k, v):
92
+ self.exec("INSERT OR REPLACE INTO kv(k,v) VALUES(?,?)", (k, json.dumps(v)))
93
+
94
+
95
+ class Engine:
96
+ def __init__(self, db=None, quiet=False):
97
+ self.db = db or DB()
98
+ self.quiet = quiet
99
+ self.state = {d: {"level": "off", "msg": "starting", "ts": time.time()} for d in DOMAINS}
100
+ self.lock = threading.Lock()
101
+ self.sources = {}
102
+ self.stop = threading.Event()
103
+ self._last_emit = {}
104
+ self.live = {} # free-form live detail per detector (shown on dashboard)
105
+ self._last_push = {}
106
+
107
+ # -- status -------------------------------------------------------
108
+ def status(self, domain, level, msg, source="main"):
109
+ """Each source reports its own level; the domain shows the worst one."""
110
+ with self.lock:
111
+ prev = self.state[domain]["level"]
112
+ self.sources.setdefault(domain, {})[source] = (level, msg, time.time())
113
+ worst = max(self.sources[domain].values(), key=lambda v: LEVELS.index(v[0]))
114
+ self.state[domain] = {"level": worst[0], "msg": worst[1], "ts": time.time()}
115
+ cur = worst[0]
116
+ if cur == "alert" and prev != "alert":
117
+ self.notify(f"HOMEWATCH ALERT: {DOMAINS[domain]}", worst[1], push=PUSH_TEXT.get(domain))
118
+
119
+ # -- events -------------------------------------------------------
120
+ def emit(self, domain, level, kind, key, msg, detail=None, cooldown=600):
121
+ now = time.time()
122
+ k = (kind, key)
123
+ if now - self._last_emit.get(k, 0) < cooldown:
124
+ return False
125
+ self._last_emit[k] = now
126
+ self.db.exec(
127
+ "INSERT INTO events(ts,domain,level,kind,key,msg,detail) VALUES(?,?,?,?,?,?,?)",
128
+ (now, domain, level, kind, key, msg, json.dumps(detail or {}, default=str)),
129
+ )
130
+ if not self.quiet:
131
+ stamp = time.strftime("%H:%M:%S", time.localtime(now))
132
+ print(f"[{stamp}] {level.upper():5} {domain:7} {msg}", flush=True)
133
+ return True
134
+
135
+ def beep(self, note="", ts=None):
136
+ self.db.exec("INSERT INTO beeps(ts,note) VALUES(?,?)", (ts or time.time(), note))
137
+
138
+ # -- alerting -----------------------------------------------------
139
+ def push(self, text, title="Homewatch"):
140
+ """Phone alert via ntfy.sh. Off until `homewatch ntfy on`. Never includes MACs/coordinates."""
141
+ cfg = load_config().get("ntfy", {})
142
+ if self.quiet or not (cfg.get("enabled") and cfg.get("topic")):
143
+ return False
144
+ now = time.time()
145
+ if now - self._last_push.get(text, 0) < 120:
146
+ return False
147
+ self._last_push[text] = now
148
+ try:
149
+ req = urllib.request.Request(f"https://ntfy.sh/{cfg['topic']}", data=text.encode(),
150
+ headers={"Title": title, "Tags": "satellite"}, method="POST")
151
+ urllib.request.urlopen(req, timeout=8).read()
152
+ return True
153
+ except Exception:
154
+ return False
155
+
156
+ def notify(self, title, body, push=None):
157
+ if self.quiet:
158
+ return
159
+ if push:
160
+ threading.Thread(target=self.push, args=(push,), daemon=True).start()
161
+ # Soft chime only - no voice. Set HOMEWATCH_SOUND=off to silence completely.
162
+ if os.environ.get("HOMEWATCH_SOUND", "ding") != "off" and shutil.which("paplay"):
163
+ snd = "/usr/share/sounds/freedesktop/stereo/message.oga"
164
+ subprocess.Popen(["paplay", snd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
165
+ if shutil.which("notify-send"):
166
+ subprocess.Popen(["notify-send", "-u", "normal", title, body],
167
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
168
+
169
+ def snapshot(self):
170
+ with self.lock:
171
+ st = {d: dict(v, title=DOMAINS[d]) for d, v in self.state.items()}
172
+ recent = self.db.query("SELECT ts,domain,level,msg FROM events ORDER BY id DESC LIMIT 40")
173
+ return {"now": time.time(), "state": st, "events": recent, "live": self.live,
174
+ "home": load_config().get("home"), "my_ssids": load_config().get("my_ssids", [])}