2026-04-12 17:47:54 +08:00

259 lines
10 KiB
Python

#!/usr/bin/env python3
"""
KL630 Golf Cart Event Mock Server
Two independent channels:
Channel A (iPad / BLE path): POST /api/event → real-time violation JSON
Channel B (OOB / Cloud path): POST /api/upload → tar.gz event archive
Plus:
GET /api/time → provides UTC time to KL630 (no NTP needed)
GET / → web dashboard
"""
import json
import os
import io
import tarfile
import time
import datetime
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
TZ_TW = datetime.timezone(datetime.timedelta(hours=8))
def now_tw(): return datetime.datetime.now(TZ_TW)
def tw_str(dt=None):
if dt is None: dt = now_tw()
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
os.makedirs(UPLOAD_DIR, exist_ok=True)
events = [] # Channel A log
events_lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
ts = datetime.datetime.now().strftime('%H:%M:%S')
print(f"[{ts}] {fmt % args}")
# ------------------------------------------------------------------
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
# ------------------------------------------------------------------
def do_GET(self):
path = urlparse(self.path).path
if path == '/' or path == '/index.html':
self._serve_file(os.path.join(STATIC_DIR, 'index.html'), 'text/html')
elif path == '/api/time':
self._json({
'unix': int(time.time()),
'iso': tw_str(),
})
elif path == '/api/events':
with events_lock:
self._json(list(events))
elif path == '/api/files':
files = []
for name in sorted(os.listdir(UPLOAD_DIR), reverse=True):
fp = os.path.join(UPLOAD_DIR, name)
if not os.path.isfile(fp):
continue
files.append({
'name': name,
'size': os.path.getsize(fp),
'mtime': datetime.datetime.fromtimestamp(
os.path.getmtime(fp)
).astimezone(TZ_TW).strftime('%Y-%m-%d %H:%M:%S'),
'url': f'/uploads/{name}',
})
self._json(files)
elif path.startswith('/uploads/'):
parts = path[len('/uploads/'):].split('/', 1)
name = parts[0]
fp = os.path.join(UPLOAD_DIR, name)
if len(parts) == 1:
# Download the tar.gz
if os.path.isfile(fp):
with open(fp, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', 'application/gzip')
self.send_header('Content-Disposition', f'attachment; filename="{name}"')
self.send_header('Content-Length', str(len(data)))
self._cors()
self.end_headers()
self.wfile.write(data)
else:
self.send_error(404)
else:
# Serve a file from inside the tar.gz
# BusyBox tar creates entries as "./filename"; normalise to bare name
inner = parts[1].lstrip('./')
if os.path.isfile(fp):
try:
with tarfile.open(fp, 'r:gz') as tar:
# Try bare name first, then with "./" prefix
try:
member = tar.getmember(inner)
except KeyError:
member = tar.getmember('./' + inner)
f = tar.extractfile(member)
data = f.read()
ext = os.path.splitext(inner)[1].lower()
ct = {'jpg':'image/jpeg','jpeg':'image/jpeg',
'png':'image/png','json':'application/json'}.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', str(len(data)))
self._cors()
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_error(404, str(e))
else:
self.send_error(404)
elif path.startswith('/api/contents/'):
# List files inside a tar.gz: GET /api/contents/<filename>
name = os.path.basename(path[len('/api/contents/'):])
fp = os.path.join(UPLOAD_DIR, name)
if os.path.isfile(fp):
try:
with tarfile.open(fp, 'r:gz') as tar:
members = []
for m in tar.getmembers():
if not m.isfile():
continue
# Strip leading "./" added by BusyBox "tar cf - ."
clean = m.name.lstrip('./')
if not clean:
continue
members.append({
'name': clean,
'size': m.size,
'is_image': clean.lower().endswith(('.jpg','.jpeg','.png')),
})
self._json({'ok': True, 'archive': name, 'files': members})
except Exception as e:
self._json({'ok': False, 'error': str(e)})
else:
self.send_error(404)
else:
self.send_error(404)
# ------------------------------------------------------------------
def do_POST(self):
path = urlparse(self.path).path
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
# ── Channel A: real-time event JSON ──────────────────────────
if path == '/api/event':
try:
data = json.loads(body.decode('utf-8'))
entry = {
'server_time': tw_str(),
}
entry.update(data)
with events_lock:
events.append(entry)
if len(events) > 200:
del events[:-200]
ctype = data.get('content', {}).get('type', '?')
level = data.get('content', {}).get('level', '?')
print(f" [CHANNEL-A] event type={ctype} level={level}")
self._json({'ok': True})
except Exception as e:
print(f" [CHANNEL-A] parse error: {e}")
self.send_error(400, str(e))
# ── Channel B: tar.gz archive upload ─────────────────────────
elif path == '/api/upload':
# 1) kCurl sends filename as query param: /api/upload?filename=xxx
qs = parse_qs(urlparse(self.path).query)
filename = (qs.get('filename') or [None])[0]
# 2) fallback: raw-socket upload uses Content-Disposition header
if not filename:
cd = self.headers.get('Content-Disposition', '')
for part in cd.split(';'):
part = part.strip()
if part.startswith('filename='):
filename = part[9:].strip().strip('"')
# 3) last resort: timestamp-based name
if not filename:
ts = now_tw().strftime('%Y%m%d_%H%M%S')
filename = f'event_{ts}.tar.gz'
# sanitise
filename = os.path.basename(filename)
fp = os.path.join(UPLOAD_DIR, filename)
with open(fp, 'wb') as f:
f.write(body)
print(f" [CHANNEL-B] saved {filename} ({len(body):,} bytes)")
self._json({'ok': True, 'filename': filename, 'bytes': len(body)})
else:
self.send_error(404)
# ------------------------------------------------------------------
def _json(self, obj):
data = json.dumps(obj, ensure_ascii=False, indent=None).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
self._cors()
self.end_headers()
self.wfile.write(data)
def _cors(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Content-Disposition')
def _serve_file(self, filepath, content_type):
if not os.path.isfile(filepath):
self.send_error(404)
return
with open(filepath, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type + '; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
# ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
PORT = 8081
server = HTTPServer(('0.0.0.0', PORT), Handler)
print("=" * 55)
print(" KL630 Golf Event Mock Server")
print("=" * 55)
print(f" Dashboard : http://localhost:{PORT}/")
print(f" Time API : GET http://localhost:{PORT}/api/time")
print(f" Event API : POST http://localhost:{PORT}/api/event")
print(f" Upload API : POST http://localhost:{PORT}/api/upload")
print(f" Uploads : {UPLOAD_DIR}")
print("=" * 55)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")