84 Commits

Author SHA1 Message Date
adrien balleyguier
d593dfd759 feat(nodes): setting the fastest BLE advertising frequency to 1 minute instead of 2
This is motivated by the measure period by the css811 sensor, which is 1 minute and cannot be set to 2.
Hence, we adverstise every minute to avoid trashing half of the measurements
2026-06-04 21:01:43 +02:00
adrien balleyguier
83705f4b5e fix(nodes): reduce battery consumption
co-authored-by: Klagarge <remi@heredero.ch>
co-authored-by: Terrazed <simon.donnet-monay@nordicsemi.no>
2026-06-04 21:01:43 +02:00
adrien balleyguier
dffdcd36db feat(nodes): using an explicit thread, as it may cause the huge battery usage 2026-06-04 21:01:43 +02:00
adrien balleyguier
676913e280 feat(nodes): adding battery percent as new key/value in the BLE frame
Closes: #66
2026-06-04 21:01:43 +02:00
adrien balleyguier
8a559024a6 feat(nodes): add the battery level in the ble frame
wip: The battery_setup fails for some unknown reasons
2026-06-04 21:01:43 +02:00
adrien balleyguier
7c19b5dadb fix(nodes): using MAC address as BT_ADDR 2026-06-04 21:01:42 +02:00
adrien balleyguier
c4e1315478 feat(nodes): adding management of read error on each values, hence
Closes: #65
2026-06-04 21:01:42 +02:00
adrien balleyguier
6b12612580 fix(nodes): changing incorrect comment 2026-06-04 21:01:42 +02:00
adrien balleyguier
c32728ccbe doc(nodes): adding comments in the nodes code
close #62
2026-06-04 21:01:42 +02:00
adrien balleyguier
78167ceb38 feat(nodes): adding nodes firmware v1.0
Refs: #3
2026-06-04 21:01:42 +02:00
adrien balleyguier
3cdfbef680 fix(nodes): changing window status sensor to have unequipped nodes return 'window is closed' status
Refs: #3
2026-06-04 21:01:42 +02:00
adrien balleyguier
94e0518fa6 fix(nodes): co2 level fetched as desibed in the documentation
The first samples (up to 3) sends co2 level 0xffffffff since the sensor is not ready yet
Refs: #3
2026-06-04 20:59:58 +02:00
adrien balleyguier
f4ac6e91b0 fix(nodes): properly fetching data from temp/hygro sensor
Refs: #3
2026-06-04 20:59:36 +02:00
adrien balleyguier
6fd9959329 fix(nodes): fixing switch device tree and wrong ordering of ble values 2026-06-04 20:59:20 +02:00
adrien balleyguier
35fbe7c488 feat(nodes): adding window status reading 2026-06-04 20:59:20 +02:00
adrien balleyguier
158767deec fix(nodes): adjusting broadcasted data to fit definition
Refs: #3
2026-06-04 20:59:20 +02:00
adrien balleyguier
bce2417ded feat(nodes): adding co2_level retrieval
Refs: #3
2026-06-04 20:59:10 +02:00
adrien balleyguier
53ccda306b feat(nodes): WIP adding first implementation for BLE advertising.
wip: Not tested for now

Refs: #3
2026-06-04 20:58:59 +02:00
adrien balleyguier
71ed4c4bf9 feat(nodes): adding thermometer and hygrometer
Changed sensor value retrieval to return error_code
2026-06-04 20:58:14 +02:00
adrien balleyguier
e1d274470b feat(nodes): WIP supervisor first implementation
wip: untested
2026-06-04 20:58:14 +02:00
adrien balleyguier
f0bc4ee373 feat(nodes) : setting folder construction 2026-06-04 20:47:48 +02:00
adrien balleyguier
f71db42f47 feat(nodes) : adding blinky as starting point for the node implementation 2026-06-04 20:47:48 +02:00
adrien balleyguier
8b9e215cc6 doc(nodes): adding battery to class diagram
linked #63
2026-06-04 15:06:21 +02:00
adrien balleyguier
caee92c595 fix(doc) : changing small mishaps following discussions 2026-06-04 15:06:21 +02:00
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
54 changed files with 8595 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 /gateway @DjeAvd
/db/ @Klagarge /db/ @Klagarge
/ui/ @khalil-bot /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 INFLUX_PORT=8181
UI_PORT=8093 UI_PORT=8093
INFLUX_DATABASE=provence
REST_USERNAME=
REST_PASSWORD=
MQTT_BROKER_URL=tls://mqtt.e.kb28.ch:8883
MQTT_USERNAME= MQTT_USERNAME=
MQTT_PASSWORD= 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: 1. Create the Influx token offline:
@@ -18,5 +18,24 @@ cp .env.template .env
docker compose up -d 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 ## 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. 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 \ --object-store=file \
--data-dir=/var/lib/influxdb3/data \ --data-dir=/var/lib/influxdb3/data \
--admin-token-file=/tmp/admin-token.json \ --admin-token-file=/tmp/admin-token.json \
--query-file-limit=1000 \
--disable-authz=health --disable-authz=health
' '
secrets: secrets:
@@ -63,6 +64,7 @@ services:
mqtt: mqtt:
image: rabbitmq:4-management-alpine image: rabbitmq:4-management-alpine
container_name: mqtt container_name: mqtt
hostname: "mqtt"
restart: unless-stopped restart: unless-stopped
ports: ports:
- "15672:15672" # Management plugin HTTP port - "15672:15672" # Management plugin HTTP port
@@ -74,6 +76,36 @@ services:
- RABBITMQ_DEFAULT_PASS=${MQTT_PASSWORD:?MQTT_PASSWORD is required} - RABBITMQ_DEFAULT_PASS=${MQTT_PASSWORD:?MQTT_PASSWORD is required}
command: sh -c "rabbitmq-plugins enable rabbitmq_mqtt && rabbitmq-server" 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: volumes:
influxdb3_data: influxdb3_data:
rabbitmq_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: 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: # middlewares:
# oidc-auth-pi-db: # oidc-auth-pi-db:
# plugin: # plugin:
@@ -10,15 +15,22 @@ http:
# ClientId: "" # ClientId: ""
# ClientSecret: "" # ClientSecret: ""
routers: 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: pi-db:
rule: "Host(`ui.e.kb28.ch`)" rule: "Host(`db.e.kb28.ch`)"
entryPoints: entryPoints:
- websecure - websecure
service: pi-db service: pi-db
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
# middlewares:
# - oidc-auth-pi-db@file
pi-mqtt-management: pi-mqtt-management:
rule: "Host(`mqtt.e.kb28.ch`)" rule: "Host(`mqtt.e.kb28.ch`)"
entryPoints: entryPoints:
@@ -26,18 +38,55 @@ http:
service: pi-mqtt-management service: pi-mqtt-management
tls: tls:
certResolver: letsencrypt 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: services:
pi-db: pi-db-ui:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://192.168.42.211:8093" - url: "http://192.168.42.211:8093"
passHostHeader: true passHostHeader: true
pi-db:
loadBalancer:
servers:
- url: "h2c://192.168.42.211:8181"
passHostHeader: true
pi-mqtt-management: pi-mqtt-management:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://192.168.42.211:15672" - url: "http://192.168.42.211:15672"
passHostHeader: true 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: tcp:
routers: routers:

View File

@@ -32,7 +32,6 @@ else
gw -> gw : add UTC timestamp gw -> gw : add UTC timestamp
gw -> broker : publish JSON\n{gateway_id}/{mac}/update gw -> broker : publish JSON\n{gateway_id}/{mac}/update
broker --> gw : on_publish confirmed broker --> gw : on_publish confirmed
broker -> db : store measurement
end end
end end
end end

View File

@@ -15,6 +15,7 @@ class "Window_status" as win{}
class "Hygrometer" as hygro{} class "Hygrometer" as hygro{}
class "Thermometer" as thermo{} class "Thermometer" as thermo{}
class "CO2_level" as co2{} class "CO2_level" as co2{}
class "Battery_level" as batt{}
sup o-d- ble sup o-d- ble
sup o-u- sens sup o-u- sens
@@ -22,5 +23,6 @@ sens <|-l- win
sens <|-u- hygro sens <|-u- hygro
sens <|-u- thermo sens <|-u- thermo
sens <|-r- co2 sens <|-r- co2
sens <|-- batt
@enduml @enduml

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

8
nodes/CMakeLists.txt Normal file
View File

@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(plein_de_eeeeeeeeeeee)
target_sources(app PRIVATE src/main.c src/window_status.c src/window_status.h src/thermometer.c src/thermometer.h src/hygrometer.c src/hygrometer.h src/co2_level.c src/co2_level.h src/supervisor.c src/supervisor.h src/ble_advertiser.c src/ble_advertiser.h src/battery_percent.h src/battery_percent.c)
target_sources(app PRIVATE src/modules/battery.h src/modules/battery.c)

97
nodes/README.rst Normal file
View File

@@ -0,0 +1,97 @@
.. zephyr:code-sample:: blinky
:name: Blinky
:relevant-api: gpio_interface
Blink an LED forever using the GPIO API.
Overview
********
The Blinky sample blinks an LED forever using the :ref:`GPIO API <gpio_api>`.
The source code shows how to:
#. Get a pin specification from the :ref:`devicetree <dt-guide>` as a
:c:struct:`gpio_dt_spec`
#. Configure the GPIO pin as an output
#. Toggle the pin forever
See :zephyr:code-sample:`pwm-blinky` for a similar sample that uses the PWM API instead.
.. _blinky-sample-requirements:
Requirements
************
Your board must:
#. Have an LED connected via a GPIO pin (these are called "User LEDs" on many of
Zephyr's :ref:`boards`).
#. Have the LED configured using the ``led0`` devicetree alias.
Building and Running
********************
Build and flash Blinky as follows, changing ``reel_board`` for your board:
.. zephyr-app-commands::
:zephyr-app: samples/basic/blinky
:board: reel_board
:goals: build flash
:compact:
After flashing, the LED starts to blink and messages with the current LED state
are printed on the console. If a runtime error occurs, the sample exits without
printing to the console.
Build errors
************
You will see a build error at the source code line defining the ``struct
gpio_dt_spec led`` variable if you try to build Blinky for an unsupported
board.
On GCC-based toolchains, the error looks like this:
.. code-block:: none
error: '__device_dts_ord_DT_N_ALIAS_led_P_gpios_IDX_0_PH_ORD' undeclared here (not in a function)
Adding board support
********************
To add support for your board, add something like this to your devicetree:
.. code-block:: DTS
/ {
aliases {
led0 = &myled0;
};
leds {
compatible = "gpio-leds";
myled0: led_0 {
gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
};
};
};
The above sets your board's ``led0`` alias to use pin 13 on GPIO controller
``gpio0``. The pin flags :c:macro:`GPIO_ACTIVE_LOW` mean the LED is on when
the pin is set to its low state, and off when the pin is in its high state.
Tips:
- See :dtcompatible:`gpio-leds` for more information on defining GPIO-based LEDs
in devicetree.
- If you're not sure what to do, check the devicetrees for supported boards which
use the same SoC as your target. See :ref:`get-devicetree-outputs` for details.
- See :zephyr_file:`include/zephyr/dt-bindings/gpio/gpio.h` for the flags you can use
in devicetree.
- If the LED is built in to your board hardware, the alias should be defined in
your :ref:`BOARD.dts file <devicetree-in-out-files>`. Otherwise, you can
define one in a :ref:`devicetree overlay <set-devicetree-overlays>`.

11
nodes/app.overlay Normal file
View File

@@ -0,0 +1,11 @@
/* SPDX-License-Identifier: Apache-2.0 */
/ {
buttons{
compatible = "gpio-keys";
window_switch: window_switch {
gpios = <&sx1509b 0 GPIO_ACTIVE_LOW>;
label = "Window Switch";
};
};
};

16
nodes/prj.conf Normal file
View File

@@ -0,0 +1,16 @@
CONFIG_ADC=y
CONFIG_I2C=y
CONFIG_SENSOR=y
CONFIG_BT=y
CONFIG_GPIO=y
CONFIG_LOG=n
CONFIG_CONSOLE=n
CONFIG_SERIAL=n
CONFIG_PRINTK=n
CONFIG_UART_CONSOLE=n
CONFIG_PM=y
CONFIG_PM_DEVICE=y
CONFIG_PM_DEVICE_RUNTIME=y
CONFIG_CCS811_DRIVE_MODE_3=y

4281
nodes/release/nodes_v1.0.hex Normal file

File diff suppressed because it is too large Load Diff

12
nodes/sample.yaml Normal file
View File

@@ -0,0 +1,12 @@
sample:
name: Blinky Sample
tests:
sample.basic.blinky:
tags:
- LED
- gpio
filter: dt_enabled_alias_with_parent_compat("led0", "gpio-leds")
depends_on: gpio
harness: led
integration_platforms:
- frdm_k64f

View File

@@ -0,0 +1,42 @@
#include "battery_percent.h"
/**
* Taken from sample/board/nordic/battery/src/main.c
* A discharge curve specific to the power source.
*/
static const struct battery_level_point levels[] = {
/* "Curve" here eyeballed from captured data for the [Adafruit
* 3.7v 2000 mAh](https://www.adafruit.com/product/2011) LIPO
* under full load that started with a charge of 3.96 V and
* dropped about linearly to 3.58 V over 15 hours. It then
* dropped rapidly to 3.10 V over one hour, at which point it
* stopped transmitting.
*
* Based on eyeball comparisons we'll say that 15/16 of life
* goes between 3.95 and 3.55 V, and 1/16 goes between 3.55 V
* and 3.1 V.
*/
{ 10000, 3950 },
{ 625, 3550 },
{ 0, 3100 },
};
enum error_code battery_init(){
enum error_code ret = init_failed;
if(0 == battery_measure_enable(true)){
ret = success;
}else{}
return ret;
}
enum error_code battery_get_value(int* holder){
enum error_code ret = read_failed;
// battery_voltage in [mV]
int battery_voltage = battery_sample();
if(battery_voltage >= 0){
*holder = (battery_level_pptt(battery_voltage, levels)/100);
ret = success;
}else{}
return ret;
}

View File

@@ -0,0 +1,27 @@
#ifndef BATTERY_PERCENT_H
#define BATTERY_PERCENT_H
#include <zephyr/kernel.h>
#include "modules/battery.h"
#include "error_code.h"
/**
* @brief init the battery module
* @return init_failed upon any error during init
* @return success otherwise
*/
enum error_code battery_init();
/**
* @brief Retrieve the battery charge percentage and stores it in the given parameter
* @param[out] holder : pointer where the measurement will be stored
* @return read_failed upon any error occuring during measurement
* @return success otherwise
* @note the content of holder should not be trusted upon returning something else than success
*/
enum error_code battery_get_value(int* holder);
#endif //BATTERY_PERCENT_H

View File

@@ -0,0 +1,79 @@
#include "ble_advertiser.h"
static const int BT_MS_ADV_UP = 500; // [ms] time during which the advertising is active
// keys as defined in the specs
static const char BT_KEY_SIZE = 1; // [B]
static const char BT_KEY_WINDOW = 0x01;
static const char BT_KEY_HUMIDITY = 0x02;
static const char BT_KEY_TEMP = 0x03;
static const char BT_KEY_CO2_LVL = 0x04;
static const char BT_KEY_BATT = 0x05;
// value size [B]
static const char BT_PREAMBLE_SIZE = 2;
static const char BT_VALUE_SIZE_WINDOW = 1;
static const char BT_VALUE_SIZE_HUMIDITY = 1;
static const char BT_VALUE_SIZE_TEMP = 2;
static const char BT_VALUE_SIZE_CO2_LVL = 4;
static const char BT_VALUE_SIZE_BATT = 1;
// value start index in the frame - equal to {prior field index + prior value size + key size}
static const int BT_AD_DATA_INDEX_WINDOW = BT_PREAMBLE_SIZE + BT_KEY_SIZE;
static const int BT_AD_DATA_INDEX_HUMIDITY = BT_AD_DATA_INDEX_WINDOW + BT_VALUE_SIZE_WINDOW + BT_KEY_SIZE;
static const int BT_AD_DATA_INDEX_TEMP = BT_AD_DATA_INDEX_HUMIDITY + BT_VALUE_SIZE_HUMIDITY + BT_KEY_SIZE;
static const int BT_AD_DATA_INDEX_CO2_LVL = BT_AD_DATA_INDEX_TEMP + BT_VALUE_SIZE_TEMP + BT_KEY_SIZE;
static const int BT_AD_DATA_INDEX_BATT = BT_AD_DATA_INDEX_CO2_LVL + BT_VALUE_SIZE_CO2_LVL + BT_KEY_SIZE;
// sum of all value size + size for all keys + size preamble
static const char BT_AD_TOTAL_SIZE =
BT_PREAMBLE_SIZE + (5 * BT_KEY_SIZE) +
BT_VALUE_SIZE_WINDOW + BT_VALUE_SIZE_HUMIDITY + BT_VALUE_SIZE_TEMP + BT_VALUE_SIZE_CO2_LVL + BT_VALUE_SIZE_BATT;
// data field in the broadcasted frame
// all values are set to 0 and the "company id" field is set to 0xffff
static uint8_t ad_data[] = {
0xff, 0xff,
BT_KEY_WINDOW, 0x00,
BT_KEY_HUMIDITY, 0x00,
BT_KEY_TEMP, 0x00, 0x00,
BT_KEY_CO2_LVL, 0x00, 0x00, 0x00, 0x00,
BT_KEY_BATT, 0x00
};
// frame that will be broadcasted
static const struct bt_data ad[] = {
BT_DATA(BT_DATA_MANUFACTURER_DATA, ad_data, BT_AD_TOTAL_SIZE),
};
enum error_code ble_init(){
return ((0 == bt_enable(NULL)) ? success : init_failed);
}
enum error_code ble_advertise(
enum window_status window_value,
int hygro_value,
int thermo_value,
int co2_lvl_value,
int batt_value
){
enum error_code ret = write_failed;
int shift = 0;
ad_data[BT_AD_DATA_INDEX_WINDOW] = (uint8_t)(window_value == open ? 0x1 : 0x0);
ad_data[BT_AD_DATA_INDEX_HUMIDITY] = (uint8_t)(hygro_value & 0xff);
ad_data[BT_AD_DATA_INDEX_BATT] = (uint8_t)(batt_value & 0xff);
for(int i=0;i<BT_VALUE_SIZE_TEMP;i++){
shift = 8 * (BT_VALUE_SIZE_TEMP - i - 1);
ad_data[BT_AD_DATA_INDEX_TEMP + i] = (uint8_t)((thermo_value>>(shift)) & 0xff);
}
for(int i=0;i<BT_VALUE_SIZE_CO2_LVL;i++){
shift = 8 * (BT_VALUE_SIZE_CO2_LVL - i - 1);
ad_data[BT_AD_DATA_INDEX_CO2_LVL + i] = (uint8_t)((co2_lvl_value>>(shift)) & 0xff);
}
if(0 == bt_le_adv_start(BT_LE_ADV_NCONN_IDENTITY, ad, ARRAY_SIZE(ad), NULL, 0)){
k_msleep(BT_MS_ADV_UP);
if(0 == bt_le_adv_stop()){
ret = success;
}else{}
}else{}
return ret;
}

View File

@@ -0,0 +1,37 @@
#ifndef BLE_ADVERTISER_H
#define BLE_ADVERTISER_H
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/assigned_numbers.h>
#include <zephyr/bluetooth/bluetooth.h>
#include "error_code.h"
#include "window_status.h"
/**
* @brief Initialise the ble module
* @return init_failed upon any error occuring during init
* @return success otherwise
*/
enum error_code ble_init();
/**
* @brief Sets the given values as data and broadcast the BLE frame
* @param window_value : window opening status in {open/closed}
* @param hygro_value : humidity percentage [%]
* @param thermo_value : temperature in [d°C]
* @param co2_lvl_value : co2 level in [ppm]
* @param battery_percent_value : battery current level of charge [%]
* @return write_failed if any problem occur during the BLE broadcast
* @return success otherwise
* @note The broadcast is stopped at the return time
*/
enum error_code ble_advertise(
enum window_status window_value,
int hygro_value,
int thermo_value,
int co2_lvl_value,
int batt_value
);
#endif //BLE_ADVERTISER_H

37
nodes/src/co2_level.c Normal file
View File

@@ -0,0 +1,37 @@
#include "co2_level.h"
static const struct device* dev = DEVICE_DT_GET_ONE(ams_ccs811);
const int CO2_LEVEL_EMPTY_ROOM = 400; // [ppm]
enum error_code co2_lvl_init(){
enum error_code ret = init_failed;
if(device_is_ready(dev)){
ret = success;
}else{}
return ret;
}
enum error_code co2_lvl_get_value(int* holder){
struct sensor_value temp, humidity, co2;
enum error_code ret = read_failed;
int temp_value, humidity_value;
if( (success == thermo_get_value(&temp_value)) && (success == hygro_get_value(&humidity_value)) ){
// temperature conversion from deci, no function in the API
temp.val1 = temp_value/10;
temp.val2 = temp_value%10;
// humidity conversion is straight away
humidity.val1 = humidity_value;
if(
// co2 measurement requires temperature and humidity
(0 == ccs811_envdata_update(dev, &temp, &humidity)) &&
// fetch is required to update sensor read data
(0 == sensor_sample_fetch(dev)) &&
(0 == sensor_channel_get(dev, SENSOR_CHAN_CO2, &co2))
){
*holder = co2.val1; // taking only the integer part
ret = success;
}else{}
}else{}
return success;
}

30
nodes/src/co2_level.h Normal file
View File

@@ -0,0 +1,30 @@
#ifndef CO2_LEVEL_H
#define CO2_LEVEL_H
#include <zephyr/drivers/sensor.h>
#include <zephyr/drivers/sensor/ccs811.h>
#include "error_code.h"
#include "hygrometer.h"
#include "thermometer.h"
extern const int CO2_LEVEL_EMPTY_ROOM; // [ppm]
/**
* @brief init the co2 level measurement module
* @return init_failed upon any error during init
* @return success otherwise
*/
enum error_code co2_lvl_init();
/**
* @brief Retrieve the CO2 level measurement and stores it in the given parameter
* @param[out] holder : pointer where the measurement will be stored
* @return read_failed upon any error occuring during measurement
* @return success otherwise
* @note the content of holder should not be trusted upon returning something else than success
* @note the value is not read for the first few measurements after a reboot
*/
enum error_code co2_lvl_get_value(int* holder);
#endif //CO2_LEVEL_H

16
nodes/src/error_code.h Normal file
View File

@@ -0,0 +1,16 @@
#ifndef ERROR_CODE_H
#define ERROR_CODE_H
#include <limits.h>
// enum of all error code that may occur during the plein_de_eeeeeeeeeeeeeee application runtime
enum error_code{
success = 0, // any kind of success
init_failed, // error related to initialization
read_failed, // error related to reading action
write_failed, // error related to writing action
error_unknown, // any error not linked to other code
error_code_last, // iteration purpose
};
#endif //ERROR_CODE_H

25
nodes/src/hygrometer.c Normal file
View File

@@ -0,0 +1,25 @@
#include "hygrometer.h"
static const struct device* dev = DEVICE_DT_GET_ONE(st_hts221);
enum error_code hygro_init(){
enum error_code ret = init_failed;
if(device_is_ready(dev)){
ret = success;
}else{}
return ret;
}
enum error_code hygro_get_value(int* holder){
enum error_code ret = read_failed;
struct sensor_value humidity;
if(
// fetch is required to update sensor read data
(sensor_sample_fetch(dev) >= 0) &&
(sensor_channel_get(dev, SENSOR_CHAN_HUMIDITY, &humidity) >= 0)
){
*holder = humidity.val1; //taking only the integer part
ret = success;
}else{}
return ret;
}

26
nodes/src/hygrometer.h Normal file
View File

@@ -0,0 +1,26 @@
#ifndef HYGROMETER_H
#define HYGROMETER_H
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include "error_code.h"
/**
* @brief init the hygrometer module
* @return init_failed upon any error during init
* @return success otherwise
*/
enum error_code hygro_init();
/**
* @brief Retrieve the humidity level and stores it in the given parameter
* @param[out] holder : pointer where the measurement will be stored
* @return read_failed upon any error occuring during measurement
* @return success otherwise
* @note the content of holder should not be trusted upon returning something else than success
*/
enum error_code hygro_get_value(int* holder);
#endif //HYGROMETER_H

18
nodes/src/main.c Normal file
View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2016 Intel Corporation
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include "supervisor.h"
// size of a stack used by the supervisor thread
#define STACKSIZE 1024
// scheduling priority used by the supervisor thread
#define PRIORITY 7
K_THREAD_DEFINE(supervisor_id, STACKSIZE, thread_supervisor, NULL, NULL, NULL, PRIORITY, 0, 0);

231
nodes/src/modules/battery.c Normal file
View File

@@ -0,0 +1,231 @@
/*
* Copyright (c) 2018-2019 Peter Bigot Consulting, LLC
* Copyright (c) 2019-2020 Nordic Semiconductor ASA
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <zephyr/kernel.h>
#include <zephyr/init.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/adc.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/logging/log.h>
#include "battery.h"
LOG_MODULE_REGISTER(BATTERY, CONFIG_ADC_LOG_LEVEL);
#define VBATT DT_PATH(vbatt)
#define ZEPHYR_USER DT_PATH(zephyr_user)
#ifdef CONFIG_BOARD_THINGY52_NRF52832
/* This board uses a divider that reduces max voltage to
* reference voltage (600 mV).
*/
#define BATTERY_ADC_GAIN ADC_GAIN_1
#else
/* Other boards may use dividers that only reduce battery voltage to
* the maximum supported by the hardware (3.6 V)
*/
#define BATTERY_ADC_GAIN ADC_GAIN_1_6
#endif
struct io_channel_config {
uint8_t channel;
};
struct divider_config {
struct io_channel_config io_channel;
struct gpio_dt_spec power_gpios;
/* output_ohm is used as a flag value: if it is nonzero then
* the battery is measured through a voltage divider;
* otherwise it is assumed to be directly connected to Vdd.
*/
uint32_t output_ohm;
uint32_t full_ohm;
};
static const struct divider_config divider_config = {
#if DT_NODE_HAS_STATUS_OKAY(VBATT)
.io_channel = {
DT_IO_CHANNELS_INPUT(VBATT),
},
.power_gpios = GPIO_DT_SPEC_GET_OR(VBATT, power_gpios, {}),
.output_ohm = DT_PROP(VBATT, output_ohms),
.full_ohm = DT_PROP(VBATT, full_ohms),
#else /* /vbatt exists */
.io_channel = {
DT_IO_CHANNELS_INPUT(ZEPHYR_USER),
},
#endif /* /vbatt exists */
};
struct divider_data {
const struct device *adc;
struct adc_channel_cfg adc_cfg;
struct adc_sequence adc_seq;
int16_t raw;
};
static struct divider_data divider_data = {
#if DT_NODE_HAS_STATUS_OKAY(VBATT)
.adc = DEVICE_DT_GET(DT_IO_CHANNELS_CTLR(VBATT)),
#else
.adc = DEVICE_DT_GET(DT_IO_CHANNELS_CTLR(ZEPHYR_USER)),
#endif
};
static int divider_setup(void)
{
const struct divider_config *cfg = &divider_config;
const struct io_channel_config *iocp = &cfg->io_channel;
const struct gpio_dt_spec *gcp = &cfg->power_gpios;
struct divider_data *ddp = &divider_data;
struct adc_sequence *asp = &ddp->adc_seq;
struct adc_channel_cfg *accp = &ddp->adc_cfg;
int rc;
if (!device_is_ready(ddp->adc)) {
LOG_ERR("ADC device is not ready %s", ddp->adc->name);
return -ENOENT;
}
if (gcp->port) {
if (!device_is_ready(gcp->port)) {
LOG_ERR("%s: device not ready", gcp->port->name);
return -ENOENT;
}
rc = gpio_pin_configure_dt(gcp, GPIO_OUTPUT_INACTIVE);
if (rc != 0) {
LOG_ERR("Failed to control feed %s.%u: %d",
gcp->port->name, gcp->pin, rc);
return rc;
}
}
*asp = (struct adc_sequence){
.channels = BIT(0),
.buffer = &ddp->raw,
.buffer_size = sizeof(ddp->raw),
.oversampling = 4,
.calibrate = true,
};
#ifdef CONFIG_ADC_NRFX_SAADC
*accp = (struct adc_channel_cfg){
.gain = BATTERY_ADC_GAIN,
.reference = ADC_REF_INTERNAL,
.acquisition_time = ADC_ACQ_TIME(ADC_ACQ_TIME_MICROSECONDS, 40),
};
if (cfg->output_ohm != 0) {
accp->input_positive = SAADC_CH_PSELP_PSELP_AnalogInput0
+ iocp->channel;
} else {
accp->input_positive = SAADC_CH_PSELP_PSELP_VDD;
}
asp->resolution = 14;
#else /* CONFIG_ADC_var */
#error Unsupported ADC
#endif /* CONFIG_ADC_var */
rc = adc_channel_setup(ddp->adc, accp);
LOG_INF("Setup AIN%u got %d", iocp->channel, rc);
return rc;
}
static bool battery_ok;
static int battery_setup(void)
{
int rc = divider_setup();
battery_ok = (rc == 0);
LOG_INF("Battery setup: %d %d", rc, battery_ok);
return rc;
}
SYS_INIT(battery_setup, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);
int battery_measure_enable(bool enable)
{
int rc = -ENOENT;
if (battery_ok) {
const struct gpio_dt_spec *gcp = &divider_config.power_gpios;
rc = 0;
if (gcp->port) {
rc = gpio_pin_set_dt(gcp, enable);
}
}
return rc;
}
int battery_sample(void)
{
int rc = -ENOENT;
if (battery_ok) {
struct divider_data *ddp = &divider_data;
const struct divider_config *dcp = &divider_config;
struct adc_sequence *sp = &ddp->adc_seq;
rc = adc_read(ddp->adc, sp);
sp->calibrate = false;
if (rc == 0) {
int32_t val = ddp->raw;
adc_raw_to_millivolts(adc_ref_internal(ddp->adc),
ddp->adc_cfg.gain,
sp->resolution,
&val);
if (dcp->output_ohm != 0) {
rc = val * (uint64_t)dcp->full_ohm
/ dcp->output_ohm;
LOG_INF("raw %u ~ %u mV => %d mV\n",
ddp->raw, val, rc);
} else {
rc = val;
LOG_INF("raw %u ~ %u mV\n", ddp->raw, val);
}
}
}
return rc;
}
unsigned int battery_level_pptt(unsigned int batt_mV,
const struct battery_level_point *curve)
{
const struct battery_level_point *pb = curve;
if (batt_mV >= pb->lvl_mV) {
/* Measured voltage above highest point, cap at maximum. */
return pb->lvl_pptt;
}
/* Go down to the last point at or below the measured voltage. */
while ((pb->lvl_pptt > 0)
&& (batt_mV < pb->lvl_mV)) {
++pb;
}
if (batt_mV < pb->lvl_mV) {
/* Below lowest point, cap at minimum */
return pb->lvl_pptt;
}
/* Linear interpolation between below and above points. */
const struct battery_level_point *pa = pb - 1;
return pb->lvl_pptt
+ ((pa->lvl_pptt - pb->lvl_pptt)
* (batt_mV - pb->lvl_mV)
/ (pa->lvl_mV - pb->lvl_mV));
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2018-2019 Peter Bigot Consulting, LLC
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef APPLICATION_BATTERY_H_
#define APPLICATION_BATTERY_H_
/** Enable or disable measurement of the battery voltage.
*
* @param enable true to enable, false to disable
*
* @return zero on success, or a negative error code.
*/
int battery_measure_enable(bool enable);
/** Measure the battery voltage.
*
* @return the battery voltage in millivolts, or a negative error
* code.
*/
int battery_sample(void);
/** A point in a battery discharge curve sequence.
*
* A discharge curve is defined as a sequence of these points, where
* the first point has #lvl_pptt set to 10000 and the last point has
* #lvl_pptt set to zero. Both #lvl_pptt and #lvl_mV should be
* monotonic decreasing within the sequence.
*/
struct battery_level_point {
/** Remaining life at #lvl_mV. */
uint16_t lvl_pptt;
/** Battery voltage at #lvl_pptt remaining life. */
uint16_t lvl_mV;
};
/** Calculate the estimated battery level based on a measured voltage.
*
* @param batt_mV a measured battery voltage level.
*
* @param curve the discharge curve for the type of battery installed
* on the system.
*
* @return the estimated remaining capacity in parts per ten
* thousand.
*/
unsigned int battery_level_pptt(unsigned int batt_mV,
const struct battery_level_point *curve);
#endif /* APPLICATION_BATTERY_H_ */

75
nodes/src/supervisor.c Normal file
View File

@@ -0,0 +1,75 @@
#include "supervisor.h"
const int SLEEP_GRANULARITY = 1; // [min]
const int SLEEP_MIN_DURATION = SLEEP_GRANULARITY; // [min]
const int SLEEP_MAX_DURATION = 30; // [min]
// Zephyr related stuff
void thread_supervisor(){
supervisor_init();
supervisor_run();
//should never return
}
// supervisor stuff
enum error_code supervisor_init(){
enum error_code ret = init_failed;
if(
success == ble_init() &&
success == co2_lvl_init() &&
success == hygro_init() &&
success == thermo_init() &&
success == window_init() &&
success == battery_init()
){
ret = success;
}else{}
return ret;
}
enum error_code supervisor_run(){
int co2_lvl_value = -1;
int hygro_value = -1;
int thermo_value = -1;
int batt_value = -1;
enum window_status window_value = unknown;
enum error_code co2_lvl_status, hygro_status, thermo_status, window_status, batt_status;
int current_sleep_time = SLEEP_MIN_DURATION;
while(1){
co2_lvl_status = co2_lvl_get_value(&co2_lvl_value);
hygro_status = hygro_get_value(&hygro_value);
thermo_status = thermo_get_value(&thermo_value);
window_status = window_get_value(&window_value);
batt_status = battery_get_value(&batt_value);
if(success != window_status){
window_value = unknown;
}else{}
if(success != hygro_status){
hygro_value = -1;
}else{}
if(success != thermo_status){
thermo_value = -1;
}else{}
if(success != co2_lvl_status){
co2_lvl_value = -1;
}else{}
if(success != batt_status){
batt_value = -1;
}else{}
ble_advertise(window_value, hygro_value, thermo_value, co2_lvl_value, batt_value);
if((co2_lvl_value > CO2_LEVEL_EMPTY_ROOM) || (window_value == open)){
// there are people in the room, or someone forgot to close the window
current_sleep_time = SLEEP_MIN_DURATION;
}else{
// no one is in the room, we can wait a litle bit longer before getting the next data point
current_sleep_time += SLEEP_GRANULARITY;
if(current_sleep_time > SLEEP_MAX_DURATION){
current_sleep_time = SLEEP_MAX_DURATION;
}else{}
}
k_sleep(K_MINUTES(current_sleep_time));
}
return error_unknown; // should never return
}

38
nodes/src/supervisor.h Normal file
View File

@@ -0,0 +1,38 @@
#ifndef SUPERVISOR_H
#define SUPERVISOR_H
#include <zephyr/kernel.h>
#include "error_code.h"
#include "ble_advertiser.h"
#include "window_status.h"
#include "thermometer.h"
#include "hygrometer.h"
#include "co2_level.h"
#include "battery_percent.h"
extern const int SLEEP_GRANULARITY; // [min]
extern const int SLEEP_MIN_DURATION; // [min]
extern const int SLEEP_MAX_DURATION; // [min]
/**
* @brief thread function to run the supervisor in zephyr environment
*/
void thread_supervisor();
/**
* @brief init the supervisor, and thus the complete plein_de_ee application
* @return init_failed upon any error occurring during the initialisation of any module
* @return success otherwise
*/
enum error_code supervisor_init();
/**
* @brief exexcute the plein_de_eeeeeee application
* @return error_unknown
* @note should never return
*/
enum error_code supervisor_run();
#endif //SUPERVISOR_H

23
nodes/src/thermometer.c Normal file
View File

@@ -0,0 +1,23 @@
#include "thermometer.h"
static const struct device* dev = DEVICE_DT_GET_ONE(st_hts221);
enum error_code thermo_init(){
enum error_code ret = init_failed;
if(device_is_ready(dev)){
ret = success;
}else{}
return ret;
}
enum error_code thermo_get_value(int* holder){
enum error_code ret = read_failed;
struct sensor_value temp;
if( (sensor_sample_fetch(dev) >= 0) &&
(sensor_channel_get(dev, SENSOR_CHAN_AMBIENT_TEMP, &temp) >= 0)
){
*holder = sensor_value_to_deci(&temp);
ret = success;
}else{}
return ret;
}

26
nodes/src/thermometer.h Normal file
View File

@@ -0,0 +1,26 @@
#ifndef THERMOMETER_H
#define THERMOMETER_H
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include "error_code.h"
/**
* @brief init the thermometer module
* @return init_failed upon any error during init
* @return success otherwise
*/
enum error_code thermo_init();
/**
* @brief Retrieve the temperature and stores it in the given parameter
* @param[out] holder : pointer where the measurement will be stored
* @return read_failed upon any error occuring during measurement
* @return success otherwise
* @note the content of holder should not be trusted upon returning something else than success
*/
enum error_code thermo_get_value(int* holder);
#endif //THERMOMETER_H

17
nodes/src/window_status.c Normal file
View File

@@ -0,0 +1,17 @@
#include "window_status.h"
static const struct gpio_dt_spec window_switch = GPIO_DT_SPEC_GET(DT_NODELABEL(window_switch), gpios);
enum error_code window_init(){
enum error_code ret = init_failed;
if(gpio_is_ready_dt(&window_switch) && (0 == gpio_pin_configure_dt(&window_switch, GPIO_INPUT))){
ret = success;
}else{}
return ret;
}
enum error_code window_get_value(enum window_status* holder){
// active high
*holder = (gpio_pin_get_dt(&window_switch) ? closed : open);
return success;
}

31
nodes/src/window_status.h Normal file
View File

@@ -0,0 +1,31 @@
#ifndef WINDOW_STATUS_H
#define WINDOW_STATUS_H
#include <zephyr/drivers/gpio.h>
#include "error_code.h"
enum window_status{
closed = 0, // window is closed or sensor is not connected
open, // window is opened
unknown, // window opening status could not be read
windows_status_last, // iteration purpose
};
/**
* @brief init the window opening status module
* @return init_failed upon any error during init
* @return success otherwise
*/
enum error_code window_init();
/**
* @brief Retrieve the window opening status and stores it in the given parameter
* @param[out] holder : pointer where the measurement will be stored
* @return read_failed upon any error occuring during measurement
* @return success otherwise
* @note the content of holder should not be trusted upon returning something else than success
*/
enum error_code window_get_value(enum window_status* holder);
#endif //WINDOW_STATUS_