espark-core 0.3.0__tar.gz
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.
- espark_core-0.3.0/LICENSE +21 -0
- espark_core-0.3.0/MANIFEST.in +4 -0
- espark_core-0.3.0/PKG-INFO +137 -0
- espark_core-0.3.0/README.md +102 -0
- espark_core-0.3.0/espark_core.egg-info/PKG-INFO +137 -0
- espark_core-0.3.0/espark_core.egg-info/SOURCES.txt +61 -0
- espark_core-0.3.0/espark_core.egg-info/dependency_links.txt +1 -0
- espark_core-0.3.0/espark_core.egg-info/requires.txt +19 -0
- espark_core-0.3.0/espark_core.egg-info/top_level.txt +1 -0
- espark_core-0.3.0/esparkcore/__init__.py +0 -0
- espark_core-0.3.0/esparkcore/constants.py +9 -0
- espark_core-0.3.0/esparkcore/data/__init__.py +1 -0
- espark_core-0.3.0/esparkcore/data/database.py +15 -0
- espark_core-0.3.0/esparkcore/data/models/__init__.py +3 -0
- espark_core-0.3.0/esparkcore/data/models/device.py +13 -0
- espark_core-0.3.0/esparkcore/data/models/outbox.py +20 -0
- espark_core-0.3.0/esparkcore/data/models/telemetry.py +12 -0
- espark_core-0.3.0/esparkcore/data/repositories/__init__.py +4 -0
- espark_core-0.3.0/esparkcore/data/repositories/base_repository.py +70 -0
- espark_core-0.3.0/esparkcore/data/repositories/device_repository.py +26 -0
- espark_core-0.3.0/esparkcore/data/repositories/outbox_repository.py +22 -0
- espark_core-0.3.0/esparkcore/data/repositories/telemetry_repository.py +25 -0
- espark_core-0.3.0/esparkcore/routers/__init__.py +2 -0
- espark_core-0.3.0/esparkcore/routers/base_router.py +97 -0
- espark_core-0.3.0/esparkcore/routers/device_router.py +29 -0
- espark_core-0.3.0/esparkcore/routers/telemetry_router.py +45 -0
- espark_core-0.3.0/esparkcore/schedules/__init__.py +2 -0
- espark_core-0.3.0/esparkcore/schedules/outbox.py +24 -0
- espark_core-0.3.0/esparkcore/schedules/scheduler.py +8 -0
- espark_core-0.3.0/esparkcore/services/__init__.py +1 -0
- espark_core-0.3.0/esparkcore/services/mqtt.py +116 -0
- espark_core-0.3.0/esparkcore/utils/__init__.py +1 -0
- espark_core-0.3.0/esparkcore/utils/logging.py +11 -0
- espark_core-0.3.0/requirements.dev.txt +11 -0
- espark_core-0.3.0/requirements.txt +6 -0
- espark_core-0.3.0/setup.cfg +7 -0
- espark_core-0.3.0/setup.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alan Tai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: espark-core
|
|
3
|
+
Version: 0.3.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
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: aiomqtt==2.4.0
|
|
10
|
+
Requires-Dist: apscheduler==3.11.1
|
|
11
|
+
Requires-Dist: fastapi==0.124.0
|
|
12
|
+
Requires-Dist: sqlalchemy==2.0.45
|
|
13
|
+
Requires-Dist: sqlmodel==0.0.27
|
|
14
|
+
Requires-Dist: uvicorn[standard]==0.38.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: aiosqlite==0.21.0; extra == "dev"
|
|
17
|
+
Requires-Dist: autopep8==2.3.2; extra == "dev"
|
|
18
|
+
Requires-Dist: build==1.3.0; extra == "dev"
|
|
19
|
+
Requires-Dist: fastapi-cli[standard-no-fastapi-cloud-cli]==0.0.16; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx==0.28.1; extra == "dev"
|
|
21
|
+
Requires-Dist: pycodestyle==2.14.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pylint==4.0.4; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov==7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine==6.2.0; extra == "dev"
|
|
27
|
+
Dynamic: description
|
|
28
|
+
Dynamic: description-content-type
|
|
29
|
+
Dynamic: license
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
Dynamic: provides-extra
|
|
32
|
+
Dynamic: requires-dist
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
# Espark
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## Project Goals
|
|
41
|
+
|
|
42
|
+
- Simplify the development of ESP32 applications.
|
|
43
|
+
- Provide a modular and extensible architecture.
|
|
44
|
+
- Support common IoT protocols like MQTT.
|
|
45
|
+
- Ensure efficient resource management for low-power devices.
|
|
46
|
+
- Provide a clean and easy-to-use API.
|
|
47
|
+
- Provide an user-friendly UI for configuration and monitoring.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Device Provisioning**: Easy setup and configuration of ESP32 devices.
|
|
52
|
+
- **Telemetry Collection**: Built-in support for collecting and sending telemetry data.
|
|
53
|
+
- **Scalable Architecture**: Designed to handle a large number of devices efficiently.
|
|
54
|
+
- **Seamless Communication**: Support for MQTT protocol.
|
|
55
|
+
|
|
56
|
+
## Hardware Requirements
|
|
57
|
+
|
|
58
|
+
- ESP32 Development Board
|
|
59
|
+
- USB Cable for programming and power
|
|
60
|
+
- Optional: Sensors and triggers for specific applications
|
|
61
|
+
|
|
62
|
+
## Project Structure
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
espark/
|
|
66
|
+
├── espark-core/
|
|
67
|
+
│ ├── esparkcore/ # FastAPI backend framework
|
|
68
|
+
│ │ ├── data/ # Models, repositories
|
|
69
|
+
│ │ ├── routers/ # API endpoints
|
|
70
|
+
│ │ ├── schedules/ # Background tasks
|
|
71
|
+
│ │ ├── services/ # Business logic, MQTT handling
|
|
72
|
+
│ │ └── utils/ # Utility functions
|
|
73
|
+
│ └── Makefile
|
|
74
|
+
├── espark-node/
|
|
75
|
+
│ ├── esparknode/ # MicroPython application framework
|
|
76
|
+
│ │ ├── actions/ # Action handlers
|
|
77
|
+
│ │ ├── data/ # Data storage
|
|
78
|
+
│ │ ├── libraries/ # External libraries
|
|
79
|
+
│ │ ├── networks/ # Network management
|
|
80
|
+
│ │ ├── sensors/ # Sensor interfaces
|
|
81
|
+
│ │ ├── triggers/ # Trigger interfaces
|
|
82
|
+
│ │ ├── utils/ # Utility functions
|
|
83
|
+
│ │ └── base_node.py # Main application file
|
|
84
|
+
│ └── Makefile
|
|
85
|
+
└── espark-react/ # React frontend application
|
|
86
|
+
├── src/
|
|
87
|
+
│ ├── data/ # Data models and data providers
|
|
88
|
+
│ ├── i18n/ # Internationalization files
|
|
89
|
+
│ ├── pages/ # Application pages
|
|
90
|
+
│ ├── routes/ # Application routing
|
|
91
|
+
│ ├── utils/ # Utility functions
|
|
92
|
+
│ ├── App.tsx # Main application component
|
|
93
|
+
│ └── index.tsx # Application entry point
|
|
94
|
+
└── package.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development Workflows
|
|
98
|
+
|
|
99
|
+
### Setting up the backend
|
|
100
|
+
|
|
101
|
+
1. Add espark-core as a dependency in your FastAPI project.
|
|
102
|
+
2. Configure database connections and MQTT settings as environment variables.
|
|
103
|
+
3. Implement additional data models, repositories, routers, and business logic if needed.
|
|
104
|
+
4. Add the `DeviceRouter`, `TelemetryRouter`, and other additional routers to your FastAPI app.
|
|
105
|
+
|
|
106
|
+
### Setting up the ESP32 application
|
|
107
|
+
|
|
108
|
+
1. Clone the espark-node repository to your local machine.
|
|
109
|
+
2. Copy `espark-core/Makefile.template` to `Makefile` and customize it for your device.
|
|
110
|
+
3. Run `make upgrade` to copy the espark-core library to your device project.
|
|
111
|
+
4. Implement device-specific actions, sensors, and triggers as needed.
|
|
112
|
+
5. Run `make flash` to upload the firmware to your ESP32 device.
|
|
113
|
+
6. Run `make deploy` to upload the application to the device.
|
|
114
|
+
|
|
115
|
+
### Setting up the frontend
|
|
116
|
+
|
|
117
|
+
1. Add espark-react as a dependency in your React project.
|
|
118
|
+
2. Render `<EsparkApp />` in your main application file.
|
|
119
|
+
|
|
120
|
+
### Configurations
|
|
121
|
+
|
|
122
|
+
- **espark-core**: Use environment variables, or `.env` file, for database and MQTT configurations.
|
|
123
|
+
- **espark-node**: Use `esparknode.configs` for device-specific configurations.
|
|
124
|
+
- **espark-react**: Customise `EsparkApp` props for application settings.
|
|
125
|
+
|
|
126
|
+
## Examples and Patterns
|
|
127
|
+
|
|
128
|
+
- **Router Example**: `device_router.py` in `espark-core/esparkcore/routers/` demonstrates how to create API endpoints for device management.
|
|
129
|
+
- **Respository Example**: `device_repository.py` in `espark-core/esparkcore/data/repositories/` shows how to implement data access logic for devices.
|
|
130
|
+
- **Action Example**: `esp32_relay.py` in `espark-node/esparknode/actions/` illustrates how to define actions for ESP32 devices.
|
|
131
|
+
- **Sensor Example**: `sht20_sensor.py` in `espark-node/esparknode/sensors/` demonstrates how to read data from a SHT20 sensor.
|
|
132
|
+
- **Trigger Example**: `gpio_trigger.py` in `espark-node/esparknode/triggers/` shows how to create GPIO-based triggers for device actions.
|
|
133
|
+
- **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.
|
|
134
|
+
|
|
135
|
+
## Example Projects
|
|
136
|
+
|
|
137
|
+
- **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,102 @@
|
|
|
1
|
+
# Espark
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Project Goals
|
|
6
|
+
|
|
7
|
+
- Simplify the development of ESP32 applications.
|
|
8
|
+
- Provide a modular and extensible architecture.
|
|
9
|
+
- Support common IoT protocols like MQTT.
|
|
10
|
+
- Ensure efficient resource management for low-power devices.
|
|
11
|
+
- Provide a clean and easy-to-use API.
|
|
12
|
+
- Provide an user-friendly UI for configuration and monitoring.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Device Provisioning**: Easy setup and configuration of ESP32 devices.
|
|
17
|
+
- **Telemetry Collection**: Built-in support for collecting and sending telemetry data.
|
|
18
|
+
- **Scalable Architecture**: Designed to handle a large number of devices efficiently.
|
|
19
|
+
- **Seamless Communication**: Support for MQTT protocol.
|
|
20
|
+
|
|
21
|
+
## Hardware Requirements
|
|
22
|
+
|
|
23
|
+
- ESP32 Development Board
|
|
24
|
+
- USB Cable for programming and power
|
|
25
|
+
- Optional: Sensors and triggers for specific applications
|
|
26
|
+
|
|
27
|
+
## Project Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
espark/
|
|
31
|
+
├── espark-core/
|
|
32
|
+
│ ├── esparkcore/ # FastAPI backend framework
|
|
33
|
+
│ │ ├── data/ # Models, repositories
|
|
34
|
+
│ │ ├── routers/ # API endpoints
|
|
35
|
+
│ │ ├── schedules/ # Background tasks
|
|
36
|
+
│ │ ├── services/ # Business logic, MQTT handling
|
|
37
|
+
│ │ └── utils/ # Utility functions
|
|
38
|
+
│ └── Makefile
|
|
39
|
+
├── espark-node/
|
|
40
|
+
│ ├── esparknode/ # MicroPython application framework
|
|
41
|
+
│ │ ├── actions/ # Action handlers
|
|
42
|
+
│ │ ├── data/ # Data storage
|
|
43
|
+
│ │ ├── libraries/ # External libraries
|
|
44
|
+
│ │ ├── networks/ # Network management
|
|
45
|
+
│ │ ├── sensors/ # Sensor interfaces
|
|
46
|
+
│ │ ├── triggers/ # Trigger interfaces
|
|
47
|
+
│ │ ├── utils/ # Utility functions
|
|
48
|
+
│ │ └── base_node.py # Main application file
|
|
49
|
+
│ └── Makefile
|
|
50
|
+
└── espark-react/ # React frontend application
|
|
51
|
+
├── src/
|
|
52
|
+
│ ├── data/ # Data models and data providers
|
|
53
|
+
│ ├── i18n/ # Internationalization files
|
|
54
|
+
│ ├── pages/ # Application pages
|
|
55
|
+
│ ├── routes/ # Application routing
|
|
56
|
+
│ ├── utils/ # Utility functions
|
|
57
|
+
│ ├── App.tsx # Main application component
|
|
58
|
+
│ └── index.tsx # Application entry point
|
|
59
|
+
└── package.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Development Workflows
|
|
63
|
+
|
|
64
|
+
### Setting up the backend
|
|
65
|
+
|
|
66
|
+
1. Add espark-core as a dependency in your FastAPI project.
|
|
67
|
+
2. Configure database connections and MQTT settings as environment variables.
|
|
68
|
+
3. Implement additional data models, repositories, routers, and business logic if needed.
|
|
69
|
+
4. Add the `DeviceRouter`, `TelemetryRouter`, and other additional routers to your FastAPI app.
|
|
70
|
+
|
|
71
|
+
### Setting up the ESP32 application
|
|
72
|
+
|
|
73
|
+
1. Clone the espark-node repository to your local machine.
|
|
74
|
+
2. Copy `espark-core/Makefile.template` to `Makefile` and customize it for your device.
|
|
75
|
+
3. Run `make upgrade` to copy the espark-core library to your device project.
|
|
76
|
+
4. Implement device-specific actions, sensors, and triggers as needed.
|
|
77
|
+
5. Run `make flash` to upload the firmware to your ESP32 device.
|
|
78
|
+
6. Run `make deploy` to upload the application to the device.
|
|
79
|
+
|
|
80
|
+
### Setting up the frontend
|
|
81
|
+
|
|
82
|
+
1. Add espark-react as a dependency in your React project.
|
|
83
|
+
2. Render `<EsparkApp />` in your main application file.
|
|
84
|
+
|
|
85
|
+
### Configurations
|
|
86
|
+
|
|
87
|
+
- **espark-core**: Use environment variables, or `.env` file, for database and MQTT configurations.
|
|
88
|
+
- **espark-node**: Use `esparknode.configs` for device-specific configurations.
|
|
89
|
+
- **espark-react**: Customise `EsparkApp` props for application settings.
|
|
90
|
+
|
|
91
|
+
## Examples and Patterns
|
|
92
|
+
|
|
93
|
+
- **Router Example**: `device_router.py` in `espark-core/esparkcore/routers/` demonstrates how to create API endpoints for device management.
|
|
94
|
+
- **Respository Example**: `device_repository.py` in `espark-core/esparkcore/data/repositories/` shows how to implement data access logic for devices.
|
|
95
|
+
- **Action Example**: `esp32_relay.py` in `espark-node/esparknode/actions/` illustrates how to define actions for ESP32 devices.
|
|
96
|
+
- **Sensor Example**: `sht20_sensor.py` in `espark-node/esparknode/sensors/` demonstrates how to read data from a SHT20 sensor.
|
|
97
|
+
- **Trigger Example**: `gpio_trigger.py` in `espark-node/esparknode/triggers/` shows how to create GPIO-based triggers for device actions.
|
|
98
|
+
- **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.
|
|
99
|
+
|
|
100
|
+
## Example Projects
|
|
101
|
+
|
|
102
|
+
- **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,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: espark-core
|
|
3
|
+
Version: 0.3.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
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: aiomqtt==2.4.0
|
|
10
|
+
Requires-Dist: apscheduler==3.11.1
|
|
11
|
+
Requires-Dist: fastapi==0.124.0
|
|
12
|
+
Requires-Dist: sqlalchemy==2.0.45
|
|
13
|
+
Requires-Dist: sqlmodel==0.0.27
|
|
14
|
+
Requires-Dist: uvicorn[standard]==0.38.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: aiosqlite==0.21.0; extra == "dev"
|
|
17
|
+
Requires-Dist: autopep8==2.3.2; extra == "dev"
|
|
18
|
+
Requires-Dist: build==1.3.0; extra == "dev"
|
|
19
|
+
Requires-Dist: fastapi-cli[standard-no-fastapi-cloud-cli]==0.0.16; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx==0.28.1; extra == "dev"
|
|
21
|
+
Requires-Dist: pycodestyle==2.14.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pylint==4.0.4; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov==7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine==6.2.0; extra == "dev"
|
|
27
|
+
Dynamic: description
|
|
28
|
+
Dynamic: description-content-type
|
|
29
|
+
Dynamic: license
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
Dynamic: provides-extra
|
|
32
|
+
Dynamic: requires-dist
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
# Espark
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## Project Goals
|
|
41
|
+
|
|
42
|
+
- Simplify the development of ESP32 applications.
|
|
43
|
+
- Provide a modular and extensible architecture.
|
|
44
|
+
- Support common IoT protocols like MQTT.
|
|
45
|
+
- Ensure efficient resource management for low-power devices.
|
|
46
|
+
- Provide a clean and easy-to-use API.
|
|
47
|
+
- Provide an user-friendly UI for configuration and monitoring.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Device Provisioning**: Easy setup and configuration of ESP32 devices.
|
|
52
|
+
- **Telemetry Collection**: Built-in support for collecting and sending telemetry data.
|
|
53
|
+
- **Scalable Architecture**: Designed to handle a large number of devices efficiently.
|
|
54
|
+
- **Seamless Communication**: Support for MQTT protocol.
|
|
55
|
+
|
|
56
|
+
## Hardware Requirements
|
|
57
|
+
|
|
58
|
+
- ESP32 Development Board
|
|
59
|
+
- USB Cable for programming and power
|
|
60
|
+
- Optional: Sensors and triggers for specific applications
|
|
61
|
+
|
|
62
|
+
## Project Structure
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
espark/
|
|
66
|
+
├── espark-core/
|
|
67
|
+
│ ├── esparkcore/ # FastAPI backend framework
|
|
68
|
+
│ │ ├── data/ # Models, repositories
|
|
69
|
+
│ │ ├── routers/ # API endpoints
|
|
70
|
+
│ │ ├── schedules/ # Background tasks
|
|
71
|
+
│ │ ├── services/ # Business logic, MQTT handling
|
|
72
|
+
│ │ └── utils/ # Utility functions
|
|
73
|
+
│ └── Makefile
|
|
74
|
+
├── espark-node/
|
|
75
|
+
│ ├── esparknode/ # MicroPython application framework
|
|
76
|
+
│ │ ├── actions/ # Action handlers
|
|
77
|
+
│ │ ├── data/ # Data storage
|
|
78
|
+
│ │ ├── libraries/ # External libraries
|
|
79
|
+
│ │ ├── networks/ # Network management
|
|
80
|
+
│ │ ├── sensors/ # Sensor interfaces
|
|
81
|
+
│ │ ├── triggers/ # Trigger interfaces
|
|
82
|
+
│ │ ├── utils/ # Utility functions
|
|
83
|
+
│ │ └── base_node.py # Main application file
|
|
84
|
+
│ └── Makefile
|
|
85
|
+
└── espark-react/ # React frontend application
|
|
86
|
+
├── src/
|
|
87
|
+
│ ├── data/ # Data models and data providers
|
|
88
|
+
│ ├── i18n/ # Internationalization files
|
|
89
|
+
│ ├── pages/ # Application pages
|
|
90
|
+
│ ├── routes/ # Application routing
|
|
91
|
+
│ ├── utils/ # Utility functions
|
|
92
|
+
│ ├── App.tsx # Main application component
|
|
93
|
+
│ └── index.tsx # Application entry point
|
|
94
|
+
└── package.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development Workflows
|
|
98
|
+
|
|
99
|
+
### Setting up the backend
|
|
100
|
+
|
|
101
|
+
1. Add espark-core as a dependency in your FastAPI project.
|
|
102
|
+
2. Configure database connections and MQTT settings as environment variables.
|
|
103
|
+
3. Implement additional data models, repositories, routers, and business logic if needed.
|
|
104
|
+
4. Add the `DeviceRouter`, `TelemetryRouter`, and other additional routers to your FastAPI app.
|
|
105
|
+
|
|
106
|
+
### Setting up the ESP32 application
|
|
107
|
+
|
|
108
|
+
1. Clone the espark-node repository to your local machine.
|
|
109
|
+
2. Copy `espark-core/Makefile.template` to `Makefile` and customize it for your device.
|
|
110
|
+
3. Run `make upgrade` to copy the espark-core library to your device project.
|
|
111
|
+
4. Implement device-specific actions, sensors, and triggers as needed.
|
|
112
|
+
5. Run `make flash` to upload the firmware to your ESP32 device.
|
|
113
|
+
6. Run `make deploy` to upload the application to the device.
|
|
114
|
+
|
|
115
|
+
### Setting up the frontend
|
|
116
|
+
|
|
117
|
+
1. Add espark-react as a dependency in your React project.
|
|
118
|
+
2. Render `<EsparkApp />` in your main application file.
|
|
119
|
+
|
|
120
|
+
### Configurations
|
|
121
|
+
|
|
122
|
+
- **espark-core**: Use environment variables, or `.env` file, for database and MQTT configurations.
|
|
123
|
+
- **espark-node**: Use `esparknode.configs` for device-specific configurations.
|
|
124
|
+
- **espark-react**: Customise `EsparkApp` props for application settings.
|
|
125
|
+
|
|
126
|
+
## Examples and Patterns
|
|
127
|
+
|
|
128
|
+
- **Router Example**: `device_router.py` in `espark-core/esparkcore/routers/` demonstrates how to create API endpoints for device management.
|
|
129
|
+
- **Respository Example**: `device_repository.py` in `espark-core/esparkcore/data/repositories/` shows how to implement data access logic for devices.
|
|
130
|
+
- **Action Example**: `esp32_relay.py` in `espark-node/esparknode/actions/` illustrates how to define actions for ESP32 devices.
|
|
131
|
+
- **Sensor Example**: `sht20_sensor.py` in `espark-node/esparknode/sensors/` demonstrates how to read data from a SHT20 sensor.
|
|
132
|
+
- **Trigger Example**: `gpio_trigger.py` in `espark-node/esparknode/triggers/` shows how to create GPIO-based triggers for device actions.
|
|
133
|
+
- **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.
|
|
134
|
+
|
|
135
|
+
## Example Projects
|
|
136
|
+
|
|
137
|
+
- **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,61 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
requirements.dev.txt
|
|
5
|
+
requirements.txt
|
|
6
|
+
setup.cfg
|
|
7
|
+
setup.py
|
|
8
|
+
../README.md
|
|
9
|
+
./esparkcore/__init__.py
|
|
10
|
+
./esparkcore/constants.py
|
|
11
|
+
./esparkcore/data/__init__.py
|
|
12
|
+
./esparkcore/data/database.py
|
|
13
|
+
./esparkcore/data/models/__init__.py
|
|
14
|
+
./esparkcore/data/models/device.py
|
|
15
|
+
./esparkcore/data/models/outbox.py
|
|
16
|
+
./esparkcore/data/models/telemetry.py
|
|
17
|
+
./esparkcore/data/repositories/__init__.py
|
|
18
|
+
./esparkcore/data/repositories/base_repository.py
|
|
19
|
+
./esparkcore/data/repositories/device_repository.py
|
|
20
|
+
./esparkcore/data/repositories/outbox_repository.py
|
|
21
|
+
./esparkcore/data/repositories/telemetry_repository.py
|
|
22
|
+
./esparkcore/routers/__init__.py
|
|
23
|
+
./esparkcore/routers/base_router.py
|
|
24
|
+
./esparkcore/routers/device_router.py
|
|
25
|
+
./esparkcore/routers/telemetry_router.py
|
|
26
|
+
./esparkcore/schedules/__init__.py
|
|
27
|
+
./esparkcore/schedules/outbox.py
|
|
28
|
+
./esparkcore/schedules/scheduler.py
|
|
29
|
+
./esparkcore/services/__init__.py
|
|
30
|
+
./esparkcore/services/mqtt.py
|
|
31
|
+
./esparkcore/utils/__init__.py
|
|
32
|
+
./esparkcore/utils/logging.py
|
|
33
|
+
espark_core.egg-info/PKG-INFO
|
|
34
|
+
espark_core.egg-info/SOURCES.txt
|
|
35
|
+
espark_core.egg-info/dependency_links.txt
|
|
36
|
+
espark_core.egg-info/requires.txt
|
|
37
|
+
espark_core.egg-info/top_level.txt
|
|
38
|
+
esparkcore/__init__.py
|
|
39
|
+
esparkcore/constants.py
|
|
40
|
+
esparkcore/data/__init__.py
|
|
41
|
+
esparkcore/data/database.py
|
|
42
|
+
esparkcore/data/models/__init__.py
|
|
43
|
+
esparkcore/data/models/device.py
|
|
44
|
+
esparkcore/data/models/outbox.py
|
|
45
|
+
esparkcore/data/models/telemetry.py
|
|
46
|
+
esparkcore/data/repositories/__init__.py
|
|
47
|
+
esparkcore/data/repositories/base_repository.py
|
|
48
|
+
esparkcore/data/repositories/device_repository.py
|
|
49
|
+
esparkcore/data/repositories/outbox_repository.py
|
|
50
|
+
esparkcore/data/repositories/telemetry_repository.py
|
|
51
|
+
esparkcore/routers/__init__.py
|
|
52
|
+
esparkcore/routers/base_router.py
|
|
53
|
+
esparkcore/routers/device_router.py
|
|
54
|
+
esparkcore/routers/telemetry_router.py
|
|
55
|
+
esparkcore/schedules/__init__.py
|
|
56
|
+
esparkcore/schedules/outbox.py
|
|
57
|
+
esparkcore/schedules/scheduler.py
|
|
58
|
+
esparkcore/services/__init__.py
|
|
59
|
+
esparkcore/services/mqtt.py
|
|
60
|
+
esparkcore/utils/__init__.py
|
|
61
|
+
esparkcore/utils/logging.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
aiomqtt==2.4.0
|
|
2
|
+
apscheduler==3.11.1
|
|
3
|
+
fastapi==0.124.0
|
|
4
|
+
sqlalchemy==2.0.45
|
|
5
|
+
sqlmodel==0.0.27
|
|
6
|
+
uvicorn[standard]==0.38.0
|
|
7
|
+
|
|
8
|
+
[dev]
|
|
9
|
+
aiosqlite==0.21.0
|
|
10
|
+
autopep8==2.3.2
|
|
11
|
+
build==1.3.0
|
|
12
|
+
fastapi-cli[standard-no-fastapi-cloud-cli]==0.0.16
|
|
13
|
+
httpx==0.28.1
|
|
14
|
+
pycodestyle==2.14.0
|
|
15
|
+
pylint==4.0.4
|
|
16
|
+
pytest==9.0.2
|
|
17
|
+
pytest-asyncio==1.3.0
|
|
18
|
+
pytest-cov==7.0.0
|
|
19
|
+
twine==6.2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
esparkcore
|
|
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,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,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,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,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 @@
|
|
|
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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from setuptools import find_packages, setup
|
|
3
|
+
|
|
4
|
+
def read_requirements(filename: str):
|
|
5
|
+
return [line.strip() for line in Path(filename).read_text().splitlines() if line.strip() and not line.startswith("#")]
|
|
6
|
+
|
|
7
|
+
root = Path(__file__).resolve().parent
|
|
8
|
+
production_requirements = (root / 'requirements.txt').read_text().splitlines()
|
|
9
|
+
extra_requirements = (root / 'requirements.dev.txt').read_text().splitlines()
|
|
10
|
+
|
|
11
|
+
setup(
|
|
12
|
+
name='espark-core',
|
|
13
|
+
version='0.3.0',
|
|
14
|
+
description='The core module of the Espark ESP32-based IoT device management framework.',
|
|
15
|
+
long_description=(root / 'README.md').read_text(),
|
|
16
|
+
long_description_content_type='text/markdown',
|
|
17
|
+
license='MIT',
|
|
18
|
+
license_files=[
|
|
19
|
+
'LICENSE',
|
|
20
|
+
],
|
|
21
|
+
python_requires='>=3.8',
|
|
22
|
+
packages=find_packages(where='.'),
|
|
23
|
+
package_dir={
|
|
24
|
+
'' : '.',
|
|
25
|
+
},
|
|
26
|
+
install_requires=production_requirements,
|
|
27
|
+
extras_require={
|
|
28
|
+
'dev' : extra_requirements,
|
|
29
|
+
},
|
|
30
|
+
)
|