Compare commits

...

37 Commits

Author SHA1 Message Date
f0a90a4882 all lowercase
All checks were successful
Build Project / test (push) Successful in 3m9s
2025-11-05 19:56:38 +00:00
d4dc0cbbf7 also this one
All checks were successful
Build Project / test (push) Successful in 2m57s
2025-11-05 18:51:16 +01:00
86f2518b6e run as root user
Some checks failed
Build Project / test (push) Has been cancelled
2025-11-05 18:51:06 +01:00
1d7e9fe22b update docker image
Some checks failed
Build Project / test (push) Failing after 3m14s
2025-11-05 18:43:11 +01:00
e4eb26ae5f Use image 2.1
Some checks failed
Build Project / test (push) Failing after 39s
2025-11-05 17:48:15 +01:00
2dbdc149a2 Use new container version
Some checks failed
Build Project / test (push) Failing after 35s
2025-11-05 17:27:04 +01:00
986ad8f457 Test new build stuff
Some checks failed
Build Project / test (push) Failing after 42s
2025-11-04 21:39:53 +00:00
2e9b685789 Version fix hopefully
Some checks failed
Build Project / test (push) Failing after 4m34s
2025-11-04 22:34:13 +01:00
6bc4bd18d7 fix
Some checks failed
Build Project / test (push) Has been cancelled
2025-11-04 22:32:42 +01:00
e753fb4d74 Added rev2
All checks were successful
Build Project / test (push) Successful in 5m40s
2025-11-02 17:25:47 +00:00
f51e831c47 Merge branch 'main' of ssh://gitea.tobiasmaier.me:222/tobimai/iot-cloud-api
All checks were successful
Build Project / test (push) Successful in 5m41s
2025-11-02 18:07:53 +01:00
9be4aedcab Update .gitea/workflows/build_docker.yaml
All checks were successful
Build Project / test (push) Successful in 5m47s
2025-11-02 16:12:07 +01:00
d0d9af1a56 fix urls 2025-11-02 13:11:38 +01:00
8ad1813bb2 Merge pull request 'Fixed clippy, updated dependencies' (#1) from rework into main
Some checks failed
Build Project / test (push) Failing after 6m2s
Reviewed-on: #1
2025-10-17 18:28:20 +02:00
ae005867e7 fi8x
All checks were successful
Build and Test Project / test (pull_request) Successful in 6m30s
2025-10-17 16:15:07 +00:00
64328ed3ba fixed pipelines
Some checks failed
Build and Test Project / test (pull_request) Failing after 7s
2025-10-17 16:14:13 +00:00
add82496a6 Fixed clippy, updated dependencies
Some checks failed
Build Project / test (push) Failing after 9m3s
2025-10-17 15:58:52 +00:00
75593fa003 Now deleting older files
All checks were successful
Build Project / test (push) Successful in 6m5s
2025-02-27 19:36:47 +00:00
5e0ba37bc7 added method to upload filesystem images
All checks were successful
Build Project / test (push) Successful in 5m58s
2025-02-18 20:47:31 +00:00
a5e374b79f Added configurable firmware path
All checks were successful
Build Project / test (push) Successful in 6m6s
2025-02-10 15:59:00 +00:00
386f0f54b2 fix
All checks were successful
Build Project / test (push) Successful in 5m48s
2025-02-09 16:50:40 +00:00
7d542aef8f Fixed dockerbuild
All checks were successful
Build Project / test (push) Successful in 5m43s
2025-02-09 16:18:32 +00:00
4fb7baf954 Added external URL
All checks were successful
Build Project / test (push) Successful in 5m53s
2025-02-09 16:00:42 +00:00
79d42c2760 me stupid
All checks were successful
Build Project / test (push) Successful in 5m25s
2025-02-06 22:19:48 +00:00
75b99aa9d8 Test new
All checks were successful
Build Project / test (push) Successful in 5m34s
2025-02-06 22:13:56 +00:00
691b09c954 First dev-ready build
Some checks failed
Build Project / test (push) Failing after 5m8s
2025-02-06 22:06:05 +00:00
246194b8dd test
All checks were successful
Build Project / test (push) Successful in 5m55s
2025-02-06 21:57:15 +00:00
0257434b26 Test
Some checks failed
Build Project / test (push) Failing after 6m9s
2025-02-06 17:27:59 +00:00
31b90e223f readme updated
All checks were successful
Build Project / test (push) Successful in 5m7s
2025-02-05 22:18:49 +00:00
eea776d31e Added add_display_name
Some checks failed
Build Project / test (push) Has been cancelled
2025-02-05 22:16:09 +00:00
acbb61131b Changes
All checks were successful
Build Project / test (push) Successful in 5m30s
2025-01-19 17:08:49 +00:00
92159bc276 Format
All checks were successful
Build Project / test (push) Successful in 5m38s
2025-01-19 16:14:22 +00:00
b336b6b926 Cleanup
Some checks failed
Build Project / test (push) Has been cancelled
2025-01-19 16:14:03 +00:00
01d35a502f added error when exists and download now working
All checks were successful
Build Project / test (push) Successful in 5m35s
2025-01-19 16:07:26 +00:00
afe1b7de9a Gitignore fix to not ignore test folder
All checks were successful
Build Project / test (push) Successful in 5m45s
2025-01-19 15:02:21 +00:00
e5339f7c6c Updates
All checks were successful
Build Project / test (push) Successful in 5m58s
2025-01-19 15:43:04 +01:00
e19a11d5f3 Fixed stuff
All checks were successful
Build Project / test (push) Successful in 5m30s
2025-01-15 23:10:54 +00:00
18 changed files with 553 additions and 236 deletions

3
.env
View File

@@ -1,2 +1,3 @@
DATABASE_URL=postgres://dev:dev@db/iot
RUST_LOG=debug
RUST_LOG=debug
FIRMWARE_FOLDER=/temp/firmware

View File

@@ -1,24 +1,21 @@
name: Build Project
on: [push]
on:
push:
branches: ["main"]
jobs:
test:
runs-on: ubuntu-latest
container:
image: rust:bullseye
# Service containers to run with `container-job`
image: gitea.tobiasmaier.me/tobimai/devcontainer-rust:2.2
options: --user root
services:
# Label used to access the service container
db:
# Docker Hub image
image: postgres:latest
# Provide the password for postgres
env:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: iot
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
@@ -28,14 +25,10 @@ jobs:
- 5432:5432
steps:
- name: Install necessary dependencies
run: apt update && apt install nodejs pkg-config -y
- name: Install docker
run: curl -fsSL get.docker.com -o get-docker.sh && sh get-docker.sh
- name: Checkout Code
uses: actions/checkout@v2
- name: Run migrations
run: cargo install sqlx-cli && sqlx migrate run
run: sqlx migrate run
- name: Build server binary
run: cargo build --release
- name: Set up Docker Buildx
@@ -43,14 +36,38 @@ jobs:
- name: Log in to Docker Registry
uses: docker/login-action@v1
with:
registry: gitea.maiertobi.de # Replace with your Docker registry URL
registry: gitea.tobiasmaier.me
username: tobimai
password: ${{ secrets.docker_registry_key }}
- name: Build and Push Docker Image
- name: Extract Version from Cargo.toml
run: |
VERSION_PATCH=$(grep '^version' Cargo.toml | head -n 1 | cut -d ' ' -f 3 | tr -d '"')
VERSION_MINOR=$(grep '^version' Cargo.toml | head -n 1 | cut -d ' ' -f 3 | tr -d '"' | cut -d '.' -f 1,2)
VERSION_MAJOR=$(grep '^version' Cargo.toml | head -n 1 | cut -d ' ' -f 3 | tr -d '"' | cut -d '.' -f 1)
echo "VERSION_PATCH=$VERSION_PATCH" >> $GITEA_ENV
echo "VERSION_MINOR=$VERSION_MINOR" >> $GITEA_ENV
echo "VERSION_MAJOR=$VERSION_MAJOR" >> $GITEA_ENV
echo "Patch Version: $VERSION_PATCH"
echo "Minor Version: $VERSION_MINOR"
echo "Major Version: $VERSION_MAJOR"
- name: Build and Push Docker Image for Patch version
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: gitea.maiertobi.de/tobimai/iot # Replace with your Docker image's details
tags: gitea.tobiasmaier.me/tobimai/iot:${{ env.VERSION_PATCH }}
- name: Build and Push Docker Image for minor version
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: gitea.tobiasmaier.me/tobimai/iot:${{ env.VERSION_MINOR }}
- name: Build and Push Docker Image for major version
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: gitea.tobiasmaier.me/tobimai/iot:${{ env.VERSION_MAJOR }}

View File

@@ -1,24 +1,20 @@
name: Build Project
name: Build and Test Project
on: []
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: rust:latest
# Service containers to run with `container-job`
image: gitea.tobiasmaier.me/tobimai/devcontainer-rust:2.2
options: --user root
services:
# Label used to access the service container
db:
# Docker Hub image
image: postgres:latest
# Provide the password for postgres
env:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: iot
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
@@ -28,11 +24,12 @@ jobs:
- 5432:5432
steps:
- run: apt update && apt install nodejs pkgconfig -y
- name: Checkout Code
uses: actions/checkout@v2
- name: Run migrations
run: cargo install sqlx-cli && sqlx migrate run
run: sqlx migrate run
- name: Running cargo build
run: cargo build --release
- name: Running cargo build
run: cargo test

3
.gitignore vendored
View File

@@ -19,4 +19,5 @@ Cargo.lock
# Added by cargo
/target
*.bin
/fw
/firmware

View File

@@ -1,20 +1,25 @@
[package]
name = "iot-cloud"
version = "0.1.0"
version = "0.2.1"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4.4.0"
chrono = { version = "0.4.31", features = ["serde"] }
actix-service = "2.0"
actix-web = "4.11"
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
enum_stringify = "0.6.1"
env_logger = "0.11"
log = "0.4.20"
log = "0.4"
semver = "1.0.25"
serde = "1.0.188"
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls", "postgres", "migrate", "chrono", "mac_address"] }
sqlx-cli = "0.8"
strum = { version = "0.26.3", features = ["derive"] }
thiserror = "1.0"
strum = { version = "0.27", features = ["derive"] }
thiserror = "2.0"
tokio = { version = "1", features = ["fs", "rt-multi-thread"] }
[lints.clippy]
pedantic = "warn"

View File

@@ -1,4 +1,4 @@
FROM debian:bullseye
FROM debian:bookworm
ADD target/release/iot-cloud /iot-cloud
RUN apt update && apt upgrade -y && apt install openssl -y

View File

@@ -1,2 +1,5 @@
# iot-cloud
## TODO
- Env var for fw dir
- basic auth for uploading

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: http://localhost:8484/firmware/waterlevel
url: http://localhost:8282/firmware/waterlevel
body: multipartForm
auth: none
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: http://localhost:8484/device
url: http://localhost:8282/device
body: none
auth: none
}

View File

@@ -5,11 +5,11 @@ meta {
}
put {
url: http://localhost:8484/firmware/waterlevel/INA226/0.0.1
url: http://localhost:8282/firmware/waterlevel/INA233/1.0.1
body: multipartForm
auth: none
}
body:multipart-form {
: @file(/home/tobi/git/iot-cloud-api/target/debug/iot-cloud)
: @file(/home/tobi/git/waterlevel-software/.pio/build/ESP32/firmware.bin)
}

View File

@@ -34,13 +34,14 @@ impl Database {
Database { conn_pool: pool }
}
Err(err) => {
error!("Failed to connect to the database: {:?}", err);
error!("Failed to connect to the database: {err:?}");
std::process::exit(1);
}
}
}
pub async fn init_from_pool(pool: Pool<Postgres>) -> Database {
#[cfg(test)]
pub fn init_from_pool(pool: Pool<Postgres>) -> Database {
Database { conn_pool: pool }
}
@@ -48,9 +49,9 @@ impl Database {
pub async fn init_db(&self) {
info!("Checking if required tables exist");
match migrate!().run(&self.conn_pool).await {
Ok(_) => {}
Ok(()) => {}
Err(e) => {
error!("Error when running migrations {}", e);
error!("Error when running migrations {e}");
std::process::exit(1);
}
};
@@ -64,12 +65,13 @@ impl Database {
Ok(())
}
pub async fn add_display_name(
#[allow(dead_code)]
pub async fn update_display_name(
&self,
device_id: &MacAddress,
display_name: &str,
) -> Result<(), DatabaseError> {
info!("Adding Displayname {display_name} to Device with ID {device_id}");
info!("Updating Displayname to {display_name} for Device with ID {device_id}");
query!(
"UPDATE Devices SET display_name = $1 WHERE id = $2;",
display_name,
@@ -92,7 +94,7 @@ impl Database {
let exists = match exists_result {
Ok(res) => res.count > Some(0),
Err(err) => {
error!("Error checking table existence: {:?}", err);
error!("Error checking table existence: {err:?}");
std::process::exit(1);
}
};
@@ -141,7 +143,7 @@ impl Database {
pub async fn add_value(
&self,
msg: &web::Json<ValueMessageFromDevice>,
msg: &ValueMessageFromDevice,
device_id: &MacAddress,
) -> Result<(), DatabaseError> {
info!("Adding value to DB");
@@ -197,12 +199,11 @@ impl Database {
#[cfg(test)]
mod tests {
use super::*;
use dotenvy::dotenv;
use sqlx::PgPool;
#[sqlx::test]
async fn add_device(pool: PgPool) {
let db = Database::init_from_pool(pool).await;
async fn add_device_and_display_name(pool: PgPool) {
let db = Database::init_from_pool(pool);
let test_device = Device {
display_name: Some("Waterlevel daheim".to_owned()),
@@ -211,7 +212,7 @@ mod tests {
db.add_device(&MacAddress::from([0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F]))
.await
.unwrap();
db.add_display_name(
db.update_display_name(
&MacAddress::from([0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F]),
"Waterlevel daheim",
)
@@ -221,4 +222,23 @@ mod tests {
let devices = db.get_devices().await.unwrap();
assert_eq!(test_device, devices[0]);
}
#[sqlx::test]
async fn add_value(pool: PgPool) {
let db = Database::init_from_pool(pool);
let device_id = MacAddress::from([0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F]);
db.add_device(&device_id).await.unwrap();
let msg = ValueMessageFromDevice {
active_errors: 0,
value: 112.0,
value_id: 1,
};
db.add_value(&msg, &device_id).await.unwrap();
let values = db.get_values_for_id(&device_id).await.unwrap();
assert!((values[0].value - msg.value).abs() < 1e-5);
assert_eq!(values[0].value_id, msg.value_id);
}
}

137
src/device_telemetry_api.rs Normal file
View File

@@ -0,0 +1,137 @@
use actix_web::{get, post, put, web, HttpResponse, Responder};
use log::{error, info};
use sqlx::types::mac_address::MacAddress;
use crate::{
schemas::{AppState, DeviceMetadata, TelemetryMessageFromDevice, ValueMessageFromDevice},
util::parse_mac_address,
};
#[post("/telemetry/{device_id}")]
async fn receive_telemetry(
device_id: web::Path<String>,
data: web::Data<AppState>,
telemetry_message: web::Json<TelemetryMessageFromDevice>,
) -> impl Responder {
info!("POST - telementry - Processing device id {device_id}");
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError();
};
let mac_converted = MacAddress::from(mac_converted);
match data.db.create_device_if_not_exists(&mac_converted).await {
Ok(()) => {}
Err(e) => {
error!("Error creating new device: {e}");
return HttpResponse::InternalServerError();
}
}
match data
.db
.add_telemetry(&telemetry_message, &mac_converted)
.await
{
Ok(()) => HttpResponse::Created(),
Err(e) => {
error!("adding Telemetry message to DB failed \n{e}");
HttpResponse::InternalServerError()
}
}
}
#[get("/telemetry/{device_id}")]
async fn get_telemetry(device_id: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
info!("GET - telementry - Processing device id {device_id}");
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError().finish();
};
let mac_converted = MacAddress::from(mac_converted);
let messages = match data.db.get_telemetry_for_id(&mac_converted).await {
Ok(msgs) => msgs,
Err(e) => {
error!("Getting Telemetry Messages from DB failed \n{e}");
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(messages)
}
#[post("/value/{device_id}")]
async fn receive_value(
device_id: web::Path<String>,
data: web::Data<AppState>,
value_message: web::Json<ValueMessageFromDevice>,
) -> impl Responder {
info!("POST - value - Processing device id {device_id}");
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError();
};
let mac_converted = MacAddress::from(mac_converted);
match data.db.create_device_if_not_exists(&mac_converted).await {
Ok(()) => {}
Err(e) => {
error!("Error creating new device: {e}");
return HttpResponse::InternalServerError();
}
}
match data.db.add_value(&value_message, &mac_converted).await {
Ok(()) => HttpResponse::Created(),
Err(e) => {
error!("adding Telemetry message to DB failed \n{e}");
HttpResponse::InternalServerError()
}
}
}
#[get("/value/{device_id}")]
async fn get_value(device_id: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
info!("GET - value - Processing device id {device_id}");
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError().finish();
};
let mac_converted = MacAddress::from(mac_converted);
let messages = match data.db.get_values_for_id(&mac_converted).await {
Ok(msgs) => msgs,
Err(e) => {
error!("Getting Values from DB failed \n{e}");
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(messages)
}
#[get("/device")]
async fn get_devices(data: web::Data<AppState>) -> impl Responder {
info!("GET - devices - Processing");
let devices = match data.db.get_devices().await {
Ok(devs) => devs,
Err(e) => {
error!("Getting Devices from DB failed \n{e}");
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(devices)
}
#[put("/device/{device_id}/display_name")]
async fn set_display_name(
data: web::Data<AppState>,
device_id: web::Path<String>,
device_data: web::Json<DeviceMetadata>,
) -> impl Responder {
info!("PUT - device/{device_id} - Display Name",);
let display_name = device_data.0.display_name;
let db = &data.db;
let Ok(mac_converted) = parse_mac_address(&device_id) else {
error!("Failed to convert mac address");
return HttpResponse::InternalServerError().finish();
};
let mac_converted = MacAddress::from(mac_converted);
if let Ok(()) = db.update_display_name(&mac_converted, &display_name).await {
HttpResponse::Ok().finish()
} else {
error!("Failed to update/set displayName for device");
HttpResponse::InternalServerError().finish()
}
}

125
src/firmware_api.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::{fs, path::PathBuf};
use actix_web::{get, put, web, HttpResponse, Responder};
use log::{debug, info, warn};
use semver::Version;
use std::str::FromStr;
use crate::{
schemas::{AppState, OTAConfigurationList, Services},
util::{get_files, prune_files},
};
// Upload Firmware file
#[put("/{service}/{device}/{config}/{version}")]
async fn upload_firmware(
data: web::Data<AppState>,
path: web::Path<(String, String, String, String)>,
body: web::Bytes,
) -> impl Responder {
let (service, device, config, version) = path.into_inner();
let Ok(version) = Version::parse(&version) else {
return HttpResponse::InternalServerError().body("Failed to parse version");
};
debug!("{version:?}");
let version = format!("{}-{}-{}", version.major, version.minor, version.patch);
let firmware_root_path = &data.firmwares_path;
let Ok(service) = Services::from_str(&service) else {
return HttpResponse::NotFound().finish();
};
let firmware_folder = match service {
Services::Firmware => firmware_root_path.join(&device),
Services::Filesystem => firmware_root_path.join(format!("{}_filesystem", &device))
};
let firmware_path = firmware_folder
.join(format!("{service}_{}_{version}", config.to_lowercase()))
.with_extension("bin");
if firmware_path.is_file() {
warn!("{service} with product: {device}, config: {config} and version: {version} at path {} already exists, cant upload", firmware_path.display());
return HttpResponse::Conflict().body(format!("{}", firmware_path.display()));
}
info!("Uploading {service} with product: {device}, config: {config} and version: {version} to {}", firmware_path.display());
fs::create_dir_all(&firmware_folder).unwrap();
let x = tokio::fs::write(&firmware_path, &body).await;
debug!("{x:?}");
debug!("pruning now");
prune_files(&firmware_folder, &service, 3);
HttpResponse::Ok().body(format!("Firmware version {version} uploaded successfully"))
}
#[get("/{service}/{device}")]
async fn get_firmware_json(
data: web::Data<AppState>,
path: web::Path<(String, String)>,
) -> impl Responder {
let (service, device) = path.into_inner();
let Ok(firmware_or_filesystem) = Services::from_str(&service) else {
return HttpResponse::NotFound().finish();
};
let fw_path = match firmware_or_filesystem {
Services::Firmware => &data.firmwares_path.join(device),
Services::Filesystem => &data.firmwares_path.join(format!("{device}_filesystem"))
};
if fw_path.is_dir() {
match get_files(fw_path, &data.hostname) {
Ok(cfg) => HttpResponse::Ok().json(OTAConfigurationList {
configurations: cfg,
}),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
} else {
HttpResponse::Ok().json(OTAConfigurationList {
configurations: vec![],
})
}
}
#[get("/{service}/{product}/{config}/{version}.bin")]
async fn serve_firmware(
path: web::Path<(String, String, String, String)>,
data: web::Data<AppState>,
) -> impl Responder {
let (service, product, config, version) = path.into_inner();
let version = version.replace(['.', '_'], "-");
let service = service.to_lowercase();
let config = config.to_lowercase();
let Ok(service) = Services::from_str(&service) else {
return HttpResponse::NotFound().finish();
};
let fw_root_path = &data.firmwares_path;
let file_path = match service {
Services::Firmware => PathBuf::from(format!("{product}/firmware_{config}_{version}.bin")),
Services::Filesystem => PathBuf::from(format!("{product}/filesystem_{config}_{version}.bin")),
};
let file_path = fw_root_path.join(&file_path);
info!("Requested firmware for product: {product}, config: {config} and version: {version}, expected to be stored at {}", file_path.display());
if file_path.exists() {
info!("File exists, serving download now");
HttpResponse::Ok()
.content_type("application/octet-stream")
.insert_header((
"Content-Disposition",
format!("attachment; filename=\"{product}_{config}_{version}.bin\""),
))
.body(std::fs::read(file_path).unwrap_or_else(|_| Vec::new()))
} else {
warn!("File does not exist");
HttpResponse::NotFound().body("Firmware version not found")
}
}

View File

@@ -1,171 +1,17 @@
use std::{env, fs, process, str::FromStr};
use std::{env, path::PathBuf, process};
use crate::schemas::{TelemetryMessageFromDevice, ValueMessageFromDevice};
use actix_web::{get, post, put, web, App, HttpResponse, HttpServer, Responder};
use actix_web::{web, App, HttpServer};
use database::Database;
use dotenvy::dotenv;
use log::{error, info};
use schemas::{BoardConfig, BoardType, Device, OTAConfiguration};
use sqlx::types::mac_address::MacAddress;
use util::parse_mac_address;
use schemas::AppState;
mod database;
mod device_telemetry_api;
mod firmware_api;
mod schemas;
mod util;
struct AppState {
db: Database,
}
#[post("/telemetry/{device_id}")]
async fn receive_telemetry(
device_id: web::Path<String>,
data: web::Data<AppState>,
telemetry_message: web::Json<TelemetryMessageFromDevice>,
) -> impl Responder {
info!("POST - telementry - Processing device id {}", device_id);
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError();
};
let mac_converted = MacAddress::from(mac_converted);
match data.db.create_device_if_not_exists(&mac_converted).await {
Ok(_) => {}
Err(e) => {
error!("Error creating new device: {}", e);
return HttpResponse::InternalServerError();
}
};
match data
.db
.add_telemetry(&telemetry_message, &mac_converted)
.await
{
Ok(_) => HttpResponse::Created(),
Err(e) => {
error!("adding Telemetry message to DB failed \n{}", e);
HttpResponse::InternalServerError()
}
}
}
#[get("/telemetry/{device_id}")]
async fn get_telemetry(device_id: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
info!("GET - telementry - Processing device id {}", device_id);
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError().finish();
};
let mac_converted = MacAddress::from(mac_converted);
let messages = match data.db.get_telemetry_for_id(&mac_converted).await {
Ok(msgs) => msgs,
Err(e) => {
error!("Getting Telemetry Messages from DB failed \n{}", e);
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(messages)
}
#[post("/value/{device_id}")]
async fn receive_value(
device_id: web::Path<String>,
data: web::Data<AppState>,
value_message: web::Json<ValueMessageFromDevice>,
) -> impl Responder {
info!("POST - value - Processing device id {}", device_id);
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError();
};
let mac_converted = MacAddress::from(mac_converted);
match data.db.create_device_if_not_exists(&mac_converted).await {
Ok(_) => {}
Err(e) => {
error!("Error creating new device: {}", e);
return HttpResponse::InternalServerError();
}
};
match data.db.add_value(&value_message, &mac_converted).await {
Ok(_) => HttpResponse::Created(),
Err(e) => {
error!("adding Telemetry message to DB failed \n{}", e);
HttpResponse::InternalServerError()
}
}
}
#[get("/value/{device_id}")]
async fn get_value(device_id: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
info!("GET - value - Processing device id {}", device_id);
let Ok(mac_converted) = parse_mac_address(&device_id) else {
return HttpResponse::InternalServerError().finish();
};
let mac_converted = MacAddress::from(mac_converted);
let messages = match data.db.get_values_for_id(&mac_converted).await {
Ok(msgs) => msgs,
Err(e) => {
error!("Getting Values from DB failed \n{}", e);
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(messages)
}
#[get("/device")]
async fn get_devices(data: web::Data<AppState>) -> impl Responder {
info!("GET - devices - Processing");
let devices = match data.db.get_devices().await {
Ok(devs) => devs,
Err(e) => {
error!("Getting Devices from DB failed \n{}", e);
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok().json(devices)
}
#[put("/firmware/{product}/{config}/{version}")]
async fn upload_firmware(path: web::Path<(String, String, String)>, body: web::Bytes) -> impl Responder {
let (product, config, version) = path.into_inner();
println!("Uploading firmware version: {}", version);
let fw_folder = format!("./firmware/{product}");
fs::create_dir_all(&fw_folder).unwrap();
let file_path = format!("{fw_folder}/firmware_{config}_{version}.bin");
info!("Saving to {file_path}");
tokio::fs::write(&file_path, &body).await.unwrap();
HttpResponse::Ok().body(format!("Firmware version {} uploaded successfully", version))
}
// TODO this is more or less a placeholder. Firmware upload will be more detailed in the future
#[get("/firmware/{product}")]
async fn get_firmware_json(product: web::Path<String>) -> impl Responder {
let product = product.into_inner();
let mut configs = Vec::new();
if let Ok(entries) = fs::read_dir(format!("./firmware/{product}")) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
println!("File: {:?}", path);
let split_name: Vec<_> = path.file_name().unwrap().to_str().unwrap().split("_").collect();
let version = split_name[2].strip_suffix(".bin").unwrap();
let board_config = BoardConfig::from_str(split_name[1]).unwrap();
let board_type = BoardType::from_str(&product).unwrap();
let cfg = OTAConfiguration{board: board_type, configuration: board_config, version: version.to_string(), url: path.to_str().to_owned().unwrap().to_owned() };
configs.push(cfg);
} else if path.is_dir() {
println!("Directory: {:?}", path);
}
}
} else {
return HttpResponse::InternalServerError().finish()
}
HttpResponse::Ok().json(configs)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
@@ -179,24 +25,34 @@ async fn main() -> std::io::Result<()> {
process::exit(1);
}
};
info!("Connecting to Database {}", db_url);
let external_url = env::var("URL").unwrap_or("localhost".to_string());
let firmware_path = env::var("FIRMWARE_PATH").unwrap_or("/firmware".to_string());
info!("External URL: {external_url}");
info!("Firmware path set to: {firmware_path}");
info!("Connecting to Database {db_url}");
let db = Database::init(&db_url).await;
db.init_db().await;
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(AppState { db: db.clone() }))
.app_data(web::PayloadConfig::new(256 * 1024 * 1024)) //256MB
.service(receive_telemetry)
.service(get_telemetry)
.service(receive_value)
.service(get_value)
.service(get_devices)
.service(upload_firmware)
.service(get_firmware_json)
.app_data(web::Data::new(AppState {
db: db.clone(),
firmwares_path: PathBuf::from(firmware_path.clone()),
hostname: external_url.clone(),
}))
.app_data(web::PayloadConfig::new(2 * 1024 * 1024 * 1024)) //1GB
.service(device_telemetry_api::receive_telemetry)
.service(device_telemetry_api::get_telemetry)
.service(device_telemetry_api::receive_value)
.service(device_telemetry_api::get_value)
.service(device_telemetry_api::get_devices)
.service(firmware_api::upload_firmware)
.service(firmware_api::get_firmware_json)
.service(firmware_api::serve_firmware)
})
.bind(("0.0.0.0", 8484))?
.bind(("0.0.0.0", 8282))?
.run()
.await
}

View File

@@ -1,8 +1,12 @@
use std::path::PathBuf;
use chrono::NaiveDateTime;
use enum_stringify::EnumStringify;
use semver::Version;
use serde::{ser::SerializeStruct, Deserialize, Serialize};
use sqlx::types::mac_address::MacAddress;
use strum::EnumString;
use strum::{Display, EnumString};
use crate::database::Database;
#[derive(Deserialize, Debug, Serialize)]
pub struct TelemetryMessage {
@@ -21,14 +25,14 @@ pub struct TelemetryMessageFromDevice {
pub software_version: i32,
}
#[derive(Deserialize, Debug, Serialize)]
#[derive(Deserialize, Debug, Serialize, PartialEq)]
pub struct ValueMessageFromDevice {
pub value: f64,
pub value_id: i32,
pub active_errors: i32,
}
#[derive(Deserialize, Debug, Serialize)]
#[derive(Deserialize, Debug, Serialize, PartialEq)]
pub struct ValueMessage {
pub value: f64,
pub value_id: i32,
@@ -65,21 +69,53 @@ impl Serialize for Device {
}
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct OTAConfigurationList {
pub configurations: Vec<OTAConfiguration>,
}
#[derive(serde::Serialize, PartialEq, Debug)]
#[serde(rename_all = "PascalCase")]
pub struct OTAConfiguration {
pub board: BoardType,
pub configuration: BoardConfig,
pub version: String,
pub url: String
pub version: Version,
#[serde(rename = "URL")]
pub url: String,
pub board: Option<BoardType>,
pub config: Option<BoardConfig>,
}
#[derive(serde::Serialize, EnumString)]
#[derive(serde::Serialize, EnumString, PartialEq, Debug, Display)]
#[strum(serialize_all = "snake_case")]
pub enum BoardType {
Waterlevel
Waterlevel,
WaterlevelFilesystem,
}
#[derive(serde::Serialize, EnumString)]
#[derive(serde::Serialize, EnumString, PartialEq, Debug, Display)]
#[strum(ascii_case_insensitive, serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum BoardConfig {
INA226,
INA233
INA233,
INA226REV2,
INA233REV2,
Generic,
}
pub struct AppState {
pub db: Database,
pub firmwares_path: PathBuf,
pub hostname: String,
}
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Debug)]
pub struct DeviceMetadata {
pub display_name: String,
}
#[derive(EnumString, PartialEq, Debug, Display, Serialize)]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum Services {
Firmware,
Filesystem,
}

View File

@@ -1,5 +1,12 @@
use std::{fs, path::PathBuf};
use log::{debug, error, info};
use semver::Version;
use std::str::FromStr;
use thiserror::Error;
use crate::schemas::{BoardConfig, BoardType, OTAConfiguration, Services};
pub fn parse_mac_address(mac: &str) -> Result<[u8; 6], MacAddressError> {
if mac.len() != 12 {
return Err(MacAddressError::Length(mac.len()));
@@ -22,10 +29,122 @@ pub enum MacAddressError {
Length(usize),
}
#[derive(Error, Debug)]
pub enum GetFilesError {
#[error("IO Error while reading files")]
IO(#[from] std::io::Error),
#[error("Error getting filename")]
Filename,
#[error("Error getting extension (.bin)")]
Extension,
#[error("Strum parse Error")]
Conversion(#[from] strum::ParseError),
#[error("Failed to parse the version")]
VersionParse(#[from] semver::Error),
}
pub fn get_files(
root_path: &PathBuf,
hostname: &str,
) -> Result<Vec<OTAConfiguration>, GetFilesError> {
info!("Getting all files from path {}", root_path.display());
let mut configs = Vec::new();
let product_name = root_path
.file_name()
.ok_or(GetFilesError::Filename)?
.to_string_lossy();
let entries = fs::read_dir(root_path)?;
for entry in entries.flatten() {
info!("Reading entry: {entry:?}");
let path = entry.path();
if path.is_file() {
info!("processing file: {}", path.display());
// Splits the filename at the underscores. This is safe to do as names get sanitized on upload and are only uploaded by the pipeline
let split_name: Vec<_> = path
.file_name()
.ok_or(GetFilesError::Filename)?
.to_str()
.ok_or(GetFilesError::Filename)?
.split('_')
.collect();
let version = split_name[2]
.strip_suffix(".bin")
.ok_or(GetFilesError::Extension)?
.replace('-', ".");
debug!("Version: {:?}", version);
debug!("split_name: {:?}", split_name);
// TODO this is kinda messy
let board_config = BoardConfig::from_str(split_name[1])?;
let service = split_name[0];
let board_type = BoardType::from_str(&product_name).unwrap();
let version = if version.contains('.') {
Version::parse(&version)?
} else {
// Handle simple version number by adding .0.0
Version::parse(&format!("{}.0.0", version))?
};
let version_replaced = format!("{}-{}-{}", version.major, version.minor, version.patch);
let fw_url =
format!("{hostname}/{service}/{board_type}/{board_config}/{version_replaced}.bin");
let cfg = OTAConfiguration {
version,
url: fw_url,
board: Some(board_type),
config: Some(board_config),
};
configs.push(cfg);
} else if path.is_dir() {
println!("Directory: {}", path.display());
}
}
Ok(configs)
}
pub fn prune_files(path: &PathBuf, service: &Services, keep_last: usize) {
let Ok(mut config_list) = get_files(path, "irrelevant") else {
error!("failed to get file list for pruning");
return
};
config_list.sort_by_key(|x| x.version.clone());
config_list.truncate(config_list.len() - keep_last);
for cfg in config_list {
println!("{cfg:?}");
let path = path.to_string_lossy();
let board_type = cfg. config.unwrap();
let path_to_remove = format!("{path}/{service}_{}_{}-{}-{}.bin", board_type, cfg.version.major, cfg.version.minor, cfg.version.patch);
if let Err(e) = fs::remove_file(&path_to_remove) {
error!("Failed to delete {path_to_remove}, {e:?}");
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_loading() {
let expected_1 = OTAConfiguration {
version: Version::parse("1.2.3").unwrap(),
url: "example.com/firmware/waterlevel/INA233/1-2-3.bin".to_string(),
board: Some(BoardType::Waterlevel),
config: Some(BoardConfig::INA233),
};
let expected_2 = OTAConfiguration {
version: Version::parse("4.5.6").unwrap(),
url: "example.com/firmware/waterlevel/INA226/4-5-6.bin".to_string(),
board: Some(BoardType::Waterlevel),
config: Some(BoardConfig::INA226),
};
let loaded_configs = get_files(&PathBuf::from("./test/waterlevel"), "example.com").unwrap();
assert_eq!(loaded_configs[1], expected_1);
assert_eq!(loaded_configs[0], expected_2);
}
#[test]
fn test_valid_mac_address_plain() {
let mac_str = "001A2B3C4D5E";