feat: v0.1.3 — fleet management endpoints

- GET /sensors/<hostname>/config: query sensor STATUS, parse response
- PUT /sensors/<hostname>/config: update rate, power, adaptive, etc.
- POST /sensors/<hostname>/ota: trigger OTA update with URL
- POST /sensors/<hostname>/calibrate: trigger baseline calibration

Added 14 new tests for fleet management endpoints.
This commit is contained in:
user
2026-02-05 21:21:35 +01:00
parent 58c974b535
commit 4b72b3293e
5 changed files with 367 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
"""Sensor endpoints."""
import json
import socket
from flask import request, current_app
from . import bp
@@ -37,7 +38,8 @@ def send_command(hostname):
# Whitelist allowed commands
allowed = ('STATUS', 'REBOOT', 'IDENTIFY', 'BLE', 'ADAPTIVE', 'RATE', 'POWER',
'CSIMODE', 'PRESENCE', 'CALIBRATE', 'CHANSCAN')
'CSIMODE', 'PRESENCE', 'CALIBRATE', 'CHANSCAN', 'OTA', 'TARGET',
'THRESHOLD', 'SCANRATE', 'PROBERATE', 'POWERSAVE', 'FLOODTHRESH')
if not any(command.startswith(a) for a in allowed):
return {'error': 'Command not allowed'}, 403
@@ -51,3 +53,163 @@ def send_command(hostname):
return {'error': f'Socket error: {e}'}, 500
return {'status': 'sent', 'command': command}
def _send_command_with_response(ip: str, command: str, timeout: float = 2.0) -> str | None:
"""Send UDP command and wait for response."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
sock.sendto(command.encode(), (ip, current_app.config['SENSOR_CMD_PORT']))
data, _ = sock.recvfrom(1400)
sock.close()
return data.decode('utf-8', errors='replace').strip()
except socket.timeout:
return None
except socket.error:
return None
def _parse_status_response(response: str) -> dict:
"""Parse STATUS response into dict."""
result = {}
if not response or not response.startswith('OK STATUS'):
return result
# Parse key=value pairs
for part in response.split():
if '=' in part:
key, value = part.split('=', 1)
# Try to convert to appropriate type
if value.isdigit():
result[key] = int(value)
elif value.replace('.', '').replace('-', '').isdigit():
try:
result[key] = float(value)
except ValueError:
result[key] = value
elif value in ('on', 'true'):
result[key] = True
elif value in ('off', 'false'):
result[key] = False
else:
result[key] = value
return result
@bp.route('/sensors/<hostname>/config')
def get_sensor_config(hostname):
"""Get sensor configuration by querying STATUS."""
sensor = db.session.scalar(db.select(Sensor).where(Sensor.hostname == hostname))
if not sensor:
return {'error': 'Sensor not found'}, 404
response = _send_command_with_response(sensor.ip, 'STATUS')
if not response:
return {'error': 'Sensor not responding'}, 504
config = _parse_status_response(response)
config['hostname'] = sensor.hostname
config['ip'] = sensor.ip
# Store config in database
sensor.config_json = json.dumps(config)
db.session.commit()
return {'config': config}
@bp.route('/sensors/<hostname>/config', methods=['PUT'])
def update_sensor_config(hostname):
"""Update sensor configuration."""
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:
return {'error': 'No configuration provided'}, 400
results = {}
errors = []
# Map config keys to commands
config_commands = {
'rate': lambda v: f'RATE {v}',
'power': lambda v: f'POWER {v}',
'adaptive': lambda v: f'ADAPTIVE {"ON" if v else "OFF"}',
'threshold': lambda v: f'THRESHOLD {v}',
'ble': lambda v: f'BLE {"ON" if v else "OFF"}',
'csi_mode': lambda v: f'CSIMODE {v.upper()}',
'presence': lambda v: f'PRESENCE {"ON" if v else "OFF"}',
'powersave': lambda v: f'POWERSAVE {"ON" if v else "OFF"}',
'chanscan': lambda v: f'CHANSCAN {"ON" if v else "OFF"}',
}
for key, value in data.items():
if key not in config_commands:
errors.append(f'Unknown config key: {key}')
continue
command = config_commands[key](value)
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()
results[key] = 'ok'
except socket.error as e:
errors.append(f'{key}: {e}')
return {'results': results, 'errors': errors}
@bp.route('/sensors/<hostname>/ota', methods=['POST'])
def trigger_ota(hostname):
"""Trigger OTA update on 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 'url' not in data:
return {'error': 'Missing OTA URL'}, 400
url = data['url']
if not url.startswith(('http://', 'https://')):
return {'error': 'Invalid URL scheme'}, 400
command = f'OTA {url}'
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': 'ota_triggered', 'url': url}
@bp.route('/sensors/<hostname>/calibrate', methods=['POST'])
def trigger_calibrate(hostname):
"""Trigger baseline calibration on 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() or {}
seconds = data.get('seconds', 10)
if not isinstance(seconds, int) or seconds < 3 or seconds > 60:
return {'error': 'seconds must be 3-60'}, 400
command = f'CALIBRATE {seconds}'
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': 'calibration_started', 'seconds': seconds}