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:
9
src/esp32_web/models/__init__.py
Normal file
9
src/esp32_web/models/__init__.py
Normal 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']
|
||||
37
src/esp32_web/models/alert.py
Normal file
37
src/esp32_web/models/alert.py
Normal 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
|
||||
37
src/esp32_web/models/device.py
Normal file
37
src/esp32_web/models/device.py
Normal 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,
|
||||
}
|
||||
27
src/esp32_web/models/event.py
Normal file
27
src/esp32_web/models/event.py
Normal 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(),
|
||||
}
|
||||
31
src/esp32_web/models/probe.py
Normal file
31
src/esp32_web/models/probe.py
Normal 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(),
|
||||
}
|
||||
30
src/esp32_web/models/sensor.py
Normal file
30
src/esp32_web/models/sensor.py
Normal 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,
|
||||
}
|
||||
27
src/esp32_web/models/sighting.py
Normal file
27
src/esp32_web/models/sighting.py
Normal 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(),
|
||||
}
|
||||
Reference in New Issue
Block a user