Commit que implemeta api en flask
This commit is contained in:
parent
7ab5308949
commit
895fb69875
29
README.md
29
README.md
@ -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.).
|
||||||
BIN
__pycache__/spacer.cpython-313.pyc
Normal file
BIN
__pycache__/spacer.cpython-313.pyc
Normal file
Binary file not shown.
109
api_client.py
Normal file
109
api_client.py
Normal 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
187
app.py
Normal 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
123
logs/run_web.log
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
The hackspace is unknown.
|
||||||
|
* Serving Flask app 'app'
|
||||||
|
* Debug mode: off
|
||||||
|
[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||||
|
* Running on all addresses (0.0.0.0)
|
||||||
|
* Running on http://127.0.0.1:5000
|
||||||
|
* Running on http://192.168.100.59:5000
|
||||||
|
[33mPress CTRL+C to quit[0m
|
||||||
|
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] "[36mGET /static/style.css HTTP/1.1[0m" 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
1
logs/run_web.pid
Normal file
@ -0,0 +1 @@
|
|||||||
|
61233
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Flask>=2.0
|
||||||
|
Pillow
|
||||||
|
opencv-python
|
||||||
44
run_web.fish
Executable file
44
run_web.fish
Executable 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
|
||||||
BIN
screenshots/shot_20251020T044611Z.jpg
Normal file
BIN
screenshots/shot_20251020T044611Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
screenshots/shot_20251020T044658Z.jpg
Normal file
BIN
screenshots/shot_20251020T044658Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
screenshots/shot_20251020T045342Z.jpg
Normal file
BIN
screenshots/shot_20251020T045342Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
screenshots/shot_20251020T045423Z.jpg
Normal file
BIN
screenshots/shot_20251020T045423Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
55
spacer.py
55
spacer.py
@ -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'."""
|
||||||
with Image.open(image_path) as img:
|
if not image_path or not os.path.isfile(image_path):
|
||||||
# Resize image to 2x2
|
return 'unknown'
|
||||||
img = img.resize((2, 2))
|
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
|
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
45
static/style.css
Normal 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
49
templates/index.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user