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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user