Compare commits
75 Commits
gateway-v1
...
nodes-v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78167ceb38
|
||
|
|
3cdfbef680
|
||
|
|
94e0518fa6
|
||
|
|
f4ac6e91b0
|
||
|
|
6fd9959329
|
||
|
|
35fbe7c488
|
||
|
|
158767deec
|
||
|
|
bce2417ded
|
||
|
|
53ccda306b
|
||
|
|
71ed4c4bf9
|
||
|
|
e1d274470b
|
||
|
|
f0bc4ee373 | ||
|
|
f71db42f47 | ||
|
|
8b9e215cc6 | ||
|
|
caee92c595 | ||
|
2f7c88b701
|
|||
|
0f30749534
|
|||
|
f4ab3093c3
|
|||
|
89023b86ac
|
|||
|
811641c58b
|
|||
|
be5772e488
|
|||
|
53fbc87af6
|
|||
|
2b766d3d96
|
|||
|
7c000f3b9c
|
|||
|
5a3f8c3c5c
|
|||
|
2f57e886c0
|
|||
|
fab79aa6b6
|
|||
|
3ff484359e
|
|||
|
5198784c37
|
|||
|
eecb4a196b
|
|||
|
8c3b00edd8
|
|||
|
25c8327662
|
|||
|
2355c8f0e9
|
|||
|
c78f1e1509
|
|||
|
9776695228
|
|||
|
bde8184ad1
|
|||
|
022fb97153
|
|||
|
c34ec94a6b
|
|||
|
679f6fece2
|
|||
|
ef51f9b3ed
|
|||
|
04c3883744
|
|||
|
1bcaad895d
|
|||
|
56c6299417
|
|||
|
abf19fb4e2
|
|||
|
641f6af1f0
|
|||
|
ccec4efca6
|
|||
|
77574e1dfa
|
|||
|
c472064451
|
|||
|
c4cf1ba704
|
|||
|
980b43e669
|
|||
|
1678ac535b
|
|||
|
b8ddfefc2c
|
|||
|
50583bb79b
|
|||
|
790961b15a
|
|||
|
b4110b81eb
|
|||
|
bf7d0a7005
|
|||
|
fcdb5b5485
|
|||
|
9163fd494b
|
|||
|
c2a67684ed
|
|||
|
25438f085e
|
|||
|
cea4435bbc
|
|||
|
a49c3a8472
|
|||
|
2e8f92888e
|
|||
|
3ed1e36c56
|
|||
|
3587e10671
|
|||
|
0086a31f73
|
|||
|
a11573609e
|
|||
|
979c502e27
|
|||
|
1fb294f495
|
|||
|
567e9162e2
|
|||
|
5a1bfefffb
|
|||
|
28a8377231
|
|||
|
|
a84ad89e02
|
||
|
|
35f990daa5
|
||
|
|
3668ef4520
|
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,4 +2,4 @@
|
||||
/gateway @DjeAvd
|
||||
/db/ @Klagarge
|
||||
/ui/ @khalil-bot
|
||||
/ml @imfeldd
|
||||
/model @AlisonLec
|
||||
|
||||
9
.pre-commit-config.yaml
Normal file
9
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: swag-init
|
||||
name: swag init
|
||||
entry: bash -c 'export PATH=$PATH:$(go env GOPATH)/bin && cd db/src && swag init'
|
||||
language: system
|
||||
files: ^db/src/.*\.go$
|
||||
pass_filenames: false
|
||||
@@ -1,4 +1,10 @@
|
||||
INFLUX_PORT=8181
|
||||
UI_PORT=8093
|
||||
INFLUX_DATABASE=provence
|
||||
|
||||
REST_USERNAME=
|
||||
REST_PASSWORD=
|
||||
|
||||
MQTT_BROKER_URL=tls://mqtt.e.kb28.ch:8883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
|
||||
28
db/.gitignore
vendored
Normal file
28
db/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
### Go template
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
mapping.json
|
||||
26
db/Dockerfile
Normal file
26
db/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./src/go.mod ./src/go.sum ./
|
||||
RUN go mod tidy
|
||||
|
||||
COPY ./src .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /gateway .
|
||||
|
||||
|
||||
FROM alpine:latest AS certs
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
FROM scratch AS final
|
||||
LABEL org.opencontainers.image.authors="remi.heredero@hevs.ch" \
|
||||
org.opencontainers.image.title="PI-E2EEDA Gateway MQTT/Influx/REST" \
|
||||
org.opencontainers.image.description="This container is an application for the E2EEDA PI. Use MQTT to communicate with devices, enabling real-time updates and control. Time-series data, such as device states, is stored in InfluxDB for analytics and monitoring. A REST API provides external access for managing devices and retrieving data." \
|
||||
org.opencontainers.image.source="https://github.com/PI-E2EEDA/Plein-de-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-project"
|
||||
|
||||
COPY --from=builder /gateway /gateway
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/gateway"]
|
||||
21
db/README.md
21
db/README.md
@@ -1,4 +1,4 @@
|
||||
# Deployement
|
||||
# Deployment
|
||||
|
||||
1. Create the Influx token offline:
|
||||
|
||||
@@ -18,5 +18,24 @@ cp .env.template .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Swagger Documentation
|
||||
To ensure the Swagger documentation is always up to date, we use [pre-commit](https://pre-commit.com/). It runs `swag init` automatically before each commit if any Go files in `db/src` have changed.
|
||||
|
||||
To install the hooks:
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Alternatively, you can run it manually:
|
||||
```bash
|
||||
pre-commit run --all-files
|
||||
```
|
||||
or from `db/src`:
|
||||
```bash
|
||||
swag init
|
||||
```
|
||||
|
||||
## Traefik
|
||||
A traefik config file is available on [traefik.yml](./traefik.yml). It can be used with an OIDC provider ([Authentik](https://github.com/goauthentik/authentik) in our case) to control access to the database explorer.
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
--object-store=file \
|
||||
--data-dir=/var/lib/influxdb3/data \
|
||||
--admin-token-file=/tmp/admin-token.json \
|
||||
--query-file-limit=1000 \
|
||||
--disable-authz=health
|
||||
'
|
||||
secrets:
|
||||
@@ -63,6 +64,7 @@ services:
|
||||
mqtt:
|
||||
image: rabbitmq:4-management-alpine
|
||||
container_name: mqtt
|
||||
hostname: "mqtt"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "15672:15672" # Management plugin HTTP port
|
||||
@@ -74,6 +76,36 @@ services:
|
||||
- RABBITMQ_DEFAULT_PASS=${MQTT_PASSWORD:?MQTT_PASSWORD is required}
|
||||
command: sh -c "rabbitmq-plugins enable rabbitmq_mqtt && rabbitmq-server"
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
influxdb:
|
||||
condition: service_healthy
|
||||
mqtt:
|
||||
condition: service_started
|
||||
environment:
|
||||
- MQTT_BROKER_URL=${MQTT_BROKER_URL:-mqtt://mqtt:1883}
|
||||
- MQTT_USERNAME=${MQTT_USERNAME:?MQTT_USERNAME is required}
|
||||
- MQTT_PASSWORD=${MQTT_PASSWORD:?MQTT_PASSWORD is required}
|
||||
- INFLUX_URL=${INFLUX_URL:-http://influxdb:${INFLUX_PORT:-8181}}
|
||||
- INFLUX_DATABASE=${INFLUX_DATABASE}
|
||||
- INFLUX_TOKEN_FILE=/run/secrets/admin-token
|
||||
- REST_PORT=${REST_PORT:-8080}
|
||||
- REST_USERNAME=${REST_USERNAME}
|
||||
- REST_PASSWORD=${REST_PASSWORD}
|
||||
- MAPPING_CONFIG_PATH=/config/mapping.json
|
||||
- CO2_THREASHOLD_MAX=1400
|
||||
- CO2_THREASHOLD_MIN=1000
|
||||
secrets:
|
||||
- admin-token
|
||||
volumes:
|
||||
- ./mapping.json:/config/mapping.json:ro
|
||||
ports:
|
||||
- "${REST_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
influxdb3_data:
|
||||
rabbitmq_data:
|
||||
|
||||
49
db/export-csv-loop.http
Normal file
49
db/export-csv-loop.http
Normal file
@@ -0,0 +1,49 @@
|
||||
@host = localhost:8080
|
||||
@username = user
|
||||
@password = password
|
||||
@from = 2026-05-27T14:00:00+02:00
|
||||
@to = 2026-05-27T18:00:00+02:00
|
||||
|
||||
### Export CSV - Room A2 - loop over all nodes
|
||||
< {%
|
||||
request.variables.set("nodesA2", [
|
||||
{"room": "A2", "mac": "E1:C0:30:15:4E:89"},
|
||||
{"room": "A2", "mac": "C6:7E:0A:DE:DA:74"},
|
||||
{"room": "A2", "mac": "E8:F3:0A:F7:3B:F3"},
|
||||
{"room": "A2", "mac": "C2:64:0F:68:35:3E"},
|
||||
{"room": "A2", "mac": "F5:80:05:76:53:F0"},
|
||||
{"room": "A2", "mac": "C6:95:1B:A6:49:E6"}
|
||||
])
|
||||
%}
|
||||
|
||||
GET {{host}}/api/v1/export/csv?node={{$.nodesA2..mac}}&from={{from}}&to={{to}}
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
> {%
|
||||
let current = request.variables.get("nodesA2")[request.iteration()]
|
||||
client.test(`Export CSV - Room ${current.room} - Node ${current.mac}`, () => {
|
||||
client.assert(response.status === 200, `Expected 200, got ${response.status}`)
|
||||
})
|
||||
%}
|
||||
|
||||
### Export CSV - Room A3 - loop over all nodes
|
||||
< {%
|
||||
request.variables.set("nodesA3", [
|
||||
{"room": "A3", "mac": "ED:B2:F3:74:3E:C2"},
|
||||
{"room": "A3", "mac": "CE:25:63:38:34:05"},
|
||||
{"room": "A3", "mac": "E6:8A:79:C8:87:25"},
|
||||
{"room": "A3", "mac": "DC:06:D9:40:7A:CB"},
|
||||
{"room": "A3", "mac": "D5:2F:7E:30:10:5A"},
|
||||
{"room": "A3", "mac": "EA:1A:AD:15:5E:9F"}
|
||||
])
|
||||
%}
|
||||
|
||||
GET {{host}}/api/v1/export/csv?node={{$.nodesA3..mac}}&from={{from}}&to={{to}}
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
> {%
|
||||
let current = request.variables.get("nodesA3")[request.iteration()]
|
||||
client.test(`Export CSV - Room ${current.room} - Node ${current.mac}`, () => {
|
||||
client.assert(response.status === 200, `Expected 200, got ${response.status}`)
|
||||
})
|
||||
%}
|
||||
31
db/get-db.http
Normal file
31
db/get-db.http
Normal file
@@ -0,0 +1,31 @@
|
||||
@host = https://api.db.e.kb28.ch
|
||||
@room-id = B3
|
||||
@username = PIE2EEDA
|
||||
@password =
|
||||
|
||||
### GET last value of temp, co2, humidity, windows states
|
||||
GET {{host}}/api/v1/rooms/{{room-id}}/current
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
### GET history of a room
|
||||
@window = 1 day
|
||||
GET {{host}}/api/v1/rooms/{{room-id}}/history?window={{window}}
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
### GET CO2 status of all rooms
|
||||
GET {{host}}/api/v1/rooms/high-co2
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
### GET all rooms
|
||||
GET {{host}}/api/v1/rooms
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
|
||||
### GET battery status of all devices
|
||||
GET {{host}}/api/v1/battery
|
||||
|
||||
### Export sensor data as CSV for a node over a time range
|
||||
@node = C2:64:0F:68:35:3E
|
||||
@from = 2026-05-27T14:00:00+02:00
|
||||
@to = 2026-05-27T18:00:00+02:00
|
||||
GET {{host}}/api/v1/export/csv?node={{node}}&from={{from}}&to={{to}}
|
||||
Authorization: Basic {{username}} {{password}}
|
||||
8
db/mapping.json.template
Normal file
8
db/mapping.json.template
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"campus": {
|
||||
"provence": ["gw-01"]
|
||||
},
|
||||
"room": {
|
||||
"B3": ["E1:C0:30:15:4E:89", "F9:CE:0C:A4:7C:A4"]
|
||||
}
|
||||
}
|
||||
337
db/src/docs/docs.go
Normal file
337
db/src/docs/docs.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/battery": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the last battery level for each node grouped by room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"battery"
|
||||
],
|
||||
"summary": "Get last battery level for each node",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export/csv": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export CO2, temperature, humidity, window status and battery data for a node over a time range",
|
||||
"produces": [
|
||||
"text/csv"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export sensor data as CSV",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)",
|
||||
"name": "node",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "CSV file",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of all unique rooms from the measurement",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get all unique rooms",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/high-co2": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of rooms where CO2 levels are above the threshold",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get rooms with high CO2 levels",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/rest.RoomCO2Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/{room-id}/current": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the latest record for a specific room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get current data for a room",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Room ID",
|
||||
"name": "room-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/{room-id}/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get history for a specific room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get history for a room",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Room ID",
|
||||
"name": "room-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "1 day",
|
||||
"description": "Time window (e.g., 1 day, 1 hour, 30 min)",
|
||||
"name": "window",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"rest.RoomCO2Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"co2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_high": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"room": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BasicAuth": {
|
||||
"type": "basic"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "api.db.e.kb28.ch",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{},
|
||||
Title: "Gateway API",
|
||||
Description: "This is a gateway API for IoT data.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
313
db/src/docs/swagger.json
Normal file
313
db/src/docs/swagger.json
Normal file
@@ -0,0 +1,313 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "This is a gateway API for IoT data.",
|
||||
"title": "Gateway API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "api.db.e.kb28.ch",
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/battery": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the last battery level for each node grouped by room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"battery"
|
||||
],
|
||||
"summary": "Get last battery level for each node",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export/csv": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export CO2, temperature, humidity, window status and battery data for a node over a time range",
|
||||
"produces": [
|
||||
"text/csv"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export sensor data as CSV",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)",
|
||||
"name": "node",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "CSV file",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of all unique rooms from the measurement",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get all unique rooms",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/high-co2": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of rooms where CO2 levels are above the threshold",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get rooms with high CO2 levels",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/rest.RoomCO2Status"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/{room-id}/current": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the latest record for a specific room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get current data for a room",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Room ID",
|
||||
"name": "room-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/rooms/{room-id}/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get history for a specific room",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"rooms"
|
||||
],
|
||||
"summary": "Get history for a room",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Room ID",
|
||||
"name": "room-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "1 day",
|
||||
"description": "Time window (e.g., 1 day, 1 hour, 30 min)",
|
||||
"name": "window",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"rest.RoomCO2Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"co2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_high": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"room": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BasicAuth": {
|
||||
"type": "basic"
|
||||
}
|
||||
}
|
||||
}
|
||||
201
db/src/docs/swagger.yaml
Normal file
201
db/src/docs/swagger.yaml
Normal file
@@ -0,0 +1,201 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
rest.RoomCO2Status:
|
||||
properties:
|
||||
co2:
|
||||
type: integer
|
||||
is_high:
|
||||
type: boolean
|
||||
room:
|
||||
type: string
|
||||
type: object
|
||||
host: api.db.e.kb28.ch
|
||||
info:
|
||||
contact: {}
|
||||
description: This is a gateway API for IoT data.
|
||||
title: Gateway API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/battery:
|
||||
get:
|
||||
description: Get the last battery level for each node grouped by room
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
additionalProperties:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: object
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Get last battery level for each node
|
||||
tags:
|
||||
- battery
|
||||
/export/csv:
|
||||
get:
|
||||
description: Export CO2, temperature, humidity, window status and battery data
|
||||
for a node over a time range
|
||||
parameters:
|
||||
- description: Node MAC address (e.g. E8:F3:0A:F7:3B:F3)
|
||||
in: query
|
||||
name: node
|
||||
required: true
|
||||
type: string
|
||||
- description: Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)
|
||||
in: query
|
||||
name: from
|
||||
required: true
|
||||
type: string
|
||||
- description: End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)
|
||||
in: query
|
||||
name: to
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- text/csv
|
||||
responses:
|
||||
"200":
|
||||
description: CSV file
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Export sensor data as CSV
|
||||
tags:
|
||||
- export
|
||||
/rooms:
|
||||
get:
|
||||
description: Get a list of all unique rooms from the measurement
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Get all unique rooms
|
||||
tags:
|
||||
- rooms
|
||||
/rooms/{room-id}/current:
|
||||
get:
|
||||
description: Get the latest record for a specific room
|
||||
parameters:
|
||||
- description: Room ID
|
||||
in: path
|
||||
name: room-id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Get current data for a room
|
||||
tags:
|
||||
- rooms
|
||||
/rooms/{room-id}/history:
|
||||
get:
|
||||
description: Get history for a specific room
|
||||
parameters:
|
||||
- description: Room ID
|
||||
in: path
|
||||
name: room-id
|
||||
required: true
|
||||
type: string
|
||||
- default: 1 day
|
||||
description: Time window (e.g., 1 day, 1 hour, 30 min)
|
||||
in: query
|
||||
name: window
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Get history for a room
|
||||
tags:
|
||||
- rooms
|
||||
/rooms/high-co2:
|
||||
get:
|
||||
description: Get a list of rooms where CO2 levels are above the threshold
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/rest.RoomCO2Status'
|
||||
type: array
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: Get rooms with high CO2 levels
|
||||
tags:
|
||||
- rooms
|
||||
securityDefinitions:
|
||||
BasicAuth:
|
||||
type: basic
|
||||
swagger: "2.0"
|
||||
71
db/src/go.mod
Normal file
71
db/src/go.mod
Normal file
@@ -0,0 +1,71 @@
|
||||
module gateway
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/InfluxCommunity/influxdb3-go/v2 v2.13.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/influxdata/line-protocol/v2 v2.2.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
258
db/src/go.sum
Normal file
258
db/src/go.sum
Normal file
@@ -0,0 +1,258 @@
|
||||
github.com/InfluxCommunity/influxdb3-go/v2 v2.13.0 h1:IQVpiJ0t92OsJXf/RJ0+HHoIbK3mgJaMntEfLRgxS9Q=
|
||||
github.com/InfluxCommunity/influxdb3-go/v2 v2.13.0/go.mod h1:fXhSEgDgX7iv++4t5cFVKRB6kqqeLjRRf5d2IlUdiWw=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
|
||||
github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
|
||||
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
|
||||
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/frankban/quicktest v1.11.0/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
|
||||
github.com/frankban/quicktest v1.11.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
|
||||
github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk=
|
||||
github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/influxdata/line-protocol-corpus v0.0.0-20210519164801-ca6fa5da0184/go.mod h1:03nmhxzZ7Xk2pdG+lmMd7mHDfeVOYFyhOgwO61qWU98=
|
||||
github.com/influxdata/line-protocol-corpus v0.0.0-20210922080147-aa28ccfb8937 h1:MHJNQ+p99hFATQm6ORoLmpUCF7ovjwEFshs/NHzAbig=
|
||||
github.com/influxdata/line-protocol-corpus v0.0.0-20210922080147-aa28ccfb8937/go.mod h1:BKR9c0uHSmRgM/se9JhFHtTT7JTO67X23MtKMHtZcpo=
|
||||
github.com/influxdata/line-protocol/v2 v2.0.0-20210312151457-c52fdecb625a/go.mod h1:6+9Xt5Sq1rWx+glMgxhcg2c0DUaehK+5TDcPZ76GypY=
|
||||
github.com/influxdata/line-protocol/v2 v2.1.0/go.mod h1:QKw43hdUBg3GTk2iC3iyCxksNj7PX9aUSeYOYE/ceHY=
|
||||
github.com/influxdata/line-protocol/v2 v2.2.1 h1:EAPkqJ9Km4uAxtMRgUubJyqAr6zgWM0dznKMLRauQRE=
|
||||
github.com/influxdata/line-protocol/v2 v2.2.1/go.mod h1:DmB3Cnh+3oxmG6LOBIxce4oaL4CPj3OmMPgvauXh+tM=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
|
||||
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
96
db/src/influx/influx.go
Normal file
96
db/src/influx/influx.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Package influx provides an abstraction to the client influx.
|
||||
package influx
|
||||
|
||||
import (
|
||||
"context"
|
||||
datapoint "gateway/point"
|
||||
|
||||
"github.com/InfluxCommunity/influxdb3-go/v2/influxdb3"
|
||||
"github.com/InfluxCommunity/influxdb3-go/v2/influxdb3/batching"
|
||||
)
|
||||
|
||||
const BatchSize = 50 // Number of points to batch before pushing to InfluxDB
|
||||
const BatchCapacity = 50 // Capacity maximum of the buffer
|
||||
|
||||
// Gateway provides the abstraction of all gateways to send data points
|
||||
type Gateway interface {
|
||||
AddDatapoint(dp datapoint.DataPointInfo) error
|
||||
Close() error
|
||||
Flush() error
|
||||
Query(ctx context.Context, query string) (*influxdb3.QueryIterator, error)
|
||||
}
|
||||
|
||||
// An InfluxGateway is the abstracted influx gateway.
|
||||
// It provides the influx parameters to initialize the connection
|
||||
// and the batcher to batch the points before pushing to Influx.
|
||||
type InfluxGateway struct {
|
||||
client *influxdb3.Client
|
||||
batcher *batching.Batcher
|
||||
}
|
||||
|
||||
// NewInfluxGateway creates a new InfluxGateway with the given parameters.
|
||||
func NewInfluxGateway(url string, token string, database string) (*InfluxGateway, error) {
|
||||
|
||||
client, err := influxdb3.New(influxdb3.ClientConfig{
|
||||
Host: url,
|
||||
Token: token,
|
||||
Database: database,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &InfluxGateway{
|
||||
client: client,
|
||||
batcher: batching.NewBatcher(
|
||||
batching.WithSize(BatchSize),
|
||||
batching.WithInitialCapacity(BatchCapacity),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddDatapoint is used to add a datapoint in the batcher. It uses the
|
||||
// DataPointInfo interface for abstracting the generic type of the DataPoint.
|
||||
// It pushes the batch of point when the number of points >= batch size.
|
||||
func (g *InfluxGateway) AddDatapoint(dp datapoint.DataPointInfo) error {
|
||||
|
||||
tagsList := map[string]string{}
|
||||
for _, t := range dp.Tags() {
|
||||
tagsList[t.Subject] = t.Content
|
||||
}
|
||||
g.batcher.Add(
|
||||
influxdb3.NewPoint(
|
||||
dp.MeasurementName(),
|
||||
tagsList,
|
||||
dp.PayloadAsAny(),
|
||||
dp.Timestamp(),
|
||||
),
|
||||
)
|
||||
|
||||
// if not ready, we are done
|
||||
if g.batcher.Ready() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If ready, flush and return the result directly
|
||||
return g.Flush()
|
||||
}
|
||||
|
||||
// Flush sends the current batch of points to InfluxDB.
|
||||
func (g *InfluxGateway) Flush() error {
|
||||
// Send batch to influx DB
|
||||
err := g.client.WritePoints(context.Background(), g.batcher.Emit())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the InfluxGateway client.
|
||||
func (g *InfluxGateway) Close() error {
|
||||
return g.client.Close()
|
||||
}
|
||||
|
||||
// Query executes a SQL query against InfluxDB using the Arrow Flight (gRPC) API.
|
||||
func (g *InfluxGateway) Query(ctx context.Context, query string) (*influxdb3.QueryIterator, error) {
|
||||
return g.client.Query(ctx, query)
|
||||
}
|
||||
195
db/src/main.go
Normal file
195
db/src/main.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gateway/influx"
|
||||
"gateway/mqtt"
|
||||
point "gateway/point"
|
||||
"gateway/rest"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type ProvenceData struct {
|
||||
CO2PPM int `json:"co2_ppm"`
|
||||
Temp float64 `json:"temp"`
|
||||
Humidity int `json:"humidity"`
|
||||
Battery int `json:"battery"`
|
||||
Window bool `json:"window_open"`
|
||||
}
|
||||
|
||||
func mqttConnection() *mqtt.MqttGateway {
|
||||
BrokerUrl := getEnv("MQTT_BROKER_URL", "tls://localhost:8883")
|
||||
Username := getEnv("MQTT_USERNAME", "user")
|
||||
Password := getEnv("MQTT_PASSWORD", "password")
|
||||
|
||||
ClientId := "mqtt-gateway-test-client_" + fmt.Sprint(time.Now().Unix())
|
||||
|
||||
// Create config & gateway
|
||||
mqttP := &mqtt.MqttParams{
|
||||
Broker: BrokerUrl,
|
||||
ClientId: ClientId,
|
||||
Qos: 1,
|
||||
Username: Username,
|
||||
Password: Password,
|
||||
TlsConfig: nil,
|
||||
OnConnect: nil,
|
||||
OnConnectionLost: nil,
|
||||
Timeout: 1 * time.Second,
|
||||
}
|
||||
gateway, err := mqtt.NewMqttGateway(*mqttP)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return gateway
|
||||
}
|
||||
|
||||
func influxConnection() *influx.InfluxGateway {
|
||||
influxUrl := getEnv("INFLUX_URL", "http://influxdb:8181")
|
||||
|
||||
influxDatabase := getEnv("INFLUX_DATABASE", "provence")
|
||||
influxToken := getEnv("INFLUX_TOKEN", "")
|
||||
if influxToken == "" {
|
||||
if tokenFile := getEnv("INFLUX_TOKEN_FILE", "/run/secrets/admin-token"); tokenFile != "" {
|
||||
content, err := os.ReadFile(tokenFile)
|
||||
if err == nil {
|
||||
influxToken = strings.TrimSpace(string(content))
|
||||
} else {
|
||||
log.Printf("[Main] Warning: could not read token file %s: %v\n", tokenFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if influxToken == "" {
|
||||
influxToken = "password"
|
||||
}
|
||||
|
||||
log.Printf("[Main] InfluxDB config: URL=%s, DB=%s\n", influxUrl, influxDatabase)
|
||||
|
||||
// Create the gateway
|
||||
gateway, err := influx.NewInfluxGateway(influxUrl, influxToken, influxDatabase)
|
||||
if err != nil {
|
||||
log.Fatalf("Creating gateway failed !, %v", err)
|
||||
}
|
||||
|
||||
return gateway
|
||||
}
|
||||
|
||||
// @title Gateway API
|
||||
// @version 1.0
|
||||
// @description This is a gateway API for IoT data.
|
||||
// @host api.db.e.kb28.ch
|
||||
// @BasePath /api/v1
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
func main() {
|
||||
noMqtt := slices.Contains(os.Args[1:], "--no-mqtt")
|
||||
|
||||
// Load mapping configuration (reloaded dynamically on each access)
|
||||
mappingPath := getEnv("MAPPING_CONFIG_PATH", "mapping.json")
|
||||
mapping := NewDynamicMapping(mappingPath)
|
||||
|
||||
influxGateway := influxConnection()
|
||||
defer influxGateway.Close()
|
||||
|
||||
measurementName := getEnv("CAMPUS", "provence")
|
||||
|
||||
if !noMqtt {
|
||||
mqttGateway := mqttConnection()
|
||||
defer mqttGateway.Disconnect()
|
||||
|
||||
// Create measurement for provence topic
|
||||
provenceMeasurement := point.CreateMeasurement[ProvenceData](measurementName)
|
||||
// The incoming MQTT topic structure is: <gateway_id>/<node_id>/update
|
||||
topicStructur := []string{"gateway", "node"}
|
||||
err := mqtt.SubscribeTyped(mqttGateway, "+/+/update", provenceMeasurement, topicStructur, func(dp point.DataPoint[ProvenceData]) {
|
||||
var gatewayID, nodeID string
|
||||
for _, tag := range dp.Tags() {
|
||||
switch tag.Subject {
|
||||
case "gateway":
|
||||
gatewayID = tag.Content
|
||||
case "node":
|
||||
nodeID = tag.Content
|
||||
}
|
||||
}
|
||||
|
||||
campus, campusOk := mapping.GetCampus(gatewayID)
|
||||
room, roomOk := mapping.GetRoom(nodeID)
|
||||
|
||||
batteryLevel := dp.GetValues().Battery
|
||||
log.Printf("[Main] Received gateway=%s node=%s -> campus=%s room=%s (Battery %d%%)\n", gatewayID, nodeID, campus, room, batteryLevel)
|
||||
|
||||
var influxTags []point.Topic
|
||||
|
||||
if !campusOk {
|
||||
log.Printf("[Main] No mapping found for gateway=%s\n", gatewayID)
|
||||
influxTags = []point.Topic{
|
||||
{Subject: "node", Content: nodeID},
|
||||
}
|
||||
} else if !roomOk {
|
||||
log.Printf("[Main] No mapping found for gateway=%s\n", gatewayID)
|
||||
influxTags = []point.Topic{
|
||||
{Subject: "campus", Content: campus},
|
||||
{Subject: "node", Content: nodeID},
|
||||
}
|
||||
} else {
|
||||
influxTags = []point.Topic{
|
||||
{Subject: "campus", Content: campus},
|
||||
{Subject: "room", Content: room},
|
||||
{Subject: "node", Content: nodeID},
|
||||
}
|
||||
}
|
||||
|
||||
// If CO2PPM value is present and over 1,000,000,000 delete the field, it's calibration value
|
||||
values := dp.GetValues()
|
||||
if values.CO2PPM > 1000000000 {
|
||||
log.Printf("[Main] Warning: CO2PPM value %d is calibrating, setting to 0\n", values.CO2PPM)
|
||||
values.CO2PPM = 0
|
||||
}
|
||||
|
||||
// Still too high value, something wrong, dropping datapoint
|
||||
if values.CO2PPM > 10000 {
|
||||
log.Printf("[Main] Error: CO2PPM value %d is over threshold, dropping Datapoint\n", values.CO2PPM)
|
||||
return
|
||||
}
|
||||
|
||||
translatedDp := provenceMeasurement.CreateDataPoint(influxTags, values, dp.Timestamp())
|
||||
|
||||
if err := influxGateway.AddDatapoint(&translatedDp); err != nil {
|
||||
log.Printf("[Main] Error adding datapoint to influx: %v\n", err)
|
||||
}
|
||||
if err := influxGateway.Flush(); err != nil {
|
||||
log.Printf("[Main] Error flushing to influx: %v\n", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Println("[Main] MQTT disabled (--no-mqtt flag set)")
|
||||
}
|
||||
|
||||
// Initialize and start REST Gateway
|
||||
restUsername := getEnv("REST_USERNAME", "user")
|
||||
restPassword := getEnv("REST_PASSWORD", "password")
|
||||
restGateway := rest.NewRestGateway(influxGateway, mapping, measurementName, restUsername, restPassword)
|
||||
|
||||
port := getEnv("REST_PORT", "8080")
|
||||
|
||||
log.Printf("[Main] Starting REST Gateway on port %s\n", port)
|
||||
if err := restGateway.Run(":" + port); err != nil {
|
||||
log.Fatalf("[Main] Failed to start REST Gateway: %v", err)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
152
db/src/mapping.go
Normal file
152
db/src/mapping.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// mappingFile is the structure of the JSON config file.
|
||||
// Campus names map to the list of gateway IDs that belong to them.
|
||||
// Room names map to the list of node IDs that belong to them.
|
||||
type mappingFile struct {
|
||||
Campus map[string][]string `json:"campus"`
|
||||
Room map[string][]string `json:"room"`
|
||||
}
|
||||
|
||||
// MappingConfig holds the reverse lookup maps built from the config file:
|
||||
//
|
||||
// gateway_id -> campus name
|
||||
// node_id -> room name
|
||||
type MappingConfig struct {
|
||||
gatewayToCampus map[string]string
|
||||
nodeToRoom map[string]string
|
||||
}
|
||||
|
||||
// DynamicMapping reloads the mapping file on every access so that changes
|
||||
// to the JSON file take effect without restarting the programme.
|
||||
type DynamicMapping struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewDynamicMapping creates a DynamicMapping that reads from the given path.
|
||||
func NewDynamicMapping(path string) *DynamicMapping {
|
||||
return &DynamicMapping{path: path}
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) load() *MappingConfig {
|
||||
cfg, err := LoadMapping(d.path)
|
||||
if err != nil {
|
||||
return EmptyMapping()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) GetCampus(gatewayID string) (string, bool) {
|
||||
return d.load().GetCampus(gatewayID)
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) GetRoom(nodeID string) (string, bool) {
|
||||
return d.load().GetRoom(nodeID)
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) NodesForRoom(room string) []string {
|
||||
return d.load().NodesForRoom(room)
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) AllNodes() []string {
|
||||
return d.load().AllNodes()
|
||||
}
|
||||
|
||||
func (d *DynamicMapping) Rooms() []string {
|
||||
return d.load().Rooms()
|
||||
}
|
||||
|
||||
// EmptyMapping returns a MappingConfig with no entries.
|
||||
// GetCampus and GetRoom will return their "unknown_*" fallback values.
|
||||
func EmptyMapping() *MappingConfig {
|
||||
return &MappingConfig{
|
||||
gatewayToCampus: make(map[string]string),
|
||||
nodeToRoom: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMapping reads the mapping configuration from a JSON file and builds
|
||||
// the internal reverse-lookup tables.
|
||||
func LoadMapping(path string) (*MappingConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read mapping file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var raw mappingFile
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mapping JSON: %w", err)
|
||||
}
|
||||
|
||||
cfg := &MappingConfig{
|
||||
gatewayToCampus: make(map[string]string),
|
||||
nodeToRoom: make(map[string]string),
|
||||
}
|
||||
|
||||
for campus, gateways := range raw.Campus {
|
||||
for _, gwID := range gateways {
|
||||
cfg.gatewayToCampus[gwID] = campus
|
||||
}
|
||||
}
|
||||
|
||||
for room, nodes := range raw.Room {
|
||||
for _, nodeID := range nodes {
|
||||
cfg.nodeToRoom[nodeID] = room
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetCampus returns the campus name for a given gateway ID.
|
||||
// The boolean is false if the gateway ID has no mapping.
|
||||
func (c *MappingConfig) GetCampus(gatewayID string) (string, bool) {
|
||||
campus, ok := c.gatewayToCampus[gatewayID]
|
||||
return campus, ok
|
||||
}
|
||||
|
||||
// GetRoom returns the room name for a given node ID.
|
||||
// The boolean is false if the node ID has no mapping.
|
||||
func (c *MappingConfig) GetRoom(nodeID string) (string, bool) {
|
||||
room, ok := c.nodeToRoom[nodeID]
|
||||
return room, ok
|
||||
}
|
||||
|
||||
// NodesForRoom returns the list of node IDs that belong to the given room.
|
||||
func (c *MappingConfig) NodesForRoom(room string) []string {
|
||||
var nodes []string
|
||||
for nodeID, r := range c.nodeToRoom {
|
||||
if r == room {
|
||||
nodes = append(nodes, nodeID)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// AllNodes returns all node IDs defined in the mapping.
|
||||
func (c *MappingConfig) AllNodes() []string {
|
||||
nodes := make([]string, 0, len(c.nodeToRoom))
|
||||
for nodeID := range c.nodeToRoom {
|
||||
nodes = append(nodes, nodeID)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// Rooms returns the list of all room names defined in the mapping.
|
||||
func (c *MappingConfig) Rooms() []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, room := range c.nodeToRoom {
|
||||
seen[room] = struct{}{}
|
||||
}
|
||||
rooms := make([]string, 0, len(seen))
|
||||
for room := range seen {
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
248
db/src/mqtt/mqtt.go
Normal file
248
db/src/mqtt/mqtt.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Package mqtt_gateway provides an abstraction to an MQTT broker client.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
dp "gateway/point"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQoS = 2
|
||||
defaultTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// A MqttParams is the abstracted MQTT gateway.
|
||||
// It provides the MQTT parameters to initialize the connection and the method to add data.
|
||||
type MqttParams struct {
|
||||
Broker string
|
||||
ClientId string
|
||||
Qos byte
|
||||
Username string
|
||||
Password string
|
||||
TlsConfig *tls.Config
|
||||
OnConnect mqtt.OnConnectHandler
|
||||
OnConnectionLost mqtt.ConnectionLostHandler
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// MqttGateway is the abstracted MQTT gateway.
|
||||
// It connects to the MQTT broker and provides the method to send data to the broker.
|
||||
type MqttGateway struct {
|
||||
MqttParams MqttParams
|
||||
Client mqtt.Client
|
||||
}
|
||||
|
||||
// mqttPayload is the JSON structure published to the broker in a specific topic.
|
||||
// It contains the values and the timestamp of the data.
|
||||
type mqttPayload map[string]any
|
||||
|
||||
// connectHandler is called when the client connects to the broker. It prints a message to the console.
|
||||
var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) {
|
||||
log.Println("[MQTT Gateway] Connected to MQTT Broker")
|
||||
}
|
||||
|
||||
// connectLostHandler is called when the client loses connection to the broker. It prints a message to the console with the error.
|
||||
var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) {
|
||||
log.Printf("[MQTT Gateway] Connection lost: %v\n", err)
|
||||
}
|
||||
|
||||
func getTopic(t []dp.Topic) string {
|
||||
var topic []string
|
||||
for _, t := range t {
|
||||
topic = append(topic, t.Content)
|
||||
}
|
||||
return strings.Join(topic, "/")
|
||||
}
|
||||
|
||||
// NewMqttGateway creates a new MqttGateway with the given parameters.
|
||||
// And establishes the connection
|
||||
func NewMqttGateway(p MqttParams) (*MqttGateway, error) {
|
||||
// Verify input variable
|
||||
if p.Broker == "" {
|
||||
return nil, errors.New("[MQTT Gateway] Invalid broker address")
|
||||
}
|
||||
|
||||
if p.ClientId == "" {
|
||||
return nil, errors.New("[MQTT Gateway] Invalid client id")
|
||||
}
|
||||
|
||||
if p.Qos > maxQoS {
|
||||
return nil, errors.New("[MQTT Gateway] Invalid QoS level")
|
||||
}
|
||||
|
||||
if p.Timeout == 0 {
|
||||
// Set to default value
|
||||
p.Timeout = defaultTimeout
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions()
|
||||
opts.AddBroker(p.Broker)
|
||||
opts.SetClientID(p.ClientId)
|
||||
|
||||
if p.TlsConfig != nil {
|
||||
opts.SetTLSConfig(p.TlsConfig)
|
||||
}
|
||||
|
||||
if p.OnConnect != nil {
|
||||
opts.SetOnConnectHandler(p.OnConnect)
|
||||
} else {
|
||||
opts.SetOnConnectHandler(connectHandler)
|
||||
}
|
||||
|
||||
if p.OnConnectionLost != nil {
|
||||
opts.SetConnectionLostHandler(p.OnConnectionLost)
|
||||
} else {
|
||||
opts.SetConnectionLostHandler(connectLostHandler)
|
||||
}
|
||||
|
||||
if p.Username != "" {
|
||||
opts.SetUsername(p.Username)
|
||||
opts.SetPassword(p.Password)
|
||||
}
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
token := client.Connect()
|
||||
if !token.WaitTimeout(p.Timeout) {
|
||||
return nil, fmt.Errorf("[MQTT Gateway] Mqtt connect timed out")
|
||||
}
|
||||
if err := token.Error(); err != nil {
|
||||
return nil, fmt.Errorf("[MQTT Gateway] Mqtt connect failed: %w", err)
|
||||
}
|
||||
|
||||
return &MqttGateway{
|
||||
MqttParams: p,
|
||||
Client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendData is used to send data in the MQTT gateway.
|
||||
// It uses the DataPointInfo interface for abstracting the generic type of the DataPoint
|
||||
func (g *MqttGateway) SendData(msg dp.DataPointInfo) error {
|
||||
|
||||
topic := getTopic(msg.Tags())
|
||||
if topic == "" {
|
||||
return errors.New("[MQTT Gateway] Invalid topic")
|
||||
}
|
||||
|
||||
payload := mqttPayload{
|
||||
"timestamp": msg.Timestamp().Unix(),
|
||||
}
|
||||
|
||||
for key, value := range msg.PayloadAsAny() {
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
payloadJson, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[MQTT Gateway] Failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
token := g.Client.Publish(topic, g.MqttParams.Qos, false, payloadJson)
|
||||
if !token.WaitTimeout(g.MqttParams.Timeout) {
|
||||
return fmt.Errorf("[MQTT Gateway] Mqtt connect timed out")
|
||||
}
|
||||
if token.Error() != nil {
|
||||
return fmt.Errorf("[MQTT Gateway] Failed to publish message: %w", token.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect is used to disconnect the MQTT gateway from the broker.
|
||||
// It prints a message to the console when the disconnection is successful.
|
||||
func (g *MqttGateway) Disconnect() {
|
||||
g.Client.Disconnect(0)
|
||||
log.Println("[MQTT Gateway] Disconnected from MQTT Broker")
|
||||
}
|
||||
|
||||
// Subscribe is used to subscribe to a topic in the MQTT gateway.
|
||||
// It takes a topic and a callback function as parameters.
|
||||
// The callback function is called when a message is received on the subscribed topic.
|
||||
func (g *MqttGateway) Subscribe(topic string, callback mqtt.MessageHandler) error {
|
||||
token := g.Client.Subscribe(topic, g.MqttParams.Qos, callback)
|
||||
if !token.WaitTimeout(g.MqttParams.Timeout) {
|
||||
return fmt.Errorf("[MQTT Gateway] MQTT gateway timed out")
|
||||
}
|
||||
if token.Error() != nil {
|
||||
return fmt.Errorf("[MQTT Gateway] MQTT gateway failed to subscribe: %w", token.Error())
|
||||
}
|
||||
|
||||
log.Printf("[MQTT Gateway] Subscribed to topic: %s\n", topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unsubscribe is used to unsubscribe from a topic in the MQTT gateway.
|
||||
func (g *MqttGateway) Unsubscribe(topic string) error {
|
||||
token := g.Client.Unsubscribe(topic)
|
||||
if !token.WaitTimeout(g.MqttParams.Timeout) {
|
||||
return fmt.Errorf("[MQTT Gateway] MQTT gateway timed out")
|
||||
}
|
||||
if token.Error() != nil {
|
||||
return fmt.Errorf("[MQTT Gateway] MQTT gateway failed to unsubscribe: %w", token.Error())
|
||||
}
|
||||
|
||||
log.Printf("[MQTT Gateway] Unsubscribed from topic: %s\n", topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubscribeTyped is a helper to subscribe to a topic and automatically convert
|
||||
// the received JSON message to a DataPoint of type T.
|
||||
// T should be a struct or a map that matches the JSON payload (excluding timestamp).
|
||||
// tagSubjects is a list of tag subjects that correspond to the parts of the topic.
|
||||
// For example, if the topic is "provence/B3/update" and tagSubjects is ["city", "room"],
|
||||
// it will create tags {Subject: "city", Content: "provence"} and {Subject: "room", Content: "B3"}.
|
||||
func SubscribeTyped[T any](g *MqttGateway, topic string, m dp.Measurement[T], tagSubjects []string, handler func(dp.DataPoint[T])) error {
|
||||
return g.Subscribe(topic, func(client mqtt.Client, msg mqtt.Message) {
|
||||
// Unmarshal into T for fields
|
||||
var fields T
|
||||
if err := json.Unmarshal(msg.Payload(), &fields); err != nil {
|
||||
log.Printf("[MQTT Gateway] Error unmarshaling fields: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmarshal into a map to extract timestamp
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(msg.Payload(), &raw); err != nil {
|
||||
log.Printf("[MQTT Gateway] Error unmarshaling raw: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now()
|
||||
if tsRaw, ok := raw["timestamp"].(string); ok {
|
||||
// Try RFC3339 first (default)
|
||||
if parsedTs, err := time.Parse(time.RFC3339, tsRaw); err == nil {
|
||||
ts = parsedTs
|
||||
} else {
|
||||
log.Printf("[MQTT Gateway] Failed to parse timestamp '%s' as RFC3339: %v", tsRaw, err)
|
||||
}
|
||||
} else if tsRaw, ok := raw["timestamp"].(float64); ok {
|
||||
// Handle Unix timestamp in seconds
|
||||
ts = time.Unix(int64(tsRaw), 0)
|
||||
}
|
||||
|
||||
// Extract tags from topic
|
||||
parts := strings.Split(msg.Topic(), "/")
|
||||
var tags []dp.Topic
|
||||
for i, subject := range tagSubjects {
|
||||
if i < len(parts) && subject != "" {
|
||||
tags = append(tags, dp.Topic{Subject: subject, Content: parts[i]})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for backward compatibility if no tagSubjects provided
|
||||
if len(tagSubjects) == 0 && len(parts) > 1 {
|
||||
tags = append(tags, dp.Topic{Subject: "id", Content: parts[1]})
|
||||
}
|
||||
|
||||
dp := m.CreateDataPoint(tags, fields, ts)
|
||||
handler(dp)
|
||||
})
|
||||
}
|
||||
82
db/src/point/datapoint.go
Normal file
82
db/src/point/datapoint.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package datapoint implements measurement and datapoint
|
||||
// for database gateways.
|
||||
package datapoint
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Topic struct {
|
||||
Subject string
|
||||
Content string
|
||||
}
|
||||
|
||||
// DataPointInfo provides an interface for accessing
|
||||
// all fields of a DataPoint.
|
||||
type DataPointInfo interface {
|
||||
MeasurementName() string
|
||||
Tags() []Topic
|
||||
PayloadAsAny() map[string]any
|
||||
Timestamp() time.Time
|
||||
}
|
||||
|
||||
// A Measurement represents a type of measurement such as
|
||||
// temperature, humidity, ...
|
||||
type Measurement[T any] struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// A DataPoint represents values associated with a measurement, along with tags,
|
||||
// values, and a timestamp. It contains a pointer to its parent Measurement
|
||||
type DataPoint[T any] struct {
|
||||
measurement *Measurement[T]
|
||||
tags []Topic
|
||||
values T
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func CreateMeasurement[T any](name string) Measurement[T] {
|
||||
return Measurement[T]{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDataPoint forces the creation of DataPoint with the type according
|
||||
// to the generic.
|
||||
func (m *Measurement[T]) CreateDataPoint(tags []Topic, values T, timestamp time.Time) DataPoint[T] {
|
||||
return DataPoint[T]{
|
||||
measurement: m,
|
||||
tags: tags,
|
||||
values: values,
|
||||
timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
func (dp *DataPoint[T]) MeasurementName() string {
|
||||
return dp.measurement.name
|
||||
}
|
||||
|
||||
func (dp *DataPoint[T]) Tags() []Topic {
|
||||
return dp.tags
|
||||
}
|
||||
|
||||
func (dp *DataPoint[T]) GetValues() T {
|
||||
return dp.values
|
||||
}
|
||||
|
||||
// PayloadAsAny returns the DataPoint value with type any.
|
||||
// (useful for adding the DataPoint for batching)
|
||||
func (dp *DataPoint[T]) PayloadAsAny() map[string]any {
|
||||
b, err := json.Marshal(dp.values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var res map[string]any
|
||||
_ = json.Unmarshal(b, &res)
|
||||
return res
|
||||
}
|
||||
|
||||
func (dp *DataPoint[T]) Timestamp() time.Time {
|
||||
return dp.timestamp
|
||||
}
|
||||
598
db/src/rest/rest.go
Normal file
598
db/src/rest/rest.go
Normal file
@@ -0,0 +1,598 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
_ "gateway/docs"
|
||||
"gateway/influx"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow-go/v18/arrow"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// RoomMapper is an interface for mapping node IDs to rooms and listing rooms.
|
||||
type RoomMapper interface {
|
||||
Rooms() []string
|
||||
NodesForRoom(room string) []string
|
||||
AllNodes() []string
|
||||
GetRoom(nodeID string) (string, bool)
|
||||
}
|
||||
|
||||
type RestGateway struct {
|
||||
influxGateway *influx.InfluxGateway
|
||||
mapping RoomMapper
|
||||
engine *gin.Engine
|
||||
measurementName string
|
||||
username string
|
||||
password string
|
||||
co2ThresholdMax int
|
||||
co2ThresholdMin int
|
||||
roomStatus map[string]*RoomCO2Status
|
||||
statusMu sync.RWMutex
|
||||
}
|
||||
|
||||
type RoomCO2Status struct {
|
||||
RoomID string `json:"room"`
|
||||
IsHigh bool `json:"is_high"`
|
||||
CO2 int `json:"co2"`
|
||||
}
|
||||
|
||||
func NewRestGateway(influxGateway *influx.InfluxGateway, mapping RoomMapper, measurementName string, username, password string) *RestGateway {
|
||||
maxThreshold, _ := strconv.Atoi(os.Getenv("CO2_THREASHOLD_MAX"))
|
||||
if maxThreshold == 0 {
|
||||
maxThreshold = 1400
|
||||
}
|
||||
minThreshold, _ := strconv.Atoi(os.Getenv("CO2_THREASHOLD_MIN"))
|
||||
if minThreshold == 0 {
|
||||
minThreshold = 1000
|
||||
}
|
||||
|
||||
g := &RestGateway{
|
||||
influxGateway: influxGateway,
|
||||
mapping: mapping,
|
||||
engine: gin.Default(),
|
||||
measurementName: measurementName,
|
||||
username: username,
|
||||
password: password,
|
||||
co2ThresholdMax: maxThreshold,
|
||||
co2ThresholdMin: minThreshold,
|
||||
roomStatus: make(map[string]*RoomCO2Status),
|
||||
}
|
||||
|
||||
g.setupRoutes()
|
||||
go g.runWatchdog()
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *RestGateway) setupRoutes() {
|
||||
// Setup CORS middleware to allow all *.e.kb28.ch origins
|
||||
corsConfig := cors.Config{
|
||||
AllowOriginFunc: func(origin string) bool {
|
||||
// Match any origin like *.e.kb28.ch
|
||||
pattern := regexp.MustCompile(`^https://.*\.e\.kb28\.ch$`)
|
||||
return pattern.MatchString(origin)
|
||||
},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Authorization", "Content-Type"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}
|
||||
g.engine.Use(cors.New(corsConfig))
|
||||
|
||||
v1 := g.engine.Group("/api/v1")
|
||||
|
||||
// Public endpoints (no auth required)
|
||||
v1.GET("/battery", g.getBattery)
|
||||
|
||||
if g.username != "" && g.password != "" {
|
||||
v1.Use(gin.BasicAuth(gin.Accounts{
|
||||
g.username: g.password,
|
||||
}))
|
||||
}
|
||||
|
||||
{
|
||||
v1.GET("/rooms", g.getRooms)
|
||||
v1.GET("/rooms/:room-id/current", g.getRoomCurrent)
|
||||
v1.GET("/rooms/:room-id/history", g.getRoomHistory)
|
||||
v1.GET("/rooms/high-co2", g.getHighCO2Rooms)
|
||||
v1.GET("/export/csv", g.getExportCSV)
|
||||
}
|
||||
|
||||
g.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
}
|
||||
|
||||
// buildNodeFilter builds a SQL WHERE clause fragment matching any of the given node IDs.
|
||||
func buildNodeFilter(nodes []string) string {
|
||||
quotedNodes := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
quotedNodes[i] = fmt.Sprintf("'%s'", n)
|
||||
}
|
||||
return fmt.Sprintf(`"node" IN (%s)`, strings.Join(quotedNodes, ", "))
|
||||
}
|
||||
|
||||
// GET /api/v1/battery
|
||||
// getBattery godoc
|
||||
// @Summary Get last battery level for each node
|
||||
// @Description Get the last battery level for each node grouped by room
|
||||
// @Tags battery
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]map[string]map[string]any
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Security BasicAuth
|
||||
// @Router /battery [get]
|
||||
func (g *RestGateway) getBattery(c *gin.Context) {
|
||||
allNodes := g.mapping.AllNodes()
|
||||
if len(allNodes) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
|
||||
nodeFilter := buildNodeFilter(allNodes)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
"node",
|
||||
"battery"
|
||||
FROM "%s"
|
||||
WHERE %s
|
||||
AND time > now() - INTERVAL '30 minutes'
|
||||
ORDER BY time DESC
|
||||
`, g.measurementName, nodeFilter,
|
||||
)
|
||||
|
||||
it, err := g.influxGateway.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Collect last battery per node (first occurrence = most recent due to DESC order)
|
||||
nodeBattery := make(map[string]any)
|
||||
for it.Next() {
|
||||
row := it.Value()
|
||||
nodeVal, ok := row["node"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nodeID, ok := nodeVal.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, seen := nodeBattery[nodeID]; !seen {
|
||||
nodeBattery[nodeID] = row["battery"]
|
||||
}
|
||||
}
|
||||
|
||||
if err := it.Err(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Group by room
|
||||
result := make(map[string]map[string]map[string]any)
|
||||
for _, nodeID := range allNodes {
|
||||
room, ok := g.mapping.GetRoom(nodeID)
|
||||
if !ok {
|
||||
room = "not-attributed"
|
||||
}
|
||||
if result[room] == nil {
|
||||
result[room] = make(map[string]map[string]any)
|
||||
}
|
||||
battery, hasData := nodeBattery[nodeID]
|
||||
if !hasData {
|
||||
battery = "offline (30 min)"
|
||||
}
|
||||
result[room][nodeID] = map[string]any{"battery": battery}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// This function is 100% AI generated with Junie (Claude Sonnet 4.6)
|
||||
// GET /api/v1/export/csv
|
||||
// getExportCSV godoc
|
||||
// @Summary Export sensor data as CSV
|
||||
// @Description Export CO2, temperature, humidity, window status and battery data for a node over a time range
|
||||
// @Tags export
|
||||
// @Produce text/csv
|
||||
// @Param node query string true "Node MAC address (e.g. E8:F3:0A:F7:3B:F3)"
|
||||
// @Param from query string true "Start time in RFC3339 format (e.g. 2026-05-27T13:00:00Z)"
|
||||
// @Param to query string true "End time in RFC3339 format (e.g. 2026-05-27T15:00:00Z)"
|
||||
// @Success 200 {string} string "CSV file"
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Security BasicAuth
|
||||
// @Router /export/csv [get]
|
||||
func (g *RestGateway) getExportCSV(c *gin.Context) {
|
||||
node := c.Query("node")
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
|
||||
if node == "" || from == "" || to == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "node, from and to query parameters are required"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := time.Parse(time.RFC3339, from); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "from must be a valid RFC3339 timestamp"})
|
||||
return
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, to); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "to must be a valid RFC3339 timestamp"})
|
||||
return
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
time,
|
||||
co2_ppm,
|
||||
temp,
|
||||
battery,
|
||||
humidity,
|
||||
window_open
|
||||
FROM "%s"
|
||||
WHERE time >= '%s'
|
||||
AND time <= '%s'
|
||||
AND "node" = '%s'
|
||||
ORDER BY time ASC
|
||||
`, g.measurementName, from, to, node,
|
||||
)
|
||||
|
||||
it, err := g.influxGateway.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="export-%s.csv"`, strings.ReplaceAll(node, ":", "_")))
|
||||
c.Header("Content-Type", "text/csv")
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
_ = w.Write([]string{"time", "co2", "temperature", "humidity", "windows", "battery"})
|
||||
|
||||
for it.Next() {
|
||||
row := it.Value()
|
||||
|
||||
var tsStr string
|
||||
if t, ok := row["time"]; ok {
|
||||
var tt time.Time
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
tt = v
|
||||
case int64:
|
||||
tt = time.Unix(0, v)
|
||||
}
|
||||
loc := time.FixedZone("UTC+2", 2*60*60)
|
||||
tsStr = tt.In(loc).Format("2006-01-02T15:04:05")
|
||||
}
|
||||
|
||||
co2 := formatField(row["co2_ppm"])
|
||||
temp := formatField(row["temp"])
|
||||
humidity := formatField(row["humidity"])
|
||||
windowOpen := formatField(row["window_open"])
|
||||
battery := formatField(row["battery"])
|
||||
|
||||
_ = w.Write([]string{
|
||||
tsStr,
|
||||
co2,
|
||||
temp,
|
||||
humidity,
|
||||
windowOpen,
|
||||
battery,
|
||||
})
|
||||
}
|
||||
|
||||
if err := it.Err(); err != nil {
|
||||
// Headers already sent; best effort
|
||||
_ = w.Write([]string{"error", err.Error()})
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// formatField converts an any value to its string representation for CSV output.
|
||||
func formatField(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(val), 'f', -1, 32)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case bool:
|
||||
if val {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// formatTime normalizes a time value (possibly a nanosecond int64) into an RFC3339 UTC string.
|
||||
func formatTime(v any) any {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
case arrow.Timestamp:
|
||||
return time.Unix(0, int64(t)).UTC().Format(time.RFC3339)
|
||||
case int64:
|
||||
return time.Unix(0, t).UTC().Format(time.RFC3339)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RestGateway) Run(addr string) error {
|
||||
return g.engine.Run(addr)
|
||||
}
|
||||
|
||||
// GET /api/v1/rooms
|
||||
// getRooms godoc
|
||||
// @Summary Get all unique rooms
|
||||
// @Description Get a list of all unique rooms from the measurement
|
||||
// @Tags rooms
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Security BasicAuth
|
||||
// @Router /rooms [get]
|
||||
func (g *RestGateway) getRooms(c *gin.Context) {
|
||||
rooms := g.mapping.Rooms()
|
||||
slices.Sort(rooms)
|
||||
c.JSON(http.StatusOK, rooms)
|
||||
}
|
||||
|
||||
// GET /api/v1/rooms/{room-id}/current
|
||||
// getRoomCurrent godoc
|
||||
// @Summary Get current data for a room
|
||||
// @Description Get the latest record for a specific room
|
||||
// @Tags rooms
|
||||
// @Produce json
|
||||
// @Param room-id path string true "Room ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Security BasicAuth
|
||||
// @Router /rooms/{room-id}/current [get]
|
||||
func (g *RestGateway) getRoomCurrent(c *gin.Context) {
|
||||
roomID := c.Param("room-id")
|
||||
|
||||
nodes := g.mapping.NodesForRoom(roomID)
|
||||
if len(nodes) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Room not found in mapping"})
|
||||
return
|
||||
}
|
||||
nodeFilter := buildNodeFilter(nodes)
|
||||
|
||||
// Get the last record for the specific room by matching node IDs, aggregated by 5m intervals
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
date_bin(INTERVAL '5 minutes', time)::TIMESTAMP AS time,
|
||||
ROUND(AVG(co2_ppm)) AS co2_ppm,
|
||||
ROUND(AVG(temp), 2) AS temp,
|
||||
ROUND(AVG(humidity), 2) AS humidity,
|
||||
MAX(window_open) AS window_open
|
||||
FROM "%s"
|
||||
WHERE time > now() - INTERVAL '1 day'
|
||||
AND %s
|
||||
GROUP BY date_bin(INTERVAL '5 minutes', time)
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
`, g.measurementName, nodeFilter,
|
||||
)
|
||||
|
||||
// Using context.Background() as seen in working snippet
|
||||
it, err := g.influxGateway.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if it.Next() {
|
||||
val := it.Value()
|
||||
val["room"] = roomID
|
||||
val["window"] = val["window_open"]
|
||||
delete(val, "window_open")
|
||||
if t, ok := val["time"]; ok {
|
||||
val["time"] = formatTime(t)
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
return
|
||||
}
|
||||
|
||||
if err := it.Err(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Room not found or no data available"})
|
||||
}
|
||||
|
||||
// GET /api/v1/rooms/{room-id}/history
|
||||
// getRoomHistory godoc
|
||||
// @Summary Get history for a room
|
||||
// @Description Get history for a specific room
|
||||
// @Tags rooms
|
||||
// @Produce json
|
||||
// @Param room-id path string true "Room ID"
|
||||
// @Param window query string false "Time window (e.g., 1 day, 1 hour, 30 min)" default(1 day)
|
||||
// @Success 200 {array} map[string]any
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Security BasicAuth
|
||||
// @Router /rooms/{room-id}/history [get]
|
||||
func (g *RestGateway) getRoomHistory(c *gin.Context) {
|
||||
roomID := c.Param("room-id")
|
||||
window := c.DefaultQuery("window", "1 day")
|
||||
|
||||
nodes := g.mapping.NodesForRoom(roomID)
|
||||
if len(nodes) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Room not found in mapping"})
|
||||
return
|
||||
}
|
||||
nodeFilter := buildNodeFilter(nodes)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH binned AS (
|
||||
SELECT
|
||||
date_bin(INTERVAL '1 minute', time)::TIMESTAMP AS time,
|
||||
AVG(co2_ppm) AS co2_ppm,
|
||||
AVG(temp) AS temp,
|
||||
AVG(humidity) AS humidity,
|
||||
MAX(window_open) AS window_open
|
||||
FROM "%s"
|
||||
WHERE time > now() - INTERVAL '%s' - INTERVAL '5 minutes'
|
||||
AND %s
|
||||
GROUP BY date_bin(INTERVAL '1 minute', time)
|
||||
)
|
||||
SELECT
|
||||
time,
|
||||
ROUND(AVG(co2_ppm) OVER (ORDER BY time RANGE BETWEEN INTERVAL '4 minutes' PRECEDING AND CURRENT ROW)) AS co2_ppm,
|
||||
ROUND(AVG(temp) OVER (ORDER BY time RANGE BETWEEN INTERVAL '4 minutes' PRECEDING AND CURRENT ROW), 2) AS temp,
|
||||
ROUND(AVG(humidity) OVER (ORDER BY time RANGE BETWEEN INTERVAL '4 minutes' PRECEDING AND CURRENT ROW), 2) AS humidity,
|
||||
window_open
|
||||
FROM binned
|
||||
WHERE time > now() - INTERVAL '%s'
|
||||
ORDER BY time DESC
|
||||
`, g.measurementName, window, nodeFilter, window,
|
||||
)
|
||||
|
||||
// Using context.Background() as seen in working snippet
|
||||
it, err := g.influxGateway.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var history []map[string]any
|
||||
for it.Next() {
|
||||
val := it.Value()
|
||||
val["room"] = roomID
|
||||
val["window"] = val["window_open"]
|
||||
delete(val, "window_open")
|
||||
if t, ok := val["time"]; ok {
|
||||
val["time"] = formatTime(t)
|
||||
}
|
||||
history = append(history, val)
|
||||
}
|
||||
|
||||
if err := it.Err(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
func (g *RestGateway) runWatchdog() {
|
||||
// Initial check
|
||||
g.checkCO2()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
g.checkCO2()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RestGateway) checkCO2() {
|
||||
rooms := g.mapping.Rooms()
|
||||
for _, roomID := range rooms {
|
||||
nodes := g.mapping.NodesForRoom(roomID)
|
||||
if len(nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
nodeFilter := buildNodeFilter(nodes)
|
||||
|
||||
// Get the last record for the specific room by matching node IDs, aggregated by 5m intervals
|
||||
// Same logic as getRoomCurrent
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
date_bin(INTERVAL '5 minutes', time)::TIMESTAMP AS time,
|
||||
ROUND(AVG(co2_ppm)) AS co2_ppm
|
||||
FROM "%s"
|
||||
WHERE time > now() - INTERVAL '1 day'
|
||||
AND %s
|
||||
GROUP BY date_bin(INTERVAL '5 minutes', time)
|
||||
ORDER BY time ASC
|
||||
LIMIT 1
|
||||
`, g.measurementName, nodeFilter,
|
||||
)
|
||||
|
||||
it, err := g.influxGateway.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
log.Printf("[Watchdog] Error querying CO2 for room %s: %v\n", roomID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if it.Next() {
|
||||
val := it.Value()
|
||||
if co2Val, ok := val["co2_ppm"]; ok {
|
||||
co2 := int(co2Val.(float64))
|
||||
|
||||
g.statusMu.Lock()
|
||||
status, ok := g.roomStatus[roomID]
|
||||
if !ok {
|
||||
status = &RoomCO2Status{RoomID: roomID}
|
||||
g.roomStatus[roomID] = status
|
||||
}
|
||||
status.CO2 = co2
|
||||
|
||||
if co2 > g.co2ThresholdMax {
|
||||
status.IsHigh = true
|
||||
} else if co2 < g.co2ThresholdMin {
|
||||
status.IsHigh = false
|
||||
}
|
||||
g.statusMu.Unlock()
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
log.Printf("[Watchdog] Error in iterator for room %s: %v\n", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/rooms/high-co2
|
||||
// getHighCO2Rooms godoc
|
||||
// @Summary Get rooms with high CO2 levels
|
||||
// @Description Get a list of rooms where CO2 levels are above the threshold
|
||||
// @Tags rooms
|
||||
// @Produce json
|
||||
// @Success 200 {array} RoomCO2Status
|
||||
// @Security BasicAuth
|
||||
// @Router /rooms/high-co2 [get]
|
||||
func (g *RestGateway) getHighCO2Rooms(c *gin.Context) {
|
||||
g.statusMu.RLock()
|
||||
defer g.statusMu.RUnlock()
|
||||
|
||||
var result = []RoomCO2Status{}
|
||||
for _, status := range g.roomStatus {
|
||||
if status.IsHigh {
|
||||
result = append(result, *status)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by room ID for stability
|
||||
slices.SortFunc(result, func(a, b RoomCO2Status) int {
|
||||
return strings.Compare(a.RoomID, b.RoomID)
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
http:
|
||||
middlewares:
|
||||
pi-db-doc-redirect:
|
||||
redirectRegex:
|
||||
regex: "^https://doc.db.e.kb28.ch/$"
|
||||
replacement: "https://doc.db.e.kb28.ch/swagger/index.html"
|
||||
# middlewares:
|
||||
# oidc-auth-pi-db:
|
||||
# plugin:
|
||||
@@ -10,15 +15,22 @@ http:
|
||||
# ClientId: ""
|
||||
# ClientSecret: ""
|
||||
routers:
|
||||
pi-db-ui:
|
||||
rule: "Host(`ui.db.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: pi-db-ui
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
# middlewares:
|
||||
# - oidc-auth-pi-db@file
|
||||
pi-db:
|
||||
rule: "Host(`ui.e.kb28.ch`)"
|
||||
rule: "Host(`db.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: pi-db
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
# middlewares:
|
||||
# - oidc-auth-pi-db@file
|
||||
pi-mqtt-management:
|
||||
rule: "Host(`mqtt.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
@@ -26,18 +38,55 @@ http:
|
||||
service: pi-mqtt-management
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
pi-db-api:
|
||||
rule: "Host(`api.db.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: pi-db-api
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
pi-db-doc:
|
||||
rule: "Host(`doc.db.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: pi-db-api
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- pi-db-doc-redirect
|
||||
pi-ui:
|
||||
rule: "Host(`ui.e.kb28.ch`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: pi-ui
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
pi-db:
|
||||
pi-db-ui:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.42.211:8093"
|
||||
passHostHeader: true
|
||||
pi-db:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "h2c://192.168.42.211:8181"
|
||||
passHostHeader: true
|
||||
pi-mqtt-management:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.42.211:15672"
|
||||
passHostHeader: true
|
||||
pi-db-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.42.211:8080"
|
||||
passHostHeader: true
|
||||
pi-ui:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.42.211:80"
|
||||
|
||||
tcp:
|
||||
routers:
|
||||
|
||||
@@ -32,7 +32,6 @@ else
|
||||
gw -> gw : add UTC timestamp
|
||||
gw -> broker : publish JSON\n{gateway_id}/{mac}/update
|
||||
broker --> gw : on_publish confirmed
|
||||
broker -> db : store measurement
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ class "Window_status" as win{}
|
||||
class "Hygrometer" as hygro{}
|
||||
class "Thermometer" as thermo{}
|
||||
class "CO2_level" as co2{}
|
||||
class "Battery_level" as batt{}
|
||||
|
||||
sup o-d- ble
|
||||
sup o-u- sens
|
||||
@@ -22,5 +23,6 @@ sens <|-l- win
|
||||
sens <|-u- hygro
|
||||
sens <|-u- thermo
|
||||
sens <|-r- co2
|
||||
sens <|-- batt
|
||||
|
||||
@enduml
|
||||
|
||||
227
model/Study_CO2_concentration_no_window_opening.py
Normal file
227
model/Study_CO2_concentration_no_window_opening.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created on Thu Mar 26 16:19:28 2026
|
||||
|
||||
@author: alisonlecointre
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import glob
|
||||
import os
|
||||
|
||||
# %%=============================
|
||||
# File loading
|
||||
# ===============================
|
||||
|
||||
def load_file(file_name):
|
||||
sheet= pd.read_excel(file_name, sheet_name=None) #loading file, sheat_name=None : return dictionary containing df for each sheet
|
||||
|
||||
for sheet_name, df in sheet.items(): #loading all sheets from the file
|
||||
df = df.dropna(axis=1, how="all") #remove empty column "all" : all elements missing
|
||||
df.columns = df.columns.str.strip() #remove leading character
|
||||
sheet[sheet_name]=df
|
||||
return sheet
|
||||
|
||||
def load_all_files(folder): #load all .xlsx files from the folder
|
||||
files = glob.glob(f"{folder}/*.xlsx")
|
||||
all_data={} #create dictionnary of all dictionnary of each file
|
||||
|
||||
for file in files:
|
||||
name = os.path.basename(file)
|
||||
all_data[name] = load_file(file)
|
||||
return all_data
|
||||
|
||||
folder = os.getcwd()
|
||||
data = load_all_files(folder)
|
||||
|
||||
# %%=============================
|
||||
# Parameters Provence classrooms
|
||||
# ===============================
|
||||
|
||||
df_Provence_rooms = pd.DataFrame({
|
||||
"classrooms": ["A2", "A3","A4","A5","A6","A7"],
|
||||
"volume": [308, 480, 326,274,323,272],
|
||||
"n_student_max": [40, 78, 48,32,58,36]
|
||||
})
|
||||
|
||||
print (df_Provence_rooms)
|
||||
|
||||
# %%=============================
|
||||
# Calculation
|
||||
# ===============================
|
||||
|
||||
# ===============================
|
||||
# Air volume per person
|
||||
# ===============================
|
||||
|
||||
def calculation_air_volum_per_pers (df) :
|
||||
air_volume_per_pers = df["Mean room volume (m3)"]/ df["Mean number of students (-)"]
|
||||
return air_volume_per_pers
|
||||
|
||||
|
||||
calculation = {} #new dictionnary to store the air volum per person
|
||||
|
||||
for file_name, sheet in data.items() : #for each file including sheets from this file and all included in data
|
||||
calculation [file_name] = {}
|
||||
for sheet_name, df in sheet.items() : #df in all sheets
|
||||
calculation[file_name][sheet_name] = calculation_air_volum_per_pers(df)
|
||||
|
||||
# ================================
|
||||
# New data frame for simulation model of CO2 concentration increase over time
|
||||
# ================================
|
||||
|
||||
def model_no_windows_opening (CO2_t0=None, N=None, V=None, df=None) :
|
||||
CO2_prod_th = 0.0155/60 #m3/min <=> ajusted at 15.5 l/h after crossing data with simaria model [20 l/h CO2 production per person during normal activity (cf. article 16 822.113 Ordonnance 3 du 18 août 1993 relative à la loi sur le travail (OLT 3) (Protection de la santé))
|
||||
t = np.arange(0, 181, 1)
|
||||
if df is not None :
|
||||
CO2_t0 = df["CO2 rate (ppm)"].iloc[0]
|
||||
N = df["Mean number of students (-)"].iloc[0]
|
||||
V = df["Mean room volume (m3)"].iloc[0]
|
||||
CO2_t_model = CO2_t0 + ((N * CO2_prod_th * t *1e6) / V)
|
||||
return t, CO2_t_model
|
||||
|
||||
model = {}
|
||||
|
||||
for file_name, sheet in data.items() : #for each file including sheets from this file and all included in data
|
||||
model [file_name] = {}
|
||||
for sheet_name, df in sheet.items() : #df in all sheets
|
||||
t, CO2_t_model = model_no_windows_opening(df=df)
|
||||
model [file_name][sheet_name] = {"t": t, "CO2_t_model": CO2_t_model}
|
||||
|
||||
# ================================
|
||||
# Find threshold time
|
||||
# ================================
|
||||
|
||||
threshold = 1400
|
||||
|
||||
def find_threshold_time(t, CO2_t_model, threshold):
|
||||
indices = np.where(CO2_t_model >= threshold)[0]
|
||||
if len(indices) == 0:
|
||||
return None # never reached
|
||||
return t[indices[0]]
|
||||
|
||||
threshold_time = {}
|
||||
|
||||
for file_name, sheet in model.items():
|
||||
threshold_time[file_name] = {}
|
||||
for sheet_name, values in sheet.items():
|
||||
t = values["t"]
|
||||
CO2_t_model = values["CO2_t_model"]
|
||||
threshold_time[file_name][sheet_name] = find_threshold_time(t, CO2_t_model, threshold)
|
||||
|
||||
# ================================
|
||||
# Air quality thresholds for graphs
|
||||
# ================================
|
||||
thresholds = {
|
||||
"Good air quality":(0, 1400,"green"),
|
||||
"Bad air quality": (1400, 2000,"orange"),
|
||||
"Really bad air quality": (2000, np.inf,"red"),
|
||||
}
|
||||
|
||||
def plot_air_quality_zones (ax, thresholds):
|
||||
xmin, xmax = ax.get_xlim()
|
||||
ymin, ymax = ax.get_ylim()
|
||||
|
||||
for label, (y_min, y_max, color) in thresholds.items():
|
||||
if y_max != np.inf:
|
||||
ax.axhline(y=y_max, linestyle="--", color=color)
|
||||
y_top = min(y_max, ymax)
|
||||
y_bottom = max(y_min, ymin)
|
||||
y_mid = (y_bottom + y_top) / 2
|
||||
ax.axhspan(y_bottom, y_top, color=color, alpha=0.2)
|
||||
ax.text(
|
||||
(xmin + xmax) / 2,
|
||||
y_mid,
|
||||
label,
|
||||
ha="center",
|
||||
va="center",
|
||||
color="black",
|
||||
bbox=dict(facecolor="white", alpha=0.5)
|
||||
)
|
||||
|
||||
# %% =============================
|
||||
# Visualisation
|
||||
# ================================
|
||||
|
||||
# ================================
|
||||
# Graph time evolution of CO2 concentration open data
|
||||
# ================================
|
||||
|
||||
for file_name, sheet in data.items():
|
||||
fig0, ax = plt.subplots(figsize=(11, 7))
|
||||
|
||||
for sheet_name, df in sheet.items():
|
||||
t = model[file_name][sheet_name]["t"]
|
||||
CO2_t_model = model[file_name][sheet_name]["CO2_t_model"]
|
||||
t_threshold = threshold_time[file_name][sheet_name]
|
||||
ax.plot(df["time (min)"], df["CO2 rate (ppm)"], label=f"{file_name} - {sheet_name}")
|
||||
ax.plot(t,CO2_t_model, label = f"{file_name} - {sheet_name} simulated", linestyle="--")
|
||||
|
||||
# if t_threshold is not None :
|
||||
# ax.axvline(x = t_threshold, label = "time reaching threshold", linestyle=":", color="red")
|
||||
ax.legend(
|
||||
loc="upper center",
|
||||
bbox_to_anchor=(0.45, -0.08),
|
||||
ncol=3 if len(ax.get_legend_handles_labels()[1]) > 1 else 1
|
||||
)
|
||||
|
||||
fig0.tight_layout()
|
||||
|
||||
ax.set_ylim(400, 4000)
|
||||
plot_air_quality_zones(ax, thresholds)
|
||||
ax.grid(True, alpha=0.7)
|
||||
ax.set_xlabel("time (min)")
|
||||
ax.set_ylabel("CO2 concentration (ppm)")
|
||||
ax.set_title("CO2 concentration over time")
|
||||
|
||||
|
||||
# %%==========================================================================================
|
||||
# User
|
||||
# ==========================================================================================
|
||||
|
||||
print("\n")
|
||||
volume_room = float(input("Total volume (m3): "))
|
||||
n_student = float(input("Nomber of students : "))
|
||||
initial_CO2_level = float(input("Initial CO2 level (ppm): "))
|
||||
while initial_CO2_level < 400:
|
||||
print("The value must be >= 400 ppm, which correspond to the normal CO2 concentration in air. Try again.")
|
||||
initial_CO2_level = float(input("Initial CO2 level (ppm): "))
|
||||
|
||||
|
||||
#Create CO2 concentration evolution over time using simulation model and give time reaching threshold
|
||||
|
||||
t_user, CO2_t_model_user = model_no_windows_opening(CO2_t0=initial_CO2_level, N=n_student, V=volume_room, df=None)
|
||||
t_threshold = find_threshold_time(t_user, CO2_t_model_user, threshold)
|
||||
|
||||
print(f"Time reaching threshold of {threshold} ppm =", round(t_threshold),"minutes")
|
||||
|
||||
#Display CO2 concentration evolution over time
|
||||
reponse = input("Display CO2 evolution over time? (yes/no) : ").strip().lower()
|
||||
if reponse == "yes":
|
||||
|
||||
fig10, ax = plt.subplots(figsize=(10, 6))
|
||||
ax.plot(t_user, CO2_t_model_user , label="CO2 concentration over time (simulated)")
|
||||
|
||||
if t_threshold is not None :
|
||||
ax.axvline(x = t_threshold, label = "time reaching threshold", linestyle=":", color="red")
|
||||
ax.legend(
|
||||
loc="upper center",
|
||||
bbox_to_anchor=(0.45, -0.07),
|
||||
ncol=3 if len(ax.get_legend_handles_labels()[1]) > 1 else 1
|
||||
)
|
||||
fig10.tight_layout()
|
||||
|
||||
plot_air_quality_zones(ax, thresholds)
|
||||
|
||||
ax.set_title("Time evolution of CO2 concentration - Simulated")
|
||||
ax.set_xlabel("Time (min)")
|
||||
ax.set_ylabel("CO2 concentration (ppm)")
|
||||
ax.set_ylim(500, 5000)
|
||||
ax.grid(True)
|
||||
|
||||
else:
|
||||
print("Answer only with yes or no")
|
||||
|
||||
292
model/Study_CO2_concentration_window_opening.py
Normal file
292
model/Study_CO2_concentration_window_opening.py
Normal file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created on Thu Mar 26 16:19:28 2026
|
||||
|
||||
@author: alisonlecointre
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.pyplot as ticker
|
||||
import numpy as np
|
||||
import glob
|
||||
import os
|
||||
|
||||
# %%=============================
|
||||
# File loading
|
||||
# ===============================
|
||||
|
||||
def load_file(file_name):
|
||||
sheet= pd.read_excel(file_name, sheet_name=None) #loading file, sheat_name=None : return dictionary containing df for each sheet
|
||||
|
||||
for sheet_name, df in sheet.items(): #loading all sheets from the file
|
||||
df = df.dropna(axis=1, how="all") #remove empty column "all" : all elements missing
|
||||
df.columns = df.columns.str.strip() #remove leading character
|
||||
sheet[sheet_name]=df
|
||||
return sheet
|
||||
|
||||
def load_all_files(folder): #load all .xlsx files from the folder
|
||||
files = glob.glob(f"{folder}/*.xlsx")
|
||||
all_data={} #create dictionnary of all dictionnary of each file
|
||||
|
||||
for file in files:
|
||||
name = os.path.basename(file)
|
||||
all_data[name] = load_file(file)
|
||||
return all_data
|
||||
|
||||
folder = os.getcwd()
|
||||
data = load_all_files(folder)
|
||||
|
||||
# %%=============================
|
||||
# Parameters Provence classrooms
|
||||
# ===============================
|
||||
|
||||
df_Provence_rooms = pd.DataFrame({
|
||||
"classrooms": ["A2","A3","A4","A5","A6","A7"],
|
||||
"volume": [308, 480, 326,274,323,272],
|
||||
"n_student_max": [40, 78, 48,32,58,36],
|
||||
"windows_surface": [3.23, 22.12, 3.23, 22.12, 3.23, 5.53]
|
||||
})
|
||||
|
||||
print (df_Provence_rooms)
|
||||
|
||||
# %%=============================
|
||||
# Calculation
|
||||
# ===============================
|
||||
|
||||
# ===============================
|
||||
# Air volume per person
|
||||
# ===============================
|
||||
|
||||
def calculation_air_volum_per_pers (df) :
|
||||
air_volume_per_pers = df["Mean room volume (m3)"]/ df["Mean number of students (-)"]
|
||||
return air_volume_per_pers
|
||||
|
||||
|
||||
calculation = {} #new dictionnary to store the air volum per person
|
||||
|
||||
for file_name, sheet in data.items() : #for each file including sheets from this file and all included in data
|
||||
calculation [file_name] = {}
|
||||
for sheet_name, df in sheet.items() : #df in all sheets
|
||||
calculation[file_name][sheet_name] = calculation_air_volum_per_pers(df)
|
||||
|
||||
# ================================
|
||||
# New data frame for simulation model of CO2 concentration increase over time
|
||||
# ================================
|
||||
CO2_prod_th = 0.0155/60 #m3/min <=> ajusted at 15.5 l/h after crossing data with simaria model [20 l/h CO2 production per person during normal activity (cf. article 16 822.113 Ordonnance 3 du 18 août 1993 relative à la loi sur le travail (OLT 3) (Protection de la santé))
|
||||
|
||||
def model_no_windows_opening (CO2_t0=None, N=None, V=None, df=None) :
|
||||
t = np.arange(0, 181, 1)
|
||||
if df is not None :
|
||||
CO2_t0 = df["CO2 rate (ppm)"].iloc[0]
|
||||
N = df["Mean number of students (-)"].iloc[0]
|
||||
V = df["Mean room volume (m3)"].iloc[0]
|
||||
CO2_t_model = CO2_t0 + ((N * CO2_prod_th * t *1e6) / V)
|
||||
return t, CO2_t_model
|
||||
|
||||
model = {}
|
||||
|
||||
for file_name, sheet in data.items() : #for each file including sheets from this file and all included in data
|
||||
model [file_name] = {}
|
||||
for sheet_name, df in sheet.items() : #df in all sheets
|
||||
t, CO2_t_model = model_no_windows_opening(df=df)
|
||||
model [file_name][sheet_name] = {"t": t, "CO2_t_model": CO2_t_model}
|
||||
|
||||
# ================================
|
||||
# Find threshold time
|
||||
# ================================
|
||||
|
||||
threshold = 1400
|
||||
|
||||
def find_threshold_time(t, CO2_t_model, threshold):
|
||||
indices = np.where(CO2_t_model >= threshold)[0]
|
||||
if len(indices) == 0:
|
||||
return None # never reached
|
||||
return t[indices[0]]
|
||||
|
||||
threshold_time = {}
|
||||
|
||||
for file_name, sheet in model.items():
|
||||
threshold_time[file_name] = {}
|
||||
for sheet_name, values in sheet.items():
|
||||
t = values["t"]
|
||||
CO2_t_model = values["CO2_t_model"]
|
||||
threshold_time[file_name][sheet_name] = find_threshold_time(t, CO2_t_model, threshold)
|
||||
|
||||
# ================================
|
||||
# New data frame for simulation model of CO2 concentration decrease over time (using SIA 382/1 norm)
|
||||
# ================================
|
||||
v_air = 0.5 #m/s Assumption (cf. article)
|
||||
|
||||
def model_windows_opening_CO2 (S_windows=None, volume=None, df=None):
|
||||
t = np.arange(0, 1, 0.01)
|
||||
if df is not None:
|
||||
for i, row in df.iterrows():
|
||||
S_windows = df_Provence_rooms["windows_surface"].iloc[i]
|
||||
volume = df_Provence_rooms["volume"].iloc[i]
|
||||
q_air_change = S_windows * v_air * 3600 #m3/h
|
||||
CO2_ext = 400 #ppm
|
||||
CO2_int_0 = threshold #ppm
|
||||
CO2_t_model_ventilation = ((CO2_int_0 - CO2_ext - ((0.001 * (CO2_prod_th * 1000*60))/q_air_change))* np.exp((-q_air_change * t)/volume) + CO2_ext + ((0.001 * (CO2_prod_th * 1000*60))/q_air_change) ) #ppm CO2_t_model_ventilation = ((CO2_int_0 - CO2_ext - ((0.001 * (CO2_prod_thx))/q_air_change))* np.exp((-q_air_change * t)/volume) + CO2_ext + ((0.001 * (CO2_prod_thx))/q_air_change) ) #ppm
|
||||
t = t*60
|
||||
return t, CO2_t_model_ventilation
|
||||
|
||||
model_with_windows = {}
|
||||
for i, row in df.iterrows():
|
||||
t, CO2_t_model_ventilation = model_windows_opening_CO2(df=row.to_frame().T)
|
||||
model_with_windows[i] = pd.DataFrame({"CO2_t_model_ventilation" : CO2_t_model_ventilation, "t" : t})
|
||||
|
||||
# ================================
|
||||
# Find window opening duration
|
||||
# ================================
|
||||
|
||||
def find_windows_opening_time (t,CO2_t_model_ventilation):
|
||||
windows_opening_duration = np.interp(405, CO2_t_model_ventilation[::-1] , t[::-1])
|
||||
return windows_opening_duration
|
||||
|
||||
|
||||
# ================================
|
||||
# Air quality thresholds for graphs
|
||||
# ================================
|
||||
thresholds = {
|
||||
"Good air quality":(0, 1400,"green"),
|
||||
"Bad air quality": (1400, 2000,"orange"),
|
||||
"Really bad air quality": (2000, np.inf,"red"),
|
||||
}
|
||||
|
||||
def plot_air_quality_zones (ax, thresholds):
|
||||
xmin, xmax = ax.get_xlim()
|
||||
ymin, ymax = ax.get_ylim()
|
||||
|
||||
for label, (y_min, y_max, color) in thresholds.items():
|
||||
if y_max != np.inf:
|
||||
ax.axhline(y=y_max, linestyle="--", color=color)
|
||||
y_top = min(y_max, ymax)
|
||||
y_bottom = max(y_min, ymin)
|
||||
y_mid = (y_bottom + y_top) / 2
|
||||
ax.axhspan(y_bottom, y_top, color=color, alpha=0.2)
|
||||
ax.text(
|
||||
(xmin + xmax) / 2,
|
||||
y_mid,
|
||||
label,
|
||||
ha="center",
|
||||
va="center",
|
||||
color="black",
|
||||
bbox=dict(facecolor="white", alpha=0.5)
|
||||
)
|
||||
|
||||
# %% =============================
|
||||
# Visualisation
|
||||
# ================================
|
||||
|
||||
# ================================
|
||||
# Graph time evolution of CO2 concentration open data
|
||||
# ================================
|
||||
|
||||
for file_name, sheet in data.items():
|
||||
fig0, ax = plt.subplots(figsize=(10, 7))
|
||||
|
||||
for sheet_name, df in sheet.items():
|
||||
t = model[file_name][sheet_name]["t"]
|
||||
CO2_t_model = model[file_name][sheet_name]["CO2_t_model"]
|
||||
t_threshold = threshold_time[file_name][sheet_name]
|
||||
ax.plot(df["time (min)"], df["CO2 rate (ppm)"], label=f"{file_name} - {sheet_name}")
|
||||
ax.plot(t,CO2_t_model, label = f"{file_name} - {sheet_name} simulated", linestyle="--")
|
||||
|
||||
if t_threshold is not None :
|
||||
ax.axvline(x = t_threshold, label = "time reaching threshold", linestyle=":", color="red")
|
||||
ax.legend(
|
||||
loc="upper center",
|
||||
bbox_to_anchor=(0.45, -0.08),
|
||||
ncol=3 if len(ax.get_legend_handles_labels()[1]) > 1 else 1
|
||||
)
|
||||
|
||||
fig0.tight_layout()
|
||||
|
||||
ax.set_ylim(0, 5000)
|
||||
plot_air_quality_zones (ax, thresholds)
|
||||
ax.grid(True, alpha=0.7)
|
||||
ax.set_xlabel("time (min)")
|
||||
ax.set_ylabel("CO2 concentration (ppm)")
|
||||
ax.set_title("CO2 concentration over time")
|
||||
|
||||
# ================================
|
||||
# Graph time evolution of CO2 concentration after window opening
|
||||
# ================================
|
||||
|
||||
fig1, ax= plt.subplots(figsize=(10, 6))
|
||||
for i in model_with_windows:
|
||||
t = model_with_windows [i]["t"]
|
||||
CO2_t_model_ventilation = model_with_windows[i]["CO2_t_model_ventilation"]
|
||||
ax.plot(t, CO2_t_model_ventilation, label=df_Provence_rooms.loc[i, "classrooms"])
|
||||
ax.legend()
|
||||
ax.xaxis.set_major_locator(ticker.MultipleLocator(2))
|
||||
ax.set_xlim(0, 20)
|
||||
ax.grid(True, alpha=0.7)
|
||||
ax.set_xlabel("time (min)")
|
||||
ax.set_ylabel("CO2 concentration (ppm)")
|
||||
ax.set_title("CO2 concentration over time after window opening - with open data")
|
||||
|
||||
# %%==========================================================================================
|
||||
# User
|
||||
# ==========================================================================================
|
||||
|
||||
print("\n")
|
||||
name_classroom = input("Classroom number (A2 to A7) : ")
|
||||
volume_room = df_Provence_rooms.loc[df_Provence_rooms["classrooms"] == name_classroom, "volume"].values
|
||||
S_windows_room = df_Provence_rooms.loc[df_Provence_rooms["classrooms"] == name_classroom, "windows_surface"].values
|
||||
|
||||
n_student = float(input("Number of students : "))
|
||||
|
||||
initial_CO2_level = float(input("Initial CO2 level (ppm): "))
|
||||
|
||||
while initial_CO2_level < 400:
|
||||
print("The value must be >= 400 ppm, which correspond to the normal CO2 concentration in air. Try again.")
|
||||
initial_CO2_level = float(input("Initial CO2 level (ppm): "))
|
||||
|
||||
|
||||
#Create CO2 concentration evolution over time using simulation model and give time reaching threshold
|
||||
|
||||
t_user, CO2_t_model_user = model_no_windows_opening(CO2_t0=initial_CO2_level, N=n_student, V=volume_room, df=None)
|
||||
t_threshold = find_threshold_time(t_user, CO2_t_model_user, threshold)
|
||||
|
||||
t_user2, CO2_t_model_ventilation_user = model_windows_opening_CO2(S_windows =S_windows_room, volume = volume_room, df=None)
|
||||
t_user3 = t_user2 + t_threshold #start at the time reaching threshold
|
||||
|
||||
windows_opening_duration = find_windows_opening_time (t_user2, CO2_t_model_ventilation_user)
|
||||
|
||||
print(f"Time reaching threshold of {threshold} ppm =", round(t_threshold),"minutes")
|
||||
print(f"Open windows for {windows_opening_duration:.0f} min after reaching threshold")
|
||||
|
||||
#Display CO2 concentration evolution over time
|
||||
reponse = input("Display CO2 evolution over time? (yes/no) : ").strip().lower()
|
||||
while reponse not in ["yes", "no"]:
|
||||
print("Answer with yes or no")
|
||||
reponse = input("Display CO2 evolution over time? (yes/no) : ").strip().lower()
|
||||
|
||||
if reponse == "yes":
|
||||
|
||||
fig10, ax = plt.subplots(figsize=(10, 6))
|
||||
ax.plot(t_user, CO2_t_model_user , label="CO2 concentration over time (simulated)")
|
||||
ax.plot(t_user3, CO2_t_model_ventilation_user, label="with opened windows", color="brown")
|
||||
|
||||
if t_threshold is not None :
|
||||
ax.axvline(x = t_threshold, label = "time reaching threshold", linestyle=":", color="red")
|
||||
ax.legend(
|
||||
loc="upper center",
|
||||
bbox_to_anchor=(0.45, -0.07),
|
||||
ncol=3 if len(ax.get_legend_handles_labels()[1]) > 1 else 1
|
||||
)
|
||||
fig10.tight_layout()
|
||||
|
||||
plot_air_quality_zones(ax, thresholds)
|
||||
|
||||
ax.set_title("Time evolution of CO2 concentration - Simulated")
|
||||
ax.set_xlabel("Time (min)")
|
||||
ax.set_ylabel("CO2 concentration (ppm)")
|
||||
ax.set_xlim(0, 150)
|
||||
ax.set_ylim(400, 5000)
|
||||
ax.grid(True)
|
||||
|
||||
|
||||
4
model/requirements.txt
Normal file
4
model/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pandas
|
||||
matplotlib
|
||||
numpy
|
||||
openpyxl
|
||||
7
nodes/CMakeLists.txt
Normal file
7
nodes/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
cmake_minimum_required(VERSION 3.20.0)
|
||||
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
|
||||
project(plein_de_eeeeeeeeeeee)
|
||||
|
||||
target_sources(app PRIVATE src/main.c src/window_status.c src/window_status.h src/thermometer.c src/thermometer.h src/hygrometer.c src/hygrometer.h src/co2_level.c src/co2_level.h src/supervisor.c src/supervisor.h src/ble_advertiser.c src/ble_advertiser.h)
|
||||
97
nodes/README.rst
Normal file
97
nodes/README.rst
Normal file
@@ -0,0 +1,97 @@
|
||||
.. zephyr:code-sample:: blinky
|
||||
:name: Blinky
|
||||
:relevant-api: gpio_interface
|
||||
|
||||
Blink an LED forever using the GPIO API.
|
||||
|
||||
Overview
|
||||
********
|
||||
|
||||
The Blinky sample blinks an LED forever using the :ref:`GPIO API <gpio_api>`.
|
||||
|
||||
The source code shows how to:
|
||||
|
||||
#. Get a pin specification from the :ref:`devicetree <dt-guide>` as a
|
||||
:c:struct:`gpio_dt_spec`
|
||||
#. Configure the GPIO pin as an output
|
||||
#. Toggle the pin forever
|
||||
|
||||
See :zephyr:code-sample:`pwm-blinky` for a similar sample that uses the PWM API instead.
|
||||
|
||||
.. _blinky-sample-requirements:
|
||||
|
||||
Requirements
|
||||
************
|
||||
|
||||
Your board must:
|
||||
|
||||
#. Have an LED connected via a GPIO pin (these are called "User LEDs" on many of
|
||||
Zephyr's :ref:`boards`).
|
||||
#. Have the LED configured using the ``led0`` devicetree alias.
|
||||
|
||||
Building and Running
|
||||
********************
|
||||
|
||||
Build and flash Blinky as follows, changing ``reel_board`` for your board:
|
||||
|
||||
.. zephyr-app-commands::
|
||||
:zephyr-app: samples/basic/blinky
|
||||
:board: reel_board
|
||||
:goals: build flash
|
||||
:compact:
|
||||
|
||||
After flashing, the LED starts to blink and messages with the current LED state
|
||||
are printed on the console. If a runtime error occurs, the sample exits without
|
||||
printing to the console.
|
||||
|
||||
Build errors
|
||||
************
|
||||
|
||||
You will see a build error at the source code line defining the ``struct
|
||||
gpio_dt_spec led`` variable if you try to build Blinky for an unsupported
|
||||
board.
|
||||
|
||||
On GCC-based toolchains, the error looks like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
error: '__device_dts_ord_DT_N_ALIAS_led_P_gpios_IDX_0_PH_ORD' undeclared here (not in a function)
|
||||
|
||||
Adding board support
|
||||
********************
|
||||
|
||||
To add support for your board, add something like this to your devicetree:
|
||||
|
||||
.. code-block:: DTS
|
||||
|
||||
/ {
|
||||
aliases {
|
||||
led0 = &myled0;
|
||||
};
|
||||
|
||||
leds {
|
||||
compatible = "gpio-leds";
|
||||
myled0: led_0 {
|
||||
gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
The above sets your board's ``led0`` alias to use pin 13 on GPIO controller
|
||||
``gpio0``. The pin flags :c:macro:`GPIO_ACTIVE_LOW` mean the LED is on when
|
||||
the pin is set to its low state, and off when the pin is in its high state.
|
||||
|
||||
Tips:
|
||||
|
||||
- See :dtcompatible:`gpio-leds` for more information on defining GPIO-based LEDs
|
||||
in devicetree.
|
||||
|
||||
- If you're not sure what to do, check the devicetrees for supported boards which
|
||||
use the same SoC as your target. See :ref:`get-devicetree-outputs` for details.
|
||||
|
||||
- See :zephyr_file:`include/zephyr/dt-bindings/gpio/gpio.h` for the flags you can use
|
||||
in devicetree.
|
||||
|
||||
- If the LED is built in to your board hardware, the alias should be defined in
|
||||
your :ref:`BOARD.dts file <devicetree-in-out-files>`. Otherwise, you can
|
||||
define one in a :ref:`devicetree overlay <set-devicetree-overlays>`.
|
||||
11
nodes/app.overlay
Normal file
11
nodes/app.overlay
Normal file
@@ -0,0 +1,11 @@
|
||||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
|
||||
/ {
|
||||
buttons{
|
||||
compatible = "gpio-keys";
|
||||
window_switch: window_switch {
|
||||
gpios = <&sx1509b 0 GPIO_ACTIVE_LOW>;
|
||||
label = "Window Switch";
|
||||
};
|
||||
};
|
||||
};
|
||||
3
nodes/prj.conf
Normal file
3
nodes/prj.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
CONFIG_I2C=y
|
||||
CONFIG_SENSOR=y
|
||||
CONFIG_BT=y
|
||||
4281
nodes/release/nodes_v1.0.hex
Normal file
4281
nodes/release/nodes_v1.0.hex
Normal file
File diff suppressed because it is too large
Load Diff
12
nodes/sample.yaml
Normal file
12
nodes/sample.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
sample:
|
||||
name: Blinky Sample
|
||||
tests:
|
||||
sample.basic.blinky:
|
||||
tags:
|
||||
- LED
|
||||
- gpio
|
||||
filter: dt_enabled_alias_with_parent_compat("led0", "gpio-leds")
|
||||
depends_on: gpio
|
||||
harness: led
|
||||
integration_platforms:
|
||||
- frdm_k64f
|
||||
67
nodes/src/ble_advertiser.c
Normal file
67
nodes/src/ble_advertiser.c
Normal file
@@ -0,0 +1,67 @@
|
||||
#include "ble_advertiser.h"
|
||||
|
||||
static const int BT_MS_ADV_UP = 500; // [ms] time during which the advertising is active
|
||||
|
||||
static const char BT_KEY_WINDOW = 0x01;
|
||||
static const char BT_KEY_HUMIDITY = 0x02;
|
||||
static const char BT_KEY_TEMP = 0x03;
|
||||
static const char BT_KEY_CO2_LVL = 0x04;
|
||||
|
||||
// value size [B]
|
||||
static const char BT_PREAMBLE_SIZE = 2;
|
||||
static const char BT_VALUE_SIZE_WINDOW = 1;
|
||||
static const char BT_VALUE_SIZE_HUMIDITY = 1;
|
||||
static const char BT_VALUE_SIZE_TEMP = 2;
|
||||
static const char BT_VALUE_SIZE_CO2_LVL = 4;
|
||||
|
||||
static const int BT_AD_DATA_INDEX_WINDOW = BT_PREAMBLE_SIZE + 1;
|
||||
static const int BT_AD_DATA_INDEX_HUMIDITY = BT_AD_DATA_INDEX_WINDOW + 2;
|
||||
static const int BT_AD_DATA_INDEX_TEMP = BT_AD_DATA_INDEX_HUMIDITY + 2;
|
||||
static const int BT_AD_DATA_INDEX_CO2_LVL = BT_AD_DATA_INDEX_TEMP + 3;
|
||||
|
||||
// sum of all value size + size for all keys
|
||||
static const char BT_AD_TOTAL_SIZE =
|
||||
BT_VALUE_SIZE_WINDOW + BT_VALUE_SIZE_HUMIDITY + BT_VALUE_SIZE_TEMP + BT_VALUE_SIZE_CO2_LVL + 4 + 2;
|
||||
|
||||
static uint8_t ad_data[] = {
|
||||
0xff, 0xff,
|
||||
BT_KEY_WINDOW, 0x00,
|
||||
BT_KEY_HUMIDITY, 0x00,
|
||||
BT_KEY_TEMP, 0x00, 0x00,
|
||||
BT_KEY_CO2_LVL, 0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
static const struct bt_data ad[] = {
|
||||
BT_DATA(BT_DATA_MANUFACTURER_DATA, ad_data, BT_AD_TOTAL_SIZE),
|
||||
};
|
||||
|
||||
enum error_code ble_init(){
|
||||
return ((0 == bt_enable(NULL)) ? success : init_failed);
|
||||
}
|
||||
|
||||
enum error_code ble_advertise(
|
||||
enum window_status window_value,
|
||||
int hygro_value,
|
||||
int thermo_value,
|
||||
int co2_lvl_value
|
||||
){
|
||||
enum error_code ret = write_failed;
|
||||
int shift = 0;
|
||||
// set values here
|
||||
ad_data[BT_AD_DATA_INDEX_WINDOW] = (uint8_t)(window_value == open ? 0x1 : 0x0);
|
||||
ad_data[BT_AD_DATA_INDEX_HUMIDITY] = (uint8_t)(hygro_value & 0xff);
|
||||
for(int i=0;i<BT_VALUE_SIZE_TEMP;i++){
|
||||
shift = 8 * (BT_VALUE_SIZE_TEMP - i - 1);
|
||||
ad_data[BT_AD_DATA_INDEX_TEMP + i] = (uint8_t)((thermo_value>>(shift)) & 0xff);
|
||||
}
|
||||
for(int i=0;i<BT_VALUE_SIZE_CO2_LVL;i++){
|
||||
shift = 8 * (BT_VALUE_SIZE_CO2_LVL - i - 1);
|
||||
ad_data[BT_AD_DATA_INDEX_CO2_LVL + i] = (uint8_t)((co2_lvl_value>>(shift)) & 0xff);
|
||||
}
|
||||
if(0 == bt_le_adv_start(BT_LE_ADV_NCONN, ad, ARRAY_SIZE(ad), NULL, 0)){
|
||||
k_msleep(BT_MS_ADV_UP);
|
||||
if(0 == bt_le_adv_stop()){
|
||||
ret = success;
|
||||
}else{}
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
19
nodes/src/ble_advertiser.h
Normal file
19
nodes/src/ble_advertiser.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef BLE_ADVERTISER_H
|
||||
#define BLE_ADVERTISER_H
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/bluetooth/assigned_numbers.h>
|
||||
#include <zephyr/bluetooth/bluetooth.h>
|
||||
|
||||
#include "error_code.h"
|
||||
#include "window_status.h"
|
||||
|
||||
enum error_code ble_init();
|
||||
enum error_code ble_advertise(
|
||||
enum window_status window_value,
|
||||
int hygro_value,
|
||||
int thermo_value,
|
||||
int co2_lvl_value
|
||||
);
|
||||
|
||||
#endif //BLE_ADVERTISER_H
|
||||
36
nodes/src/co2_level.c
Normal file
36
nodes/src/co2_level.c
Normal file
@@ -0,0 +1,36 @@
|
||||
#include "co2_level.h"
|
||||
|
||||
static const struct device* dev = DEVICE_DT_GET_ONE(ams_ccs811);
|
||||
|
||||
const int CO2_LEVEL_EMPTY_ROOM = 400; // [ppm]
|
||||
|
||||
enum error_code co2_lvl_init(){
|
||||
enum error_code ret = init_failed;
|
||||
if(device_is_ready(dev)){
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum error_code co2_lvl_get_value(int* holder){
|
||||
struct sensor_value temp, humidity, co2;
|
||||
enum error_code ret = read_failed;
|
||||
int temp_value, humidity_value;
|
||||
if( (success == thermo_get_value(&temp_value)) && (success == hygro_get_value(&humidity_value)) ){
|
||||
// temperature conversion from deci, no function in the API
|
||||
temp.val1 = temp_value/10;
|
||||
temp.val2 = temp_value%10;
|
||||
// humidity conversion is straight away
|
||||
humidity.val1 = humidity_value;
|
||||
|
||||
if(
|
||||
(0 == ccs811_envdata_update(dev, &temp, &humidity)) &&
|
||||
(0 == sensor_sample_fetch(dev)) &&
|
||||
(0 == sensor_channel_get(dev, SENSOR_CHAN_CO2, &co2))
|
||||
){
|
||||
*holder = co2.val1; // taking only the integer part
|
||||
ret = success;
|
||||
}else{}
|
||||
}else{}
|
||||
return success;
|
||||
}
|
||||
16
nodes/src/co2_level.h
Normal file
16
nodes/src/co2_level.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#ifndef CO2_LEVEL_H
|
||||
#define CO2_LEVEL_H
|
||||
|
||||
#include <zephyr/drivers/sensor.h>
|
||||
#include <zephyr/drivers/sensor/ccs811.h>
|
||||
|
||||
#include "error_code.h"
|
||||
#include "hygrometer.h"
|
||||
#include "thermometer.h"
|
||||
|
||||
extern const int CO2_LEVEL_EMPTY_ROOM; // [ppm]
|
||||
|
||||
enum error_code co2_lvl_init();
|
||||
enum error_code co2_lvl_get_value(int* holder);
|
||||
|
||||
#endif //CO2_LEVEL_H
|
||||
15
nodes/src/error_code.h
Normal file
15
nodes/src/error_code.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#ifndef ERROR_CODE_H
|
||||
#define ERROR_CODE_H
|
||||
|
||||
#include <limits.h>
|
||||
|
||||
enum error_code{
|
||||
success = 0,
|
||||
init_failed,
|
||||
read_failed,
|
||||
write_failed,
|
||||
error_unknown,
|
||||
error_code_last, // iteration purpose
|
||||
};
|
||||
|
||||
#endif //ERROR_CODE_H
|
||||
23
nodes/src/hygrometer.c
Normal file
23
nodes/src/hygrometer.c
Normal file
@@ -0,0 +1,23 @@
|
||||
#include "hygrometer.h"
|
||||
|
||||
static const struct device* dev = DEVICE_DT_GET_ONE(st_hts221);
|
||||
|
||||
enum error_code hygro_init(){
|
||||
enum error_code ret = init_failed;
|
||||
if(device_is_ready(dev)){
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum error_code hygro_get_value(int* holder){
|
||||
enum error_code ret = read_failed;
|
||||
struct sensor_value humidity;
|
||||
if( (sensor_sample_fetch(dev) >= 0) &&
|
||||
(sensor_channel_get(dev, SENSOR_CHAN_HUMIDITY, &humidity) >= 0)
|
||||
){
|
||||
*holder = humidity.val1; //taking only the integer part
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
13
nodes/src/hygrometer.h
Normal file
13
nodes/src/hygrometer.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef HYGROMETER_H
|
||||
#define HYGROMETER_H
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/drivers/sensor.h>
|
||||
|
||||
#include "error_code.h"
|
||||
|
||||
enum error_code hygro_init();
|
||||
enum error_code hygro_get_value(int* holder);
|
||||
|
||||
#endif //HYGROMETER_H
|
||||
18
nodes/src/main.c
Normal file
18
nodes/src/main.c
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2016 Intel Corporation
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/drivers/gpio.h>
|
||||
|
||||
#include "supervisor.h"
|
||||
|
||||
int main(void){
|
||||
supervisor_init();
|
||||
supervisor_run();
|
||||
// should never come here
|
||||
return 0;
|
||||
}
|
||||
53
nodes/src/supervisor.c
Normal file
53
nodes/src/supervisor.c
Normal file
@@ -0,0 +1,53 @@
|
||||
#include "supervisor.h"
|
||||
|
||||
const int SLEEP_GRANULARITY = 2; // [min]
|
||||
const int SLEEP_MIN_DURATION = SLEEP_GRANULARITY; // [min]
|
||||
const int SLEEP_MAX_DURATION = 30; // [min]
|
||||
|
||||
enum error_code supervisor_init(){
|
||||
enum error_code ret = init_failed;
|
||||
ret = ble_init();
|
||||
if(success == ret){
|
||||
ret = co2_lvl_init();
|
||||
if(success == ret){
|
||||
ret = hygro_init();
|
||||
if(success == ret){
|
||||
ret = thermo_init();
|
||||
if(success == ret){
|
||||
ret = window_init();
|
||||
}else{}
|
||||
}else{}
|
||||
}else{}
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum error_code supervisor_run(){
|
||||
int co2_lvl_value = -1;
|
||||
int hygro_value = -1;
|
||||
int thermo_value = -1;
|
||||
enum window_status window_value = unknown;
|
||||
enum error_code co2_lvl_status, hygro_status, thermo_status, window_status;
|
||||
int current_sleep_time = SLEEP_MIN_DURATION;
|
||||
while(1){
|
||||
co2_lvl_status = co2_lvl_get_value(&co2_lvl_value);
|
||||
hygro_status = hygro_get_value(&hygro_value);
|
||||
thermo_status = thermo_get_value(&thermo_value);
|
||||
window_status = window_get_value(&window_value);
|
||||
// maybe change arguments order
|
||||
// todo : manage special values
|
||||
ble_advertise(window_value, hygro_value, thermo_value, co2_lvl_value);
|
||||
if((co2_lvl_value > CO2_LEVEL_EMPTY_ROOM) || (window_value == open)){
|
||||
// there are people in the room, or someone forgot to close the window
|
||||
current_sleep_time = SLEEP_MIN_DURATION;
|
||||
}else{
|
||||
// no one is in the room, we can wait a liitle bit longer before getting the next data point
|
||||
current_sleep_time += SLEEP_GRANULARITY;
|
||||
if(current_sleep_time > SLEEP_MAX_DURATION){
|
||||
current_sleep_time = SLEEP_MAX_DURATION;
|
||||
}else{}
|
||||
}
|
||||
k_sleep(K_MINUTES(current_sleep_time));
|
||||
}
|
||||
return error_unknown; // should never return
|
||||
}
|
||||
21
nodes/src/supervisor.h
Normal file
21
nodes/src/supervisor.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#ifndef SUPERVISOR_H
|
||||
#define SUPERVISOR_H
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
|
||||
#include "error_code.h"
|
||||
|
||||
#include "ble_advertiser.h"
|
||||
#include "window_status.h"
|
||||
#include "thermometer.h"
|
||||
#include "hygrometer.h"
|
||||
#include "co2_level.h"
|
||||
|
||||
extern const int SLEEP_GRANULARITY; // [min]
|
||||
extern const int SLEEP_MIN_DURATION; // [min]
|
||||
extern const int SLEEP_MAX_DURATION; // [min]
|
||||
|
||||
enum error_code supervisor_init();
|
||||
enum error_code supervisor_run();
|
||||
|
||||
#endif //SUPERVISOR_H
|
||||
23
nodes/src/thermometer.c
Normal file
23
nodes/src/thermometer.c
Normal file
@@ -0,0 +1,23 @@
|
||||
#include "thermometer.h"
|
||||
|
||||
static const struct device* dev = DEVICE_DT_GET_ONE(st_hts221);
|
||||
|
||||
enum error_code thermo_init(){
|
||||
enum error_code ret = init_failed;
|
||||
if(device_is_ready(dev)){
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum error_code thermo_get_value(int* holder){
|
||||
enum error_code ret = read_failed;
|
||||
struct sensor_value temp;
|
||||
if( (sensor_sample_fetch(dev) >= 0) &&
|
||||
(sensor_channel_get(dev, SENSOR_CHAN_AMBIENT_TEMP, &temp) >= 0)
|
||||
){
|
||||
*holder = sensor_value_to_deci(&temp);
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
13
nodes/src/thermometer.h
Normal file
13
nodes/src/thermometer.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef THERMOMETER_H
|
||||
#define THERMOMETER_H
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/drivers/sensor.h>
|
||||
|
||||
#include "error_code.h"
|
||||
|
||||
enum error_code thermo_init();
|
||||
enum error_code thermo_get_value(int* holder);
|
||||
|
||||
#endif //THERMOMETER_H
|
||||
17
nodes/src/window_status.c
Normal file
17
nodes/src/window_status.c
Normal file
@@ -0,0 +1,17 @@
|
||||
#include "window_status.h"
|
||||
|
||||
static const struct gpio_dt_spec window_switch = GPIO_DT_SPEC_GET(DT_NODELABEL(window_switch), gpios);
|
||||
|
||||
enum error_code window_init(){
|
||||
enum error_code ret = init_failed;
|
||||
if(gpio_is_ready_dt(&window_switch) && gpio_pin_configure_dt(&window_switch, GPIO_INPUT)){
|
||||
ret = success;
|
||||
}else{}
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum error_code window_get_value(enum window_status* holder){
|
||||
// active high
|
||||
*holder = (gpio_pin_get_dt(&window_switch) ? closed : open);
|
||||
return success;
|
||||
}
|
||||
18
nodes/src/window_status.h
Normal file
18
nodes/src/window_status.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#ifndef WINDOW_STATUS_H
|
||||
#define WINDOW_STATUS_H
|
||||
|
||||
#include <zephyr/drivers/gpio.h>
|
||||
|
||||
#include "error_code.h"
|
||||
|
||||
enum window_status{
|
||||
closed = 0,
|
||||
open,
|
||||
unknown,
|
||||
windows_status_last, // iteration purpose
|
||||
};
|
||||
|
||||
enum error_code window_init();
|
||||
enum error_code window_get_value(enum window_status* holder);
|
||||
|
||||
#endif //WINDOW_STATUS_
|
||||
Reference in New Issue
Block a user