Commit que implemeta api en flask

This commit is contained in:
21Hell 2025-10-19 23:02:59 -06:00
parent 7ab5308949
commit 895fb69875
15 changed files with 629 additions and 16 deletions

View File

@ -1,3 +1,30 @@
# KPRSpaceAPI # KPRSpaceAPI
Implementation of the Space API used in KPR, based on a simple webcam running through Motion which detects whether the light is turned on and updates the status of the space accordingly. Implementation of the Space API used in KPR, based on a simple webcam running through Motion which detects whether the light is turned on and updates the status of the space accordingly.
## Run (fish shell)
Create and activate a virtual environment, install dependencies and run the server (example using fish):
```fish
python3 -m venv venv
source venv/bin/activate.fish
pip install -r requirements.txt
# Configure environment (examples)
set -x SNAPSHOT_INTERVAL 600 # seconds (600 = 10 minutes)
set -x BRIGHTNESS_THRESHOLD 100 # 0-255
set -x AUTO_START false # true to auto-start on process launch
# Run the app
python app.py
```
Notes
- Screenshots are saved to `./screenshots` by default. Override with `MOTION_DIR`.
- To start/stop the monitor at runtime:
- Start: `curl -X POST http://localhost:5000/api/start`
- Stop: `curl -X POST http://localhost:5000/api/stop`
- The `SNAPSHOT_INTERVAL` env var is interpreted in seconds.
- If you want the server to auto-start the monitor on launch, set `AUTO_START=true` before launching.
- For production use, run under a WSGI server (gunicorn, systemd unit, docker, etc.).

Binary file not shown.

109
api_client.py Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Simple CLI to call the running Space API (no external deps).
Usage:
python api_client.py status [--url URL]
python api_client.py start [--url URL]
python api_client.py stop [--url URL]
python api_client.py screenshot [--url URL] [--out FILE]
Defaults URL: http://localhost:5000
"""
import sys
import argparse
import json
from urllib import request, error, parse
DEFAULT_URL = 'http://localhost:5000'
def get(url):
try:
with request.urlopen(url, timeout=5) as resp:
return resp.read(), resp.getcode(), resp.info().get_content_type()
except error.HTTPError as e:
return e.read(), e.code, None
except Exception as e:
print('Request error:', e)
return None, None, None
def post(url):
req = request.Request(url, method='POST')
try:
with request.urlopen(req, timeout=5) as resp:
return resp.read(), resp.getcode()
except error.HTTPError as e:
return e.read(), e.code
except Exception as e:
print('Request error:', e)
return None, None
def cmd_status(base_url):
data, code, ctype = get(f"{base_url.rstrip('/')}/api/status")
if data is None:
print('No response')
return 2
try:
j = json.loads(data.decode())
print(json.dumps(j, indent=2))
except Exception:
print(data.decode(errors='ignore'))
return 0
def cmd_start(base_url):
data, code = post(f"{base_url.rstrip('/')}/api/start")
print('HTTP', code)
if data:
print(data.decode(errors='ignore'))
return 0
def cmd_stop(base_url):
data, code = post(f"{base_url.rstrip('/')}/api/stop")
print('HTTP', code)
if data:
print(data.decode(errors='ignore'))
return 0
def cmd_screenshot(base_url, out):
data, code, ctype = get(f"{base_url.rstrip('/')}/api/screenshot")
if data is None or code != 200:
print('Failed to get screenshot, HTTP', code)
if data:
print(data.decode(errors='ignore'))
return 2
with open(out, 'wb') as f:
f.write(data)
print('Saved screenshot to', out)
return 0
def main(argv):
p = argparse.ArgumentParser()
p.add_argument('--url', '-u', default=DEFAULT_URL, help='Base URL of the API')
sub = p.add_subparsers(dest='cmd')
sub.add_parser('status')
sub.add_parser('start')
sub.add_parser('stop')
ss = sub.add_parser('screenshot')
ss.add_argument('--out', '-o', default='latest.jpg')
args = p.parse_args(argv)
if not args.cmd:
p.print_help()
return 1
if args.cmd == 'status':
return cmd_status(args.url)
if args.cmd == 'start':
return cmd_start(args.url)
if args.cmd == 'stop':
return cmd_stop(args.url)
if args.cmd == 'screenshot':
return cmd_screenshot(args.url, args.out)
return 0
if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))

187
app.py Normal file
View File

@ -0,0 +1,187 @@
import os
import threading
import time
from datetime import datetime, timezone
import logging
from flask import Flask, jsonify, send_file, render_template
import cv2
from spacer import get_latest_screenshot, process_image, get_brightness
# Configuration
BASE_DIR = os.path.dirname(__file__)
SCREENSHOT_DIR = os.environ.get('MOTION_DIR', os.path.join(BASE_DIR, 'screenshots'))
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
MIN_MOTION_CONTOUR_AREA = int(os.environ.get('MIN_MOTION_AREA', 4000))
CAPTURE_DEVICE = int(os.environ.get('CAPTURE_DEVICE', 0))
# default one snapshot every 10 minutes (600 seconds). Override with SNAPSHOT_INTERVAL env var.
SNAPSHOT_INTERVAL_SECONDS = int(os.environ.get('SNAPSHOT_INTERVAL', 600))
# Brightness threshold (0-255) used to decide open/closed state from a photo
BRIGHTNESS_THRESHOLD = int(os.environ.get('BRIGHTNESS_THRESHOLD', 50))
app = Flask(__name__)
# ensure logger prints info messages
app.logger.setLevel(logging.INFO)
state = {
'last_image': None,
'last_status': 'unknown',
'last_brightness': None,
'running': False,
'monitor_thread': None,
}
state_lock = threading.Lock()
def save_frame(frame):
# use timezone-aware UTC datetime to avoid DeprecationWarning
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
filename = f'shot_{ts}.jpg'
path = os.path.join(SCREENSHOT_DIR, filename)
cv2.imwrite(path, frame)
app.logger.info('Saved snapshot: %s', path)
return path
def motion_monitor():
cap = cv2.VideoCapture(CAPTURE_DEVICE)
if not cap.isOpened():
app.logger.error('Could not open video device %s', CAPTURE_DEVICE)
with state_lock:
state['running'] = False
return
app.logger.info('Periodic snapshot monitor started on device %s', CAPTURE_DEVICE)
last_snapshot = 0
try:
while True:
with state_lock:
if not state.get('running', False):
break
ret, frame = cap.read()
if not ret:
time.sleep(0.1)
continue
current_time = time.time()
if current_time - last_snapshot >= SNAPSHOT_INTERVAL_SECONDS:
path = save_frame(frame)
brightness = get_brightness(path)
if brightness is not None:
status = 'open' if brightness >= BRIGHTNESS_THRESHOLD else 'closed'
else:
status = process_image(path)
with state_lock:
state['last_image'] = path
state['last_status'] = status
state['last_brightness'] = brightness
app.logger.info(
'Periodic snapshot: %s brightness=%.1f status=%s',
path, brightness if brightness is not None else -1.0, status
)
last_snapshot = current_time
time.sleep(0.2)
finally:
cap.release()
app.logger.info('Periodic snapshot monitor stopped')
with state_lock:
state['running'] = False
state['monitor_thread'] = None
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/status')
def api_status():
with state_lock:
last_image = state['last_image'] or get_latest_screenshot(SCREENSHOT_DIR)
# prefer a recent brightness-derived status if available, otherwise fallback
last_status = state.get('last_status') if state.get('last_status') != 'unknown' else process_image(last_image)
last_brightness = state.get('last_brightness')
# if we don't have brightness yet attempt to compute it for the latest image
if last_brightness is None and last_image:
last_brightness = get_brightness(last_image)
if last_brightness is not None:
last_status = 'open' if last_brightness >= BRIGHTNESS_THRESHOLD else 'closed'
image_url = '/api/screenshot' if last_image else None
return jsonify({
'status': last_status,
'brightness': last_brightness,
'image': image_url,
'screenshot_dir': SCREENSHOT_DIR,
'brightness_threshold': BRIGHTNESS_THRESHOLD
})
@app.route('/api/screenshot')
def api_screenshot():
path = get_latest_screenshot(SCREENSHOT_DIR)
if not path:
return jsonify({'error': 'no screenshot available'}), 404
return send_file(path, mimetype='image/jpeg')
@app.route('/api/start', methods=['POST'])
def api_start():
with state_lock:
# if a monitor thread exists and is alive, report running
thr = state.get('monitor_thread')
if thr is not None and thr.is_alive():
state['running'] = True
return jsonify({'running': True}), 200
# start a new monitor thread
state['running'] = True
t = threading.Thread(target=motion_monitor, daemon=True)
state['monitor_thread'] = t
t.start()
return jsonify({'running': True}), 200
@app.route('/api/stop', methods=['POST'])
def api_stop():
# request monitor to stop and join the thread briefly
thr = None
with state_lock:
state['running'] = False
thr = state.get('monitor_thread')
if thr is not None:
# give the thread a short time to exit
thr.join(timeout=3.0)
with state_lock:
if state.get('monitor_thread') is thr:
state['monitor_thread'] = None
return jsonify({'running': False}), 200
if __name__ == '__main__':
# Console (no-web) mode: run monitor in foreground and print status
CONSOLE_MODE = os.environ.get('CONSOLE_MODE', 'false').lower() in ('1', 'true', 'yes')
if CONSOLE_MODE:
print('Starting in CONSOLE_MODE — monitor will run in foreground. Press Ctrl+C to stop.')
with state_lock:
state['running'] = True
try:
# run monitor in the main thread (blocking) so logs appear live in console
motion_monitor()
except KeyboardInterrupt:
print('Interrupted, stopping monitor...')
with state_lock:
state['running'] = False
finally:
print('Monitor stopped')
else:
# Start monitor only if AUTO_START is set (web mode)
AUTO_START = os.environ.get('AUTO_START', 'false').lower() in ('1', 'true', 'yes')
if AUTO_START:
with state_lock:
state['running'] = True
t = threading.Thread(target=motion_monitor, daemon=True)
state['monitor_thread'] = t
t.start()
# run the Flask webserver
app.run(host='0.0.0.0', port=5000)

123
logs/run_web.log Normal file
View File

@ -0,0 +1,123 @@
The hackspace is unknown.
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://192.168.100.59:5000
Press CTRL+C to quit
127.0.0.1 - - [19/Oct/2025 22:56:44] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:44] "GET /static/style.css HTTP/1.1" 304 -
127.0.0.1 - - [19/Oct/2025 22:56:44] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:44] "GET /api/screenshot?_=1760936204560 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:48] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:48] "GET /api/screenshot?_=1760936208506 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:53] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:53] "GET /api/screenshot?_=1760936213171 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:53] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:53] "GET /api/screenshot?_=1760936213695 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:56:57] "GET /api/screenshot?_=1760936217046 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:03] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:14] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:14] "GET /api/screenshot?_=1760936234785 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:17] "GET /api/screenshot?_=1760936237772 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:23] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:23] "GET /api/screenshot?_=1760936243201 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:23] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:23] "GET /api/screenshot?_=1760936243721 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:27] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:27] "GET /api/screenshot?_=1760936247048 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:47] "GET /api/screenshot?_=1760936267049 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:47] "GET /api/screenshot?_=1760936267759 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:53] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:53] "GET /api/screenshot?_=1760936273203 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:53] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:53] "GET /api/screenshot?_=1760936273748 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:57:57] "GET /api/screenshot?_=1760936277092 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:17] "GET /api/screenshot?_=1760936297052 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:17] "GET /api/screenshot?_=1760936297787 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:23] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:23] "GET /api/screenshot?_=1760936303401 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:23] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:23] "GET /api/screenshot?_=1760936303747 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:27] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:27] "GET /api/screenshot?_=1760936307080 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:47] "GET /api/screenshot?_=1760936327822 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:54] "GET /api/screenshot?_=1760936334144 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:54] "GET /api/screenshot?_=1760936334164 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:58:57] "GET /api/screenshot?_=1760936337056 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:17] "GET /api/screenshot?_=1760936357820 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:24] "GET /api/screenshot?_=1760936364177 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:24] "GET /api/screenshot?_=1760936364196 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:27] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:27] "GET /api/screenshot?_=1760936367065 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:47] "GET /api/screenshot?_=1760936387824 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:54] "GET /api/screenshot?_=1760936394242 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:54] "GET /api/screenshot?_=1760936394257 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 22:59:57] "GET /api/screenshot?_=1760936397213 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:17] "GET /api/screenshot?_=1760936417830 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:24] "GET /api/screenshot?_=1760936424288 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:24] "GET /api/screenshot?_=1760936424296 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:27] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:27] "GET /api/screenshot?_=1760936427112 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:47] "GET /api/screenshot?_=1760936447861 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:54] "GET /api/screenshot?_=1760936454363 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:54] "GET /api/screenshot?_=1760936454383 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:00:57] "GET /api/screenshot?_=1760936457242 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:17] "GET /api/screenshot?_=1760936477860 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:24] "GET /api/screenshot?_=1760936484323 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:24] "GET /api/screenshot?_=1760936484393 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:27] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:27] "GET /api/screenshot?_=1760936487271 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:48] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:48] "GET /api/screenshot?_=1760936508795 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:54] "GET /api/screenshot?_=1760936514322 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:55] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:55] "GET /api/screenshot?_=1760936515341 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:01:57] "GET /api/screenshot?_=1760936517311 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:17] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:17] "GET /api/screenshot?_=1760936537962 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:24] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:24] "GET /api/screenshot?_=1760936544325 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:25] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:25] "GET /api/screenshot?_=1760936545368 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:29] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:29] "GET /api/screenshot?_=1760936549633 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:47] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:48] "GET /api/screenshot?_=1760936567968 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:54] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:54] "GET /api/screenshot?_=1760936574359 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:55] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:55] "GET /api/screenshot?_=1760936575398 HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:57] "GET /api/status HTTP/1.1" 200 -
127.0.0.1 - - [19/Oct/2025 23:02:57] "GET /api/screenshot?_=1760936577339 HTTP/1.1" 200 -

1
logs/run_web.pid Normal file
View File

@ -0,0 +1 @@
61233

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
Flask>=2.0
Pillow
opencv-python

44
run_web.fish Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env fish
# Run the app in web mode with recommended env vars (fish shell)
# Usage: ./run_web.fish
# create venv if missing
if not test -d venv
python3 -m venv venv
end
# activate venv
source venv/bin/activate.fish
# ensure dependencies
pip install -r requirements.txt
# recommended env vars (adjust if needed)
set -x SNAPSHOT_INTERVAL 600 # seconds (600 = 10 minutes)
set -x BRIGHTNESS_THRESHOLD 50 # 0-255
set -x AUTO_START false # true to auto-start monitor inside web server
# ensure CONSOLE_MODE is unset so the Flask server will run
set -e CONSOLE_MODE
# logging
set -x LOG_DIR logs
mkdir -p $LOG_DIR
set -x LOG_FILE $LOG_DIR/run_web.log
set -x PID_FILE $LOG_DIR/run_web.pid
echo "Starting web app in background; logs: $LOG_FILE"
# Use bash to ensure POSIX redirection works reliably
bash -lc "python app.py > '$LOG_FILE' 2>&1 & echo \$! > '$PID_FILE'"
# wait briefly for process to start
sleep 1
if test -f $PID_FILE
set PID (cat $PID_FILE)
echo "Started pid: $PID"
else
echo "Failed to start process; see $LOG_FILE for details"
end
# tail the log so the user can see live output (Ctrl+C to stop tail but process keeps running)
echo "Tailing log (press Ctrl+C to stop tail, process will continue running in background)."
tail -n 200 -f $LOG_FILE

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -2,27 +2,52 @@ import os
from PIL import Image from PIL import Image
import glob import glob
def get_latest_screenshot(directory):
""" Get the latest screenshot file from the given directory. """ def get_latest_screenshot(directory, ext='jpg'):
list_of_files = glob.glob(os.path.join(directory, '*.jpg')) # You can change the file extension if needed """Get the latest screenshot file from the given directory. Returns None if none found."""
if not os.path.isdir(directory):
return None
pattern = os.path.join(directory, f'*.{ext}')
list_of_files = glob.glob(pattern)
if not list_of_files:
return None
latest_file = max(list_of_files, key=os.path.getctime) latest_file = max(list_of_files, key=os.path.getctime)
return latest_file return latest_file
def process_image(image_path): def process_image(image_path):
""" Process the image to decide if it's dark or light. """ """Process the image to decide if it's dark or light. Returns 'open', 'closed' or 'unknown'."""
if not image_path or not os.path.isfile(image_path):
return 'unknown'
try:
with Image.open(image_path) as img: with Image.open(image_path) as img:
# Resize image to 2x2 img = img.convert('RGB')
# shrink to 2x2 to average quickly
img = img.resize((2, 2)) img = img.resize((2, 2))
pixels = list(img.getdata())
# average each channel
avg = [sum(ch) / len(pixels) for ch in zip(*pixels)]
luminance = sum(avg) / 3.0
return 'closed' if luminance < 128 else 'open'
except Exception:
return 'unknown'
# Get average color of the pixels
average_color = sum(img.getdata(), (0, 0, 0))
average_color = [color / 4 for color in average_color] # Since the image is 2x2
# Determine if the image is dark or light def get_brightness(image_path):
if sum(average_color) / 3 < 128: # Average RGB values less than 128 is considered dark """Return the average brightness (0-255) of the image, or None on error."""
return 'closed' if not image_path or not os.path.isfile(image_path):
else: return None
return 'open' try:
with Image.open(image_path) as img:
img = img.convert('L')
# sample more pixels for a smoother average
img = img.resize((16, 16))
pixels = list(img.getdata())
if not pixels:
return None
return sum(pixels) / len(pixels)
except Exception:
return None
# Directory where the screenshots are stored # Directory where the screenshots are stored
screenshot_directory = '/var/lib/motion/' screenshot_directory = '/var/lib/motion/'

45
static/style.css Normal file
View File

@ -0,0 +1,45 @@
body {
font-family: Arial, sans-serif;
background: #f4f6f8;
color: #222;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
width: 420px;
text-align: center;
}
.status {
font-size: 1.6rem;
padding: 10px;
margin-bottom: 12px;
border-radius: 6px;
color: white;
}
.status.open { background: #28a745; }
.status.closed { background: #dc3545; }
.status.unknown { background: #6c757d; }
img#latest {
max-width: 100%;
border-radius: 6px;
margin-bottom: 12px;
background: #111;
height: auto;
}
.controls button {
margin: 6px;
padding: 8px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
}
.controls button#start { background: #007bff; color: white; }
.controls button#stop { background: #ffc107; color: #222; }
pre#info { text-align: left; background: #f0f0f0; padding: 8px; border-radius: 6px; font-size: 12px; }

49
templates/index.html Normal file
View File

@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Hackspace Status</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="container">
<h1>Hackspace Status</h1>
<div id="status" class="status unknown">Loading...</div>
<img id="latest" src="" alt="Latest screenshot" />
<div class="controls">
<button id="start">Start Monitor</button>
<button id="stop">Stop Monitor</button>
</div>
<pre id="info"></pre>
</div>
<script>
async function refresh() {
try {
const r = await fetch('/api/status');
const j = await r.json();
const s = document.getElementById('status');
s.textContent = (j.status || 'unknown').toUpperCase();
s.className = 'status ' + (j.status || 'unknown');
const img = document.getElementById('latest');
if (j.image) {
img.src = j.image + '?_=' + Date.now();
} else {
img.src = '';
}
document.getElementById('info').textContent = JSON.stringify(j, null, 2);
} catch (e) {
console.error(e);
}
}
document.getElementById('start').addEventListener('click', async () => {
await fetch('/api/start', {method: 'POST'});
});
document.getElementById('stop').addEventListener('click', async () => {
await fetch('/api/stop', {method: 'POST'});
});
setInterval(refresh, 30000);
refresh();
</script>
</body>
</html>