n0rma-sec 0.3.1__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,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: n0rma-sec
3
+ Version: 0.3.1
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/n0rma
8
+ Project-URL: Issues, https://github.com/sloppytopp/n0rma/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
+ # N0RMA
19
+
20
+ _Formerly called Homewatch._
21
+
22
+ Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
23
+ Run: `./n0rma.sh setup` once, then `./n0rma.sh run` -> http://127.0.0.1:8777
24
+
25
+ ![dashboard with made-up demo data](https://raw.githubusercontent.com/sloppytopp/n0rma/main/docs/screenshot.png)
26
+
27
+ ## What's new in 0.2
28
+ - Dark, muted, low-glare dashboard (no flashing alerts; every status is word + icon + color) with a **Night** switch (dim red on black)
29
+ - A plain-language banner ("All clear" / "Keeping an eye on something" / "Needs your attention") with a breathing dot and live scan ages, so you can see it is working
30
+ - **"This is mine"** on trackers: your own Tile/AirTag stops flagging and shows as yours
31
+ - Sister project: [n0rma-android](https://github.com/sloppytopp/n0rma-android) (public, with signed APKs on its Releases page) - same detection on a phone, with tap-to-find, room sweeps and survey exports
32
+
33
+ ## Install
34
+ `pip install n0rma-sec` (the command it installs is `n0rma`) (or from source below). System packages are still needed:
35
+ `sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
36
+ camera/drone vendor detection - N0RMA warns at startup if it is missing), then
37
+ `python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
38
+ Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
39
+
40
+ ## Commands
41
+ | Command | What |
42
+ |---|---|
43
+ | `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
44
+ | `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
45
+ | `scan` | one-shot scan |
46
+ | `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
47
+ | `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
48
+ | `find <BLE addr>` | hot/cold locator |
49
+ | `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
50
+ | `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
51
+ | `selftest` | unit tests |
52
+
53
+ ## Read this first
54
+ - **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
55
+ Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
56
+ - **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
57
+ drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
58
+ **never written to disk**; events are pruned after 30 days.
59
+ - **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
60
+ enforcement or the FAA.
61
+ - **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
62
+ - **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
63
+ enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
64
+ - **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
65
+ Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
66
+ `X-Homewatch` header.
67
+ - Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/n0rma/config.json` (mode 600), never in the code.
@@ -0,0 +1,50 @@
1
+ # N0RMA
2
+
3
+ _Formerly called Homewatch._
4
+
5
+ Home counter-surveillance **detector** for **Linux** (BlueZ + NetworkManager + `iw`). Detect-only: no jamming, no spoofing.
6
+ Run: `./n0rma.sh setup` once, then `./n0rma.sh run` -> http://127.0.0.1:8777
7
+
8
+ ![dashboard with made-up demo data](https://raw.githubusercontent.com/sloppytopp/n0rma/main/docs/screenshot.png)
9
+
10
+ ## What's new in 0.2
11
+ - Dark, muted, low-glare dashboard (no flashing alerts; every status is word + icon + color) with a **Night** switch (dim red on black)
12
+ - A plain-language banner ("All clear" / "Keeping an eye on something" / "Needs your attention") with a breathing dot and live scan ages, so you can see it is working
13
+ - **"This is mine"** on trackers: your own Tile/AirTag stops flagging and shows as yours
14
+ - Sister project: [n0rma-android](https://github.com/sloppytopp/n0rma-android) (public, with signed APKs on its Releases page) - same detection on a phone, with tap-to-find, room sweeps and survey exports
15
+
16
+ ## Install
17
+ `pip install n0rma-sec` (the command it installs is `n0rma`) (or from source below). System packages are still needed:
18
+ `sudo apt install ieee-data nmap iw network-manager bluez` (the vendor database from `ieee-data`/`nmap` is required for
19
+ camera/drone vendor detection - N0RMA warns at startup if it is missing), then
20
+ `python3 -m venv .venv --system-site-packages && .venv/bin/pip install bleak`.
21
+ Optional RF module: `sudo apt install rtl-sdr` + an RTL-SDR dongle (24 MHz-1.7 GHz only; not 2.4/5.8 GHz).
22
+
23
+ ## Commands
24
+ | Command | What |
25
+ |---|---|
26
+ | `setup` | first-run walkthrough: your Wi-Fi, home position, which devices are yours |
27
+ | `run [--lan]` | all detectors + dashboard (type `b`+Enter to log a sensor beep) |
28
+ | `scan` | one-shot scan |
29
+ | `beep [--ago MIN]` / `report` | log sensor beeps / line them up against detections (with background baseline) |
30
+ | `devices` / `trust all\|<mac>` | LAN inventory; the first scan auto-trusts everything - **review it** |
31
+ | `find <BLE addr>` | hot/cold locator |
32
+ | `home <lat> <lon>` | home position for the drone map (west longitude is negative) |
33
+ | `ntfy setup\|test\|on\|off` | phone alerts (generic text only, via ntfy.sh) |
34
+ | `selftest` | unit tests |
35
+
36
+ ## Read this first
37
+ - **This is not a guarantee of safety.** A quiet dashboard means "nothing seen by these radios", not "nothing is there".
38
+ Blind spots: drones without Remote ID, SD-card-only cameras, cellular/GPS trackers, anything on 2.4/5.8 GHz video.
39
+ - **Remote ID is unauthenticated.** Anyone can broadcast a fake one, so a red drone alert means a broadcast *claims* a
40
+ drone. Position and operator location are whatever the sender says. Operator coordinates are shown live and are
41
+ **never written to disk**; events are pruned after 30 days.
42
+ - **Do not interfere with a drone** (shooting at, jamming or spoofing one is a federal crime). Report it to law
43
+ enforcement or the FAA.
44
+ - **The radar is a rough estimate.** Signal strength gives only a crude distance, badly distorted indoors, and no direction.
45
+ - **If you find something:** don't touch or move it, photograph it where it is, note the time, and contact local law
46
+ enforcement. A tracker you don't own: Apple/Android show unknown-tracker alerts and can help identify it.
47
+ - **`--lan` is plain HTTP.** Anyone sniffing your Wi-Fi can see the private link/token. Use it only on a network you trust.
48
+ Localhost mode only accepts `Host: 127.0.0.1/localhost` (blocks DNS rebinding) and state-changing requests need an
49
+ `X-Homewatch` header.
50
+ - Your Wi-Fi name, home position, tokens and the ntfy topic live in `~/.local/share/n0rma/config.json` (mode 600), never in the code.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ main()
@@ -0,0 +1,352 @@
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, clean
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__('n0rma.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"N0RMA 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: n0rma 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: n0rma 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
+ name = clean(d.get("label") or d.get("hostname") or "")
112
+ print(f"{t} {d['ip']:15} {d['mac']} {name[:22]:22} {d['klass']:8} {d['vendor'][:28]:28} ports:{d['ports'] or '-'}")
113
+ print("\nMark yours as trusted: n0rma trust all | n0rma trust <mac> | give one a name: n0rma name <mac> Missy iPhone")
114
+
115
+
116
+ def cmd_trust(a):
117
+ db = DB()
118
+ if a.mac == "all":
119
+ db.exec("UPDATE devices SET trusted=1")
120
+ else:
121
+ db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (a.mac.lower(),))
122
+ print("ok")
123
+
124
+
125
+ def cmd_name(a):
126
+ label = clean(" ".join(a.label), 40)
127
+ DB().exec("UPDATE devices SET label=? WHERE mac=?", (label, a.mac.lower()))
128
+ print("ok" if label else "name cleared")
129
+
130
+
131
+ def cmd_ignore(a):
132
+ db = DB()
133
+ cur = set(db.kv_get("ble_ignore", []))
134
+ cur.add(a.addr.upper())
135
+ db.kv_set("ble_ignore", sorted(cur))
136
+ print("Ignoring BLE device", a.addr, "(note: AirTags rotate addresses; this only mutes this one address)")
137
+
138
+
139
+ def cmd_find(a):
140
+ import asyncio
141
+ from bleak import BleakScanner
142
+ target = a.addr.upper()
143
+ print(f"Hot/cold finder for {target}. Walk around; stronger bar = closer. Ctrl-C to stop.")
144
+
145
+ def cb(dev, adv):
146
+ if dev.address.upper() == target:
147
+ n = max(0, min(40, int((adv.rssi + 100) * 40 / 60)))
148
+ print(f"{adv.rssi:4} dBm |{'#' * n}", flush=True)
149
+
150
+ async def go():
151
+ async with BleakScanner(cb):
152
+ while True:
153
+ await asyncio.sleep(1)
154
+ try:
155
+ asyncio.run(go())
156
+ except KeyboardInterrupt:
157
+ pass
158
+
159
+
160
+ def cmd_home(a):
161
+ cfg = load_config()
162
+ if a.lat is not None:
163
+ cfg["home"] = {"lat": a.lat, "lon": a.lon}
164
+ save_config(cfg)
165
+ print("Home location:", cfg.get("home") or "not set",
166
+ "\nTip: for exact position, long-press your house in Google/Apple Maps and copy the two numbers, then:"
167
+ "\n n0rma home <lat> <lon>")
168
+
169
+
170
+ def cmd_ntfy(a):
171
+ import urllib.request
172
+ cfg = load_config()
173
+ n = cfg.setdefault("ntfy", {})
174
+ if a.action == "setup":
175
+ n["topic"] = n.get("topic") or "n0rma-" + new_token()
176
+ n.setdefault("enabled", False)
177
+ save_config(cfg)
178
+ print(f"Topic created: {n['topic']}\n"
179
+ "1. Install the free 'ntfy' app (iOS App Store / Android Play Store).\n"
180
+ f"2. In the app tap + and subscribe to topic: {n['topic']} (server: ntfy.sh)\n"
181
+ "3. Then run: n0rma ntfy test (sends one test message)\n"
182
+ "4. Then run: n0rma ntfy on\n"
183
+ "Note: the topic name is the only 'password', so keep it private. Pushes are generic - never MACs or coordinates.")
184
+ return
185
+ if not n.get("topic"):
186
+ sys.exit("Run first: n0rma ntfy setup")
187
+ if a.action in ("on", "off"):
188
+ n["enabled"] = a.action == "on"
189
+ save_config(cfg)
190
+ print("Phone alerts", "ON" if n["enabled"] else "OFF")
191
+ elif a.action == "test":
192
+ req = urllib.request.Request(f"https://ntfy.sh/{n['topic']}", data=b"N0RMA test: phone alerts work.",
193
+ headers={"Title": "N0RMA"}, method="POST")
194
+ urllib.request.urlopen(req, timeout=10).read()
195
+ print("Test message sent. Check your phone.")
196
+
197
+
198
+ def cmd_setup(a):
199
+ """First-run walkthrough: your Wi-Fi, home position, and which devices on the network are yours."""
200
+ import socket
201
+ import subprocess
202
+ from . import det_lan
203
+ cfg = load_config()
204
+ print("N0RMA setup - takes about a minute.\n")
205
+ out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], capture_output=True, text=True).stdout
206
+ mine = sorted({l.split(":", 1)[1] for l in out.splitlines() if l.startswith("yes:") and l.split(":", 1)[1]})
207
+ if mine:
208
+ ok = input(f"1. Is your home Wi-Fi '{', '.join(mine)}'? [Y/n] ").strip().lower() != "n"
209
+ if ok:
210
+ cfg["my_ssids"] = mine
211
+ if "home" not in cfg:
212
+ print("\n2. Home position (optional, for the drone map). In Google/Apple Maps long-press your house and copy the")
213
+ print(" two numbers. West longitude is negative (e.g. 40.7128 -74.0060). Press Enter to skip.")
214
+ raw = input(" lat lon: ").split()
215
+ if len(raw) == 2:
216
+ try:
217
+ cfg["home"] = {"lat": float(raw[0]), "lon": float(raw[1])}
218
+ except ValueError:
219
+ print(" (couldn't read that - skipped)")
220
+ save_config(cfg)
221
+ print("\n3. Scanning your network for devices...")
222
+ eng = Engine(quiet=True)
223
+ det_lan.LanDetector(eng).scan()
224
+ pend = eng.db.query("SELECT * FROM devices WHERE trusted=0 ORDER BY ip")
225
+ if not pend:
226
+ print(" Nothing to review.")
227
+ for d in pend:
228
+ try:
229
+ name = socket.gethostbyaddr(d["ip"])[0]
230
+ except OSError:
231
+ name = ""
232
+ what = f"{d['ip']:15} {d['mac']} {d['vendor']} {name}".rstrip()
233
+ ans = input(f" Is this yours? {what} [y/N/a=all remaining] ").strip().lower()
234
+ if ans == "a":
235
+ eng.db.exec("UPDATE devices SET trusted=1 WHERE trusted=0")
236
+ break
237
+ if ans == "y":
238
+ eng.db.exec("UPDATE devices SET trusted=1 WHERE mac=?", (d["mac"],))
239
+ left = eng.db.query("SELECT COUNT(*) n FROM devices WHERE trusted=0")[0]["n"]
240
+ print(f"\nDone. {left} device(s) left unreviewed - they will show amber until you identify them "
241
+ "(your router's client list can help).\nStart with: n0rma run")
242
+
243
+
244
+ def cmd_rebaseline(a):
245
+ """Accept everything currently listening / plugged in as normal (after you have checked it)."""
246
+ from . import det_host
247
+ db = DB()
248
+ p, u = det_host.listening(), det_host.usb_devices()
249
+ db.kv_set("host_ports", sorted(p))
250
+ db.kv_set("host_usb", sorted(u))
251
+ print(f"Accepted {len(p)} listening ports and {len(u)} USB devices as normal.")
252
+
253
+
254
+ def cmd_forget_network(a):
255
+ """Moved house / changed router: forget the saved home network and device list, then re-learn."""
256
+ db = DB()
257
+ db.exec("DELETE FROM devices")
258
+ db.kv_set("lan_baseline", False)
259
+ db.exec("DELETE FROM kv WHERE k='lan_subnet'")
260
+ print("Forgot the home network. The next `n0rma setup` / `run` learns the current one.")
261
+
262
+
263
+ def cmd_scan(a):
264
+ """One-shot scan of everything, print answers, exit."""
265
+ from . import det_ble, det_host, det_lan, det_wifi
266
+ eng = Engine(quiet=True)
267
+ ble = det_ble.BLEDetector(eng)
268
+ ble.start()
269
+ print("Scanning ~25s (Bluetooth + Wi-Fi + LAN + this computer)...", flush=True)
270
+ wd = det_wifi.WifiDetector(eng)
271
+ try:
272
+ wd.evaluate(wd.scan())
273
+ except Exception as e:
274
+ eng.status("drone", "watch", f"Wi-Fi: {e}", "wifi")
275
+ try:
276
+ det_lan.LanDetector(eng).scan()
277
+ except Exception as e:
278
+ eng.status("network", "watch", f"LAN: {e}", "lan")
279
+ try:
280
+ det_host.HostDetector(eng).check()
281
+ except Exception as e:
282
+ eng.status("host", "watch", str(e))
283
+ from . import det_sdr
284
+ det_sdr.SdrDetector(eng).start()
285
+ time.sleep(20)
286
+ ble._evaluate()
287
+ eng.stop.set()
288
+ for d, t in DOMAINS.items():
289
+ s = eng.state[d]
290
+ print(f"{ICON[s['level']]} {t}\n {s['msg']}")
291
+
292
+
293
+ def cmd_selftest(a):
294
+ import os
295
+ import unittest
296
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
297
+ if not os.path.isdir(os.path.join(root, "tests")):
298
+ sys.exit("selftest needs a source checkout (tests/ not found).")
299
+ os.chdir(root)
300
+ sys.exit(unittest.main(module=None, argv=["x", "discover", "-s", "tests", "-t", "."]))
301
+
302
+
303
+ def legacy():
304
+ """Old `homewatch` command: still works, but says it was renamed."""
305
+ print("note: homewatch is now called n0rma - use `n0rma` from now on.", file=sys.stderr)
306
+ main()
307
+
308
+
309
+ def main():
310
+ p = argparse.ArgumentParser(prog="n0rma", description="Home counter-surveillance detector")
311
+ sp = p.add_subparsers(dest="cmd", required=True)
312
+ r = sp.add_parser("run", help="run all detectors + dashboard")
313
+ r.add_argument("--lan", action="store_true", help="serve dashboard to phones on your LAN")
314
+ r.add_argument("--port", type=int, default=8777)
315
+ r.add_argument("--no-wifi", action="store_true")
316
+ r.set_defaults(f=cmd_run)
317
+ b = sp.add_parser("beep", help="log that the sensor just beeped")
318
+ b.add_argument("--ago", type=float, default=0, help="minutes ago")
319
+ b.add_argument("note", nargs="*")
320
+ b.set_defaults(f=cmd_beep)
321
+ rp = sp.add_parser("report", help="line up beeps against detections")
322
+ rp.add_argument("--hours", type=float, default=72)
323
+ rp.add_argument("--window", type=float, default=5, help="minutes either side of a beep")
324
+ rp.set_defaults(f=cmd_report)
325
+ sp.add_parser("devices", help="LAN inventory").set_defaults(f=cmd_devices)
326
+ t = sp.add_parser("trust", help="mark LAN device(s) as yours")
327
+ t.add_argument("mac")
328
+ t.set_defaults(f=cmd_trust)
329
+ nm = sp.add_parser("name", help="give a LAN device a name you will recognise")
330
+ nm.add_argument("mac")
331
+ nm.add_argument("label", nargs="*", help="empty clears the name")
332
+ nm.set_defaults(f=cmd_name)
333
+ i = sp.add_parser("ignore", help="mute a BLE address (your own tracker)")
334
+ i.add_argument("addr")
335
+ i.set_defaults(f=cmd_ignore)
336
+ f = sp.add_parser("find", help="hot/cold locator for a BLE device")
337
+ f.add_argument("addr")
338
+ f.set_defaults(f=cmd_find)
339
+ h = sp.add_parser("home", help="show/set home lat lon for the drone map")
340
+ h.add_argument("lat", type=float, nargs="?")
341
+ h.add_argument("lon", type=float, nargs="?")
342
+ h.set_defaults(f=cmd_home)
343
+ nt = sp.add_parser("ntfy", help="phone alerts via ntfy.sh")
344
+ nt.add_argument("action", choices=["setup", "test", "on", "off"])
345
+ nt.set_defaults(f=cmd_ntfy)
346
+ sp.add_parser("rebaseline", help="accept current listening ports + USB devices as normal").set_defaults(f=cmd_rebaseline)
347
+ sp.add_parser("forget-network", help="forget the saved home network and re-learn").set_defaults(f=cmd_forget_network)
348
+ sp.add_parser("setup", help="first-run walkthrough").set_defaults(f=cmd_setup)
349
+ sp.add_parser("scan", help="one-shot scan, print answers").set_defaults(f=cmd_scan)
350
+ sp.add_parser("selftest").set_defaults(f=cmd_selftest)
351
+ a = p.parse_args()
352
+ a.f(a)