feat: Initial project scaffold
Flask API backend for ESP32 sensor fleet: - App factory pattern with blueprints - SQLAlchemy 2.x models (Sensor, Device, Sighting, Alert, Event, Probe) - UDP collector for sensor data streams - REST API endpoints for sensors, devices, alerts, events, probes, stats - pytest setup with fixtures - Containerfile for podman deployment - Makefile for common tasks
This commit is contained in:
6
src/esp32_web/api/__init__.py
Normal file
6
src/esp32_web/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""API Blueprint."""
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('api', __name__)
|
||||
|
||||
from . import sensors, devices, alerts, events, probes, stats # noqa: E402, F401
|
||||
29
src/esp32_web/api/alerts.py
Normal file
29
src/esp32_web/api/alerts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Alert endpoints."""
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from flask import request
|
||||
from . import bp
|
||||
from ..models import Alert
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/alerts')
|
||||
def list_alerts():
|
||||
"""List alerts with filters."""
|
||||
alert_type = request.args.get('type')
|
||||
sensor_id = request.args.get('sensor_id', type=int)
|
||||
hours = request.args.get('hours', 24, type=int)
|
||||
limit = min(int(request.args.get('limit', 100)), 1000)
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
query = db.select(Alert).where(Alert.timestamp >= since).order_by(Alert.timestamp.desc())
|
||||
|
||||
if alert_type:
|
||||
query = query.where(Alert.alert_type == alert_type)
|
||||
if sensor_id:
|
||||
query = query.where(Alert.sensor_id == sensor_id)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
alerts = db.session.scalars(query).all()
|
||||
|
||||
return {'alerts': [a.to_dict() for a in alerts], 'limit': limit, 'offset': offset}
|
||||
42
src/esp32_web/api/devices.py
Normal file
42
src/esp32_web/api/devices.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Device endpoints."""
|
||||
from flask import request
|
||||
from . import bp
|
||||
from ..models import Device, Sighting
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/devices')
|
||||
def list_devices():
|
||||
"""List all devices."""
|
||||
device_type = request.args.get('type') # 'ble' or 'wifi'
|
||||
limit = min(int(request.args.get('limit', 100)), 1000)
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
query = db.select(Device).order_by(Device.last_seen.desc())
|
||||
if device_type:
|
||||
query = query.where(Device.device_type == device_type)
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
devices = db.session.scalars(query).all()
|
||||
return {'devices': [d.to_dict() for d in devices], 'limit': limit, 'offset': offset}
|
||||
|
||||
|
||||
@bp.route('/devices/<mac>')
|
||||
def get_device(mac):
|
||||
"""Get device by MAC."""
|
||||
mac = mac.lower()
|
||||
device = db.session.scalar(db.select(Device).where(Device.mac == mac))
|
||||
if not device:
|
||||
return {'error': 'Device not found'}, 404
|
||||
|
||||
# Include recent sightings
|
||||
sightings = db.session.scalars(
|
||||
db.select(Sighting)
|
||||
.where(Sighting.device_id == device.id)
|
||||
.order_by(Sighting.timestamp.desc())
|
||||
.limit(20)
|
||||
).all()
|
||||
|
||||
result = device.to_dict()
|
||||
result['sightings'] = [s.to_dict() for s in sightings]
|
||||
return result
|
||||
29
src/esp32_web/api/events.py
Normal file
29
src/esp32_web/api/events.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Event endpoints."""
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from flask import request
|
||||
from . import bp
|
||||
from ..models import Event
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/events')
|
||||
def list_events():
|
||||
"""List sensor events."""
|
||||
event_type = request.args.get('type')
|
||||
sensor_id = request.args.get('sensor_id', type=int)
|
||||
hours = request.args.get('hours', 24, type=int)
|
||||
limit = min(int(request.args.get('limit', 100)), 1000)
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
query = db.select(Event).where(Event.timestamp >= since).order_by(Event.timestamp.desc())
|
||||
|
||||
if event_type:
|
||||
query = query.where(Event.event_type == event_type)
|
||||
if sensor_id:
|
||||
query = query.where(Event.sensor_id == sensor_id)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
events = db.session.scalars(query).all()
|
||||
|
||||
return {'events': [e.to_dict() for e in events], 'limit': limit, 'offset': offset}
|
||||
44
src/esp32_web/api/probes.py
Normal file
44
src/esp32_web/api/probes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Probe request endpoints."""
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from flask import request
|
||||
from sqlalchemy import func
|
||||
from . import bp
|
||||
from ..models import Probe, Device
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/probes')
|
||||
def list_probes():
|
||||
"""List probe requests."""
|
||||
ssid = request.args.get('ssid')
|
||||
hours = request.args.get('hours', 24, type=int)
|
||||
limit = min(int(request.args.get('limit', 100)), 1000)
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
query = db.select(Probe).where(Probe.timestamp >= since).order_by(Probe.timestamp.desc())
|
||||
|
||||
if ssid:
|
||||
query = query.where(Probe.ssid == ssid)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
probes = db.session.scalars(query).all()
|
||||
|
||||
return {'probes': [p.to_dict() for p in probes], 'limit': limit, 'offset': offset}
|
||||
|
||||
|
||||
@bp.route('/probes/ssids')
|
||||
def list_ssids():
|
||||
"""List SSIDs with counts."""
|
||||
hours = request.args.get('hours', 24, type=int)
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
|
||||
results = db.session.execute(
|
||||
db.select(Probe.ssid, func.count(Probe.id).label('count'))
|
||||
.where(Probe.timestamp >= since)
|
||||
.group_by(Probe.ssid)
|
||||
.order_by(func.count(Probe.id).desc())
|
||||
.limit(100)
|
||||
).all()
|
||||
|
||||
return {'ssids': [{'ssid': r.ssid, 'count': r.count} for r in results]}
|
||||
53
src/esp32_web/api/sensors.py
Normal file
53
src/esp32_web/api/sensors.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Sensor endpoints."""
|
||||
import socket
|
||||
from flask import request, current_app
|
||||
from . import bp
|
||||
from ..models import Sensor
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/sensors')
|
||||
def list_sensors():
|
||||
"""List all sensors."""
|
||||
sensors = db.session.scalars(db.select(Sensor).order_by(Sensor.hostname)).all()
|
||||
return {'sensors': [s.to_dict() for s in sensors]}
|
||||
|
||||
|
||||
@bp.route('/sensors/<hostname>')
|
||||
def get_sensor(hostname):
|
||||
"""Get sensor by hostname."""
|
||||
sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname))
|
||||
if not sensor:
|
||||
return {'error': 'Sensor not found'}, 404
|
||||
return sensor.to_dict()
|
||||
|
||||
|
||||
@bp.route('/sensors/<hostname>/command', methods=['POST'])
|
||||
def send_command(hostname):
|
||||
"""Send UDP command to sensor."""
|
||||
sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname))
|
||||
if not sensor:
|
||||
return {'error': 'Sensor not found'}, 404
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'command' not in data:
|
||||
return {'error': 'Missing command'}, 400
|
||||
|
||||
command = data['command'].strip().upper()
|
||||
|
||||
# Whitelist allowed commands
|
||||
allowed = ('STATUS', 'REBOOT', 'IDENTIFY', 'BLE', 'ADAPTIVE', 'RATE', 'POWER',
|
||||
'CSIMODE', 'PRESENCE', 'CALIBRATE', 'CHANSCAN')
|
||||
if not any(command.startswith(a) for a in allowed):
|
||||
return {'error': 'Command not allowed'}, 403
|
||||
|
||||
# Send UDP command
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.sendto(command.encode(), (sensor.ip, current_app.config['SENSOR_CMD_PORT']))
|
||||
sock.close()
|
||||
except socket.error as e:
|
||||
return {'error': f'Socket error: {e}'}, 500
|
||||
|
||||
return {'status': 'sent', 'command': command}
|
||||
43
src/esp32_web/api/stats.py
Normal file
43
src/esp32_web/api/stats.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Statistics endpoints."""
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from flask import request
|
||||
from sqlalchemy import func
|
||||
from . import bp
|
||||
from ..models import Sensor, Device, Alert, Event, Probe
|
||||
from ..extensions import db
|
||||
|
||||
|
||||
@bp.route('/stats')
|
||||
def get_stats():
|
||||
"""Get aggregate statistics."""
|
||||
hours = request.args.get('hours', 24, type=int)
|
||||
since = datetime.now(UTC) - timedelta(hours=hours)
|
||||
|
||||
sensors_total = db.session.scalar(db.select(func.count(Sensor.id)))
|
||||
sensors_online = db.session.scalar(
|
||||
db.select(func.count(Sensor.id)).where(Sensor.status == 'online')
|
||||
)
|
||||
devices_total = db.session.scalar(db.select(func.count(Device.id)))
|
||||
devices_ble = db.session.scalar(
|
||||
db.select(func.count(Device.id)).where(Device.device_type == 'ble')
|
||||
)
|
||||
devices_wifi = db.session.scalar(
|
||||
db.select(func.count(Device.id)).where(Device.device_type == 'wifi')
|
||||
)
|
||||
alerts_count = db.session.scalar(
|
||||
db.select(func.count(Alert.id)).where(Alert.timestamp >= since)
|
||||
)
|
||||
events_count = db.session.scalar(
|
||||
db.select(func.count(Event.id)).where(Event.timestamp >= since)
|
||||
)
|
||||
probes_count = db.session.scalar(
|
||||
db.select(func.count(Probe.id)).where(Probe.timestamp >= since)
|
||||
)
|
||||
|
||||
return {
|
||||
'sensors': {'total': sensors_total, 'online': sensors_online},
|
||||
'devices': {'total': devices_total, 'ble': devices_ble, 'wifi': devices_wifi},
|
||||
'alerts': {'count': alerts_count, 'hours': hours},
|
||||
'events': {'count': events_count, 'hours': hours},
|
||||
'probes': {'count': probes_count, 'hours': hours},
|
||||
}
|
||||
Reference in New Issue
Block a user