diff --git a/TASKS.md b/TASKS.md
index af63b23..340c470 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -16,7 +16,7 @@
### P3 - Low
- [ ] Add pagination to all list endpoints
-- [ ] Add OpenAPI/Swagger spec
+- [x] Add OpenAPI/Swagger spec
- [ ] Add request logging middleware
## Completed: v0.1.2 - OSINT Features
diff --git a/pyproject.toml b/pyproject.toml
index e89444f..d696469 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ dependencies = [
"flask-cors>=4.0",
"gunicorn>=21.0",
"python-dotenv>=1.0",
+ "pyyaml>=6.0",
]
[project.optional-dependencies]
diff --git a/src/esp32_web/__init__.py b/src/esp32_web/__init__.py
index dfe1387..f8165ca 100644
--- a/src/esp32_web/__init__.py
+++ b/src/esp32_web/__init__.py
@@ -1,7 +1,7 @@
"""ESP32-Web Flask Application."""
import click
from datetime import datetime, UTC
-from flask import Flask
+from flask import Flask, Response, send_from_directory
from pathlib import Path
from .config import Config
@@ -42,6 +42,45 @@ def create_app(config_class=Config):
uptime_str = f'{minutes}m{seconds}s'
return {'status': 'ok', 'uptime': uptime_str, 'uptime_seconds': uptime_seconds}
+ # OpenAPI spec endpoints
+ @app.route('/openapi.yaml')
+ def openapi_yaml():
+ """Serve OpenAPI spec as YAML."""
+ spec_path = Path(__file__).parent / 'openapi.yaml'
+ return Response(spec_path.read_text(), mimetype='text/yaml')
+
+ @app.route('/openapi.json')
+ def openapi_json():
+ """Serve OpenAPI spec as JSON."""
+ import json
+ import yaml
+ spec_path = Path(__file__).parent / 'openapi.yaml'
+ spec = yaml.safe_load(spec_path.read_text())
+ return spec
+
+ @app.route('/docs')
+ def swagger_ui():
+ """Serve Swagger UI."""
+ return '''
+
+
+ ESP32-Web API
+
+
+
+
+
+
+
+'''
+
# Start UDP collector in non-testing mode
if not app.config.get('TESTING'):
from .collector import collector
diff --git a/src/esp32_web/openapi.yaml b/src/esp32_web/openapi.yaml
new file mode 100644
index 0000000..4721efc
--- /dev/null
+++ b/src/esp32_web/openapi.yaml
@@ -0,0 +1,836 @@
+openapi: 3.0.3
+info:
+ title: ESP32-Web API
+ description: REST API for ESP32 sensor fleet management
+ version: 0.1.3
+ contact:
+ name: ESP32-Web
+servers:
+ - url: /api/v1
+ description: API v1
+
+tags:
+ - name: sensors
+ description: Sensor management and fleet operations
+ - name: devices
+ description: Discovered BLE/WiFi devices
+ - name: alerts
+ description: Security alerts and anomalies
+ - name: events
+ description: Sensor events (motion, presence)
+ - name: probes
+ description: WiFi probe requests
+ - name: stats
+ description: Aggregate statistics
+ - name: export
+ description: Data export endpoints
+
+paths:
+ /sensors:
+ get:
+ tags: [sensors]
+ summary: List all sensors
+ operationId: listSensors
+ responses:
+ '200':
+ description: List of sensors
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ sensors:
+ type: array
+ items:
+ $ref: '#/components/schemas/Sensor'
+
+ /sensors/heartbeat:
+ get:
+ tags: [sensors]
+ summary: Get heartbeat status for all sensors
+ operationId: getHeartbeatStatus
+ responses:
+ '200':
+ description: Heartbeat summary
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HeartbeatSummary'
+ post:
+ tags: [sensors]
+ summary: Refresh heartbeat status for all sensors
+ operationId: refreshHeartbeats
+ responses:
+ '200':
+ description: Update result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: updated
+ online:
+ type: integer
+ stale:
+ type: integer
+ offline:
+ type: integer
+
+ /sensors/{hostname}:
+ get:
+ tags: [sensors]
+ summary: Get sensor by hostname
+ operationId: getSensor
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ responses:
+ '200':
+ description: Sensor details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Sensor'
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /sensors/{hostname}/command:
+ post:
+ tags: [sensors]
+ summary: Send UDP command to sensor
+ operationId: sendCommand
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [command]
+ properties:
+ command:
+ type: string
+ example: STATUS
+ responses:
+ '200':
+ description: Command sent
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: sent
+ command:
+ type: string
+ '403':
+ description: Command not allowed
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /sensors/{hostname}/config:
+ get:
+ tags: [sensors]
+ summary: Get sensor configuration
+ operationId: getSensorConfig
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ responses:
+ '200':
+ description: Sensor configuration
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ config:
+ $ref: '#/components/schemas/SensorConfig'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '504':
+ description: Sensor not responding
+ put:
+ tags: [sensors]
+ summary: Update sensor configuration
+ operationId: updateSensorConfig
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ rate:
+ type: integer
+ description: CSI send rate (Hz)
+ power:
+ type: integer
+ description: TX power (dBm)
+ adaptive:
+ type: boolean
+ threshold:
+ type: number
+ ble:
+ type: boolean
+ csi_mode:
+ type: string
+ enum: [raw, amplitude, hybrid]
+ presence:
+ type: boolean
+ powersave:
+ type: boolean
+ chanscan:
+ type: boolean
+ responses:
+ '200':
+ description: Update results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: object
+ errors:
+ type: array
+ items:
+ type: string
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /sensors/{hostname}/ota:
+ post:
+ tags: [sensors]
+ summary: Trigger OTA update
+ operationId: triggerOta
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [url]
+ properties:
+ url:
+ type: string
+ format: uri
+ example: https://example.com/firmware.bin
+ responses:
+ '200':
+ description: OTA triggered
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: ota_triggered
+ url:
+ type: string
+ '400':
+ description: Invalid URL
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /sensors/{hostname}/calibrate:
+ post:
+ tags: [sensors]
+ summary: Trigger baseline calibration
+ operationId: triggerCalibrate
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ seconds:
+ type: integer
+ minimum: 3
+ maximum: 60
+ default: 10
+ responses:
+ '200':
+ description: Calibration started
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: calibration_started
+ seconds:
+ type: integer
+ '400':
+ description: Invalid seconds value
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /sensors/{hostname}/metrics:
+ get:
+ tags: [sensors]
+ summary: Get sensor activity metrics
+ operationId: getSensorMetrics
+ parameters:
+ - $ref: '#/components/parameters/hostname'
+ - name: hours
+ in: query
+ schema:
+ type: integer
+ default: 24
+ minimum: 1
+ maximum: 168
+ description: Time range in hours (max 168)
+ responses:
+ '200':
+ description: Sensor metrics
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SensorMetrics'
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /devices:
+ get:
+ tags: [devices]
+ summary: List discovered devices
+ operationId: listDevices
+ parameters:
+ - name: type
+ in: query
+ schema:
+ type: string
+ enum: [ble, wifi]
+ - $ref: '#/components/parameters/limit'
+ - $ref: '#/components/parameters/offset'
+ responses:
+ '200':
+ description: List of devices
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ devices:
+ type: array
+ items:
+ $ref: '#/components/schemas/Device'
+ limit:
+ type: integer
+ offset:
+ type: integer
+
+ /devices/{mac}:
+ get:
+ tags: [devices]
+ summary: Get device by MAC address
+ operationId: getDevice
+ parameters:
+ - name: mac
+ in: path
+ required: true
+ schema:
+ type: string
+ example: aa:bb:cc:dd:ee:ff
+ responses:
+ '200':
+ description: Device details with sightings
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/Device'
+ - type: object
+ properties:
+ sightings:
+ type: array
+ items:
+ $ref: '#/components/schemas/Sighting'
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /alerts:
+ get:
+ tags: [alerts]
+ summary: List security alerts
+ operationId: listAlerts
+ parameters:
+ - name: type
+ in: query
+ schema:
+ type: string
+ - name: sensor_id
+ in: query
+ schema:
+ type: integer
+ - $ref: '#/components/parameters/hours'
+ - $ref: '#/components/parameters/limit'
+ - $ref: '#/components/parameters/offset'
+ responses:
+ '200':
+ description: List of alerts
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ alerts:
+ type: array
+ items:
+ $ref: '#/components/schemas/Alert'
+ limit:
+ type: integer
+ offset:
+ type: integer
+
+ /events:
+ get:
+ tags: [events]
+ summary: List sensor events
+ operationId: listEvents
+ parameters:
+ - name: type
+ in: query
+ schema:
+ type: string
+ - name: sensor_id
+ in: query
+ schema:
+ type: integer
+ - $ref: '#/components/parameters/hours'
+ - $ref: '#/components/parameters/limit'
+ - $ref: '#/components/parameters/offset'
+ responses:
+ '200':
+ description: List of events
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ events:
+ type: array
+ items:
+ $ref: '#/components/schemas/Event'
+ limit:
+ type: integer
+ offset:
+ type: integer
+
+ /probes:
+ get:
+ tags: [probes]
+ summary: List probe requests
+ operationId: listProbes
+ parameters:
+ - name: ssid
+ in: query
+ schema:
+ type: string
+ - $ref: '#/components/parameters/hours'
+ - $ref: '#/components/parameters/limit'
+ - $ref: '#/components/parameters/offset'
+ responses:
+ '200':
+ description: List of probe requests
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ probes:
+ type: array
+ items:
+ $ref: '#/components/schemas/Probe'
+ limit:
+ type: integer
+ offset:
+ type: integer
+
+ /probes/ssids:
+ get:
+ tags: [probes]
+ summary: List SSIDs with counts
+ operationId: listSsids
+ parameters:
+ - $ref: '#/components/parameters/hours'
+ responses:
+ '200':
+ description: SSID list with counts
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ssids:
+ type: array
+ items:
+ type: object
+ properties:
+ ssid:
+ type: string
+ count:
+ type: integer
+
+ /stats:
+ get:
+ tags: [stats]
+ summary: Get aggregate statistics
+ operationId: getStats
+ parameters:
+ - $ref: '#/components/parameters/hours'
+ responses:
+ '200':
+ description: Statistics
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Stats'
+
+ /export/devices.csv:
+ get:
+ tags: [export]
+ summary: Export devices as CSV
+ operationId: exportDevicesCsv
+ responses:
+ '200':
+ description: CSV file
+ content:
+ text/csv:
+ schema:
+ type: string
+
+ /export/devices.json:
+ get:
+ tags: [export]
+ summary: Export devices as JSON
+ operationId: exportDevicesJson
+ responses:
+ '200':
+ description: JSON file
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Device'
+
+ /export/alerts.csv:
+ get:
+ tags: [export]
+ summary: Export alerts as CSV
+ operationId: exportAlertsCsv
+ parameters:
+ - $ref: '#/components/parameters/hours'
+ responses:
+ '200':
+ description: CSV file
+ content:
+ text/csv:
+ schema:
+ type: string
+
+ /export/probes.csv:
+ get:
+ tags: [export]
+ summary: Export probes as CSV
+ operationId: exportProbesCsv
+ parameters:
+ - $ref: '#/components/parameters/hours'
+ responses:
+ '200':
+ description: CSV file
+ content:
+ text/csv:
+ schema:
+ type: string
+
+components:
+ parameters:
+ hostname:
+ name: hostname
+ in: path
+ required: true
+ schema:
+ type: string
+ example: muddy-storm
+ limit:
+ name: limit
+ in: query
+ schema:
+ type: integer
+ default: 100
+ maximum: 1000
+ offset:
+ name: offset
+ in: query
+ schema:
+ type: integer
+ default: 0
+ hours:
+ name: hours
+ in: query
+ schema:
+ type: integer
+ default: 24
+
+ responses:
+ NotFound:
+ description: Resource not found
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+
+ schemas:
+ Sensor:
+ type: object
+ properties:
+ id:
+ type: integer
+ hostname:
+ type: string
+ ip:
+ type: string
+ last_seen:
+ type: string
+ format: date-time
+ status:
+ type: string
+ enum: [online, stale, offline, unknown]
+
+ SensorConfig:
+ type: object
+ properties:
+ hostname:
+ type: string
+ ip:
+ type: string
+ version:
+ type: string
+ uptime:
+ type: string
+ rate:
+ type: integer
+ tx_power:
+ type: integer
+ adaptive:
+ type: boolean
+ ble:
+ type: boolean
+ presence:
+ type: boolean
+ powersave:
+ type: boolean
+ csi_mode:
+ type: string
+ channel:
+ type: integer
+ rssi:
+ type: number
+ heap:
+ type: integer
+
+ SensorMetrics:
+ type: object
+ properties:
+ hostname:
+ type: string
+ hours:
+ type: integer
+ activity:
+ type: object
+ properties:
+ sightings:
+ type: integer
+ alerts:
+ type: integer
+ events:
+ type: integer
+ recent_events:
+ type: array
+ items:
+ $ref: '#/components/schemas/Event'
+
+ HeartbeatSummary:
+ type: object
+ properties:
+ total:
+ type: integer
+ online:
+ type: integer
+ stale:
+ type: integer
+ offline:
+ type: integer
+ sensors:
+ type: array
+ items:
+ type: object
+ properties:
+ hostname:
+ type: string
+ ip:
+ type: string
+ status:
+ type: string
+ last_seen:
+ type: string
+ format: date-time
+ seconds_ago:
+ type: integer
+
+ Device:
+ type: object
+ properties:
+ id:
+ type: integer
+ mac:
+ type: string
+ device_type:
+ type: string
+ enum: [ble, wifi]
+ vendor:
+ type: string
+ nullable: true
+ name:
+ type: string
+ nullable: true
+ company_id:
+ type: integer
+ nullable: true
+ manufacturer:
+ type: string
+ nullable: true
+ first_seen:
+ type: string
+ format: date-time
+ last_seen:
+ type: string
+ format: date-time
+
+ Sighting:
+ type: object
+ properties:
+ id:
+ type: integer
+ device_id:
+ type: integer
+ sensor_id:
+ type: integer
+ rssi:
+ type: integer
+ timestamp:
+ type: string
+ format: date-time
+
+ Alert:
+ type: object
+ properties:
+ id:
+ type: integer
+ sensor_id:
+ type: integer
+ alert_type:
+ type: string
+ source_mac:
+ type: string
+ nullable: true
+ target_mac:
+ type: string
+ nullable: true
+ rssi:
+ type: integer
+ nullable: true
+ timestamp:
+ type: string
+ format: date-time
+
+ Event:
+ type: object
+ properties:
+ id:
+ type: integer
+ sensor_id:
+ type: integer
+ type:
+ type: string
+ payload:
+ type: object
+ timestamp:
+ type: string
+ format: date-time
+
+ Probe:
+ type: object
+ properties:
+ id:
+ type: integer
+ sensor_id:
+ type: integer
+ device_id:
+ type: integer
+ ssid:
+ type: string
+ rssi:
+ type: integer
+ channel:
+ type: integer
+ timestamp:
+ type: string
+ format: date-time
+
+ Stats:
+ type: object
+ properties:
+ sensors:
+ type: object
+ properties:
+ total:
+ type: integer
+ online:
+ type: integer
+ devices:
+ type: object
+ properties:
+ total:
+ type: integer
+ ble:
+ type: integer
+ wifi:
+ type: integer
+ alerts:
+ type: object
+ properties:
+ count:
+ type: integer
+ hours:
+ type: integer
+ events:
+ type: object
+ properties:
+ count:
+ type: integer
+ hours:
+ type: integer
+ probes:
+ type: object
+ properties:
+ count:
+ type: integer
+ hours:
+ type: integer