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.
- homewatch-0.1.0/LICENSE +21 -0
- homewatch-0.1.0/PKG-INFO +58 -0
- homewatch-0.1.0/README.md +41 -0
- homewatch-0.1.0/homewatch/__init__.py +1 -0
- homewatch-0.1.0/homewatch/__main__.py +2 -0
- homewatch-0.1.0/homewatch/cli.py +314 -0
- homewatch-0.1.0/homewatch/core.py +174 -0
- homewatch-0.1.0/homewatch/det_ble.py +137 -0
- homewatch-0.1.0/homewatch/det_host.py +115 -0
- homewatch-0.1.0/homewatch/det_lan.py +130 -0
- homewatch-0.1.0/homewatch/det_sdr.py +100 -0
- homewatch-0.1.0/homewatch/det_wifi.py +138 -0
- homewatch-0.1.0/homewatch/oui.py +82 -0
- homewatch-0.1.0/homewatch/remoteid.py +89 -0
- homewatch-0.1.0/homewatch/web.py +161 -0
- homewatch-0.1.0/homewatch.egg-info/PKG-INFO +58 -0
- homewatch-0.1.0/homewatch.egg-info/SOURCES.txt +22 -0
- homewatch-0.1.0/homewatch.egg-info/dependency_links.txt +1 -0
- homewatch-0.1.0/homewatch.egg-info/entry_points.txt +2 -0
- homewatch-0.1.0/homewatch.egg-info/requires.txt +1 -0
- homewatch-0.1.0/homewatch.egg-info/top_level.txt +1 -0
- homewatch-0.1.0/pyproject.toml +28 -0
- homewatch-0.1.0/setup.cfg +4 -0
- homewatch-0.1.0/tests/test_parsers.py +257 -0
homewatch-0.1.0/LICENSE
ADDED
|
@@ -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.
|
homewatch-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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", [])}
|