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:
user
2026-02-05 20:56:52 +01:00
commit a676136f5d
34 changed files with 1054 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
"""Database models."""
from .sensor import Sensor
from .device import Device
from .sighting import Sighting
from .alert import Alert
from .event import Event
from .probe import Probe
__all__ = ['Sensor', 'Device', 'Sighting', 'Alert', 'Event', 'Probe']

View File

@@ -0,0 +1,37 @@
"""Alert model."""
from datetime import datetime, UTC
from ..extensions import db
class Alert(db.Model):
"""Security alert (deauth, disassoc, flood)."""
__tablename__ = 'alerts'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True)
alert_type: db.Mapped[str] = db.mapped_column(db.String(16), index=True) # deauth, disassoc, deauth_flood
source_mac: db.Mapped[str | None] = db.mapped_column(db.String(17), nullable=True)
target_mac: db.Mapped[str | None] = db.mapped_column(db.String(17), nullable=True)
rssi: db.Mapped[int | None] = db.mapped_column(nullable=True)
flood_count: db.Mapped[int | None] = db.mapped_column(nullable=True)
flood_window: db.Mapped[int | None] = db.mapped_column(nullable=True)
timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True)
# Relationships
sensor = db.relationship('Sensor', back_populates='alerts')
def to_dict(self):
d = {
'id': self.id,
'sensor_id': self.sensor_id,
'type': self.alert_type,
'timestamp': self.timestamp.isoformat(),
}
if self.alert_type == 'deauth_flood':
d['flood_count'] = self.flood_count
d['flood_window'] = self.flood_window
else:
d['source_mac'] = self.source_mac
d['target_mac'] = self.target_mac
d['rssi'] = self.rssi
return d

View File

@@ -0,0 +1,37 @@
"""Device model."""
from datetime import datetime, UTC
from ..extensions import db
class Device(db.Model):
"""Discovered BLE/WiFi device."""
__tablename__ = 'devices'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
mac: db.Mapped[str] = db.mapped_column(db.String(17), unique=True, index=True)
device_type: db.Mapped[str] = db.mapped_column(db.String(8)) # 'ble' or 'wifi'
vendor: db.Mapped[str | None] = db.mapped_column(db.String(64), nullable=True)
name: db.Mapped[str | None] = db.mapped_column(db.String(64), nullable=True)
first_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC))
last_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC))
# BLE-specific fields
company_id: db.Mapped[int | None] = db.mapped_column(nullable=True)
tx_power: db.Mapped[int | None] = db.mapped_column(nullable=True)
# Relationships
sightings = db.relationship('Sighting', back_populates='device', lazy='dynamic')
probes = db.relationship('Probe', back_populates='device', lazy='dynamic')
def to_dict(self):
return {
'id': self.id,
'mac': self.mac,
'type': self.device_type,
'vendor': self.vendor,
'name': self.name,
'first_seen': self.first_seen.isoformat(),
'last_seen': self.last_seen.isoformat(),
'company_id': self.company_id,
'tx_power': self.tx_power,
}

View File

@@ -0,0 +1,27 @@
"""Event model."""
from datetime import datetime, UTC
from ..extensions import db
class Event(db.Model):
"""Sensor event (motion, presence, calibration)."""
__tablename__ = 'events'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True)
event_type: db.Mapped[str] = db.mapped_column(db.String(32), index=True)
payload_json: db.Mapped[str | None] = db.mapped_column(db.Text, nullable=True)
timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True)
# Relationships
sensor = db.relationship('Sensor', back_populates='events')
def to_dict(self):
import json
return {
'id': self.id,
'sensor_id': self.sensor_id,
'type': self.event_type,
'payload': json.loads(self.payload_json) if self.payload_json else {},
'timestamp': self.timestamp.isoformat(),
}

View File

@@ -0,0 +1,31 @@
"""Probe request model."""
from datetime import datetime, UTC
from ..extensions import db
class Probe(db.Model):
"""WiFi probe request."""
__tablename__ = 'probes'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
device_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('devices.id'), index=True)
sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True)
ssid: db.Mapped[str] = db.mapped_column(db.String(32), index=True)
rssi: db.Mapped[int] = db.mapped_column()
channel: db.Mapped[int] = db.mapped_column()
timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True)
# Relationships
device = db.relationship('Device', back_populates='probes')
sensor = db.relationship('Sensor', back_populates='probes')
def to_dict(self):
return {
'id': self.id,
'device_id': self.device_id,
'sensor_id': self.sensor_id,
'ssid': self.ssid,
'rssi': self.rssi,
'channel': self.channel,
'timestamp': self.timestamp.isoformat(),
}

View File

@@ -0,0 +1,30 @@
"""Sensor model."""
from datetime import datetime, UTC
from ..extensions import db
class Sensor(db.Model):
"""ESP32 sensor node."""
__tablename__ = 'sensors'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
hostname: db.Mapped[str] = db.mapped_column(db.String(32), unique=True, index=True)
ip: db.Mapped[str] = db.mapped_column(db.String(15))
last_seen: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC))
status: db.Mapped[str] = db.mapped_column(db.String(16), default='unknown')
config_json: db.Mapped[str | None] = db.mapped_column(db.Text, nullable=True)
# Relationships
sightings = db.relationship('Sighting', back_populates='sensor', lazy='dynamic')
alerts = db.relationship('Alert', back_populates='sensor', lazy='dynamic')
events = db.relationship('Event', back_populates='sensor', lazy='dynamic')
probes = db.relationship('Probe', back_populates='sensor', lazy='dynamic')
def to_dict(self):
return {
'id': self.id,
'hostname': self.hostname,
'ip': self.ip,
'last_seen': self.last_seen.isoformat(),
'status': self.status,
}

View File

@@ -0,0 +1,27 @@
"""Sighting model."""
from datetime import datetime, UTC
from ..extensions import db
class Sighting(db.Model):
"""Device sighting by a sensor."""
__tablename__ = 'sightings'
id: db.Mapped[int] = db.mapped_column(primary_key=True)
device_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('devices.id'), index=True)
sensor_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('sensors.id'), index=True)
rssi: db.Mapped[int] = db.mapped_column()
timestamp: db.Mapped[datetime] = db.mapped_column(default=lambda: datetime.now(UTC), index=True)
# Relationships
device = db.relationship('Device', back_populates='sightings')
sensor = db.relationship('Sensor', back_populates='sightings')
def to_dict(self):
return {
'id': self.id,
'device_id': self.device_id,
'sensor_id': self.sensor_id,
'rssi': self.rssi,
'timestamp': self.timestamp.isoformat(),
}