diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7ec05e --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +venv/ +.venv/ +ENV/ +env/ + +# Packaging +build/ +dist/ +*.egg-info/ + +# Logs and runtime files +*.log +logs/ +run_web.pid + +# Screenshots and generated images +screenshots/ + +# OS files +.DS_Store +Thumbs.db + +# IDE/editor +.vscode/ +.idea/ + +# Environment files +.env +.env.* + +# Cache and test +.pytest_cache/ +htmlcov/ +.coverage + +# SQLite DBs +*.sqlite3 +*.db + +# pip wheel cache +pip-wheel-metadata/ + +# Ignore local secrets or config +secrets.yaml + +# Node +node_modules/ diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..3887e49 --- /dev/null +++ b/api_client.py @@ -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:])) diff --git a/app.py b/app.py new file mode 100644 index 0000000..3806c73 --- /dev/null +++ b/app.py @@ -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) diff --git a/run_web.fish b/run_web.fish new file mode 100755 index 0000000..d741bef --- /dev/null +++ b/run_web.fish @@ -0,0 +1,78 @@ +#!/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 + +# If a previous PID file exists, try to stop that process first +if test -f $PID_FILE + set OLD_PID (cat $PID_FILE) + if test -n "$OLD_PID" + if ps -p $OLD_PID > /dev/null 2>&1 + echo "Stopping previous process pid $OLD_PID" + kill $OLD_PID 2>/dev/null || kill -9 $OLD_PID 2>/dev/null + sleep 1 + end + rm -f $PID_FILE + end +end + +# Also check for any process listening on port 5000 and kill it to free the port +if type lsof >/dev/null 2>&1 + set PORT_PIDS (lsof -ti:5000 2>/dev/null) + if test -n "$PORT_PIDS" + echo "Killing processes on port 5000: $PORT_PIDS" + for p in $PORT_PIDS + kill $p 2>/dev/null || kill -9 $p 2>/dev/null + end + end +else + # fallback using ss and awk if lsof not available + if type ss >/dev/null 2>&1 + for line in (ss -ltnp 2>/dev/null | grep ':5000' | awk '{print $NF}' | sed -n 's/.*pid=\([0-9]*\).*/\1/p') + if test -n "$line" + echo "Killing process pid $line listening on port 5000" + kill $line 2>/dev/null || kill -9 $line 2>/dev/null + end + end + end +end + +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 diff --git a/spacer.py b/spacer.py index 067f398..822dd1f 100644 --- a/spacer.py +++ b/spacer.py @@ -1,35 +1,53 @@ import os -from PIL import Image import glob +from PIL import Image + + +def get_latest_screenshot(directory, ext='jpg'): + """Return the latest file path with the given extension in directory or None if none found.""" + if not directory: + return None + if not os.path.isdir(directory): + return None + pattern = os.path.join(directory, f'*.{ext}') + files = glob.glob(pattern) + if not files: + return None + return max(files, key=os.path.getctime) -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 - 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)) + """Return 'open', 'closed' or 'unknown' based on average brightness of an image. - # 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 + The function is defensive and returns 'unknown' if the path is missing or an error occurs. + """ + 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') + img = img.resize((2, 2)) + pixels = list(img.getdata()) + if not pixels: + return 'unknown' + 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' - # 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' -# Directory where the screenshots are stored -screenshot_directory = '/var/lib/motion/' - -# Get the latest screenshot -latest_screenshot = get_latest_screenshot(screenshot_directory) - -# Process the image and determine hackspace status -hackspace_status = process_image(latest_screenshot) -print(f"The hackspace is {hackspace_status}.") \ No newline at end of file +def get_brightness(image_path): + """Return average brightness (0-255) as float, 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') + img = img.resize((16, 16)) + pixels = list(img.getdata()) + if not pixels: + return None + return float(sum(pixels) / len(pixels)) + except Exception: + return None \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..08797c3 --- /dev/null +++ b/static/style.css @@ -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; } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6eeaeaf --- /dev/null +++ b/templates/index.html @@ -0,0 +1,49 @@ + + + + + Hackspace Status + + + +
+

Hackspace Status

+
Loading...
+ Latest screenshot +
+ + +
+

+  
+ + + +