60 Commits

Author SHA1 Message Date
2f7c88b701 refactor(db): add constante for defaultTimeout 2026-06-04 14:46:36 +02:00
0f30749534 refactor(db): add constante for max QOS 2026-06-04 14:46:36 +02:00
f4ab3093c3 fix(db): return error directly 2026-06-04 14:46:36 +02:00
89023b86ac fix(db): return only high co2 room for co2-status endpoint
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:36 +02:00
811641c58b refactor(db): change order for history
Get now value from the most recent to the most ancien requested value

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:36 +02:00
be5772e488 refactor(db): using moving average for room history
History is now a moving average over 5min by slice of 1min

Assisted-by: Junie:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:36 +02:00
53fbc87af6 feat(db): add co2-status endpoint in REST API
This endpoint get the co2 status and return for each room if the co2 is too high or in the acceptable level

Assisted-by: Junie:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:36 +02:00
2b766d3d96 feat(db): add co2 watchdog on each room
Assisted-by: Junie:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
7c000f3b9c fix(db): return window tag in room status
Breakchange in Influx, it's now window_open. Change this tag in the returned json to previous tag "window" to avoir a breaking change

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
5a3f8c3c5c feat(server): add traefik entry for ui dashboard
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
2f57e886c0 fix(db): return time in RFC3339 to avoid breakchange
Assisted-by: Junie:claude-opus-4.8
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
fab79aa6b6 fix(db): add the room in the returned json
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
3ff484359e refactor(db): round averages in SQL queries
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
5198784c37 refactor(db): adapt SQL query for 5 min average
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
eecb4a196b refactor(db): add flag to run without MQTT part
Use for local test for developement

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:35 +02:00
8c3b00edd8 feat(db): add endpoint to export influx data to csv
Assisted-by: Junie:claude-sonnet-4.6
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
25c8327662 fix(db): mqtt hostname to keep internal config identique in case of rebuild
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
2355c8f0e9 chore(db): set 30min before offline for battery endpoint
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
c78f1e1509 fix(db): filter random value
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
9776695228 fix(db): push data with partial mapping
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
bde8184ad1 fix(db): window -> window_open
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
022fb97153 fix(db): remove authorization for battery status endpoint
This endpoint is now publicly available without the need of an authorization

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
c34ec94a6b feat(db): get mapping dynamically from file
Assisted-by: Junie:claude-sonnet-4.6
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
679f6fece2 feat(db): add battery REST endpoint
Get latest value of battery for each node

Assisted-by: Junie:claude-sonnet-4.6
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:34 +02:00
ef51f9b3ed refactor(db): map room and node on REST API
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
04c3883744 feat(db): add battery field
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
1bcaad895d fix(db): set higher file limit for influx limitations
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
56c6299417 fix(db): set time interval for influx limitations
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
abf19fb4e2 fix(db): get rooms from mapping file
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
641f6af1f0 feat(db): remove CO2PPM data over 1'000'000'000
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
ccec4efca6 feat(db): add node tag for influx
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
77574e1dfa refactor(db): split campus and room mapping error detection
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
c472064451 fix(db): url for api start with https
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:33 +02:00
c4cf1ba704 fix(db): CORS request
Cross-Origin Resource Sharing now allow all *.e.kb28.ch

Assisted-by: Gemini:gemini-3.1-pro
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
980b43e669 fix(db): proper api url instead of using swagger doc url
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
1678ac535b chore(db): add local gitignore
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
b8ddfefc2c feat(db): add deploiement stack
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
50583bb79b chore(db): add pre-commit for swagger documentation
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
790961b15a chore(db): update critical dependancy
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
b4110b81eb refactor(db): unify env getters
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
bf7d0a7005 feat(db): add basic auth
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
fcdb5b5485 feat(db): add parameter for time window in history endpoint
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:32 +02:00
9163fd494b feat(db): add swagger doc
Assisted-by: Junie:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
c2a67684ed feat(db): add mapping
MQTT topic gateway-id/node-id is now mapped to campus/room for influx

Assisted-by: Junie:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
25438f085e chore(db): get InfluxDB token by secrets
Assisted-by: Gemini:gemini-3-flash
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
cea4435bbc fix(db): allow gRPC proxy in traefik
Assisted-by: Gemini:gemini-3.1-pro
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
a49c3a8472 feat(db): add GET history endpoint
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
2e8f92888e feat(db): add GET current value endpoint
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
3ed1e36c56 feat(db): add rest gateway
- Implement GET rooms list endpoint only

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
3587e10671 feat(db): add initial main implementation
- connect to MQTTS broker
- connect to Influx DB
- subscribe to +/+/update MQTT topic and send receive message to influx
- add traefik config for ui and db

Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:31 +02:00
0086a31f73 feat(db): add SubscribeTyped function and refactor DataPoint structure
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:30 +02:00
a11573609e refactor(db): move package
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:30 +02:00
979c502e27 feat(db): merge datapoint and message
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:30 +02:00
1fb294f495 chore(db): typo and respect go guidelines
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 14:46:30 +02:00
567e9162e2 feat(db): add mqtt gateway from previous project
Co-authored-by: Aydong <coudray@nathan.ch>
Signed-off-by: Klagarge <remi@heredero.ch>
Signed-off-by: Aydong <coudray@nathan.ch>
2026-06-04 14:46:30 +02:00
5a1bfefffb feat(db): add influx gateway from previous project
Co-authored-by: fastium <fastium.pro@proton.me>
Signed-off-by: Klagarge <remi@heredero.ch>
Signed-off-by: fastium <fastium.pro@proton.me>
2026-06-04 14:46:30 +02:00
28a8377231 chore(model): tidy up
Signed-off-by: Klagarge <remi@heredero.ch>
2026-06-04 12:38:11 +02:00
Alison Lecointre
a84ad89e02 created models 2026-06-04 12:38:11 +02:00
Alison Lecointre
35f990daa5 created physic model folder 2026-06-04 12:38:11 +02:00
DjeAvd
3668ef4520 docs(gateway): remove store measurement arrow from sequence diagram
broker -> db storage is handled by the database manager, not the gateway
2026-06-04 12:32:48 +02:00
29 changed files with 3337 additions and 7 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.github/CODEOWNERS vendored
View File

@@ -2,4 +2,4 @@
/gateway @DjeAvd
/db/ @Klagarge
/ui/ @khalil-bot
/ml @imfeldd
/model @AlisonLec

9
.pre-commit-config.yaml Normal file
View 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

View File

@@ -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
View 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

View File

26
db/Dockerfile Normal file
View 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"]

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View File

@@ -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:

View File

@@ -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

View File

View 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")

View 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
View File

@@ -0,0 +1,4 @@
pandas
matplotlib
numpy
openpyxl