espark-core 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of espark-core might be problematic. Click here for more details.

@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: espark-core
3
+ Version: 0.0.0
4
+ Summary: The core module of the Espark ESP32-based IoT device management framework.
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiomqtt==2.4.0
9
+ Requires-Dist: apscheduler==3.11.1
10
+ Requires-Dist: fastapi==0.124.0
11
+ Requires-Dist: sqlalchemy==2.0.45
12
+ Requires-Dist: sqlmodel==0.0.27
13
+ Requires-Dist: uvicorn[standard]==0.38.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: aiosqlite==0.21.0; extra == "dev"
16
+ Requires-Dist: autopep8==2.3.2; extra == "dev"
17
+ Requires-Dist: build==1.3.0; extra == "dev"
18
+ Requires-Dist: fastapi-cli[standard-no-fastapi-cloud-cli]==0.0.16; extra == "dev"
19
+ Requires-Dist: httpx==0.28.1; extra == "dev"
20
+ Requires-Dist: pycodestyle==2.14.0; extra == "dev"
21
+ Requires-Dist: pylint==4.0.4; extra == "dev"
22
+ Requires-Dist: pytest==9.0.2; extra == "dev"
23
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
24
+ Requires-Dist: pytest-cov==7.0.0; extra == "dev"
25
+ Requires-Dist: twine==6.2.0; extra == "dev"
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: license
29
+ Dynamic: provides-extra
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ # Espark
35
+
36
+ Espark is a lightweight framework for building scalable and efficient ESP32-based IoT applications. It provides a modular architecture, easy-to-use APIs, and built-in support for common IoT protocols.
37
+
38
+ ## Project Goals
39
+
40
+ - Simplify the development of ESP32 applications.
41
+ - Provide a modular and extensible architecture.
42
+ - Support common IoT protocols like MQTT.
43
+ - Ensure efficient resource management for low-power devices.
44
+ - Provide a clean and easy-to-use API.
45
+ - Provide an user-friendly UI for configuration and monitoring.
46
+
47
+ ## Features
48
+
49
+ - **Device Provisioning**: Easy setup and configuration of ESP32 devices.
50
+ - **Telemetry Collection**: Built-in support for collecting and sending telemetry data.
51
+ - **Scalable Architecture**: Designed to handle a large number of devices efficiently.
52
+ - **Seamless Communication**: Support for MQTT protocol.
53
+
54
+ ## Hardware Requirements
55
+
56
+ - ESP32 Development Board
57
+ - USB Cable for programming and power
58
+ - Optional: Sensors and triggers for specific applications
59
+
60
+ ## Project Structure
61
+
62
+ ```
63
+ espark/
64
+ ├── espark-core/
65
+ │ ├── esparkcore/ # FastAPI backend framework
66
+ │ │ ├── data/ # Models, repositories
67
+ │ │ ├── routers/ # API endpoints
68
+ │ │ ├── schedules/ # Background tasks
69
+ │ │ ├── services/ # Business logic, MQTT handling
70
+ │ │ └── utils/ # Utility functions
71
+ │ └── Makefile
72
+ ├── espark-node/
73
+ │ ├── esparknode/ # MicroPython application framework
74
+ │ │ ├── actions/ # Action handlers
75
+ │ │ ├── data/ # Data storage
76
+ │ │ ├── libraries/ # External libraries
77
+ │ │ ├── networks/ # Network management
78
+ │ │ ├── sensors/ # Sensor interfaces
79
+ │ │ ├── triggers/ # Trigger interfaces
80
+ │ │ ├── utils/ # Utility functions
81
+ │ │ └── base_node.py # Main application file
82
+ │ └── Makefile
83
+ └── espark-react/ # React frontend application
84
+ ├── src/
85
+ │ ├── data/ # Data models and data providers
86
+ │ ├── i18n/ # Internationalization files
87
+ │ ├── pages/ # Application pages
88
+ │ ├── routes/ # Application routing
89
+ │ ├── utils/ # Utility functions
90
+ │ ├── App.tsx # Main application component
91
+ │ └── index.tsx # Application entry point
92
+ └── package.json
93
+ ```
94
+
95
+ ## Development Workflows
96
+
97
+ ### Setting up the backend
98
+
99
+ 1. Add espark-core as a dependency in your FastAPI project.
100
+ 2. Configure database connections and MQTT settings as environment variables.
101
+ 3. Implement additional data models, repositories, routers, and business logic if needed.
102
+ 4. Add the `DeviceRouter`, `TelemetryRouter`, and other additional routers to your FastAPI app.
103
+
104
+ ### Setting up the ESP32 application
105
+
106
+ 1. Clone the espark-node repository to your local machine.
107
+ 2. Copy `espark-core/Makefile.template` to `Makefile` and customize it for your device.
108
+ 3. Run `make upgrade` to copy the espark-core library to your device project.
109
+ 4. Implement device-specific actions, sensors, and triggers as needed.
110
+ 5. Run `make flash` to upload the firmware to your ESP32 device.
111
+ 6. Run `make deploy` to upload the application to the device.
112
+
113
+ ### Setting up the frontend
114
+
115
+ 1. Add espark-react as a dependency in your React project.
116
+ 2. Render `<EsparkApp />` in your main application file.
117
+
118
+ ### Configurations
119
+
120
+ - **espark-core**: Use environment variables, or `.env` file, for database and MQTT configurations.
121
+ - **espark-node**: Use `esparknode.configs` for device-specific configurations.
122
+ - **espark-react**: Customise `EsparkApp` props for application settings.
123
+
124
+ ## Examples and Patterns
125
+
126
+ - **Router Example**: `device_router.py` in `espark-core/esparkcore/routers/` demonstrates how to create API endpoints for device management.
127
+ - **Respository Example**: `device_repository.py` in `espark-core/esparkcore/data/repositories/` shows how to implement data access logic for devices.
128
+ - **Action Example**: `esp32_relay.py` in `espark-node/esparknode/actions/` illustrates how to define actions for ESP32 devices.
129
+ - **Sensor Example**: `sht20_sensor.py` in `espark-node/esparknode/sensors/` demonstrates how to read data from a SHT20 sensor.
130
+ - **Trigger Example**: `gpio_trigger.py` in `espark-node/esparknode/triggers/` shows how to create GPIO-based triggers for device actions.
131
+ - **List, Show, Edit Screens Example**: `DeviceList`, `DeviceShow`, and `DeviceEdit` components in `espark-react/src/pages/devices/` demonstrate how to create CRUD screens for device management.
132
+
133
+ ## Example Projects
134
+
135
+ - **Espartan**: A smart thermostat and open-door alert automation system using ESP32-C3 devices, leveraging espark for device management and telemetry, available at [https://github.com/ayltai/Espartan](https://github.com/ayltai/Espartan).
@@ -0,0 +1,28 @@
1
+ esparkcore/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ esparkcore/constants.py,sha256=jgbhkana_NU_AdcGI0B0OjEOSBAuYNmTroC-vdlD69I,338
3
+ esparkcore/data/__init__.py,sha256=abMOPq6jC5qOQCl9j7iPDv8iyjzDF-oVd_gW0la5kL4,45
4
+ esparkcore/data/database.py,sha256=OaJSBDW6Qge-rsKkSIksJXOg3xF_y6LuBew--6uFbG4,532
5
+ esparkcore/data/models/__init__.py,sha256=y4tnMxZGRsqYTWw-ctWzPMHj1B9xfPYc3Ettoz2vYAI,92
6
+ esparkcore/data/models/device.py,sha256=xXewJHBx4_R_m-N81m0_mcgNmbxzcK0KyCvIGazU3Ck,823
7
+ esparkcore/data/models/outbox.py,sha256=r5bgjIbj9H-OsGhE-CWKGRf4Tp88qMvmxPYHNGOApY4,1309
8
+ esparkcore/data/models/telemetry.py,sha256=OfzGqGj2Y1sdK519sSOEXhfuErHoeEdpy5qmJMIc27A,652
9
+ esparkcore/data/repositories/__init__.py,sha256=uuzaSYEtrifTHYMXr7xGPwXLhvuvccGYfur6w9UG72E,195
10
+ esparkcore/data/repositories/base_repository.py,sha256=L5APTp8SczndbWqgJyEqKe-4NQoL_YYOapUN6i572cM,2097
11
+ esparkcore/data/repositories/device_repository.py,sha256=P9KK-Ta9MgMdrnct5hbGEoipPOPrrDQ4wINQnZZ6iZY,1131
12
+ esparkcore/data/repositories/outbox_repository.py,sha256=PdPyKq2Km4_7732O6HhA4BlV_zBjYF91GkMltNKUovg,1015
13
+ esparkcore/data/repositories/telemetry_repository.py,sha256=5UBBmY-ugcgP0Xji7cu58_nGt8MWKeHz9DHNVJxk0BY,1116
14
+ esparkcore/routers/__init__.py,sha256=kVr51MhvJYVKYZXS9maqf4XXj-4qLyM9UFk5x9BEAnQ,86
15
+ esparkcore/routers/base_router.py,sha256=t-x8tYZVzBk9bD__9yaGYhm66glq_oM_lAHn0eplM2M,3902
16
+ esparkcore/routers/device_router.py,sha256=mIBAyTyxl9AF8Os_Sms6Bb0vEvWfljyElbTWEGLo0iU,1172
17
+ esparkcore/routers/telemetry_router.py,sha256=3ZpgY1fjGkXLmoaxm2paNJNVc5VMf1iZqsDh0xa0TyI,2014
18
+ esparkcore/schedules/__init__.py,sha256=v5Wy3Cbt2AnudGMGXv_Cyaa10s6NAmP66rDjcMSvf0o,74
19
+ esparkcore/schedules/outbox.py,sha256=GGANvjkI7w1BdmmVRgKog498ASD_xRN0g8bG6yC_SN4,811
20
+ esparkcore/schedules/scheduler.py,sha256=aYQXImLZ4tW-hRavn3ObuV3lYjmOZD813YkeG1NsYfY,170
21
+ esparkcore/services/__init__.py,sha256=RIZOHBfkWn3HsApdA5rH9EZbbXGq0NfiGoOWDO_JX1k,30
22
+ esparkcore/services/mqtt.py,sha256=292ZJ9YZbAYGghNFtRoXWbMenOiOo57bIo-D9Pwyr8o,4831
23
+ esparkcore/utils/__init__.py,sha256=rzuCJUAaQl-yPhM1vcWB_1zVXqtA71ChYP2aM_3UfKk,42
24
+ esparkcore/utils/logging.py,sha256=sRO8uOcPkFH-DwepqLOTS2qGaopXpMdVOjzZL6RpGs4,257
25
+ espark_core-0.0.0.dist-info/METADATA,sha256=ozNf9Ni4OQ62CgrIEYc0MEh9Q6fhPYCGffEX2naNRp0,6221
26
+ espark_core-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
+ espark_core-0.0.0.dist-info/top_level.txt,sha256=hXVyhIPB4aGskFm5queWALxDPhcDkrN7_8vcjwA1iE4,11
28
+ espark_core-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ esparkcore
esparkcore/__init__.py ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ ENV_DATABASE_URL : str = 'DATABASE_URL'
2
+ ENV_MQTT_HOST : str = 'MQTT_HOST'
3
+ ENV_MQTT_PORT : str = 'MQTT_PORT'
4
+
5
+ TOPIC_DEVICE : str = 'espark/device'
6
+ TOPIC_REGISTRATION : str = 'espark/registration'
7
+ TOPIC_TELEMETRY : str = 'espark/telemetry'
8
+ TOPIC_ACTION : str = 'espark/action'
9
+ TOPIC_CRASH : str = 'espark/crash'
@@ -0,0 +1 @@
1
+ from .database import async_session, init_db
@@ -0,0 +1,15 @@
1
+ from os import getenv
2
+
3
+ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine
4
+ from sqlmodel import SQLModel
5
+
6
+ from ..constants import ENV_DATABASE_URL
7
+
8
+ # pylint: disable=invalid-name
9
+ engine = create_async_engine(getenv(ENV_DATABASE_URL, 'sqlite+aiosqlite:///database.db'), echo=True)
10
+ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
11
+
12
+
13
+ async def init_db():
14
+ async with engine.begin() as conn:
15
+ await conn.run_sync(SQLModel.metadata.create_all)
@@ -0,0 +1,3 @@
1
+ from .device import Device
2
+ from .outbox import OutboxEvent
3
+ from .telemetry import Telemetry
@@ -0,0 +1,13 @@
1
+ from datetime import datetime
2
+ from typing import Dict, Optional
3
+
4
+ from sqlalchemy import Column
5
+ from sqlmodel import SQLModel, Field, JSON
6
+
7
+
8
+ class Device(SQLModel, table=True):
9
+ id : str = Field(primary_key=True, description='Unique identifier for the device')
10
+ display_name : Optional[str] = Field(default=None, description='Human-readable name of the device')
11
+ capabilities : Optional[str] = Field(default=None, description='Comma separated capabilities of the device')
12
+ parameters : Dict[str, str | int | bool] = Field(default_factory=dict, sa_column=Column(JSON), description='JSON string of capability-specific parameters')
13
+ last_seen : datetime = Field(index=True, description='Last time the device was seen online')
@@ -0,0 +1,20 @@
1
+ from datetime import datetime
2
+ from typing import Dict, Optional
3
+ from uuid import UUID, uuid4
4
+
5
+ from sqlalchemy import Column
6
+ from sqlmodel import SQLModel, Field, JSON, UniqueConstraint
7
+
8
+
9
+ class OutboxEvent(SQLModel, table=True):
10
+ __table_args__ = (
11
+ UniqueConstraint('device_id', 'event_type', 'is_processed', name='uq_device_event'),
12
+ )
13
+
14
+ id : UUID = Field(default_factory=uuid4, primary_key=True, description='Unique identifier for the outbox event')
15
+ device_id : str = Field(foreign_key='device.id', ondelete='CASCADE', index=True, description='Identifier of the device associated with the event')
16
+ event_type : str = Field(index=True, description='Type of the event')
17
+ payload : Dict[str, str] = Field(default_factory=dict, sa_column=Column(JSON), description='JSON string of event-specific payload data')
18
+ created_at : datetime = Field(default_factory=datetime.now, index=True, description='Timestamp when the event was created')
19
+ processed_at : Optional[datetime] = Field(default=None, index=True, description='Timestamp when the event was processed')
20
+ is_processed : bool = Field(default=False, index=True, description='Flag indicating whether the event has been processed')
@@ -0,0 +1,12 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from sqlmodel import SQLModel, Field
5
+
6
+
7
+ class Telemetry(SQLModel, table=True):
8
+ id : Optional[int] = Field(primary_key=True, default=None)
9
+ device_id : str = Field(foreign_key='device.id', ondelete='CASCADE', description='Device that sent this data')
10
+ timestamp : datetime = Field(index=True, description='Timestamp of the data')
11
+ data_type : str = Field(index=True, description='Type of the data (e.g., motion, temperature)')
12
+ value : int = Field(description='Value of the data (e.g., temperature, human presence detected or not)')
@@ -0,0 +1,4 @@
1
+ from .base_repository import AsyncRepository
2
+ from .device_repository import DeviceRepository
3
+ from .outbox_repository import OutboxRepository
4
+ from .telemetry_repository import TelemetryRepository
@@ -0,0 +1,70 @@
1
+ from typing import Generic, Optional, Sequence, Type, TypeVar
2
+
3
+ from sqlalchemy import func, ColumnElement
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlmodel import select, SQLModel
6
+
7
+ T = TypeVar('T', bound=SQLModel)
8
+
9
+
10
+ class AsyncRepository(Generic[T]):
11
+ def __init__(self, model: Type[T]):
12
+ self.model = model
13
+
14
+ async def count(self, session: AsyncSession, *conditions: ColumnElement) -> int:
15
+ # pylint: disable=not-callable
16
+ query = select(func.count()).select_from(self.model)
17
+
18
+ if conditions:
19
+ query = query.where(*conditions)
20
+
21
+ result = await session.execute(query)
22
+ return result.scalar_one()
23
+
24
+ async def add(self, session: AsyncSession, entity: T) -> T:
25
+ session.add(entity)
26
+
27
+ await session.commit()
28
+ await session.refresh(entity)
29
+
30
+ return entity
31
+
32
+ async def delete(self, session: AsyncSession, entity: T) -> None:
33
+ await session.delete(entity)
34
+ await session.commit()
35
+
36
+ async def get(self, session: AsyncSession, *conditions: ColumnElement) -> Optional[T]:
37
+ query = select(self.model)
38
+
39
+ if conditions:
40
+ query = query.where(*conditions)
41
+
42
+ return (await session.execute(query)).scalars().first()
43
+
44
+ async def list(self, session: AsyncSession, *conditions: ColumnElement[bool], offset: Optional[int] = None, order_by=None, limit: Optional[int] = None) -> Sequence[T]:
45
+ query = select(self.model)
46
+
47
+ if conditions:
48
+ query = query.where(*conditions)
49
+
50
+ if order_by is not None:
51
+ query = query.order_by(order_by)
52
+
53
+ if offset is not None:
54
+ query = query.offset(offset)
55
+
56
+ if limit is not None:
57
+ query = query.limit(limit)
58
+
59
+ return (await session.execute(query)).scalars().all()
60
+
61
+ async def update(self, session: AsyncSession, entity: T, **kwargs) -> T:
62
+ for key, value in kwargs.items():
63
+ setattr(entity, key, value)
64
+
65
+ session.add(entity)
66
+
67
+ await session.commit()
68
+ await session.refresh(entity)
69
+
70
+ return entity
@@ -0,0 +1,26 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Optional, Sequence
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from ..models import Device
7
+ from .base_repository import AsyncRepository
8
+
9
+ OFFLINE_THRESHOLD_MINUTES: int = 1440
10
+
11
+
12
+ class DeviceRepository(AsyncRepository[Device]):
13
+ def __init__(self):
14
+ super().__init__(Device)
15
+
16
+ async def get_by_id(self, session: AsyncSession, id: str) -> Optional[Device]:
17
+ # pylint: disable=unexpected-keyword-arg
18
+ return await self.get(session, Device.id == id)
19
+
20
+ async def list_by_capability(self, session: AsyncSession, capability: str) -> Sequence[Device]:
21
+ # pylint: disable=no-member,unexpected-keyword-arg
22
+ return await self.list(session, Device.capabilities.contains(capability))
23
+
24
+ async def list_offline(self, session: AsyncSession, offline_threshold_minutes: int = OFFLINE_THRESHOLD_MINUTES) -> Sequence[Device]:
25
+ # pylint: disable=no-member
26
+ return await self.list(session, Device.last_seen < datetime.now(timezone.utc) - timedelta(minutes=offline_threshold_minutes), order_by=Device.last_seen.asc())
@@ -0,0 +1,22 @@
1
+ from typing import Optional
2
+
3
+ from sqlmodel import and_
4
+
5
+ from ..models import OutboxEvent
6
+ from .base_repository import AsyncRepository
7
+
8
+
9
+ class OutboxRepository(AsyncRepository[OutboxEvent]):
10
+ def __init__(self):
11
+ super().__init__(OutboxEvent)
12
+
13
+ async def get_next(self, session, device_id: str, event_type: str) -> Optional[OutboxEvent]:
14
+ # pylint: disable=no-member,singleton-comparison
15
+ events = await self.list(session, and_(OutboxEvent.device_id == device_id, OutboxEvent.event_type == event_type, OutboxEvent.is_processed == False), order_by=OutboxEvent.created_at.desc())
16
+ return events[0] if events else None
17
+
18
+ async def delete_pending(self, session, device_id: str, event_type: str) -> None:
19
+ # pylint: disable=singleton-comparison
20
+ events = await self.list(session, and_(OutboxEvent.device_id == device_id, OutboxEvent.event_type == event_type, OutboxEvent.is_processed == False))
21
+ for event in events:
22
+ await self.delete(session, event)
@@ -0,0 +1,25 @@
1
+ from typing import Optional, Sequence
2
+
3
+ from sqlalchemy import ColumnElement
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlmodel import and_
6
+
7
+ from ..models import Telemetry
8
+ from .base_repository import AsyncRepository
9
+
10
+
11
+ class TelemetryRepository(AsyncRepository[Telemetry]):
12
+ def __init__(self):
13
+ super().__init__(Telemetry)
14
+
15
+ async def get_latest_for_device(self, session: AsyncSession, device_id: str, data_type: str) -> Optional[Telemetry]:
16
+ # pylint: disable=no-member
17
+ results = await self.list(session, and_(Telemetry.device_id == device_id, Telemetry.data_type == data_type), order_by=Telemetry.timestamp.desc(), limit=1)
18
+ return results[0] if results else None
19
+
20
+ async def list(self, session: AsyncSession, *conditions: ColumnElement[bool], offset: Optional[int] = None, order_by=None, limit: Optional[int] = None) -> Sequence[Telemetry]:
21
+ if order_by is None:
22
+ # pylint: disable=no-member
23
+ order_by = Telemetry.timestamp.desc()
24
+
25
+ return await super().list(session, *conditions, offset=offset, order_by=order_by, limit=limit)
@@ -0,0 +1,2 @@
1
+ from .device_router import DeviceRouter
2
+ from .telemetry_router import TelemetryRouter
@@ -0,0 +1,97 @@
1
+ from typing import Generic, List, Sequence, Type, TypeVar
2
+
3
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query, status, Response
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlmodel import SQLModel
6
+
7
+ from ..data.repositories import AsyncRepository
8
+ from ..data import async_session
9
+
10
+ T = TypeVar('T', bound=SQLModel)
11
+
12
+
13
+ class BaseRouter(Generic[T]):
14
+ def __init__(self, model: Type[T], repo: AsyncRepository[T], prefix: str, tags: List[str]):
15
+ self.model = model
16
+ self.repo = repo
17
+ self.router = APIRouter(prefix=prefix, tags=tags)
18
+
19
+ self._setup_routes()
20
+
21
+ @staticmethod
22
+ async def _get_session():
23
+ async with async_session() as session:
24
+ yield session
25
+
26
+ async def _before_add(self, entity: T, session: AsyncSession) -> None:
27
+ pass
28
+
29
+ # pylint: disable=unused-argument
30
+ async def _after_add(self, entity: T, session: AsyncSession) -> None:
31
+ await session.commit()
32
+
33
+ async def _before_delete(self, entity: T, session: AsyncSession) -> None:
34
+ pass
35
+
36
+ # pylint: disable=unused-argument
37
+ async def _after_delete(self, entity: T, session: AsyncSession) -> None:
38
+ await session.commit()
39
+
40
+ # pylint: disable=unused-argument
41
+ async def _before_update(self, entity: T, data: dict, session: AsyncSession) -> None:
42
+ pass
43
+
44
+ # pylint: disable=unused-argument
45
+ async def _after_update(self, entity: T, session: AsyncSession) -> None:
46
+ await session.commit()
47
+
48
+ def _setup_routes(self) -> None:
49
+ @self.router.post('/', response_model=self.model, status_code=status.HTTP_201_CREATED)
50
+ async def add(data: dict = Body(...), session: AsyncSession = Depends(BaseRouter._get_session)) -> T:
51
+ entity = self.model(**data)
52
+
53
+ await self._before_add(entity, session)
54
+ result = await self.repo.add(session, entity)
55
+ await self._after_add(result, session)
56
+
57
+ return result
58
+
59
+ @self.router.delete('/{id}', status_code=status.HTTP_204_NO_CONTENT)
60
+ async def delete(id: str, session: AsyncSession = Depends(BaseRouter._get_session)) -> None:
61
+ entity = await self.repo.get(session, self.model.id == id)
62
+ if not entity:
63
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
64
+
65
+ await self._before_delete(entity, session)
66
+ await self.repo.delete(session, entity)
67
+ await self._after_delete(entity, session)
68
+
69
+ @self.router.get('/{id}', response_model=self.model)
70
+ async def get(id: str, session: AsyncSession = Depends(BaseRouter._get_session)) -> T:
71
+ entity = await self.repo.get(session, self.model.id == id)
72
+ if not entity:
73
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
74
+
75
+ return entity
76
+
77
+ @self.router.get('/', response_model=List[self.model])
78
+ async def list(response: Response, session: AsyncSession = Depends(BaseRouter._get_session), offset: int = Query(None, ge=0), limit: int = Query(None, ge=1, le=100)) -> Sequence[T]:
79
+ response.headers['X-Total-Count'] = str(await self.repo.count(session))
80
+
81
+ return await self.repo.list(session, offset=offset, limit=limit)
82
+
83
+ @self.router.put('/{id}', response_model=self.model)
84
+ async def update(id: str, data: dict = Body(...), session: AsyncSession = Depends(BaseRouter._get_session)) -> T:
85
+ entity = await self.repo.get(session, self.model.id == id)
86
+ if not entity:
87
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
88
+
89
+ await self._before_update(entity, data, session)
90
+
91
+ for key, value in data.items():
92
+ setattr(entity, key, value)
93
+
94
+ result = await self.repo.update(session, entity)
95
+ await self._after_update(result, session)
96
+
97
+ return result
@@ -0,0 +1,29 @@
1
+ from json import dumps
2
+ from os import getenv
3
+
4
+ from aiomqtt import Client
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from ..constants import ENV_MQTT_HOST, ENV_MQTT_PORT, TOPIC_DEVICE
8
+ from ..data.models import Device
9
+ from ..data.repositories import DeviceRepository
10
+ from ..utils import log_debug
11
+ from .base_router import BaseRouter
12
+
13
+
14
+ class DeviceRouter(BaseRouter):
15
+ def __init__(self, repo: DeviceRepository = None) -> None:
16
+ self.repo : DeviceRepository = repo or DeviceRepository()
17
+
18
+ super().__init__(Device, self.repo, '/api/v1/devices', ['device'])
19
+
20
+ async def _publish_update(self, entity: Device) -> None:
21
+ log_debug(f'Publishing parameters update for device {entity.id}: {entity.parameters}')
22
+
23
+ async with Client(getenv(ENV_MQTT_HOST, 'localhost'), int(getenv(ENV_MQTT_PORT, '1883'))) as client:
24
+ await client.publish(f'{TOPIC_DEVICE}/{entity.id}', payload=dumps(entity.parameters) if entity.parameters else None, qos=1, retain=True)
25
+
26
+ async def _after_update(self, entity: Device, session: AsyncSession) -> None:
27
+ await super()._after_update(entity, session)
28
+
29
+ await self._publish_update(entity)
@@ -0,0 +1,45 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import cast, Sequence
3
+
4
+ from fastapi import Depends, Query, Response
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from ..data.models import Telemetry
8
+ from ..data.repositories import DeviceRepository, TelemetryRepository
9
+ from .base_router import BaseRouter
10
+
11
+
12
+ class TelemetryRouter(BaseRouter):
13
+ def __init__(self, repo: TelemetryRepository = None) -> None:
14
+ self.repo : TelemetryRepository = repo or TelemetryRepository()
15
+
16
+ super().__init__(Telemetry, self.repo, '/api/v1/telemetry', ['telemetry'])
17
+
18
+ def _setup_routes(self) -> None:
19
+ @self.router.get('/recent', response_model=Sequence[Telemetry])
20
+ async def list_recent(response: Response, session: AsyncSession = Depends(BaseRouter._get_session), offset: int = Query(..., min=0)) -> Sequence[Telemetry]:
21
+ from_date = datetime.now(timezone.utc) - timedelta(seconds=offset)
22
+ device_repo = DeviceRepository()
23
+ # pylint: disable=no-member
24
+ devices = await device_repo.list(session)
25
+ results = []
26
+
27
+ for device in devices:
28
+ capabilities = device.capabilities.split(',') if device.capabilities else []
29
+ for data_type in capabilities:
30
+ if not data_type.startswith('action_'):
31
+ telemetry = await cast(TelemetryRepository, self.repo).get_latest_for_device(session, device.id, data_type)
32
+ if telemetry:
33
+ if telemetry.timestamp.tzinfo is None:
34
+ telemetry.timestamp = telemetry.timestamp.replace(tzinfo=timezone.utc)
35
+
36
+ if telemetry.timestamp >= from_date:
37
+ results.append(telemetry)
38
+
39
+ response.headers['X-Total-Count'] = str(len(results))
40
+
41
+ results.sort(key=lambda result: result.timestamp, reverse=True)
42
+
43
+ return results
44
+
45
+ super()._setup_routes()
@@ -0,0 +1,2 @@
1
+ from .outbox import consume_outbox
2
+ from .scheduler import start_scheduler
@@ -0,0 +1,24 @@
1
+ from json import dumps
2
+ from os import getenv
3
+
4
+ from aiomqtt import Client
5
+
6
+ from ..constants import ENV_MQTT_HOST, ENV_MQTT_PORT, TOPIC_ACTION
7
+ from ..data import async_session
8
+ from ..data.repositories import OutboxRepository
9
+
10
+
11
+ async def consume_outbox(device_id: str, event_type: str) -> None:
12
+ outbox_repo = OutboxRepository()
13
+
14
+ async with async_session() as session:
15
+ event = await outbox_repo.get_next(session, device_id, event_type)
16
+ if not event:
17
+ return
18
+
19
+ await outbox_repo.delete_pending(session, device_id, event_type)
20
+
21
+ async with Client(getenv(ENV_MQTT_HOST, 'localhost'), int(getenv(ENV_MQTT_PORT, '1883'))) as client:
22
+ await client.publish(f'{TOPIC_ACTION}/{device_id}', dumps(event.payload), qos=1, retain=True)
23
+
24
+ await session.commit()
@@ -0,0 +1,8 @@
1
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
2
+
3
+
4
+ async def start_scheduler():
5
+ scheduler = AsyncIOScheduler()
6
+ scheduler.start()
7
+
8
+ return scheduler
@@ -0,0 +1 @@
1
+ from .mqtt import MQTTManager
@@ -0,0 +1,116 @@
1
+ from asyncio import create_task, gather, Queue, sleep
2
+ from datetime import datetime, timezone
3
+ from json import JSONDecodeError, loads
4
+ from os import getenv
5
+ from uuid import getnode
6
+
7
+ from aiomqtt import Client, MqttError
8
+
9
+ from ..constants import ENV_MQTT_HOST, ENV_MQTT_PORT, TOPIC_CRASH, TOPIC_REGISTRATION, TOPIC_TELEMETRY
10
+ from ..data import async_session
11
+ from ..data.models import Device, Telemetry
12
+ from ..data.repositories import DeviceRepository, TelemetryRepository
13
+ from ..utils import log_debug, log_error
14
+
15
+ MQTT_RETRY_DELAY: int = 5
16
+
17
+
18
+ class MQTTManager:
19
+ def __init__(self, device_repo: DeviceRepository = None, telemetry_repo: TelemetryRepository = None):
20
+ self.mqtt_host : str = getenv(ENV_MQTT_HOST, 'localhost')
21
+ self.mqtt_port : int = int(getenv(ENV_MQTT_PORT, '1883'))
22
+ self.device_repo : DeviceRepository = device_repo
23
+ self.telemetry_repo : TelemetryRepository = telemetry_repo
24
+ self.queue : Queue = Queue()
25
+
26
+ async def _handle_registration(self, device_id: str, payload: dict) -> None:
27
+ try:
28
+ log_debug(f'Registering device: {device_id}')
29
+
30
+ async with async_session() as session:
31
+ device = await self.device_repo.get(session, Device.id == device_id)
32
+ if device:
33
+ device.capabilities = payload['capabilities']
34
+ device.last_seen = datetime.now(timezone.utc)
35
+
36
+ await self.device_repo.update(session, device, last_seen=datetime.now(timezone.utc))
37
+ else:
38
+ device = Device()
39
+
40
+ device.id = device_id
41
+ device.display_name = None
42
+ device.capabilities = payload['capabilities']
43
+ device.last_seen = datetime.now(timezone.utc)
44
+
45
+ await self.device_repo.add(session, device)
46
+ # pylint: disable=broad-exception-caught
47
+ except Exception as e:
48
+ log_error(e)
49
+
50
+ async def _handle_telemetry(self, device_id: str, payload: dict) -> None:
51
+ try:
52
+ log_debug(f'Receiving telemetry from device: {device_id} - Payload: {payload}')
53
+
54
+ async with async_session() as session:
55
+ telemetry = Telemetry()
56
+
57
+ telemetry.device_id = device_id
58
+ telemetry.timestamp = datetime.now(timezone.utc)
59
+ telemetry.data_type = payload.get('data_type')
60
+ telemetry.value = payload.get('value')
61
+
62
+ await self.telemetry_repo.add(session, telemetry)
63
+ # pylint: disable=broad-exception-caught
64
+ except Exception as e:
65
+ log_error(e)
66
+
67
+ async def _process_queue(self) -> None:
68
+ while True:
69
+ topic, payload = await self.queue.get()
70
+
71
+ topic_parts: list[str] = str(topic).split('/')
72
+ if len(topic_parts) != 3:
73
+ log_error(Exception('Invalid topic format, skipping message'))
74
+ continue
75
+
76
+ app_type, message_type, device_id = topic_parts
77
+ if app_type != 'espark':
78
+ log_debug('Invalid app type, skipping message')
79
+ continue
80
+
81
+ try:
82
+ payload = loads(payload.decode())
83
+ except JSONDecodeError as e:
84
+ log_error(e)
85
+ continue
86
+
87
+ if message_type == TOPIC_REGISTRATION.split('/')[1]:
88
+ await self._handle_registration(device_id, payload)
89
+ elif message_type == TOPIC_TELEMETRY.split('/')[1]:
90
+ await self._handle_telemetry(device_id, payload)
91
+ elif message_type == TOPIC_CRASH.split('/')[1]:
92
+ log_debug(f'Received crash report from device {device_id}: {payload}')
93
+ else:
94
+ log_debug(f'Unknown message type "{message_type}", skipping message')
95
+
96
+ self.queue.task_done()
97
+
98
+ async def _process_messages(self) -> None:
99
+ while True:
100
+ try:
101
+ async with Client(self.mqtt_host, self.mqtt_port, identifier=f'espark-core-{hex(getnode())}') as client:
102
+ await client.subscribe(f'{TOPIC_REGISTRATION}/+')
103
+ await client.subscribe(f'{TOPIC_TELEMETRY}/+')
104
+ await client.subscribe(f'{TOPIC_CRASH}/+')
105
+
106
+ async for message in client.messages:
107
+ log_debug(f'Received MQTT message on topic {message.topic}')
108
+
109
+ self.queue.put_nowait((message.topic, message.payload))
110
+ except MqttError as e:
111
+ log_error(e)
112
+
113
+ await sleep(MQTT_RETRY_DELAY)
114
+
115
+ async def start(self) -> None:
116
+ await gather(create_task(self._process_queue()), create_task(self._process_messages()))
@@ -0,0 +1 @@
1
+ from .logging import log_debug, log_error
@@ -0,0 +1,11 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def log_debug(message: str) -> None:
5
+ print(f'{datetime.now().isoformat()} [DEBUG] {message}')
6
+
7
+
8
+ def log_error(e: Exception) -> None:
9
+ print(f'{datetime.now().isoformat()} [ERROR] {type(e).__name__}: {e}')
10
+
11
+ raise e