From dc5e5972c1d7793073019311ca1211f4b54263f1 Mon Sep 17 00:00:00 2001 From: jona159 Date: Sun, 4 Jan 2026 19:21:27 +0100 Subject: [PATCH 01/27] feat: add integrations schema --- app/schema/device.ts | 5 + app/schema/enum.ts | 59 +- app/schema/index.ts | 29 +- app/schema/integration.ts | 86 ++ drizzle/0024_yummy_tony_stark.sql | 36 + drizzle/meta/0024_snapshot.json | 1521 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + 7 files changed, 1708 insertions(+), 35 deletions(-) create mode 100644 app/schema/integration.ts create mode 100644 drizzle/0024_yummy_tony_stark.sql create mode 100644 drizzle/meta/0024_snapshot.json diff --git a/app/schema/device.ts b/app/schema/device.ts index 006d2973..ba419b96 100644 --- a/app/schema/device.ts +++ b/app/schema/device.ts @@ -21,6 +21,7 @@ import { location } from './location' import { logEntry } from './log-entry' import { sensor } from './sensor' import { user } from './user' +import { deviceToIntegrations } from './integration' /** * Table @@ -84,6 +85,10 @@ export const deviceRelations = relations(device, ({ one, many }) => ({ sensors: many(sensor), locations: many(deviceToLocation), logEntries: many(logEntry), + integrations: one(deviceToIntegrations, { + fields: [device.id], + references: [deviceToIntegrations.deviceId], + }), })) // Many-to-many diff --git a/app/schema/enum.ts b/app/schema/enum.ts index da4eb094..cf452630 100644 --- a/app/schema/enum.ts +++ b/app/schema/enum.ts @@ -1,35 +1,52 @@ -import { pgEnum } from "drizzle-orm/pg-core"; -import { z } from "zod"; +import { pgEnum } from 'drizzle-orm/pg-core' +import { z } from 'zod' + +export const MqttMessageFormatEnum = pgEnum('message_format', [ + 'json', + 'csv', + 'application/json', + 'text/csv', + 'debug_plain', + '', +]) + +export const TtnProfileEnum = pgEnum('ttn_profile', [ + 'json', + 'debug', + 'sensebox/home', + 'lora-serialization', + 'cayenne-lpp', +]) // Enum for device exposure types -export const DeviceExposureEnum = pgEnum("exposure", [ - "indoor", - "outdoor", - "mobile", - "unknown", -]); +export const DeviceExposureEnum = pgEnum('exposure', [ + 'indoor', + 'outdoor', + 'mobile', + 'unknown', +]) // Zod schema for validating device exposure types -export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues); +export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues) // Type inferred from the Zod schema for device exposure types -export type DeviceExposureType = z.infer; +export type DeviceExposureType = z.infer // Enum for device status types -export const DeviceStatusEnum = pgEnum("status", ["active", "inactive", "old"]); +export const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']) // Zod schema for validating device status types -export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues); +export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues) // Type inferred from the Zod schema for device status types -export type DeviceStatusType = z.infer; +export type DeviceStatusType = z.infer // Enum for device model types -export const DeviceModelEnum = pgEnum("model", [ - "homeV2Lora", - "homeV2Ethernet", - "homeV2Wifi", - "senseBox:Edu", - "luftdaten.info", - "Custom", -]); +export const DeviceModelEnum = pgEnum('model', [ + 'homeV2Lora', + 'homeV2Ethernet', + 'homeV2Wifi', + 'senseBox:Edu', + 'luftdaten.info', + 'Custom', +]) diff --git a/app/schema/index.ts b/app/schema/index.ts index 8099e8d5..a2d3c07e 100644 --- a/app/schema/index.ts +++ b/app/schema/index.ts @@ -1,14 +1,15 @@ -export * from "./device"; -export * from "./enum"; -export * from "./measurement"; -export * from "./password"; -export * from "./profile"; -export * from "./profile-image"; -export * from "./types"; -export * from "./sensor"; -export * from "./user"; -export * from "./location"; -export * from "./log-entry"; -export * from "./refreshToken"; -export * from "./claim"; -export * from "./accessToken"; +export * from './device' +export * from './enum' +export * from './measurement' +export * from './password' +export * from './profile' +export * from './profile-image' +export * from './types' +export * from './sensor' +export * from './user' +export * from './location' +export * from './log-entry' +export * from './integration' +export * from './refreshToken' +export * from './claim' +export * from './accessToken' diff --git a/app/schema/integration.ts b/app/schema/integration.ts new file mode 100644 index 00000000..8da70855 --- /dev/null +++ b/app/schema/integration.ts @@ -0,0 +1,86 @@ +import { createId } from '@paralleldrive/cuid2' +import { + boolean, + integer, + json, + pgTable, + primaryKey, + text, +} from 'drizzle-orm/pg-core' +import { MqttMessageFormatEnum, TtnProfileEnum } from './enum' +import { device } from './device' +import { relations, sql } from 'drizzle-orm' + +export const mqttIntegration = pgTable('mqtt_integration', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + enabled: boolean('enabled').default(false).notNull(), + url: text('url').notNull(), + topic: text('topic').notNull(), + messageFormat: MqttMessageFormatEnum('message_format') + .default('json') + .notNull(), + decodeOptions: json('decode_options'), + connectionOptions: json('connection_options'), + deviceId: text('device_id').references(() => device.id, { + onDelete: 'cascade', + }), +}) + +export const ttnIntegration = pgTable('ttn_integration', { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + enabled: boolean('enabled').default(false).notNull(), + devId: text('dev_id').notNull(), + appId: text('app_id').notNull(), + port: integer('port'), + profile: TtnProfileEnum('profile').default('json').notNull(), + decodeOptions: json('decode_options') + .$type() + .default(sql`'{}'::json`), + deviceId: text('device_id').references(() => device.id, { + onDelete: 'cascade', + }), +}) + +export const deviceToIntegrations = pgTable( + 'device_to_integrations', + { + deviceId: text('device_id') + .notNull() + .references(() => device.id, { onDelete: 'cascade' }), + mqttIntegrationId: text('mqtt_integration_id').references( + () => mqttIntegration.id, + { + onDelete: 'set null', + }, + ), + ttnIntegrationId: text('ttn_integration_id').references( + () => ttnIntegration.id, + { + onDelete: 'set null', + }, + ), + }, + (t) => ({ + pk: primaryKey({ columns: [t.deviceId] }), + }), +) + +export const deviceToIntegrationsRelations = relations( + deviceToIntegrations, + ({ one }) => ({ + mqttIntegration: one(mqttIntegration, { + fields: [deviceToIntegrations.mqttIntegrationId], + references: [mqttIntegration.id], + }), + ttnIntegration: one(ttnIntegration, { + fields: [deviceToIntegrations.ttnIntegrationId], + references: [ttnIntegration.id], + }), + }), +) diff --git a/drizzle/0024_yummy_tony_stark.sql b/drizzle/0024_yummy_tony_stark.sql new file mode 100644 index 00000000..2b457b3c --- /dev/null +++ b/drizzle/0024_yummy_tony_stark.sql @@ -0,0 +1,36 @@ +CREATE TYPE "public"."message_format" AS ENUM('json', 'csv', 'application/json', 'text/csv', 'debug_plain', '');--> statement-breakpoint +CREATE TYPE "public"."ttn_profile" AS ENUM('json', 'debug', 'sensebox/home', 'lora-serialization', 'cayenne-lpp');--> statement-breakpoint +CREATE TABLE "device_to_integrations" ( + "device_id" text NOT NULL, + "mqtt_integration_id" text, + "ttn_integration_id" text, + CONSTRAINT "device_to_integrations_device_id_pk" PRIMARY KEY("device_id") +); +--> statement-breakpoint +CREATE TABLE "mqtt_integration" ( + "id" text PRIMARY KEY NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "url" text NOT NULL, + "topic" text NOT NULL, + "message_format" "message_format" DEFAULT 'json' NOT NULL, + "decode_options" json, + "connection_options" json, + "device_id" text +); +--> statement-breakpoint +CREATE TABLE "ttn_integration" ( + "id" text PRIMARY KEY NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "dev_id" text NOT NULL, + "app_id" text NOT NULL, + "port" integer, + "profile" "ttn_profile" DEFAULT 'json' NOT NULL, + "decode_options" json DEFAULT '{}'::json, + "device_id" text +); +--> statement-breakpoint +ALTER TABLE "device_to_integrations" ADD CONSTRAINT "device_to_integrations_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_to_integrations" ADD CONSTRAINT "device_to_integrations_mqtt_integration_id_mqtt_integration_id_fk" FOREIGN KEY ("mqtt_integration_id") REFERENCES "public"."mqtt_integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_to_integrations" ADD CONSTRAINT "device_to_integrations_ttn_integration_id_ttn_integration_id_fk" FOREIGN KEY ("ttn_integration_id") REFERENCES "public"."ttn_integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mqtt_integration" ADD CONSTRAINT "mqtt_integration_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ttn_integration" ADD CONSTRAINT "ttn_integration_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..0934bfaa --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,1521 @@ +{ + "id": "b4bc2011-8fc4-4d19-9fde-edeede1726f2", + "prevId": "b7903c96-4a1f-498b-abb4-07815b2d42d8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_integrations": { + "name": "device_to_integrations", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mqtt_integration_id": { + "name": "mqtt_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ttn_integration_id": { + "name": "ttn_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_integrations_device_id_device_id_fk": { + "name": "device_to_integrations_device_id_device_id_fk", + "tableFrom": "device_to_integrations", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_to_integrations_mqtt_integration_id_mqtt_integration_id_fk": { + "name": "device_to_integrations_mqtt_integration_id_mqtt_integration_id_fk", + "tableFrom": "device_to_integrations", + "tableTo": "mqtt_integration", + "columnsFrom": [ + "mqtt_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "device_to_integrations_ttn_integration_id_ttn_integration_id_fk": { + "name": "device_to_integrations_ttn_integration_id_ttn_integration_id_fk", + "tableFrom": "device_to_integrations", + "tableTo": "ttn_integration", + "columnsFrom": [ + "ttn_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_integrations_device_id_pk": { + "name": "device_to_integrations_device_id_pk", + "columns": [ + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mqtt_integration": { + "name": "mqtt_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_format": { + "name": "message_format", + "type": "message_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'json'" + }, + "decode_options": { + "name": "decode_options", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "connection_options": { + "name": "connection_options", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mqtt_integration_device_id_device_id_fk": { + "name": "mqtt_integration_device_id_device_id_fk", + "tableFrom": "mqtt_integration", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ttn_integration": { + "name": "ttn_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dev_id": { + "name": "dev_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "profile": { + "name": "profile", + "type": "ttn_profile", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'json'" + }, + "decode_options": { + "name": "decode_options", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ttn_integration_device_id_device_id_fk": { + "name": "ttn_integration_device_id_device_id_fk", + "tableFrom": "ttn_integration", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + }, + "public.message_format": { + "name": "message_format", + "schema": "public", + "values": [ + "json", + "csv", + "application/json", + "text/csv", + "debug_plain", + "" + ] + }, + "public.ttn_profile": { + "name": "ttn_profile", + "schema": "public", + "values": [ + "json", + "debug", + "sensebox/home", + "lora-serialization", + "cayenne-lpp" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2e48cd63..0d45d7c1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1765380754120, "tag": "0023_check_location", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1767465161290, + "tag": "0024_yummy_tony_stark", + "breakpoints": true } ] } \ No newline at end of file From 30b1d4ef16f7f3c03070f3ac419a7346db96c677 Mon Sep 17 00:00:00 2001 From: jona159 Date: Sun, 4 Jan 2026 19:22:34 +0100 Subject: [PATCH 02/27] feat: add internal endpoint to post measurements via mqtt --- app/routes/api.measurements.ingest.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 app/routes/api.measurements.ingest.ts diff --git a/app/routes/api.measurements.ingest.ts b/app/routes/api.measurements.ingest.ts new file mode 100644 index 00000000..49d8525a --- /dev/null +++ b/app/routes/api.measurements.ingest.ts @@ -0,0 +1,70 @@ +import { type ActionFunctionArgs } from 'react-router' +import { z } from 'zod' +import { getDevice } from '~/models/device.server' +import { saveMeasurements } from '~/models/measurement.server' +import { StandardResponse } from '~/utils/response-utils' + +const MeasurementSchema = z.object({ + sensor_id: z.string(), + value: z.number(), + createdAt: z.string().datetime().optional(), + location: z + .object({ + lat: z.number(), + lng: z.number(), + altitude: z.number().optional(), + }) + .optional(), +}) + +const BatchMeasurementSchema = z.object({ + deviceId: z.string(), + measurements: z.array(MeasurementSchema), +}) + +export async function action({ request }: ActionFunctionArgs) { + try { + let body + try { + body = await request.json() + } catch (err) { + return StandardResponse.badRequest('Invalid JSON in request body') + } + + const validationResult = BatchMeasurementSchema.safeParse(body) + if (!validationResult.success) { + return StandardResponse.badRequest( + validationResult.error.errors[0].message, + ) + } + + const { deviceId, measurements: rawMeasurements } = validationResult.data + + const device = await getDevice({ id: deviceId }) + if (!device) { + return StandardResponse.notFound('Device not found') + } + + if (!device.sensors || device.sensors.length === 0) { + return StandardResponse.badRequest('Device has no sensors configured') + } + + const measurements = rawMeasurements.map((m) => ({ + sensor_id: m.sensor_id, + value: m.value, + createdAt: m.createdAt ? new Date(m.createdAt) : undefined, + location: m.location, + })) + + try { + await saveMeasurements(device, measurements) + } catch (saveErr) { + // Still return 202 + } + + return new Response(null, { status: 202 }) + } catch (err) { + if (err instanceof Response) throw err + return StandardResponse.internalServerError() + } +} From 53163a2a4cf649a46ba9437c8c3df33b65b9a866 Mon Sep 17 00:00:00 2001 From: jona159 Date: Sun, 4 Jan 2026 19:22:44 +0100 Subject: [PATCH 03/27] feat: mqtt server functions --- app/models/integration.server.ts | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 app/models/integration.server.ts diff --git a/app/models/integration.server.ts b/app/models/integration.server.ts new file mode 100644 index 00000000..cef31511 --- /dev/null +++ b/app/models/integration.server.ts @@ -0,0 +1,44 @@ +import { eq } from 'drizzle-orm' +import { drizzleClient } from '~/db.server' +import { mqttIntegration, deviceToIntegrations } from '~/schema' + +export async function getMqttIntegrationByDeviceId(deviceId: string) { + const [result] = await drizzleClient + .select({ + id: mqttIntegration.id, + enabled: mqttIntegration.enabled, + url: mqttIntegration.url, + topic: mqttIntegration.topic, + messageFormat: mqttIntegration.messageFormat, + decodeOptions: mqttIntegration.decodeOptions, + connectionOptions: mqttIntegration.connectionOptions, + }) + .from(deviceToIntegrations) + .innerJoin( + mqttIntegration, + eq(deviceToIntegrations.mqttIntegrationId, mqttIntegration.id), + ) + .where(eq(deviceToIntegrations.deviceId, deviceId)) + .limit(1) + + return result +} + +export async function getAllActiveMqttIntegrations() { + return await drizzleClient + .select({ + deviceId: deviceToIntegrations.deviceId, + integrationId: mqttIntegration.id, + url: mqttIntegration.url, + topic: mqttIntegration.topic, + messageFormat: mqttIntegration.messageFormat, + decodeOptions: mqttIntegration.decodeOptions, + connectionOptions: mqttIntegration.connectionOptions, + }) + .from(deviceToIntegrations) + .innerJoin( + mqttIntegration, + eq(deviceToIntegrations.mqttIntegrationId, mqttIntegration.id), + ) + .where(eq(mqttIntegration.enabled, true)) +} From cd09724e599f8c884b840b58e815a9581f261c21 Mon Sep 17 00:00:00 2001 From: jona159 Date: Sun, 4 Jan 2026 19:23:25 +0100 Subject: [PATCH 04/27] feat: endpoint for active mqtt configurations --- app/routes/api.integrations.mqtt.active.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/routes/api.integrations.mqtt.active.ts diff --git a/app/routes/api.integrations.mqtt.active.ts b/app/routes/api.integrations.mqtt.active.ts new file mode 100644 index 00000000..ca9623d1 --- /dev/null +++ b/app/routes/api.integrations.mqtt.active.ts @@ -0,0 +1,26 @@ +import { type LoaderFunction } from 'react-router' +import { getAllActiveMqttIntegrations } from '~/models/integration.server' +import { StandardResponse } from '~/utils/response-utils' + +export const loader: LoaderFunction = async ({ request }) => { + try { + // TODO: Add service authentication + + const integrations = await getAllActiveMqttIntegrations() + + const response = integrations.map((integration) => ({ + deviceId: integration.deviceId, + integrationId: integration.integrationId, + url: integration.url, + topic: integration.topic, + messageFormat: integration.messageFormat, + decodeOptions: integration.decodeOptions, + connectionOptions: integration.connectionOptions, + })) + + return Response.json(response) + } catch (err) { + console.error('Error fetching active MQTT integrations:', err) + return StandardResponse.internalServerError() + } +} From 2a2869b3589fcaf18a532e1a3819dd0d36332f90 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 8 Jan 2026 14:27:18 +0100 Subject: [PATCH 05/27] fix: make createdAt mandatory to preserver history for batch measurements --- app/routes/api.measurements.ingest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/api.measurements.ingest.ts b/app/routes/api.measurements.ingest.ts index 49d8525a..06c9746f 100644 --- a/app/routes/api.measurements.ingest.ts +++ b/app/routes/api.measurements.ingest.ts @@ -7,7 +7,7 @@ import { StandardResponse } from '~/utils/response-utils' const MeasurementSchema = z.object({ sensor_id: z.string(), value: z.number(), - createdAt: z.string().datetime().optional(), + createdAt: z.string().datetime(), location: z .object({ lat: z.number(), From 4e65d7d87fb67056424710fd2d6ede4a9178f033 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 9 Jan 2026 15:45:24 +0100 Subject: [PATCH 06/27] feat: add mqtt client --- app/lib/mqttClient.ts | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 app/lib/mqttClient.ts diff --git a/app/lib/mqttClient.ts b/app/lib/mqttClient.ts new file mode 100644 index 00000000..3e83016f --- /dev/null +++ b/app/lib/mqttClient.ts @@ -0,0 +1,104 @@ +import { env } from "./env" +import { setMqttIntegrationEnabled } from "~/models/integration.server" + +interface MqttClientResponse { + success: boolean + deviceId: string +} + +interface MqttStatusResponse { + deviceId: string + connected: boolean +} + +interface MqttHealthResponse { + status: string + connections: number + timestamp: string +} + +class MqttClient { + private baseUrl = env.MQTT_SERVICE_URL + private serviceKey = env.MQTT_SERVICE_KEY + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}` + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'x-service-key': this.serviceKey, + ...options.headers, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ + error: 'Unknown error' + })) + throw new Error( + `MQTT Service error: ${response.status} - ${error.error || error.message || 'Unknown error'}` + ) + } + + return response.json() + } + + /** + * Connect a device to its MQTT broker + */ + async connectBox(params: { box_id: string }): Promise { + await setMqttIntegrationEnabled(params.box_id, true) + return this.request( + `/devices/${params.box_id}/connect`, + { method: 'POST' } + ) + } + + /** + * Disconnect a device from its MQTT broker + */ + async disconnectBox(params: { box_id: string }): Promise { + await setMqttIntegrationEnabled(params.box_id, false) + return this.request( + `/devices/${params.box_id}/disconnect`, + { method: 'POST' } + ) + } + + /** + * Reconnect a device (disconnect then connect with fresh config) + */ + async reconnectBox(params: { box_id: string }): Promise { + return this.request( + `/devices/${params.box_id}/reconnect`, + { method: 'POST' } + ) + } + + /** + * Get connection status for a device + */ + async getStatus(deviceId: string): Promise { + return this.request( + `/devices/${deviceId}/status`, + { method: 'GET' } + ) + } + + /** + * Get health status of the MQTT service + */ + async getHealth(): Promise { + return this.request( + '/health', + { method: 'GET' } + ) + } +} + +export const mqttClient = new MqttClient() \ No newline at end of file From 7ac3a31fd2d4bd4c22df6ee34473f78c8ba0b80a Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 9 Jan 2026 15:46:03 +0100 Subject: [PATCH 07/27] feat: mqtt routes and db methods --- app/models/integration.server.ts | 14 ++++++- app/routes/api.integrations.$deviceId.mqtt.ts | 39 +++++++++++++++++++ app/routes/api.integrations.mqtt.active.ts | 7 +++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 app/routes/api.integrations.$deviceId.mqtt.ts diff --git a/app/models/integration.server.ts b/app/models/integration.server.ts index cef31511..f80414a2 100644 --- a/app/models/integration.server.ts +++ b/app/models/integration.server.ts @@ -2,10 +2,21 @@ import { eq } from 'drizzle-orm' import { drizzleClient } from '~/db.server' import { mqttIntegration, deviceToIntegrations } from '~/schema' +export async function setMqttIntegrationEnabled( + deviceId: string, + enabled: boolean, + ) { + await drizzleClient + .update(mqttIntegration) + .set({ enabled }) + .where(eq(mqttIntegration.deviceId, deviceId)) + } + export async function getMqttIntegrationByDeviceId(deviceId: string) { const [result] = await drizzleClient .select({ - id: mqttIntegration.id, + deviceId: deviceToIntegrations.deviceId, + integrationId: mqttIntegration.id, enabled: mqttIntegration.enabled, url: mqttIntegration.url, topic: mqttIntegration.topic, @@ -29,6 +40,7 @@ export async function getAllActiveMqttIntegrations() { .select({ deviceId: deviceToIntegrations.deviceId, integrationId: mqttIntegration.id, + enabled: mqttIntegration.enabled, url: mqttIntegration.url, topic: mqttIntegration.topic, messageFormat: mqttIntegration.messageFormat, diff --git a/app/routes/api.integrations.$deviceId.mqtt.ts b/app/routes/api.integrations.$deviceId.mqtt.ts new file mode 100644 index 00000000..62e6b8b2 --- /dev/null +++ b/app/routes/api.integrations.$deviceId.mqtt.ts @@ -0,0 +1,39 @@ +import { type LoaderFunctionArgs } from "react-router" +import { getMqttIntegrationByDeviceId } from "~/models/integration.server" +import { StandardResponse } from "~/utils/response-utils" + +export async function loader({ params, request }: LoaderFunctionArgs) { + try { + const deviceId = params.deviceId + + if (!deviceId) { + return StandardResponse.badRequest("Missing deviceId") + } + + const key = request.headers.get("x-service-key") + + if (key != process.env.MQTT_SERVICE_KEY){ + return StandardResponse.unauthorized("Key invalid, access denied.") + } + + const integration = await getMqttIntegrationByDeviceId(deviceId) + + if (!integration) { + return StandardResponse.notFound("MQTT integration not found") + } + + return Response.json({ + deviceId: integration.deviceId, + integrationId: integration.integrationId, + enabled: integration.enabled, + url: integration.url, + topic: integration.topic, + messageFormat: integration.messageFormat, + decodeOptions: integration.decodeOptions, + connectionOptions: integration.connectionOptions, + }) + } catch (err) { + console.error("Error fetching MQTT integration:", err) + return StandardResponse.internalServerError() + } +} diff --git a/app/routes/api.integrations.mqtt.active.ts b/app/routes/api.integrations.mqtt.active.ts index ca9623d1..054168a6 100644 --- a/app/routes/api.integrations.mqtt.active.ts +++ b/app/routes/api.integrations.mqtt.active.ts @@ -4,13 +4,18 @@ import { StandardResponse } from '~/utils/response-utils' export const loader: LoaderFunction = async ({ request }) => { try { - // TODO: Add service authentication + const key = request.headers.get("x-service-key") + + if (key != process.env.MQTT_SERVICE_KEY){ + return StandardResponse.unauthorized("Key invalid, access denied.") + } const integrations = await getAllActiveMqttIntegrations() const response = integrations.map((integration) => ({ deviceId: integration.deviceId, integrationId: integration.integrationId, + enabled: integration.enabled, url: integration.url, topic: integration.topic, messageFormat: integration.messageFormat, From 24fd9c7476d76c4f20e5e9843ce592049ab09655 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 9 Jan 2026 15:47:24 +0100 Subject: [PATCH 08/27] feat: add mqtt service url and key to env --- .env.example | 3 +++ app/lib/env.ts | 6 ++++++ app/utils/env.server.ts | 3 +++ 3 files changed, 12 insertions(+) create mode 100644 app/lib/env.ts diff --git a/.env.example b/.env.example index 06069496..bdf9f7f8 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ OSEM_API_URL="https://api.opensensemap.org/" DIRECTUS_URL="https://coelho.opensensemap.org" SENSORWIKI_API_URL="https://api.sensors.wiki/" +MQTT_SERVICE_URL="http://localhost:3001" +MQTT_SERVICE_KEY="dev-service-key-change-in-production" + MYBADGES_API_URL = "https://api.v2.mybadges.org/" MYBADGES_URL = "https://mybadges.org/" MYBADGES_SERVERADMIN_USERNAME = "" diff --git a/app/lib/env.ts b/app/lib/env.ts new file mode 100644 index 00000000..45f0646c --- /dev/null +++ b/app/lib/env.ts @@ -0,0 +1,6 @@ +import "dotenv/config" + +export const env = { + MQTT_SERVICE_URL: process.env.MQTT_SERVICE_URL!, + MQTT_SERVICE_KEY: process.env.MQTT_SERVICE_KEY!, +} \ No newline at end of file diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts index bc67a6fb..272c8e81 100644 --- a/app/utils/env.server.ts +++ b/app/utils/env.server.ts @@ -17,6 +17,8 @@ const schema = z.object({ MYBADGES_ISSUERID_OSEM: z.string(), MYBADGES_CLIENT_ID: z.string(), MYBADGES_CLIENT_SECRET: z.string(), + MQTT_SERVICE_URL: z.string(), + MQTT_SERVICE_KEY: z.string() }); declare global { @@ -45,6 +47,7 @@ export function getEnv() { MYBADGES_API_URL: process.env.MYBADGES_API_URL, MYBADGES_URL: process.env.MYBADGES_URL, SENSORWIKI_API_URL: process.env.SENSORWIKI_API_URL, + MQTT_SERVICE_URL: process.env.MQTT_SERVICE_URL, }; } From 92eddcbeff7985c97fea228a644768d197a48fa2 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 3 Feb 2026 15:31:09 +0100 Subject: [PATCH 09/27] feat: install json to form package and define base widgets for styling --- app/components/rjsf/checkboxWidget.tsx | 14 +++ app/components/rjsf/fieldTemplate.tsx | 36 +++++++ app/components/rjsf/inputTemplate.tsx | 39 +++++++ package-lock.json | 139 +++++++++++++++++++++++-- package.json | 9 +- 5 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 app/components/rjsf/checkboxWidget.tsx create mode 100644 app/components/rjsf/fieldTemplate.tsx create mode 100644 app/components/rjsf/inputTemplate.tsx diff --git a/app/components/rjsf/checkboxWidget.tsx b/app/components/rjsf/checkboxWidget.tsx new file mode 100644 index 00000000..e8a56504 --- /dev/null +++ b/app/components/rjsf/checkboxWidget.tsx @@ -0,0 +1,14 @@ +import { type WidgetProps } from "@rjsf/utils"; +import { Checkbox } from "@/components/ui/checkbox"; + +export function CheckboxWidget({ value, onChange, label }: WidgetProps) { + return ( +
+ + {label} +
+ ); +} diff --git a/app/components/rjsf/fieldTemplate.tsx b/app/components/rjsf/fieldTemplate.tsx new file mode 100644 index 00000000..988c3086 --- /dev/null +++ b/app/components/rjsf/fieldTemplate.tsx @@ -0,0 +1,36 @@ +import { type FieldTemplateProps } from "@rjsf/utils"; + +export function FieldTemplate(props: FieldTemplateProps) { + const { + id, + label, + required, + description, + errors, + children, + } = props; + + return ( +
+ {label && ( + + )} + + {description} + + {children} + + {errors && ( +
+ {errors} +
+ )} +
+ ); +} diff --git a/app/components/rjsf/inputTemplate.tsx b/app/components/rjsf/inputTemplate.tsx new file mode 100644 index 00000000..31163494 --- /dev/null +++ b/app/components/rjsf/inputTemplate.tsx @@ -0,0 +1,39 @@ +import { type BaseInputTemplateProps } from "@rjsf/utils"; + +export function BaseInputTemplate(props: BaseInputTemplateProps) { + const { + id, + value, + required, + disabled, + readonly, + autofocus, + onChange, + onBlur, + onFocus, + options, + placeholder, + type, + } = props; + + return ( + onChange(e.target.value)} + onBlur={() => onBlur(id, value)} + onFocus={() => onFocus(id, value)} + className=" + w-full rounded-md border border-gray-300 + px-3 py-2 text-sm + focus:outline-none focus:ring-2 focus:ring-green-500 + disabled:bg-gray-100 disabled:cursor-not-allowed + " + /> + ); +} diff --git a/package-lock.json b/package-lock.json index 6f0f6fa1..dd1024ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,9 +40,12 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "1.0.1", - "@react-router/express": "^7.10.1", - "@react-router/node": "^7.10.1", - "@react-router/serve": "^7.10.1", + "@react-router/express": "^7.12.0", + "@react-router/node": "^7.12.0", + "@react-router/serve": "^7.12.0", + "@rjsf/core": "^6.2.5", + "@rjsf/utils": "^6.2.5", + "@rjsf/validator-ajv8": "^6.2.5", "@stepperize/react": "^5.1.6", "@tanstack/react-table": "^8.21.3", "@turf/bbox": "^7.2.0", @@ -6664,6 +6667,101 @@ "dev": true, "license": "MIT" }, + "node_modules/@rjsf/core": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.2.5.tgz", + "integrity": "sha512-k/2aAKj9IY7JBcnPrYv7frgHkfK0KsS7h8PgPW14GJREh+X5EX/icrypcQu5ge/Ggbwi+90plJll07YiRV/lFg==", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^8.0.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.2.x", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.2.5.tgz", + "integrity": "sha512-29SvRuY3gKyAHUUnIiJiAF/mTnokgrE7XqUXMj+CZK+sGcmAegwhlnQMJgLQciTodMwTwOaDyV1Fxc47VKTHFw==", + "dependencies": { + "@x0k/json-schema-merge": "^1.0.2", + "fast-uri": "^3.1.0", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.2.5.tgz", + "integrity": "sha512-+yLhFRuT2aY91KiUujhUKg8SyTBrUuQP3QAFINeGi+RljA3S+NQN56oeCaNdz9X+35+Sdy6jqmxy/0Q2K+K9vQ==", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.2.x" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -9975,6 +10073,14 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@x0k/json-schema-merge": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.2.tgz", + "integrity": "sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==", + "dependencies": { + "@types/json-schema": "^7.0.15" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -14820,7 +14926,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -14890,7 +14995,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -17312,6 +17416,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -17874,6 +17986,22 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/markdown-to-jsx": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", + "integrity": "sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -23287,7 +23415,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index b2ef34e1..5b0ba631 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,12 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "1.0.1", - "@react-router/express": "^7.10.1", - "@react-router/node": "^7.10.1", - "@react-router/serve": "^7.10.1", + "@react-router/express": "^7.12.0", + "@react-router/node": "^7.12.0", + "@react-router/serve": "^7.12.0", + "@rjsf/core": "^6.2.5", + "@rjsf/utils": "^6.2.5", + "@rjsf/validator-ajv8": "^6.2.5", "@stepperize/react": "^5.1.6", "@tanstack/react-table": "^8.21.3", "@turf/bbox": "^7.2.0", From 884a16c582ca1beba1c5721f56bea68e30564a8d Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 3 Feb 2026 15:31:54 +0100 Subject: [PATCH 10/27] fix: check if measurement is posted by mqtt service --- app/routes/api.boxes.$deviceId.data.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routes/api.boxes.$deviceId.data.ts b/app/routes/api.boxes.$deviceId.data.ts index 05630ad8..e20479d3 100644 --- a/app/routes/api.boxes.$deviceId.data.ts +++ b/app/routes/api.boxes.$deviceId.data.ts @@ -16,8 +16,11 @@ export const action: ActionFunction = async ({ const hackair = searchParams.get("hackair") !== null; const contentType = request.headers.get("content-type") || ""; + const mqttServiceKey = request.headers.get("x-service-key"); const authorization = request.headers.get("authorization"); + const isMqttRequest= mqttServiceKey === process.env.MQTT_SERVICE_KEY; + let body: any; if (contentType.includes("application/json")) { body = await request.json(); @@ -33,7 +36,7 @@ export const action: ActionFunction = async ({ contentType, luftdaten, hackair, - authorization, + authorization: isMqttRequest ? undefined : authorization, }); return new Response("Measurements saved in box", { From 0fdec953c821e375eff0244e0f425e2997b5a798 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 3 Feb 2026 15:38:32 +0100 Subject: [PATCH 11/27] feat: form to edit mqtt config from json schema provided by mqtt service --- app/routes/device.$deviceId.edit.mqtt.tsx | 397 ++++++++-------------- 1 file changed, 134 insertions(+), 263 deletions(-) diff --git a/app/routes/device.$deviceId.edit.mqtt.tsx b/app/routes/device.$deviceId.edit.mqtt.tsx index 495dadbb..ded3f177 100644 --- a/app/routes/device.$deviceId.edit.mqtt.tsx +++ b/app/routes/device.$deviceId.edit.mqtt.tsx @@ -1,301 +1,172 @@ +import Form from "@rjsf/core"; +import validator from "@rjsf/validator-ajv8"; import { Save } from "lucide-react"; import React, { useState } from "react"; -import { data, redirect , Form, useActionData, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { data, redirect , useFetcher, useLoaderData, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; import ErrorMessage from "~/components/error-message"; +import { CheckboxWidget } from "~/components/rjsf/checkboxWidget"; +import { FieldTemplate } from "~/components/rjsf/fieldTemplate"; +import { BaseInputTemplate } from "~/components/rjsf/inputTemplate"; import { toast } from "~/components/ui/use-toast"; -import { checkMqttValidaty } from "~/models/mqtt.server"; import { getUserId } from "~/utils/session.server"; //***************************************************** -export async function loader({ request }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home +export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await getUserId(request); if (!userId) return redirect("/"); + const { deviceId } = params; - return ""; -} + try { + const headers = { + "x-service-key": process.env.MQTT_SERVICE_KEY!, + }; + + const [schemaRes, integrationRes] = await Promise.all([ + fetch(`${process.env.MQTT_SERVICE_URL}/integrations/schema/mqtt`, { headers }), + fetch(`${process.env.MQTT_SERVICE_URL}/integrations/${deviceId}`, { headers }), + ]); + + if (!schemaRes.ok) { + throw new Response("Failed to load MQTT schema", { status: 500 }); + } -//***************************************************** -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const { enableMQTTcb, mqttURL, mqttTopic } = Object.fromEntries(formData); + const schemaData = await schemaRes.json(); - //* ToDo: if mqtt checkbox is not enabled, reset mqtt to default - if (!enableMQTTcb) { - return data({ - errors: { - mqttURL: null, - mqttTopic: null, - }, - reset: true, - isMqttValid: null, - status: 200, - }); + let integration = null; + if (integrationRes.ok) { + integration = await integrationRes.json(); } - const errors = { - mqttURL: mqttURL ? null : "Invalid URL (please use ws or wss URL)", - mqttTopic: mqttTopic ? null : "Invalid mqtt topic", + const data = { + schema: schemaData.schema, + uiSchema: schemaData.uiSchema, + integration, }; - const hasErrors = Object.values(errors).some((errorMessage) => errorMessage); - if (hasErrors) { - return data({ - errors: errors, - reset: false, - isMqttValid: null, - status: 400, - }); + return data; +} + catch{ + console.log("err") } - - //* check mqtt connection validity - const isMqttValid = await checkMqttValidaty(mqttURL.toString()); - - return data({ - errors: errors, - reset: false, - isMqttValid: isMqttValid, - status: 200, - }); } -//********************************** -export default function EditBoxMQTT() { - const [mqttEnabled, setMqttEnabled] = useState(false); - const [mqttValid, setMqttValid] = useState(true); - const actionData = useActionData(); - - const mqttURLRef = React.useRef(null); - const mqttTopicRef = React.useRef(null); +export async function action({ request, params }: ActionFunctionArgs) { + const { deviceId } = params; + + const formData = await request.formData(); + const mqttConfigStr = formData.get("mqttConfig"); + + if (!mqttConfigStr) { + return data({ error: "No MQTT config provided" }, { status: 400 }); + } - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ); + const mqttConfig = JSON.parse(mqttConfigStr.toString()); + + const serviceUrl = process.env.MQTT_SERVICE_URL; + const serviceKey = process.env.MQTT_SERVICE_KEY; - // ToDo - if (actionData.reset) { - // Do nothing for now - } else if (!hasErrors) { - if (actionData.isMqttValid) { - setMqttValid(true); - //* show conn. success msg - toast({ - description: "Successfully connected to mqtt url!", - }); - } else { - setMqttValid(false); - mqttURLRef.current?.focus(); - } - } else if (hasErrors && actionData?.errors?.mqttURL) { - mqttURLRef.current?.focus(); - } else if (hasErrors && actionData?.errors?.mqttTopic) { - mqttTopicRef.current?.focus(); - } - } - }, [actionData]); + if (!serviceUrl || !serviceKey) { + throw new Error("MQTT service env vars are not configured"); + } - return ( -
-
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

MQTT

-
-
- {/* Save button */} - -
-
-
+ if (!mqttConfig.enabled) { + await fetch(`${serviceUrl}/integrations/${deviceId}`, { + method: 'DELETE', + headers: { 'x-service-key': serviceKey } + }); + return data({ success: true }); + } - {/* divider */} -
+ // Create or update integration + const response = await fetch(`${serviceUrl}/integrations/${deviceId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-service-key': serviceKey + }, + body: JSON.stringify({ + url: mqttConfig.url, + topic: mqttConfig.topic, + messageFormat: mqttConfig.messageFormat, + decodeOptions: mqttConfig.decodeOptions, + connectionOptions: mqttConfig.connectionOptions, + }) + }); -
-

- openSenseMap offers a{" "} - - MQTT{" "} - {" "} - client for connecting to public brokers. Documentation for the - parameters is provided{" "} - - in the docs.{" "} - - Please note that it's only possible to receive measurements - through MQTT. -

-
+ if (!response.ok) { + const error = await response.json(); + return data({ + error: error.error || error.details || 'Failed to save configuration' + }, { status: response.status }); + } -
- setMqttEnabled(!mqttEnabled)} - /> - -
+ return data({ success: true }); +} - {/* MQTT URL */} -
- +export default function EditBoxMQTT() { + const loaderData = useLoaderData(); -
- - {actionData?.errors?.mqttURL && ( -
- {actionData.errors.mqttURL} -
- )} + if (!loaderData) { + throw new Error("Loader data missing"); + } - {!mqttValid && ( -
- Entered mqtt url is not valid, please try again with a valid - one. -
- )} -
-
+ const { schema, uiSchema, integration } = loaderData; - {/* MQTT Topic */} -
- -
- - {actionData?.errors?.mqttTopic && ( -
- {actionData.errors.mqttTopic} -
- )} -
-
+ const [formData, setFormData] = React.useState(() => { + if (!integration) { + return { + enabled: false, + messageFormat: "json", + }; + } - {/* MQTT Message format */} -
- -
- -
- - -
-
- - -
-
-
-
+ return { + enabled: integration.enabled, + url: integration.url, + topic: integration.topic, + messageFormat: integration.messageFormat, + decodeOptions: integration.decodeOptions, + connectionOptions: integration.connectionOptions, + }; + }); + const fetcher = useFetcher(); - {/* MQTT Decoding options */} -
- -
- -
-
+ React.useEffect(() => { + if (fetcher.data?.success) { + toast({ description: "MQTT configuration saved successfully!" }); + } + }, [fetcher.data]); - {/* MQTT Decoding options */} -
- + const handleSubmit = async ({ formData: newFormData }: any) => { + await fetcher.submit( + { mqttConfig: JSON.stringify(newFormData) }, + { method: "post" } + ); +}; -
- -
-
-
-
-
+ return ( +
+

MQTT

+ +
setFormData(e.formData)} + onSubmit={handleSubmit} + > + +
); } From a25d0a576a21b062a7d8d6f9057961c1b681c44a Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 4 Feb 2026 15:04:38 +0100 Subject: [PATCH 12/27] feat: add route to fetch schema from mqtt service --- app/components/mydevices/dt/columns.tsx | 2 +- app/routes/api.integrations.schema.mqtt.ts | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 app/routes/api.integrations.schema.mqtt.ts diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index b61314e4..4da1697e 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -114,7 +114,7 @@ export const columns: ColumnDef[] = [ Show on map - + Edit diff --git a/app/routes/api.integrations.schema.mqtt.ts b/app/routes/api.integrations.schema.mqtt.ts new file mode 100644 index 00000000..96f84ef0 --- /dev/null +++ b/app/routes/api.integrations.schema.mqtt.ts @@ -0,0 +1,26 @@ +import { type LoaderFunctionArgs } from "react-router"; + +export async function loader({ request }: LoaderFunctionArgs) { + const serviceUrl = process.env.MQTT_SERVICE_URL; + const serviceKey = process.env.MQTT_SERVICE_KEY; + + if (!serviceUrl || !serviceKey) { + return new Response("MQTT service not configured", { status: 500 }); + } + + const res = await fetch(`${serviceUrl}/integrations/schema/mqtt`, { + headers: { + "x-service-key": serviceKey, + }, + }); + + if (!res.ok) { + return new Response("Failed to fetch MQTT schema", { + status: res.status, + }); + } + + const schema = await res.json(); + + return Response.json(schema); +} From 9323e3a00f3af0172c078a723d8176ce0a4fc54b Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 4 Feb 2026 15:05:42 +0100 Subject: [PATCH 13/27] feat: replace mqtt form with form generated from json schema --- app/components/device/new/advanced-info.tsx | 273 +++++++------------- 1 file changed, 96 insertions(+), 177 deletions(-) diff --git a/app/components/device/new/advanced-info.tsx b/app/components/device/new/advanced-info.tsx index ba7019d2..f1072a98 100644 --- a/app/components/device/new/advanced-info.tsx +++ b/app/components/device/new/advanced-info.tsx @@ -1,4 +1,10 @@ +import Form from "@rjsf/core"; +import validator from "@rjsf/validator-ajv8"; +import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; +import { CheckboxWidget } from "~/components/rjsf/checkboxWidget"; +import { FieldTemplate } from "~/components/rjsf/fieldTemplate"; +import { BaseInputTemplate } from "~/components/rjsf/inputTemplate"; import { Card, CardContent, @@ -6,62 +12,72 @@ import { CardHeader, CardTitle, } from "~/components/ui/card"; -import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Switch } from "~/components/ui/switch"; -import { Textarea } from "~/components/ui/textarea"; export function AdvancedStep() { - const { register, setValue, watch, resetField } = useFormContext(); - - // Watch field states - const isMqttEnabled = watch("mqttEnabled") || false; - const isTtnEnabled = watch("ttnEnabled") || false; - - // Clear corresponding fields when disabling + const { watch, setValue, resetField } = useFormContext(); + + const mqttEnabled = watch("mqttEnabled") ?? false; + const ttnEnabled = watch("ttnEnabled") ?? false; + const mqttConfig = watch("mqttConfig") ?? {}; + + const [schema, setSchema] = useState(null); + const [uiSchema, setUiSchema] = useState(null); + const [loading, setLoading] = useState(false); + const [schemaError, setSchemaError] = useState(null); + + // ---------------------------------- + // Load MQTT schema on demand + // ---------------------------------- + useEffect(() => { + if (!mqttEnabled || schema) return; + + const loadSchema = async () => { + setLoading(true); + setSchemaError(null); + + try { + const res = await fetch("/api/integrations/schema/mqtt"); + + if (!res.ok) { + throw new Error("Failed to fetch MQTT schema"); + } + + const data = await res.json(); + setSchema(data.schema); + setUiSchema(data.uiSchema); + } catch (err) { + console.error("Failed to load MQTT schema", err); + setSchemaError("Failed to load MQTT configuration schema."); + } finally { + setLoading(false); + } + }; + + void loadSchema(); + }, [mqttEnabled, schema]); + + // ---------------------------------- + // Toggle handlers + // ---------------------------------- const handleMqttToggle = (checked: boolean) => { setValue("mqttEnabled", checked); + if (!checked) { - resetField("url"); - resetField("topic"); - resetField("messageFormat"); - resetField("decodeOptions"); - resetField("connectionOptions"); + resetField("mqttConfig"); } }; const handleTtnToggle = (checked: boolean) => { setValue("ttnEnabled", checked); - if (!checked) { - resetField("dev_id"); - resetField("app_id"); - resetField("profile"); - resetField("decodeOptions"); - resetField("port"); - } - }; - - const handleInputChange = ( - event: React.ChangeEvent, - ) => { - const { name, value } = event.target; - setValue(name, value); - }; - - const handleSelectChange = (field: string, value: string) => { - setValue(field, value); }; return ( <> + {/* ============================= */} {/* MQTT Configuration */} + {/* ============================= */} MQTT Configuration @@ -69,88 +85,59 @@ export function AdvancedStep() { Configure your MQTT settings for data streaming - -
+ + +
- {isMqttEnabled && ( -
-
- - -
- -
- - -
- -
- - -
- -
- -