roomiewatch 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.
- roomiewatch-0.1.0/LICENSE +21 -0
- roomiewatch-0.1.0/PKG-INFO +150 -0
- roomiewatch-0.1.0/README.md +124 -0
- roomiewatch-0.1.0/pyproject.toml +41 -0
- roomiewatch-0.1.0/roomiewatch/__init__.py +3 -0
- roomiewatch-0.1.0/roomiewatch/__main__.py +6 -0
- roomiewatch-0.1.0/roomiewatch/core.py +669 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/PKG-INFO +150 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/SOURCES.txt +12 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/dependency_links.txt +1 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/entry_points.txt +2 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/requires.txt +3 -0
- roomiewatch-0.1.0/roomiewatch.egg-info/top_level.txt +1 -0
- roomiewatch-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 xghostient
|
|
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,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roomiewatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud.
|
|
5
|
+
Author: xghostient
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/xghostient/roomiewatch
|
|
8
|
+
Project-URL: Issues, https://github.com/xghostient/roomiewatch/issues
|
|
9
|
+
Keywords: surveillance,motion-detection,security,webcam,privacy
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video :: Capture
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: opencv-python>=4.8
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: flask>=3.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# RoomieWatch
|
|
28
|
+
|
|
29
|
+
Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud.
|
|
30
|
+
|
|
31
|
+
Built because I don't trust my roommate.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Motion detection** — OpenCV-based frame diffing with configurable sensitivity
|
|
36
|
+
- **Live dashboard** — MJPEG stream + stats + recent captures in a dark-themed web UI
|
|
37
|
+
- **Privacy-first** — no cloud, no third-party servers, all data stays on your machine
|
|
38
|
+
- **Remote access** — E2E encrypted via [Tailscale](https://tailscale.com) (or self-hosted with [Headscale](https://github.com/juanfont/headscale))
|
|
39
|
+
- **Runs locked** — keeps working when your screen is locked (macOS `caffeinate`)
|
|
40
|
+
- **Auto-restart** — launcher script recovers from crashes automatically
|
|
41
|
+
- **Zero config** — works out of the box with your laptop webcam
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install roomiewatch
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or from source:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/xghostient/roomiewatch.git
|
|
53
|
+
cd roomiewatch
|
|
54
|
+
pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Motion detection only — captures saved to ./roomiewatch_captures/
|
|
61
|
+
roomiewatch
|
|
62
|
+
|
|
63
|
+
# With live web dashboard
|
|
64
|
+
roomiewatch --stream
|
|
65
|
+
|
|
66
|
+
# Full setup — stream + prevent sleep
|
|
67
|
+
roomiewatch --stream --caffeinate
|
|
68
|
+
|
|
69
|
+
# Custom settings
|
|
70
|
+
roomiewatch --stream --port 9090 --sensitivity 5 --cooldown 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Lock your screen (`Ctrl+Cmd+Q`) and leave. **Do NOT close the lid.**
|
|
74
|
+
|
|
75
|
+
## Remote Access (Tailscale)
|
|
76
|
+
|
|
77
|
+
View your feed from anywhere on your phone — E2E encrypted, no middleman.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# On your machine
|
|
81
|
+
brew install tailscale
|
|
82
|
+
sudo brew services start tailscale
|
|
83
|
+
tailscale up
|
|
84
|
+
|
|
85
|
+
# Install Tailscale on your phone, sign in with the same account
|
|
86
|
+
# Then open: http://<tailscale-ip>:8080
|
|
87
|
+
tailscale ip -4 # shows your machine's Tailscale IP
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Why Tailscale over Cloudflare Tunnel?** Cloudflare terminates TLS at their edge — your images pass through their servers in plaintext. Tailscale is direct device-to-device WireGuard encryption. Nobody sees your footage.
|
|
91
|
+
|
|
92
|
+
For full self-hosted setup with zero third-party trust, use [Headscale](https://github.com/juanfont/headscale).
|
|
93
|
+
|
|
94
|
+
## Launcher Script
|
|
95
|
+
|
|
96
|
+
For long-running sessions with auto-restart and sleep prevention:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
chmod +x start_roomiewatch.sh
|
|
100
|
+
./start_roomiewatch.sh # stream + Tailscale
|
|
101
|
+
./start_roomiewatch.sh --no-stream # motion detection only
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## CLI Options
|
|
105
|
+
|
|
106
|
+
| Flag | Default | Description |
|
|
107
|
+
|------|---------|-------------|
|
|
108
|
+
| `--stream` | off | Enable live web dashboard |
|
|
109
|
+
| `--port` | 8080 | Web server port |
|
|
110
|
+
| `--sensitivity` | 3 | Motion threshold % (higher = less sensitive) |
|
|
111
|
+
| `--cooldown` | 5 | Seconds between captures |
|
|
112
|
+
| `--duration` | unlimited | Auto-stop after N minutes |
|
|
113
|
+
| `--camera` | 0 | Camera index |
|
|
114
|
+
| `--no-sound` | off | Disable alert beep |
|
|
115
|
+
| `--no-snapshots` | off | Stream only, don't save to disk |
|
|
116
|
+
| `--caffeinate` | off | Prevent system sleep (macOS + Linux) |
|
|
117
|
+
|
|
118
|
+
## How It Works
|
|
119
|
+
|
|
120
|
+
1. Captures frames from your webcam at ~15 FPS
|
|
121
|
+
2. Converts to grayscale, applies Gaussian blur
|
|
122
|
+
3. Computes absolute difference from previous frame
|
|
123
|
+
4. If changed pixels exceed the sensitivity threshold — saves a timestamped JPEG and logs the event
|
|
124
|
+
5. Flask serves a live MJPEG stream and a dashboard with stats and recent captures
|
|
125
|
+
|
|
126
|
+
## Files Created
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
roomiewatch_captures/
|
|
130
|
+
├── motion_20260302_143022.jpg # snapshots with timestamp overlay
|
|
131
|
+
├── motion_log.txt # text log of all motion events
|
|
132
|
+
└── launcher.log # launcher/restart log
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Troubleshooting
|
|
136
|
+
|
|
137
|
+
- **Camera not opening** — Grant Terminal camera access in System Settings > Privacy & Security > Camera
|
|
138
|
+
- **Stream not loading** — Check if port 8080 is in use: `lsof -i :8080`
|
|
139
|
+
- **Too many false alerts** — Increase sensitivity: `roomiewatch --stream --sensitivity 5`
|
|
140
|
+
- **macOS sleeping** — Don't close the lid. Use the launcher script which runs `caffeinate`
|
|
141
|
+
|
|
142
|
+
## Requirements
|
|
143
|
+
|
|
144
|
+
- Python 3.9+
|
|
145
|
+
- A webcam
|
|
146
|
+
- macOS or Linux (Windows: untested but should work)
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# RoomieWatch
|
|
2
|
+
|
|
3
|
+
Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud.
|
|
4
|
+
|
|
5
|
+
Built because I don't trust my roommate.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Motion detection** — OpenCV-based frame diffing with configurable sensitivity
|
|
10
|
+
- **Live dashboard** — MJPEG stream + stats + recent captures in a dark-themed web UI
|
|
11
|
+
- **Privacy-first** — no cloud, no third-party servers, all data stays on your machine
|
|
12
|
+
- **Remote access** — E2E encrypted via [Tailscale](https://tailscale.com) (or self-hosted with [Headscale](https://github.com/juanfont/headscale))
|
|
13
|
+
- **Runs locked** — keeps working when your screen is locked (macOS `caffeinate`)
|
|
14
|
+
- **Auto-restart** — launcher script recovers from crashes automatically
|
|
15
|
+
- **Zero config** — works out of the box with your laptop webcam
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install roomiewatch
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or from source:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/xghostient/roomiewatch.git
|
|
27
|
+
cd roomiewatch
|
|
28
|
+
pip install .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Motion detection only — captures saved to ./roomiewatch_captures/
|
|
35
|
+
roomiewatch
|
|
36
|
+
|
|
37
|
+
# With live web dashboard
|
|
38
|
+
roomiewatch --stream
|
|
39
|
+
|
|
40
|
+
# Full setup — stream + prevent sleep
|
|
41
|
+
roomiewatch --stream --caffeinate
|
|
42
|
+
|
|
43
|
+
# Custom settings
|
|
44
|
+
roomiewatch --stream --port 9090 --sensitivity 5 --cooldown 10
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Lock your screen (`Ctrl+Cmd+Q`) and leave. **Do NOT close the lid.**
|
|
48
|
+
|
|
49
|
+
## Remote Access (Tailscale)
|
|
50
|
+
|
|
51
|
+
View your feed from anywhere on your phone — E2E encrypted, no middleman.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# On your machine
|
|
55
|
+
brew install tailscale
|
|
56
|
+
sudo brew services start tailscale
|
|
57
|
+
tailscale up
|
|
58
|
+
|
|
59
|
+
# Install Tailscale on your phone, sign in with the same account
|
|
60
|
+
# Then open: http://<tailscale-ip>:8080
|
|
61
|
+
tailscale ip -4 # shows your machine's Tailscale IP
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Why Tailscale over Cloudflare Tunnel?** Cloudflare terminates TLS at their edge — your images pass through their servers in plaintext. Tailscale is direct device-to-device WireGuard encryption. Nobody sees your footage.
|
|
65
|
+
|
|
66
|
+
For full self-hosted setup with zero third-party trust, use [Headscale](https://github.com/juanfont/headscale).
|
|
67
|
+
|
|
68
|
+
## Launcher Script
|
|
69
|
+
|
|
70
|
+
For long-running sessions with auto-restart and sleep prevention:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
chmod +x start_roomiewatch.sh
|
|
74
|
+
./start_roomiewatch.sh # stream + Tailscale
|
|
75
|
+
./start_roomiewatch.sh --no-stream # motion detection only
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## CLI Options
|
|
79
|
+
|
|
80
|
+
| Flag | Default | Description |
|
|
81
|
+
|------|---------|-------------|
|
|
82
|
+
| `--stream` | off | Enable live web dashboard |
|
|
83
|
+
| `--port` | 8080 | Web server port |
|
|
84
|
+
| `--sensitivity` | 3 | Motion threshold % (higher = less sensitive) |
|
|
85
|
+
| `--cooldown` | 5 | Seconds between captures |
|
|
86
|
+
| `--duration` | unlimited | Auto-stop after N minutes |
|
|
87
|
+
| `--camera` | 0 | Camera index |
|
|
88
|
+
| `--no-sound` | off | Disable alert beep |
|
|
89
|
+
| `--no-snapshots` | off | Stream only, don't save to disk |
|
|
90
|
+
| `--caffeinate` | off | Prevent system sleep (macOS + Linux) |
|
|
91
|
+
|
|
92
|
+
## How It Works
|
|
93
|
+
|
|
94
|
+
1. Captures frames from your webcam at ~15 FPS
|
|
95
|
+
2. Converts to grayscale, applies Gaussian blur
|
|
96
|
+
3. Computes absolute difference from previous frame
|
|
97
|
+
4. If changed pixels exceed the sensitivity threshold — saves a timestamped JPEG and logs the event
|
|
98
|
+
5. Flask serves a live MJPEG stream and a dashboard with stats and recent captures
|
|
99
|
+
|
|
100
|
+
## Files Created
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
roomiewatch_captures/
|
|
104
|
+
├── motion_20260302_143022.jpg # snapshots with timestamp overlay
|
|
105
|
+
├── motion_log.txt # text log of all motion events
|
|
106
|
+
└── launcher.log # launcher/restart log
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Troubleshooting
|
|
110
|
+
|
|
111
|
+
- **Camera not opening** — Grant Terminal camera access in System Settings > Privacy & Security > Camera
|
|
112
|
+
- **Stream not loading** — Check if port 8080 is in use: `lsof -i :8080`
|
|
113
|
+
- **Too many false alerts** — Increase sensitivity: `roomiewatch --stream --sensitivity 5`
|
|
114
|
+
- **macOS sleeping** — Don't close the lid. Use the launcher script which runs `caffeinate`
|
|
115
|
+
|
|
116
|
+
## Requirements
|
|
117
|
+
|
|
118
|
+
- Python 3.9+
|
|
119
|
+
- A webcam
|
|
120
|
+
- macOS or Linux (Windows: untested but should work)
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "roomiewatch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "xghostient" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["surveillance", "motion-detection", "security", "webcam", "privacy"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Topic :: Multimedia :: Video :: Capture",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"opencv-python>=4.8",
|
|
29
|
+
"numpy>=1.24",
|
|
30
|
+
"flask>=3.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/xghostient/roomiewatch"
|
|
35
|
+
Issues = "https://github.com/xghostient/roomiewatch/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
roomiewatch = "roomiewatch.core:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
include = ["roomiewatch*"]
|
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RoomieWatch — Privacy-first motion surveillance for your room.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
import time
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import argparse
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import shutil
|
|
14
|
+
import threading
|
|
15
|
+
import signal
|
|
16
|
+
import socket
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
from roomiewatch import __version__
|
|
19
|
+
|
|
20
|
+
# Flask is optional — only needed for streaming
|
|
21
|
+
try:
|
|
22
|
+
from flask import Flask, Response, render_template_string, jsonify
|
|
23
|
+
FLASK_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
FLASK_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Config ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
DEFAULT_SENSITIVITY = 3.0 # % of pixels that must change to trigger
|
|
31
|
+
DEFAULT_COOLDOWN = 5 # seconds between captures
|
|
32
|
+
MOTION_THRESHOLD = 30 # per-pixel intensity difference threshold
|
|
33
|
+
FRAME_WIDTH = 640
|
|
34
|
+
FRAME_HEIGHT = 480
|
|
35
|
+
WARMUP_SECONDS = 3 # ignore motion for first N seconds
|
|
36
|
+
JPEG_QUALITY = 70 # JPEG quality for stream (lower = less bandwidth)
|
|
37
|
+
STREAM_FPS = 10 # target FPS for the web stream
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def timestamp():
|
|
43
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
+
|
|
45
|
+
def file_timestamp():
|
|
46
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
47
|
+
|
|
48
|
+
def log(msg, level="INFO"):
|
|
49
|
+
colors = {"INFO": "\033[36m", "ALERT": "\033[91m", "OK": "\033[92m", "WARN": "\033[93m"}
|
|
50
|
+
reset = "\033[0m"
|
|
51
|
+
c = colors.get(level, "")
|
|
52
|
+
print(f"{c}[{timestamp()}] [{level}] {msg}{reset}")
|
|
53
|
+
|
|
54
|
+
def start_caffeinate():
|
|
55
|
+
"""Prevent system sleep. Returns subprocess to kill on exit, or None."""
|
|
56
|
+
system = platform.system()
|
|
57
|
+
try:
|
|
58
|
+
if system == "Darwin" and shutil.which("caffeinate"):
|
|
59
|
+
proc = subprocess.Popen(["caffeinate", "-is"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
60
|
+
log("Sleep prevention active (caffeinate)", "OK")
|
|
61
|
+
return proc
|
|
62
|
+
elif system == "Linux" and shutil.which("systemd-inhibit"):
|
|
63
|
+
proc = subprocess.Popen(
|
|
64
|
+
["systemd-inhibit", "--what=idle:sleep", "--who=roomiewatch",
|
|
65
|
+
"--reason=Surveillance active", "sleep", "infinity"],
|
|
66
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
67
|
+
)
|
|
68
|
+
log("Sleep prevention active (systemd-inhibit)", "OK")
|
|
69
|
+
return proc
|
|
70
|
+
else:
|
|
71
|
+
log("No sleep prevention available on this platform", "WARN")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
log(f"Could not start sleep prevention: {e}", "WARN")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def beep():
|
|
78
|
+
"""Cross-platform alert sound."""
|
|
79
|
+
system = platform.system()
|
|
80
|
+
try:
|
|
81
|
+
if system == "Darwin":
|
|
82
|
+
os.system("afplay /System/Library/Sounds/Sosumi.aiff &")
|
|
83
|
+
elif system == "Linux":
|
|
84
|
+
os.system("paplay /usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga 2>/dev/null &")
|
|
85
|
+
elif system == "Windows":
|
|
86
|
+
import winsound
|
|
87
|
+
winsound.Beep(880, 300)
|
|
88
|
+
except:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ─── Web Stream Dashboard ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
DASHBOARD_HTML = """
|
|
95
|
+
<!DOCTYPE html>
|
|
96
|
+
<html lang="en">
|
|
97
|
+
<head>
|
|
98
|
+
<meta charset="UTF-8">
|
|
99
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
100
|
+
<title>RoomieWatch — Live Feed</title>
|
|
101
|
+
<style>
|
|
102
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
103
|
+
body {
|
|
104
|
+
background: #0a0a0a;
|
|
105
|
+
color: #e0e0e0;
|
|
106
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif;
|
|
107
|
+
min-height: 100vh;
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
align-items: center;
|
|
111
|
+
}
|
|
112
|
+
.header {
|
|
113
|
+
padding: 20px;
|
|
114
|
+
text-align: center;
|
|
115
|
+
border-bottom: 1px solid #222;
|
|
116
|
+
width: 100%;
|
|
117
|
+
}
|
|
118
|
+
.header h1 {
|
|
119
|
+
font-size: 1.4em;
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
letter-spacing: 2px;
|
|
122
|
+
color: #ff4444;
|
|
123
|
+
}
|
|
124
|
+
.header .subtitle {
|
|
125
|
+
font-size: 0.85em;
|
|
126
|
+
color: #666;
|
|
127
|
+
margin-top: 4px;
|
|
128
|
+
}
|
|
129
|
+
.feed-container {
|
|
130
|
+
margin: 20px auto;
|
|
131
|
+
max-width: 720px;
|
|
132
|
+
width: 95%;
|
|
133
|
+
position: relative;
|
|
134
|
+
border-radius: 12px;
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
border: 2px solid #222;
|
|
137
|
+
background: #111;
|
|
138
|
+
}
|
|
139
|
+
.feed-container img {
|
|
140
|
+
width: 100%;
|
|
141
|
+
display: block;
|
|
142
|
+
}
|
|
143
|
+
.live-badge {
|
|
144
|
+
position: absolute;
|
|
145
|
+
top: 12px;
|
|
146
|
+
left: 12px;
|
|
147
|
+
background: #ff0000;
|
|
148
|
+
color: white;
|
|
149
|
+
padding: 4px 10px;
|
|
150
|
+
border-radius: 4px;
|
|
151
|
+
font-size: 0.75em;
|
|
152
|
+
font-weight: 700;
|
|
153
|
+
letter-spacing: 1px;
|
|
154
|
+
animation: pulse 2s ease-in-out infinite;
|
|
155
|
+
}
|
|
156
|
+
@keyframes pulse {
|
|
157
|
+
0%, 100% { opacity: 1; }
|
|
158
|
+
50% { opacity: 0.5; }
|
|
159
|
+
}
|
|
160
|
+
.stats {
|
|
161
|
+
display: grid;
|
|
162
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
163
|
+
gap: 12px;
|
|
164
|
+
max-width: 720px;
|
|
165
|
+
width: 95%;
|
|
166
|
+
margin: 0 auto 20px;
|
|
167
|
+
}
|
|
168
|
+
.stat-card {
|
|
169
|
+
background: #151515;
|
|
170
|
+
border: 1px solid #222;
|
|
171
|
+
border-radius: 10px;
|
|
172
|
+
padding: 14px;
|
|
173
|
+
text-align: center;
|
|
174
|
+
}
|
|
175
|
+
.stat-card .label {
|
|
176
|
+
font-size: 0.7em;
|
|
177
|
+
color: #666;
|
|
178
|
+
text-transform: uppercase;
|
|
179
|
+
letter-spacing: 1px;
|
|
180
|
+
}
|
|
181
|
+
.stat-card .value {
|
|
182
|
+
font-size: 1.5em;
|
|
183
|
+
font-weight: 700;
|
|
184
|
+
margin-top: 4px;
|
|
185
|
+
color: #fff;
|
|
186
|
+
}
|
|
187
|
+
.stat-card .value.alert { color: #ff4444; }
|
|
188
|
+
.stat-card .value.ok { color: #44ff44; }
|
|
189
|
+
.recent-captures {
|
|
190
|
+
max-width: 720px;
|
|
191
|
+
width: 95%;
|
|
192
|
+
margin: 0 auto 30px;
|
|
193
|
+
}
|
|
194
|
+
.recent-captures h3 {
|
|
195
|
+
font-size: 0.85em;
|
|
196
|
+
color: #666;
|
|
197
|
+
text-transform: uppercase;
|
|
198
|
+
letter-spacing: 1px;
|
|
199
|
+
margin-bottom: 10px;
|
|
200
|
+
}
|
|
201
|
+
.captures-grid {
|
|
202
|
+
display: grid;
|
|
203
|
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
204
|
+
gap: 8px;
|
|
205
|
+
}
|
|
206
|
+
.captures-grid img {
|
|
207
|
+
width: 100%;
|
|
208
|
+
border-radius: 8px;
|
|
209
|
+
border: 1px solid #222;
|
|
210
|
+
}
|
|
211
|
+
.no-captures { color: #444; font-style: italic; font-size: 0.9em; }
|
|
212
|
+
</style>
|
|
213
|
+
</head>
|
|
214
|
+
<body>
|
|
215
|
+
<div class="header">
|
|
216
|
+
<h1>ROOMIEWATCH</h1>
|
|
217
|
+
<div class="subtitle">Privacy-First Motion Surveillance — Live Feed</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="feed-container">
|
|
220
|
+
<div class="live-badge">● LIVE</div>
|
|
221
|
+
<img src="/video_feed" alt="Live Feed" />
|
|
222
|
+
</div>
|
|
223
|
+
<div class="stats">
|
|
224
|
+
<div class="stat-card">
|
|
225
|
+
<div class="label">Status</div>
|
|
226
|
+
<div class="value ok" id="status">Active</div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="stat-card">
|
|
229
|
+
<div class="label">Alerts</div>
|
|
230
|
+
<div class="value alert" id="alerts">0</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="stat-card">
|
|
233
|
+
<div class="label">Uptime</div>
|
|
234
|
+
<div class="value" id="uptime">0m</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="stat-card">
|
|
237
|
+
<div class="label">Last Motion</div>
|
|
238
|
+
<div class="value" id="last-motion">None</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="recent-captures">
|
|
242
|
+
<h3>Recent Captures</h3>
|
|
243
|
+
<div class="captures-grid" id="captures">
|
|
244
|
+
<span class="no-captures">No motion detected yet</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<script>
|
|
248
|
+
function updateStats() {
|
|
249
|
+
fetch('/api/stats')
|
|
250
|
+
.then(r => r.json())
|
|
251
|
+
.then(data => {
|
|
252
|
+
document.getElementById('alerts').textContent = data.total_alerts;
|
|
253
|
+
document.getElementById('uptime').textContent = data.uptime;
|
|
254
|
+
document.getElementById('last-motion').textContent = data.last_motion || 'None';
|
|
255
|
+
if (data.recent_captures && data.recent_captures.length > 0) {
|
|
256
|
+
const grid = document.getElementById('captures');
|
|
257
|
+
grid.innerHTML = data.recent_captures.map(f =>
|
|
258
|
+
`<img src="/captures/${f}" alt="${f}" />`
|
|
259
|
+
).join('');
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
.catch(() => {});
|
|
263
|
+
}
|
|
264
|
+
setInterval(updateStats, 3000);
|
|
265
|
+
updateStats();
|
|
266
|
+
</script>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ─── Motion Detector ────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
class RoomieWatch:
|
|
275
|
+
def __init__(self, args):
|
|
276
|
+
self.sensitivity = args.sensitivity
|
|
277
|
+
self.cooldown = args.cooldown
|
|
278
|
+
self.duration = args.duration
|
|
279
|
+
self.camera_idx = args.camera
|
|
280
|
+
self.sound = not args.no_sound
|
|
281
|
+
self.save_snapshots = not args.no_snapshots
|
|
282
|
+
self.enable_stream = args.stream
|
|
283
|
+
self.stream_port = args.port
|
|
284
|
+
self.stream_host = '0.0.0.0' if args.expose else '127.0.0.1'
|
|
285
|
+
self.max_captures = args.max_captures if args.max_captures > 0 else None
|
|
286
|
+
self.capture_dir = os.path.join(os.getcwd(), "roomiewatch_captures")
|
|
287
|
+
self.log_file = os.path.join(self.capture_dir, "motion_log.txt")
|
|
288
|
+
|
|
289
|
+
self.prev_gray = None
|
|
290
|
+
self.last_capture_time = 0
|
|
291
|
+
self.total_alerts = 0
|
|
292
|
+
self.start_time = None
|
|
293
|
+
self.running = True
|
|
294
|
+
self.last_motion_time_str = None
|
|
295
|
+
|
|
296
|
+
# Thread-safe frame sharing for the web stream
|
|
297
|
+
self.current_frame = None
|
|
298
|
+
self.frame_lock = threading.Lock()
|
|
299
|
+
|
|
300
|
+
# Camera health tracking
|
|
301
|
+
self.consecutive_failures = 0
|
|
302
|
+
self.max_failures = 30 # restart camera after this many consecutive failures
|
|
303
|
+
self.camera_restarts = 0
|
|
304
|
+
|
|
305
|
+
os.makedirs(self.capture_dir, exist_ok=True)
|
|
306
|
+
|
|
307
|
+
# Graceful shutdown
|
|
308
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
309
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
310
|
+
|
|
311
|
+
def _signal_handler(self, signum, frame):
|
|
312
|
+
log("Received shutdown signal", "INFO")
|
|
313
|
+
self.running = False
|
|
314
|
+
|
|
315
|
+
def write_log(self, msg):
|
|
316
|
+
try:
|
|
317
|
+
with open(self.log_file, "a") as f:
|
|
318
|
+
f.write(f"[{timestamp()}] {msg}\n")
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
def save_snapshot(self, frame, motion_pct):
|
|
323
|
+
fname = f"motion_{file_timestamp()}.jpg"
|
|
324
|
+
path = os.path.join(self.capture_dir, fname)
|
|
325
|
+
|
|
326
|
+
overlay = frame.copy()
|
|
327
|
+
text = f"MOTION {motion_pct:.1f}% | {timestamp()}"
|
|
328
|
+
cv2.putText(overlay, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
|
|
329
|
+
0.7, (0, 0, 255), 2)
|
|
330
|
+
cv2.imwrite(path, overlay)
|
|
331
|
+
return fname
|
|
332
|
+
|
|
333
|
+
def enforce_capture_limit(self):
|
|
334
|
+
if self.max_captures is None:
|
|
335
|
+
return
|
|
336
|
+
try:
|
|
337
|
+
files = sorted(
|
|
338
|
+
f for f in os.listdir(self.capture_dir)
|
|
339
|
+
if f.startswith("motion_") and f.endswith(".jpg")
|
|
340
|
+
)
|
|
341
|
+
to_delete = len(files) - self.max_captures
|
|
342
|
+
if to_delete > 0:
|
|
343
|
+
for f in files[:to_delete]:
|
|
344
|
+
os.remove(os.path.join(self.capture_dir, f))
|
|
345
|
+
log(f"Retention: deleted {to_delete} oldest capture(s)", "INFO")
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
def detect_motion(self, frame):
|
|
350
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
351
|
+
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
|
352
|
+
|
|
353
|
+
if self.prev_gray is None:
|
|
354
|
+
self.prev_gray = gray
|
|
355
|
+
return 0.0
|
|
356
|
+
|
|
357
|
+
delta = cv2.absdiff(self.prev_gray, gray)
|
|
358
|
+
_, thresh = cv2.threshold(delta, MOTION_THRESHOLD, 255, cv2.THRESH_BINARY)
|
|
359
|
+
|
|
360
|
+
changed_pixels = np.count_nonzero(thresh)
|
|
361
|
+
total_pixels = thresh.shape[0] * thresh.shape[1]
|
|
362
|
+
motion_pct = (changed_pixels / total_pixels) * 100
|
|
363
|
+
|
|
364
|
+
self.prev_gray = gray
|
|
365
|
+
return motion_pct
|
|
366
|
+
|
|
367
|
+
def get_uptime_str(self):
|
|
368
|
+
if not self.start_time:
|
|
369
|
+
return "0m"
|
|
370
|
+
elapsed = time.time() - self.start_time
|
|
371
|
+
hours = int(elapsed // 3600)
|
|
372
|
+
mins = int((elapsed % 3600) // 60)
|
|
373
|
+
if hours > 0:
|
|
374
|
+
return f"{hours}h {mins}m"
|
|
375
|
+
return f"{mins}m"
|
|
376
|
+
|
|
377
|
+
def get_recent_captures(self, count=6):
|
|
378
|
+
try:
|
|
379
|
+
files = sorted(
|
|
380
|
+
[f for f in os.listdir(self.capture_dir) if f.startswith("motion_") and f.endswith(".jpg")],
|
|
381
|
+
reverse=True
|
|
382
|
+
)
|
|
383
|
+
return files[:count]
|
|
384
|
+
except Exception:
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
def open_camera(self):
|
|
388
|
+
"""Open camera with retries."""
|
|
389
|
+
for attempt in range(3):
|
|
390
|
+
log(f"Opening camera {self.camera_idx} (attempt {attempt + 1})...")
|
|
391
|
+
cap = cv2.VideoCapture(self.camera_idx)
|
|
392
|
+
if cap.isOpened():
|
|
393
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
|
|
394
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
|
|
395
|
+
log("Camera opened successfully", "OK")
|
|
396
|
+
return cap
|
|
397
|
+
log(f"Camera open attempt {attempt + 1} failed", "WARN")
|
|
398
|
+
time.sleep(2)
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def restart_camera(self, cap):
|
|
402
|
+
"""Release and reopen camera."""
|
|
403
|
+
self.camera_restarts += 1
|
|
404
|
+
log(f"Restarting camera (restart #{self.camera_restarts})...", "WARN")
|
|
405
|
+
self.write_log(f"Camera restart #{self.camera_restarts}")
|
|
406
|
+
try:
|
|
407
|
+
cap.release()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
time.sleep(2)
|
|
411
|
+
new_cap = self.open_camera()
|
|
412
|
+
if new_cap:
|
|
413
|
+
self.consecutive_failures = 0
|
|
414
|
+
self.prev_gray = None # reset motion baseline
|
|
415
|
+
log("Camera restarted successfully", "OK")
|
|
416
|
+
else:
|
|
417
|
+
log("Camera restart FAILED", "WARN")
|
|
418
|
+
return new_cap
|
|
419
|
+
|
|
420
|
+
def start_web_server(self):
|
|
421
|
+
"""Start Flask web server in a background thread."""
|
|
422
|
+
if not FLASK_AVAILABLE:
|
|
423
|
+
log("Flask not installed. Run: pip install flask", "WARN")
|
|
424
|
+
log("Continuing without web stream...", "WARN")
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
app = Flask(__name__)
|
|
428
|
+
app.logger.disabled = True
|
|
429
|
+
|
|
430
|
+
import logging
|
|
431
|
+
wlog = logging.getLogger('werkzeug')
|
|
432
|
+
wlog.setLevel(logging.ERROR)
|
|
433
|
+
|
|
434
|
+
watcher = self
|
|
435
|
+
|
|
436
|
+
@app.route('/')
|
|
437
|
+
def index():
|
|
438
|
+
return render_template_string(DASHBOARD_HTML)
|
|
439
|
+
|
|
440
|
+
@app.route('/video_feed')
|
|
441
|
+
def video_feed():
|
|
442
|
+
def generate():
|
|
443
|
+
while watcher.running:
|
|
444
|
+
with watcher.frame_lock:
|
|
445
|
+
frame = watcher.current_frame
|
|
446
|
+
if frame is not None:
|
|
447
|
+
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
|
|
448
|
+
frame_bytes = buffer.tobytes()
|
|
449
|
+
yield (b'--frame\r\n'
|
|
450
|
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
|
451
|
+
time.sleep(1.0 / STREAM_FPS)
|
|
452
|
+
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
|
453
|
+
|
|
454
|
+
@app.route('/api/stats')
|
|
455
|
+
def stats():
|
|
456
|
+
return jsonify({
|
|
457
|
+
'total_alerts': watcher.total_alerts,
|
|
458
|
+
'uptime': watcher.get_uptime_str(),
|
|
459
|
+
'last_motion': watcher.last_motion_time_str,
|
|
460
|
+
'recent_captures': watcher.get_recent_captures(),
|
|
461
|
+
'camera_restarts': watcher.camera_restarts,
|
|
462
|
+
'running': watcher.running
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
@app.route('/captures/<filename>')
|
|
466
|
+
def serve_capture(filename):
|
|
467
|
+
from flask import send_from_directory
|
|
468
|
+
return send_from_directory(watcher.capture_dir, filename)
|
|
469
|
+
|
|
470
|
+
# Check if port is available before starting
|
|
471
|
+
try:
|
|
472
|
+
test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
473
|
+
test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
474
|
+
test_sock.bind((self.stream_host, self.stream_port))
|
|
475
|
+
test_sock.close()
|
|
476
|
+
except OSError:
|
|
477
|
+
log(f"PORT {self.stream_port} IS ALREADY IN USE!", "WARN")
|
|
478
|
+
log(f"On macOS, port 5000 is used by AirPlay. Try: --port 8080", "WARN")
|
|
479
|
+
log(f"Or check what's using it: lsof -i :{self.stream_port}", "WARN")
|
|
480
|
+
log("Continuing without web stream...", "WARN")
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
server_thread = threading.Thread(
|
|
484
|
+
target=lambda: app.run(host=self.stream_host, port=self.stream_port, threaded=True),
|
|
485
|
+
daemon=True
|
|
486
|
+
)
|
|
487
|
+
server_thread.start()
|
|
488
|
+
log(f"Web stream started on http://localhost:{self.stream_port}", "OK")
|
|
489
|
+
if self.stream_host == '0.0.0.0':
|
|
490
|
+
log(f"Exposed on all interfaces — remote access via Tailscale: tailscale ip -4", "OK")
|
|
491
|
+
else:
|
|
492
|
+
log(f"Localhost only. Use --expose to allow remote/Tailscale access.", "INFO")
|
|
493
|
+
|
|
494
|
+
def run(self):
|
|
495
|
+
cap = self.open_camera()
|
|
496
|
+
|
|
497
|
+
if not cap:
|
|
498
|
+
log("FAILED to open camera after 3 attempts.", "WARN")
|
|
499
|
+
log("Tip: On macOS, grant Terminal camera access in System Preferences > Privacy.", "WARN")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
self.start_time = time.time()
|
|
503
|
+
self.write_log("=== RoomieWatch surveillance STARTED ===")
|
|
504
|
+
|
|
505
|
+
log(f"Sensitivity: {self.sensitivity}% | Cooldown: {self.cooldown}s", "INFO")
|
|
506
|
+
if self.save_snapshots:
|
|
507
|
+
log(f"Captures saved to: {self.capture_dir}", "INFO")
|
|
508
|
+
log(f"Warming up for {WARMUP_SECONDS}s...", "INFO")
|
|
509
|
+
|
|
510
|
+
if self.duration:
|
|
511
|
+
end_time = datetime.now() + timedelta(minutes=self.duration)
|
|
512
|
+
log(f"Auto-stop at: {end_time.strftime('%H:%M:%S')}", "INFO")
|
|
513
|
+
|
|
514
|
+
# Start web stream if requested
|
|
515
|
+
if self.enable_stream:
|
|
516
|
+
self.start_web_server()
|
|
517
|
+
|
|
518
|
+
log("━" * 50)
|
|
519
|
+
log("YOU CAN NOW LOCK YOUR SCREEN — surveillance is active", "OK")
|
|
520
|
+
log("━" * 50)
|
|
521
|
+
log("Press Ctrl+C to stop\n")
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
while self.running:
|
|
525
|
+
ret, frame = cap.read()
|
|
526
|
+
if not ret:
|
|
527
|
+
self.consecutive_failures += 1
|
|
528
|
+
if self.consecutive_failures >= self.max_failures:
|
|
529
|
+
cap = self.restart_camera(cap)
|
|
530
|
+
if not cap:
|
|
531
|
+
log("Could not restart camera. Waiting 30s before retry...", "WARN")
|
|
532
|
+
time.sleep(30)
|
|
533
|
+
cap = self.open_camera()
|
|
534
|
+
if not cap:
|
|
535
|
+
log("Camera permanently unavailable. Exiting.", "WARN")
|
|
536
|
+
break
|
|
537
|
+
else:
|
|
538
|
+
time.sleep(0.5)
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
self.consecutive_failures = 0
|
|
542
|
+
|
|
543
|
+
# Share frame with web stream
|
|
544
|
+
if self.enable_stream:
|
|
545
|
+
with self.frame_lock:
|
|
546
|
+
self.current_frame = frame.copy()
|
|
547
|
+
|
|
548
|
+
# Check duration limit
|
|
549
|
+
elapsed = time.time() - self.start_time
|
|
550
|
+
if self.duration and elapsed > self.duration * 60:
|
|
551
|
+
log(f"Duration limit reached ({self.duration} min). Stopping.", "INFO")
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
# Skip warmup period
|
|
555
|
+
if elapsed < WARMUP_SECONDS:
|
|
556
|
+
self.detect_motion(frame)
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
motion_pct = self.detect_motion(frame)
|
|
560
|
+
now = time.time()
|
|
561
|
+
|
|
562
|
+
if motion_pct > self.sensitivity and (now - self.last_capture_time) > self.cooldown:
|
|
563
|
+
self.total_alerts += 1
|
|
564
|
+
self.last_capture_time = now
|
|
565
|
+
self.last_motion_time_str = datetime.now().strftime("%H:%M:%S")
|
|
566
|
+
|
|
567
|
+
if self.save_snapshots:
|
|
568
|
+
fname = self.save_snapshot(frame, motion_pct)
|
|
569
|
+
self.enforce_capture_limit()
|
|
570
|
+
log(f"MOTION DETECTED — {motion_pct:.1f}% change -> saved {fname}", "ALERT")
|
|
571
|
+
self.write_log(f"MOTION {motion_pct:.1f}% -> {fname}")
|
|
572
|
+
else:
|
|
573
|
+
log(f"MOTION DETECTED — {motion_pct:.1f}% change", "ALERT")
|
|
574
|
+
self.write_log(f"MOTION {motion_pct:.1f}%")
|
|
575
|
+
|
|
576
|
+
if self.sound:
|
|
577
|
+
threading.Thread(target=beep, daemon=True).start()
|
|
578
|
+
|
|
579
|
+
# ~15 fps — plenty for surveillance, easy on resources
|
|
580
|
+
time.sleep(0.066)
|
|
581
|
+
|
|
582
|
+
except Exception as e:
|
|
583
|
+
log(f"Unexpected error: {e}", "WARN")
|
|
584
|
+
self.write_log(f"ERROR: {e}")
|
|
585
|
+
finally:
|
|
586
|
+
try:
|
|
587
|
+
cap.release()
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
run_time = time.time() - self.start_time
|
|
591
|
+
mins = int(run_time // 60)
|
|
592
|
+
secs = int(run_time % 60)
|
|
593
|
+
|
|
594
|
+
self.write_log(f"=== RoomieWatch STOPPED | {self.total_alerts} alerts in {mins}m {secs}s | {self.camera_restarts} camera restarts ===\n")
|
|
595
|
+
|
|
596
|
+
log("━" * 50)
|
|
597
|
+
log("RoomieWatch surveillance ENDED", "INFO")
|
|
598
|
+
log(f"Duration: {mins}m {secs}s", "INFO")
|
|
599
|
+
log(f"Total motion alerts: {self.total_alerts}", "INFO")
|
|
600
|
+
log(f"Camera restarts: {self.camera_restarts}", "INFO")
|
|
601
|
+
if self.save_snapshots:
|
|
602
|
+
log(f"Captures: {self.capture_dir}", "INFO")
|
|
603
|
+
log(f"Log file: {self.log_file}", "INFO")
|
|
604
|
+
log("━" * 50)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ─── Entry Point ─────────────────────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
def main():
|
|
610
|
+
banner = f"""
|
|
611
|
+
╔═══════════════════════════════════════════════════════╗
|
|
612
|
+
║ ROOMIEWATCH v{__version__} ║
|
|
613
|
+
║ Privacy-first motion surveillance for your room ║
|
|
614
|
+
║ E2E encrypted remote viewing via Tailscale ║
|
|
615
|
+
╚═══════════════════════════════════════════════════════╝
|
|
616
|
+
"""
|
|
617
|
+
print(banner)
|
|
618
|
+
|
|
619
|
+
parser = argparse.ArgumentParser(
|
|
620
|
+
prog="roomiewatch",
|
|
621
|
+
description="Privacy-first motion surveillance that runs on your laptop.",
|
|
622
|
+
)
|
|
623
|
+
parser.add_argument("--version", action="version", version=f"roomiewatch {__version__}")
|
|
624
|
+
parser.add_argument("--stream", action="store_true",
|
|
625
|
+
help="Enable live web stream for remote viewing")
|
|
626
|
+
parser.add_argument("--port", type=int, default=8080,
|
|
627
|
+
help="Web stream port (default: 8080)")
|
|
628
|
+
parser.add_argument("--expose", action="store_true",
|
|
629
|
+
help="Bind to all interfaces (0.0.0.0) instead of localhost only")
|
|
630
|
+
parser.add_argument("--sensitivity", type=float, default=DEFAULT_SENSITIVITY,
|
|
631
|
+
help=f"Motion threshold %% (default: {DEFAULT_SENSITIVITY}, higher=less sensitive)")
|
|
632
|
+
parser.add_argument("--cooldown", type=int, default=DEFAULT_COOLDOWN,
|
|
633
|
+
help=f"Seconds between captures (default: {DEFAULT_COOLDOWN})")
|
|
634
|
+
parser.add_argument("--duration", type=int, default=None,
|
|
635
|
+
help="Auto-stop after N minutes (default: unlimited)")
|
|
636
|
+
parser.add_argument("--camera", type=int, default=0,
|
|
637
|
+
help="Camera index (default: 0)")
|
|
638
|
+
parser.add_argument("--no-sound", action="store_true",
|
|
639
|
+
help="Disable alert sound")
|
|
640
|
+
parser.add_argument("--no-snapshots", action="store_true",
|
|
641
|
+
help="Stream only, don't save snapshots to disk")
|
|
642
|
+
parser.add_argument("--max-captures", type=int, default=1000,
|
|
643
|
+
help="Max snapshots to keep; oldest auto-deleted (default: 1000, 0=unlimited)")
|
|
644
|
+
parser.add_argument("--caffeinate", action="store_true",
|
|
645
|
+
help="Prevent system sleep (macOS: caffeinate, Linux: systemd-inhibit)")
|
|
646
|
+
args = parser.parse_args()
|
|
647
|
+
|
|
648
|
+
if args.stream and not FLASK_AVAILABLE:
|
|
649
|
+
log("Flask is required for streaming. Installing...", "WARN")
|
|
650
|
+
os.system(f"{sys.executable} -m pip install flask")
|
|
651
|
+
log("Please restart roomiewatch", "INFO")
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
caff_proc = None
|
|
655
|
+
if args.caffeinate:
|
|
656
|
+
caff_proc = start_caffeinate()
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
watcher = RoomieWatch(args)
|
|
660
|
+
watcher.run()
|
|
661
|
+
finally:
|
|
662
|
+
if caff_proc:
|
|
663
|
+
caff_proc.terminate()
|
|
664
|
+
caff_proc.wait()
|
|
665
|
+
log("Sleep prevention stopped", "INFO")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
if __name__ == "__main__":
|
|
669
|
+
main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roomiewatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud.
|
|
5
|
+
Author: xghostient
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/xghostient/roomiewatch
|
|
8
|
+
Project-URL: Issues, https://github.com/xghostient/roomiewatch/issues
|
|
9
|
+
Keywords: surveillance,motion-detection,security,webcam,privacy
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video :: Capture
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: opencv-python>=4.8
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: flask>=3.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# RoomieWatch
|
|
28
|
+
|
|
29
|
+
Privacy-first motion surveillance that runs on your laptop. Detects motion, captures snapshots, streams live — all local, no cloud.
|
|
30
|
+
|
|
31
|
+
Built because I don't trust my roommate.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Motion detection** — OpenCV-based frame diffing with configurable sensitivity
|
|
36
|
+
- **Live dashboard** — MJPEG stream + stats + recent captures in a dark-themed web UI
|
|
37
|
+
- **Privacy-first** — no cloud, no third-party servers, all data stays on your machine
|
|
38
|
+
- **Remote access** — E2E encrypted via [Tailscale](https://tailscale.com) (or self-hosted with [Headscale](https://github.com/juanfont/headscale))
|
|
39
|
+
- **Runs locked** — keeps working when your screen is locked (macOS `caffeinate`)
|
|
40
|
+
- **Auto-restart** — launcher script recovers from crashes automatically
|
|
41
|
+
- **Zero config** — works out of the box with your laptop webcam
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install roomiewatch
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or from source:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/xghostient/roomiewatch.git
|
|
53
|
+
cd roomiewatch
|
|
54
|
+
pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Motion detection only — captures saved to ./roomiewatch_captures/
|
|
61
|
+
roomiewatch
|
|
62
|
+
|
|
63
|
+
# With live web dashboard
|
|
64
|
+
roomiewatch --stream
|
|
65
|
+
|
|
66
|
+
# Full setup — stream + prevent sleep
|
|
67
|
+
roomiewatch --stream --caffeinate
|
|
68
|
+
|
|
69
|
+
# Custom settings
|
|
70
|
+
roomiewatch --stream --port 9090 --sensitivity 5 --cooldown 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Lock your screen (`Ctrl+Cmd+Q`) and leave. **Do NOT close the lid.**
|
|
74
|
+
|
|
75
|
+
## Remote Access (Tailscale)
|
|
76
|
+
|
|
77
|
+
View your feed from anywhere on your phone — E2E encrypted, no middleman.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# On your machine
|
|
81
|
+
brew install tailscale
|
|
82
|
+
sudo brew services start tailscale
|
|
83
|
+
tailscale up
|
|
84
|
+
|
|
85
|
+
# Install Tailscale on your phone, sign in with the same account
|
|
86
|
+
# Then open: http://<tailscale-ip>:8080
|
|
87
|
+
tailscale ip -4 # shows your machine's Tailscale IP
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Why Tailscale over Cloudflare Tunnel?** Cloudflare terminates TLS at their edge — your images pass through their servers in plaintext. Tailscale is direct device-to-device WireGuard encryption. Nobody sees your footage.
|
|
91
|
+
|
|
92
|
+
For full self-hosted setup with zero third-party trust, use [Headscale](https://github.com/juanfont/headscale).
|
|
93
|
+
|
|
94
|
+
## Launcher Script
|
|
95
|
+
|
|
96
|
+
For long-running sessions with auto-restart and sleep prevention:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
chmod +x start_roomiewatch.sh
|
|
100
|
+
./start_roomiewatch.sh # stream + Tailscale
|
|
101
|
+
./start_roomiewatch.sh --no-stream # motion detection only
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## CLI Options
|
|
105
|
+
|
|
106
|
+
| Flag | Default | Description |
|
|
107
|
+
|------|---------|-------------|
|
|
108
|
+
| `--stream` | off | Enable live web dashboard |
|
|
109
|
+
| `--port` | 8080 | Web server port |
|
|
110
|
+
| `--sensitivity` | 3 | Motion threshold % (higher = less sensitive) |
|
|
111
|
+
| `--cooldown` | 5 | Seconds between captures |
|
|
112
|
+
| `--duration` | unlimited | Auto-stop after N minutes |
|
|
113
|
+
| `--camera` | 0 | Camera index |
|
|
114
|
+
| `--no-sound` | off | Disable alert beep |
|
|
115
|
+
| `--no-snapshots` | off | Stream only, don't save to disk |
|
|
116
|
+
| `--caffeinate` | off | Prevent system sleep (macOS + Linux) |
|
|
117
|
+
|
|
118
|
+
## How It Works
|
|
119
|
+
|
|
120
|
+
1. Captures frames from your webcam at ~15 FPS
|
|
121
|
+
2. Converts to grayscale, applies Gaussian blur
|
|
122
|
+
3. Computes absolute difference from previous frame
|
|
123
|
+
4. If changed pixels exceed the sensitivity threshold — saves a timestamped JPEG and logs the event
|
|
124
|
+
5. Flask serves a live MJPEG stream and a dashboard with stats and recent captures
|
|
125
|
+
|
|
126
|
+
## Files Created
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
roomiewatch_captures/
|
|
130
|
+
├── motion_20260302_143022.jpg # snapshots with timestamp overlay
|
|
131
|
+
├── motion_log.txt # text log of all motion events
|
|
132
|
+
└── launcher.log # launcher/restart log
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Troubleshooting
|
|
136
|
+
|
|
137
|
+
- **Camera not opening** — Grant Terminal camera access in System Settings > Privacy & Security > Camera
|
|
138
|
+
- **Stream not loading** — Check if port 8080 is in use: `lsof -i :8080`
|
|
139
|
+
- **Too many false alerts** — Increase sensitivity: `roomiewatch --stream --sensitivity 5`
|
|
140
|
+
- **macOS sleeping** — Don't close the lid. Use the launcher script which runs `caffeinate`
|
|
141
|
+
|
|
142
|
+
## Requirements
|
|
143
|
+
|
|
144
|
+
- Python 3.9+
|
|
145
|
+
- A webcam
|
|
146
|
+
- macOS or Linux (Windows: untested but should work)
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
roomiewatch/__init__.py
|
|
5
|
+
roomiewatch/__main__.py
|
|
6
|
+
roomiewatch/core.py
|
|
7
|
+
roomiewatch.egg-info/PKG-INFO
|
|
8
|
+
roomiewatch.egg-info/SOURCES.txt
|
|
9
|
+
roomiewatch.egg-info/dependency_links.txt
|
|
10
|
+
roomiewatch.egg-info/entry_points.txt
|
|
11
|
+
roomiewatch.egg-info/requires.txt
|
|
12
|
+
roomiewatch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
roomiewatch
|