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.
@@ -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,3 @@
1
+ """RoomieWatch — Privacy-first motion surveillance for your room."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m roomiewatch"""
2
+
3
+ from roomiewatch.core import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ roomiewatch = roomiewatch.core:main
@@ -0,0 +1,3 @@
1
+ opencv-python>=4.8
2
+ numpy>=1.24
3
+ flask>=3.0
@@ -0,0 +1 @@
1
+ roomiewatch
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+