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
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
import glob
def get_latest_screenshot(directory):
""" Get the latest screenshot file from the given directory. """
list_of_files = glob.glob(os.path.join(directory, '*.jpg')) # You can change the file extension if needed
def get_latest_screenshot(directory, ext='jpg'):
"""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)
return latest_file
def process_image(image_path):
""" Process the image to decide if it's dark or light. """
with Image.open(image_path) as img:
# Resize image to 2x2
img = img.resize((2, 2))
"""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:
img = img.convert('RGB')
# shrink to 2x2 to average quickly
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
if sum(average_color) / 3 < 128: # Average RGB values less than 128 is considered dark
return 'closed'
else:
return 'open'
def get_brightness(image_path):
"""Return the average brightness (0-255) of the image, or None on error."""
if not image_path or not os.path.isfile(image_path):
return None
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
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>