From 13ac36e7492ec524034cc7b06205b08bb4eb10a0 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 14:31:53 +0200 Subject: [PATCH 01/23] feat: device schema db model and related fields --- app/db/schema/device-schema.ts | 126 +++++++++++++++++++++++++++++++++ app/db/schema/device.ts | 13 ++++ app/db/schema/index.ts | 1 + 3 files changed, 140 insertions(+) create mode 100644 app/db/schema/device-schema.ts diff --git a/app/db/schema/device-schema.ts b/app/db/schema/device-schema.ts new file mode 100644 index 000000000..f8be577b7 --- /dev/null +++ b/app/db/schema/device-schema.ts @@ -0,0 +1,126 @@ +import { createId } from '@paralleldrive/cuid2' +import { relations, sql, type InferSelectModel } from 'drizzle-orm' +import { + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core' +import { user } from './user' +import { type UploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' + +export const deviceSchemaVisibilityEnum = pgEnum('device_schema_visibility', [ + 'private', + 'public', +]) + +export const deviceSchemaVersionStatusEnum = pgEnum( + 'device_schema_version_status', + ['draft', 'published', 'deprecated'], +) + +export const deviceSchema = pgTable( + 'device_schema', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + slug: text('slug').notNull(), + name: text('name').notNull(), + description: text('description'), + tags: text('tags') + .array() + .default(sql`ARRAY[]::text[]`), + ownerUserId: text('owner_user_id') + .notNull() + .references(() => user.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + visibility: deviceSchemaVisibilityEnum('visibility') + .default('private') + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + uniqueIndex('device_schema_owner_slug_unique').on( + table.ownerUserId, + table.slug, + ), + index('device_schema_visibility_idx').on(table.visibility), + ], +) + +export const deviceSchemaVersion = pgTable( + 'device_schema_version', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + deviceSchemaId: text('device_schema_id') + .notNull() + .references(() => deviceSchema.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + version: text('version').notNull(), + formatVersion: text('format_version').notNull(), + content: jsonb('content').$type().notNull(), + hash: text('hash').notNull(), + status: deviceSchemaVersionStatusEnum('status') + .default('published') + .notNull(), + createdByUserId: text('created_by_user_id') + .notNull() + .references(() => user.id, { + onDelete: 'restrict', + }), + createdAt: timestamp('created_at').defaultNow().notNull(), + publishedAt: timestamp('published_at'), + deprecatedAt: timestamp('deprecated_at'), + }, + (table) => [ + uniqueIndex('device_schema_version_unique').on( + table.deviceSchemaId, + table.version, + ), + uniqueIndex('device_schema_version_hash_unique').on( + table.deviceSchemaId, + table.hash, + ), + ], +) + +export const deviceSchemaRelations = relations( + deviceSchema, + ({ one, many }) => ({ + owner: one(user, { + fields: [deviceSchema.ownerUserId], + references: [user.id], + }), + versions: many(deviceSchemaVersion), + }), +) + +export const deviceSchemaVersionRelations = relations( + deviceSchemaVersion, + ({ one }) => ({ + schema: one(deviceSchema, { + fields: [deviceSchemaVersion.deviceSchemaId], + references: [deviceSchema.id], + }), + createdBy: one(user, { + fields: [deviceSchemaVersion.createdByUserId], + references: [user.id], + }), + }), +) + +export type DeviceSchema = InferSelectModel +export type DeviceSchemaVersion = InferSelectModel diff --git a/app/db/schema/device.ts b/app/db/schema/device.ts index 022f77080..a593a4017 100644 --- a/app/db/schema/device.ts +++ b/app/db/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 { deviceSchemaVersion } from './device-schema' /** * Table @@ -58,6 +59,18 @@ export const device = pgTable('device', { onDelete: 'cascade', onUpdate: 'cascade', }), + deviceSchemaVersionId: text('device_schema_version_id').references( + () => deviceSchemaVersion.id, + { + onDelete: 'set null', + onUpdate: 'cascade', + }, + ), + deviceSchemaPublicId: text('device_schema_public_id'), + deviceSchemaId: text('device_schema_id'), + deviceSchemaName: text('device_schema_name'), + deviceSchemaVersion: text('device_schema_version'), + deviceSchemaHash: text('device_schema_hash'), }) // Many-to-many relation between device - location diff --git a/app/db/schema/index.ts b/app/db/schema/index.ts index 3f74b9396..b7d498d91 100644 --- a/app/db/schema/index.ts +++ b/app/db/schema/index.ts @@ -1,4 +1,5 @@ export * from './device' +export * from './device-schema' export * from './enum' export * from './measurement' export * from './password' From 1d96b97d0c6501fb4078903dd9408a7d1bca5eda Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 14:32:36 +0200 Subject: [PATCH 02/23] feat: upload device schema zod validation --- app/lib/device-schemas/device-schema-v1.ts | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/lib/device-schemas/device-schema-v1.ts diff --git a/app/lib/device-schemas/device-schema-v1.ts b/app/lib/device-schemas/device-schema-v1.ts new file mode 100644 index 000000000..34c24422e --- /dev/null +++ b/app/lib/device-schemas/device-schema-v1.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +export const deviceSchemaSensorSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + unit: z.string().min(1), + sensorType: z.string().min(1), + icon: z.string().optional(), + sensorWikiType: z.string().optional(), + sensorWikiPhenomenon: z.string().optional(), + sensorWikiUnit: z.string().optional(), +}) + +export const uploadedDeviceSchemaV1 = z + .object({ + schemaType: z.literal('opensensemap.deviceSchema'), + schemaVersion: z.literal('1.0.0'), + id: z.string().min(1), + name: z.string().min(1), + version: z.string().min(1), + description: z.string().optional(), + tags: z.array(z.string()).optional().default([]), + sensors: z.array(deviceSchemaSensorSchema).min(1), + }) + .strict() + +export type UploadedDeviceSchemaV1 = z.infer From 2c7a3b58994aed70f813af14b02a3303c232c8f1 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 14:34:34 +0200 Subject: [PATCH 03/23] feat: add optional device schema to create device schema --- app/services/device-service.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 09e6f45c0..14a76a410 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -10,6 +10,7 @@ import { } from '~/db/models/device.server' import { verifyLogin } from '~/db/models/user.server' import { type Device, type User } from '~/db/schema' +import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' import { deleteDeviceImage } from '~/lib/s3.server' export const CreateBoxSchema = z.object({ @@ -99,6 +100,7 @@ export const CreateDeviceServiceSchema = z }), ) .optional(), + deviceSchema: uploadedDeviceSchemaV1.optional(), }) .refine((data) => !(data.model && data.sensors && data.model !== 'custom'), { message: 'Model and sensors cannot be specified at the same time.', From 4fe422e4f900c84bf88fd5ceb475e84dffa19d57 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 14:35:09 +0200 Subject: [PATCH 04/23] feat: example device schema --- public/examples/device-schema.json | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 public/examples/device-schema.json diff --git a/public/examples/device-schema.json b/public/examples/device-schema.json new file mode 100644 index 000000000..1f9ff96e4 --- /dev/null +++ b/public/examples/device-schema.json @@ -0,0 +1,35 @@ +{ + "schemaType": "opensensemap.deviceSchema", + "schemaVersion": "1.0.0", + "id": "example-custom-device", + "name": "Example Custom Device", + "version": "1.0.0", + "description": "Example schema for a custom device with temperature, humidity, and particulate matter sensors.", + "tags": ["example", "custom-device"], + "sensors": [ + { + "id": "temperature", + "title": "Temperature", + "unit": "°C", + "sensorType": "HDC1080", + "sensorWikiPhenomenon": "temperature", + "sensorWikiUnit": "Cel" + }, + { + "id": "relative-humidity", + "title": "Relative humidity", + "unit": "%", + "sensorType": "HDC1080", + "sensorWikiPhenomenon": "relative_humidity", + "sensorWikiUnit": "%" + }, + { + "id": "pm10", + "title": "PM10", + "unit": "µg/m³", + "sensorType": "SDS011", + "sensorWikiPhenomenon": "pm10", + "sensorWikiUnit": "ug/m3" + } + ] +} From 371fd979aa84b120c6dbe097d5f53ac4ef3997e5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 14:35:21 +0200 Subject: [PATCH 05/23] feat: translation --- public/locales/de/newdevice.json | 5 +++++ public/locales/de/profile.json | 2 ++ public/locales/en/newdevice.json | 5 +++++ public/locales/en/profile.json | 2 ++ 4 files changed, 14 insertions(+) diff --git a/public/locales/de/newdevice.json b/public/locales/de/newdevice.json index b0c6eb834..4246c567a 100644 --- a/public/locales/de/newdevice.json +++ b/public/locales/de/newdevice.json @@ -48,6 +48,11 @@ "selectedSensors_one": "{{count}} Sensor ausgewählt", "selectedSensors_other": "{{count}} Sensoren ausgewählt", "add_sensor": "Sensor hinzufügen", + "device_schema_upload": "Geräteschema hochladen", + "device_schema_upload_text": "Importiere ein JSON-Schema, um einen festen, geteilten Sensorsatz für dieses eigene Gerät zu verwenden.", + "device_schema_locked_sensors_one": "Dieses Schema definiert {{count}} gesperrten Sensor.", + "device_schema_locked_sensors_other": "Dieses Schema definiert {{count}} gesperrte Sensoren.", + "device_schema_invalid_file": "Die ausgewählte Datei ist kein gültiges Geräteschema.", "sensor_selection_info_text": "Wähle die Sensoren für dein Gerät aus, indem du vordefinierte Gruppen oder einzelne Sensoren basierend auf deinem Gerätemodell auswählst. Wenn du ein benutzerdefiniertes Gerät verwendest, konfiguriere die Sensoren manuell.", "select_sensors_text": "Wähle die Sensoren aus die du benutzen willst. Du kannst einen Sensor mehrfach hinzufügen.", "select_sensors_info_text": "Dein Sensor is nicht in der Liste? Füge ihn im <0>sensorWiki hinzu, um ihn auf der openSenseMap zu benutzen.", diff --git a/public/locales/de/profile.json b/public/locales/de/profile.json index 8fcf2aff7..c66fbb8c7 100644 --- a/public/locales/de/profile.json +++ b/public/locales/de/profile.json @@ -4,6 +4,8 @@ "devices": "Geräte", "sensors": "Sensoren", "measurements": "Messungen", + "device_schemas": "Geräteschemas", + "download": "Herunterladen", "badges": "Badges", "take_over_device": "Gerät übernehmen", "transfer_device": "Gerät übertragen", diff --git a/public/locales/en/newdevice.json b/public/locales/en/newdevice.json index 22214f6c1..e84ecafc1 100644 --- a/public/locales/en/newdevice.json +++ b/public/locales/en/newdevice.json @@ -48,6 +48,11 @@ "selectedSensors_one": "{{count}} sensor selected", "selectedSensors_other": "{{count}} sensors selected", "add_sensor": "Add Sensor", + "device_schema_upload": "Upload device schema", + "device_schema_upload_text": "Import a JSON schema to use a fixed, shared set of sensors for this custom device.", + "device_schema_locked_sensors_one": "This schema defines {{count}} locked sensor.", + "device_schema_locked_sensors_other": "This schema defines {{count}} locked sensors.", + "device_schema_invalid_file": "The selected file is not a valid device schema.", "sensor_selection_info_text": "Select sensors for your device by choosing from predefined groups or individual sensors based on your device model. If using a custom device, configure sensors manually.", "select_sensors_text": "Select the sensors you want to use by clicking on the cards. You can add the same sensor multiple times.", "select_sensors_info_text": "Your sensor is not in the list? Add it to the <0>sensorWiki to use it on the openSenseMap.", diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 0ba0a128c..1d1e1fa8f 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -4,6 +4,8 @@ "devices": "Devices", "sensors": "Sensors", "measurements": "Measurements", + "device_schemas": "Device schemas", + "download": "Download", "badges": "Badges", "take_over_device": "Take over device", "transfer_device": "Device Transfer", From 5e22eb3afc55639e7bd7a46dff045bb2d0c7a99e Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 15 Jun 2026 15:30:54 +0200 Subject: [PATCH 06/23] feat: import and save schemas on custom devices --- .../device/new/custom-device-config.tsx | 129 +++++++++++++- .../device/new/new-device-stepper.tsx | 7 +- app/components/device/new/sensors-info.tsx | 9 + app/db/models/device-schema.server.ts | 160 ++++++++++++++++++ app/db/models/device.server.ts | 48 +++++- app/lib/device-schemas/util.ts | 31 ++++ app/routes/device.new.tsx | 1 + 7 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 app/db/models/device-schema.server.ts create mode 100644 app/lib/device-schemas/util.ts diff --git a/app/components/device/new/custom-device-config.tsx b/app/components/device/new/custom-device-config.tsx index e7027ba7e..c804ad4eb 100644 --- a/app/components/device/new/custom-device-config.tsx +++ b/app/components/device/new/custom-device-config.tsx @@ -1,13 +1,16 @@ -import { X } from 'lucide-react' +import { FileJson, Lock, X } from 'lucide-react' import { useState, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { type Sensor } from './sensors-info' +import { type CustomDeviceSchemaUpload, type Sensor } from './sensors-info' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '~/components/ui/separator' +import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1' export function CustomDeviceConfig() { const { setValue, watch } = useFormContext() @@ -16,6 +19,10 @@ export function CustomDeviceConfig() { const [sensors, setSensors] = useState( () => watch('selectedSensors') || [], ) + const [deviceSchema, setDeviceSchema] = useState( + () => watch('deviceSchema'), + ) + const [schemaError, setSchemaError] = useState(null) const [newSensor, setNewSensor] = useState({ title: '', unit: '', @@ -29,6 +36,11 @@ export function CustomDeviceConfig() { if (savedSensors.length > 0) { setSensors(savedSensors) } + + const savedDeviceSchema = watch('deviceSchema') + if (savedDeviceSchema) { + setDeviceSchema(savedDeviceSchema) + } }, [watch]) const updateNewSensor = (field: keyof Sensor, value: string) => { @@ -36,7 +48,12 @@ export function CustomDeviceConfig() { } const addSensor = () => { - if (newSensor.title && newSensor.unit && newSensor.sensorType) { + if ( + !deviceSchema && + newSensor.title && + newSensor.unit && + newSensor.sensorType + ) { const updatedSensors = [...sensors, newSensor] setSensors(updatedSensors) setValue('selectedSensors', updatedSensors) // Sync with form @@ -45,13 +62,107 @@ export function CustomDeviceConfig() { } const removeSensor = (index: number) => { + if (deviceSchema) return + const updatedSensors = sensors.filter((_, i) => i !== index) setSensors(updatedSensors) setValue('selectedSensors', updatedSensors) // Sync with form } + const importDeviceSchema = async (file: File) => { + setSchemaError(null) + + try { + const parsedJson = JSON.parse(await file.text()) + const parsedSchema = uploadedDeviceSchemaV1.parse(parsedJson) + const schemaSensors = parsedSchema.sensors.map((sensor) => ({ + id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + icon: sensor.icon, + sensorWikiType: sensor.sensorWikiType, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + sensorWikiUnit: sensor.sensorWikiUnit, + })) + + setDeviceSchema(parsedSchema) + setSensors(schemaSensors) + setValue('deviceSchema', parsedSchema) + setValue('selectedSensors', schemaSensors) + } catch (error) { + setSchemaError( + error instanceof Error + ? error.message + : t('device_schema_invalid_file'), + ) + } + } + + const clearDeviceSchema = () => { + setDeviceSchema(undefined) + setSchemaError(null) + setSensors([]) + setValue('deviceSchema', undefined) + setValue('selectedSensors', []) + } + return (
+
+
+
+
+ + {t('device_schema_upload')} +
+

+ {t('device_schema_upload_text')} +

+
+
+ { + const file = event.target.files?.[0] + if (file) void importDeviceSchema(file) + event.target.value = '' + }} + /> + {deviceSchema && ( + + )} +
+
+ + {deviceSchema && ( + + + + {deviceSchema.name} + v{deviceSchema.version} + + + {t('device_schema_locked_sensors', { + count: deviceSchema.sensors.length, + })} + + + )} + + {schemaError && ( +

{schemaError}

+ )} +
+
@@ -61,6 +172,7 @@ export function CustomDeviceConfig() { value={newSensor.title} onChange={(e) => updateNewSensor('title', e.target.value)} placeholder="e.g., Temperature" + disabled={!!deviceSchema} />
@@ -70,6 +182,7 @@ export function CustomDeviceConfig() { value={newSensor.unit} onChange={(e) => updateNewSensor('unit', e.target.value)} placeholder="e.g., °C" + disabled={!!deviceSchema} />
@@ -79,16 +192,21 @@ export function CustomDeviceConfig() { value={newSensor.sensorType} onChange={(e) => updateNewSensor('sensorType', e.target.value)} placeholder="e.g., HDC1080" + disabled={!!deviceSchema} />
@@ -103,6 +221,7 @@ export function CustomDeviceConfig() {
+ + {deviceSchemas.length > 0 && ( +
+
+ {t('device_schemas')} +
+
+ {deviceSchemas.map((schema) => ( +
+
+
+

{schema.name}

+ v{schema.version} + + {schema.content.sensors.length} {t('sensors')} + +
+ {schema.description && ( +

+ {schema.description} +

+ )} +
+ +
+ ))} +
+
+ )} From b3364f32b320db505a463e35d4244f35f5a607c7 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:20:03 +0200 Subject: [PATCH 15/23] feat: add model and schema version to custom device creation payload --- app/routes/device.new.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/routes/device.new.tsx b/app/routes/device.new.tsx index d122e6c8e..55a3e5fb0 100644 --- a/app/routes/device.new.tsx +++ b/app/routes/device.new.tsx @@ -51,6 +51,7 @@ export async function action({ request }: Route.ActionArgs) { }), ...(data['device-selection'].model === 'custom' && { + model: data['device-selection'].model, sensors: selectedSensors.map((sensor: any) => ({ title: sensor.title, sensorType: sensor.sensorType, @@ -58,6 +59,7 @@ export async function action({ request }: Route.ActionArgs) { icon: sensor.icon, })), deviceSchema: data['sensor-selection'].deviceSchema, + deviceSchemaVersionId: data['sensor-selection'].deviceSchemaVersionId, }), } From 48d9708ea26e85edffa8d5973730139e1832423a Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:20:51 +0200 Subject: [PATCH 16/23] feat: adjust add and update sensors --- app/db/models/sensor.server.ts | 40 ++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/app/db/models/sensor.server.ts b/app/db/models/sensor.server.ts index cdb1c7e72..9d1b7a999 100644 --- a/app/db/models/sensor.server.ts +++ b/app/db/models/sensor.server.ts @@ -69,7 +69,7 @@ export function getSensors(deviceId: Sensor['deviceId']) { export function getSensorsFromDevice(deviceId: Sensor['deviceId']) { return drizzleClient.query.sensor.findMany({ where: (sensor, { eq }) => eq(sensor.deviceId, deviceId), - orderBy: (sensor, { asc }) => [asc(sensor.order)], + orderBy: (sensor, { asc }) => [asc(sensor.order), asc(sensor.createdAt)], }) } @@ -167,13 +167,17 @@ export function addNewSensor({ title, unit, sensorType, + icon, deviceId, order, -}: Pick) { +}: Pick & { + icon?: Sensor['icon'] +}) { return drizzleClient.insert(sensor).values({ title, unit, sensorType, + icon, deviceId, order, }) @@ -184,17 +188,29 @@ export function updateSensor({ title, unit, sensorType, + icon, + data, order, -}: Pick) { - return drizzleClient - .update(sensor) - .set({ - title, - unit, - sensorType, - order, - }) - .where(eq(sensor.id, id)) +}: Pick & { + icon?: Sensor['icon'] + data?: Sensor['data'] +}) { + const setColumns: Partial = { + title, + unit, + sensorType, + order, + } + + if (icon !== undefined) { + setColumns.icon = icon + } + + if (data !== undefined) { + setColumns.data = data + } + + return drizzleClient.update(sensor).set(setColumns).where(eq(sensor.id, id)) } // return first sensor with its device name From 88c544da174f7aa36a372c910e16b90ab3cace2f Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:22:12 +0200 Subject: [PATCH 17/23] feat: adjust device creation for sensor schema, allow to detach schema --- app/db/models/device.server.ts | 77 ++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/app/db/models/device.server.ts b/app/db/models/device.server.ts index 4e4f77cc8..11be6bea4 100644 --- a/app/db/models/device.server.ts +++ b/app/db/models/device.server.ts @@ -38,7 +38,10 @@ import { messages as NewSenseboxDeviceMessages } from '~/emails/new-device-sense import { createDeviceApiKey } from '~/lib/jwt' import { sendMail } from '~/lib/mail.server' import { getSensorsForModel } from '~/lib/model-definitions' -import { createOrReusePrivateDeviceSchemaVersionFromUpload } from './device-schema.server' +import { + createOrReusePrivateDeviceSchemaVersionFromUpload, + getVisibleDeviceSchemaVersionForCreation, +} from './device-schema.server' const BASE_DEVICE_COLUMNS = { id: true, @@ -222,6 +225,48 @@ export function getDeviceWithoutSensors({ id }: Pick) { }) } +export async function detachDeviceSchema({ + id, + userId, +}: Pick) { + const [existingDevice] = await drizzleClient + .select() + .from(device) + .where(and(eq(device.id, id), eq(device.userId, userId))) + .limit(1) + + if (!existingDevice) { + throw new DeviceUpdateError(`Device ${id} not found`, 404) + } + + assertDeviceIsMutable(existingDevice) + + const tags = (existingDevice.tags ?? []).filter( + (tag) => !tag.startsWith('schema:'), + ) + + const [updatedDevice] = await drizzleClient + .update(device) + .set({ + tags, + deviceSchemaVersionId: null, + deviceSchemaPublicId: null, + deviceSchemaId: null, + deviceSchemaName: null, + deviceSchemaVersion: null, + deviceSchemaHash: null, + updatedAt: sql`NOW()`, + }) + .where(and(eq(device.id, id), eq(device.userId, userId))) + .returning() + + if (!updatedDevice) { + throw new DeviceUpdateError(`Device ${id} not found`, 404) + } + + return updatedDevice +} + export type DeviceWithoutSensors = Awaited< ReturnType > @@ -835,6 +880,8 @@ export async function createDevice(deviceData: any, userId: string) { // Determine sensors to use let sensorsToAdd = deviceData.sensors let storedDeviceSchemaVersion = null + const isCustomDevice = + !deviceData.model || deviceData.model?.toLowerCase() === 'custom' // If model and sensors are both specified, reject (backwards compatibility) if ( @@ -870,14 +917,11 @@ export async function createDevice(deviceData: any, userId: string) { } } - if (deviceData.model?.toLowerCase() === 'custom' && deviceData.sensors) { + if (isCustomDevice && deviceData.sensors) { sensorsToAdd = deviceData.sensors ?? [] } - if ( - deviceData.model?.toLowerCase() === 'custom' && - deviceData.deviceSchema - ) { + if (isCustomDevice && deviceData.deviceSchema) { storedDeviceSchemaVersion = await createOrReusePrivateDeviceSchemaVersionFromUpload( tx, @@ -888,6 +932,21 @@ export async function createDevice(deviceData: any, userId: string) { sensorsToAdd = storedDeviceSchemaVersion.content.sensors } + if (isCustomDevice && deviceData.deviceSchemaVersionId) { + storedDeviceSchemaVersion = + await getVisibleDeviceSchemaVersionForCreation( + tx, + userId, + deviceData.deviceSchemaVersionId, + ) + + if (!storedDeviceSchemaVersion) { + throw new Error('Device schema version not found.') + } + + sensorsToAdd = storedDeviceSchemaVersion.content.sensors + } + const schemaTag = storedDeviceSchemaVersion ? `schema:${storedDeviceSchemaVersion.schemaSlug}` : null @@ -937,7 +996,7 @@ export async function createDevice(deviceData: any, userId: string) { Array.isArray(sensorsToAdd) && sensorsToAdd.length > 0 ) { - for (const sensorData of sensorsToAdd) { + for (const [index, sensorData] of sensorsToAdd.entries()) { const [newSensor] = await tx .insert(sensor) .values({ @@ -949,6 +1008,10 @@ export async function createDevice(deviceData: any, userId: string) { sensorWikiPhenomenon: sensorData.sensorWikiPhenomenon, sensorWikiUnit: sensorData.sensorWikiUnit, deviceId: createdDevice.id, + data: storedDeviceSchemaVersion + ? { deviceSchemaSensorId: sensorData.id } + : sensorData.data, + order: index, }) .returning() From 8bb2456a5d74bc1227a83852dedf8d5db6134571 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:22:32 +0200 Subject: [PATCH 18/23] feat: add optional schema version id to zod schema --- app/services/device-service.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 14a76a410..43f8517ed 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -101,6 +101,7 @@ export const CreateDeviceServiceSchema = z ) .optional(), deviceSchema: uploadedDeviceSchemaV1.optional(), + deviceSchemaVersionId: z.string().optional(), }) .refine((data) => !(data.model && data.sensors && data.model !== 'custom'), { message: 'Model and sensors cannot be specified at the same time.', From 4a2b930e64789277f37df95139faf7f61f2a9e9b Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:22:49 +0200 Subject: [PATCH 19/23] feat: translation --- public/locales/en/settings.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 465a1990d..f7b66b7a1 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -84,5 +84,21 @@ "unsaved_changes": "Unsaved changes", "saving": "Saving...", "confirm_email_change": "Confirm email change", - "confirm_email_change_description": "Please enter your current password." + "confirm_email_change_description": "Please enter your current password.", + "device_schemas": "Device schemas", + "device_schemas_description": "Manage schemas you uploaded while creating custom devices.", + "no_device_schemas": "You have not uploaded any device schemas yet.", + "schema_visibility_private": "Private", + "schema_visibility_public": "Public", + "publish_schema": "Publish", + "hide_schema": "Hide", + "schema_version_history": "Version history", + "publish_new_schema_version": "Publish a new version", + "publish_version": "Publish version", + "schema_version_status_published": "Published", + "schema_version_status_deprecated": "Deprecated", + "schema_hash": "Hash", + "download": "Download", + "copy_link": "Copy link", + "sensors": "Sensors" } From e732bc29890685a03a8027f18fc333dae1034a2c Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:45:01 +0200 Subject: [PATCH 20/23] feat: adjust hook to track copied string --- app/hooks/use-copy-to-clipboard.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/hooks/use-copy-to-clipboard.ts b/app/hooks/use-copy-to-clipboard.ts index 20cc5dbf4..0d8a89876 100644 --- a/app/hooks/use-copy-to-clipboard.ts +++ b/app/hooks/use-copy-to-clipboard.ts @@ -1,7 +1,9 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' export function useCopyToClipboard(resetAfter = 2000) { const [copiedToClipboard, setCopiedToClipboard] = useState(false) + const [copiedValue, setCopiedValue] = useState(null) + const resetTimeoutRef = useRef(null) const copyToClipboard = useCallback( async (value: string | undefined | null) => { @@ -10,9 +12,15 @@ export function useCopyToClipboard(resetAfter = 2000) { await navigator.clipboard.writeText(value) setCopiedToClipboard(true) + setCopiedValue(value) - window.setTimeout(() => { + if (resetTimeoutRef.current) { + window.clearTimeout(resetTimeoutRef.current) + } + + resetTimeoutRef.current = window.setTimeout(() => { setCopiedToClipboard(false) + setCopiedValue(null) }, resetAfter) return true @@ -22,6 +30,7 @@ export function useCopyToClipboard(resetAfter = 2000) { return { copiedToClipboard, + copiedValue, copyToClipboard, } } From f4e72be577fcca1088e7b64cb16b3db440a2f7e9 Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:45:24 +0200 Subject: [PATCH 21/23] feat: manage own schemas on settings profile --- app/routes/settings.profile.tsx | 543 ++++++++++++++++++++++++-------- 1 file changed, 418 insertions(+), 125 deletions(-) diff --git a/app/routes/settings.profile.tsx b/app/routes/settings.profile.tsx index ac8f00074..23bf11085 100644 --- a/app/routes/settings.profile.tsx +++ b/app/routes/settings.profile.tsx @@ -1,10 +1,11 @@ import { CopyIcon, CopyCheckIcon, InfoIcon } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Link, Outlet, useLoaderData } from 'react-router' +import { Form, Link, Outlet, useActionData, useLoaderData } from 'react-router' import { type Route } from './+types/settings.profile' import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' +import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Card, @@ -24,6 +25,11 @@ import { } from '~/components/ui/tooltip' import { useToast } from '~/components/ui/use-toast' +import { + getOwnedDeviceSchemasWithVersions, + publishNewDeviceSchemaVersion, + updateDeviceSchemaVisibility, +} from '~/db/models/device-schema.server' import { getProfileByUserId, updateProfile } from '~/db/models/profile.server' import { getUserById } from '~/db/models/user.server' import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard' @@ -38,9 +44,10 @@ import { AutosaveStatusText } from '~/components/autosave-status.text' export async function loader({ request }: Route.LoaderArgs) { const userId = await requireUserId(request) - const [user, profile] = await Promise.all([ + const [user, profile, deviceSchemas] = await Promise.all([ getUserById(userId), getProfileByUserId(userId), + getOwnedDeviceSchemasWithVersions(userId), ]) if (!user || !profile) { @@ -59,6 +66,20 @@ export async function loader({ request }: Route.LoaderArgs) { return { profile, publicProfileUrl, + deviceSchemas: deviceSchemas.map((schema) => ({ + ...schema, + versions: schema.versions.map((version) => { + const downloadUrl = new URL( + `/resources/device-schema/${version.id}`, + request.url, + ).toString() + + return { + ...version, + downloadUrl, + } + }), + })), } } @@ -79,9 +100,31 @@ export type ProfileActionData = message: string } +type DeviceSchemaActionData = + | { + intent: 'update-device-schema-visibility' + success: true + } + | { + intent: 'update-device-schema-visibility' + success: false + message: string + } + | { + intent: 'publish-device-schema-version' + success: true + } + | { + intent: 'publish-device-schema-version' + success: false + message: string + } + +type SettingsProfileActionData = ProfileActionData | DeviceSchemaActionData + export async function action({ request, -}: Route.ActionArgs): Promise { +}: Route.ActionArgs): Promise { const userId = await requireUserId(request) const profile = await getProfileByUserId(userId) @@ -96,8 +139,70 @@ export async function action({ const formData = await request.formData() const intent = String(formData.get('intent') ?? '') - const displayName = String(formData.get('displayName') ?? '').trim() - const isPublic = formData.get('isPublic') === 'true' + + if (intent === 'update-device-schema-visibility') { + const schemaId = String(formData.get('schemaId') ?? '') + const visibility = String(formData.get('visibility') ?? '') + + if (!schemaId || (visibility !== 'private' && visibility !== 'public')) { + return { + intent, + success: false, + message: 'Invalid schema visibility update.', + } + } + + const updatedSchema = await updateDeviceSchemaVisibility( + userId, + schemaId, + visibility, + ) + + if (!updatedSchema) { + return { + intent, + success: false, + message: 'Device schema could not be updated.', + } + } + + return { + intent, + success: true, + } + } + + if (intent === 'publish-device-schema-version') { + const schemaId = String(formData.get('schemaId') ?? '') + const schemaFile = formData.get('schemaFile') + + if (!schemaId || !(schemaFile instanceof File) || schemaFile.size === 0) { + return { + intent, + success: false, + message: 'Please choose a schema JSON file.', + } + } + + try { + const parsedSchema = JSON.parse(await schemaFile.text()) + await publishNewDeviceSchemaVersion(userId, schemaId, parsedSchema) + + return { + intent, + success: true, + } + } catch (error) { + return { + intent, + success: false, + message: + error instanceof Error + ? error.message + : 'Schema version could not be published.', + } + } + } if (intent !== 'autosave-profile') { return { @@ -107,6 +212,9 @@ export async function action({ } } + const displayName = String(formData.get('displayName') ?? '').trim() + const isPublic = formData.get('isPublic') === 'true' + if (displayName.length < 3 || displayName.length > 40) { return { intent: 'autosave-profile', @@ -144,6 +252,7 @@ type ProfileAutosaveValues = { export default function EditUserProfilePage() { const data = useLoaderData() + const actionData = useActionData() const [displayName, setDisplayName] = useState(data.profile.displayName) const [isPublic, setIsPublic] = useState(data.profile.public ?? false) @@ -152,7 +261,7 @@ export default function EditUserProfilePage() { const { toast } = useToast() const publicProfileUrl = data.publicProfileUrl - const { copiedToClipboard, copyToClipboard } = useCopyToClipboard() + const { copiedValue, copyToClipboard } = useCopyToClipboard() const validateAutosave = useCallback((values: ProfileAutosaveValues) => { const nextDisplayName = values.displayName.trim() @@ -219,6 +328,24 @@ export default function EditUserProfilePage() { }, ) + useEffect(() => { + if (!actionData || actionData.intent === 'autosave-profile') return + + if (actionData.success) { + toast({ + title: t('saved'), + variant: 'success', + }) + return + } + + toast({ + title: t('something_went_wrong'), + description: actionData.message, + variant: 'destructive', + }) + }, [actionData, toast, t]) + useEffect(() => { setDisplayName(data.profile.displayName) setIsPublic(data.profile.public ?? false) @@ -256,132 +383,298 @@ export default function EditUserProfilePage() { }) } - return ( - - - {t('profile_settings')} - {t('profile_settings_description')} - - - - -
-
-
- - - - - - - - -

{t('if_public')}

-
-
-
-
+ const handleCopySchemaLink = async (url: string) => { + const copied = await copyToClipboard(url) - setDisplayName(event.target.value)} - /> -
+ if (!copied) return + + toast({ + title: t('copied'), + variant: 'success', + }) + } -
-
- - - - - - - - -

- {t('if_activated_public_1')}{' '} - - - {t('if_activated_public_2')} - - - {t('if_activated_public_3')} -

-
-
-
+ return ( +
+ + + {t('profile_settings')} + {t('profile_settings_description')} + + + + +
+
+
+ + + + + + + + +

{t('if_public')}

+
+
+
+
+ + setDisplayName(event.target.value)} + />
- - - {isPublic && ( -
- - -
- event.target.select()} - /> - - -
+
+
+ + + + + + + + +

+ {t('if_activated_public_1')}{' '} + + + {t('if_activated_public_2')} + + + {t('if_activated_public_3')} +

+
+
+
- )} -
-
- -
-
- - - - {getInitials(data.profile?.displayName ?? '')} - - - - - ✎ - + + {isPublic && ( +
+ + +
+ event.target.select()} + /> + + +
+
+ )} +
-
-
- -
+
+
+ + + + {getInitials(data.profile?.displayName ?? '')} + + + + + ✎ + +
+
+ + + + + + + + {t('device_schemas')} + {t('device_schemas_description')} + + + {data.deviceSchemas.length === 0 ? ( +

+ {t('no_device_schemas')} +

+ ) : ( +
+ {data.deviceSchemas.map((schema) => ( +
+
+
+
+

{schema.name}

+ + {t(`schema_visibility_${schema.visibility}`)} + +
+ {schema.description && ( +

+ {schema.description} +

+ )} +
+ +
+ + + + +
+
+ +
+ + +
+ + +
+ +
+ +
+

+ {t('schema_version_history')} +

+ {schema.versions.map((version) => ( +
+
+
+ + v{version.version} + + + {t(`schema_version_status_${version.status}`)} + + + {version.content.sensors.length} {t('sensors')} + + + {version.formatVersion} + +
+

+ {t('schema_hash')}: {version.hash.slice(0, 12)} +

+
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
+
+
) } From dd7be2560455951c2ea8c0ed82900f4b9af5befc Mon Sep 17 00:00:00 2001 From: jona159 Date: Wed, 17 Jun 2026 15:46:09 +0200 Subject: [PATCH 22/23] feat: restrict editing sensors from schema but allow to detach schema from device --- app/routes/device.$deviceId.edit.sensors.tsx | 261 ++++++++++++++++--- 1 file changed, 230 insertions(+), 31 deletions(-) diff --git a/app/routes/device.$deviceId.edit.sensors.tsx b/app/routes/device.$deviceId.edit.sensors.tsx index 387517b2b..50dba9a28 100644 --- a/app/routes/device.$deviceId.edit.sensors.tsx +++ b/app/routes/device.$deviceId.edit.sensors.tsx @@ -34,6 +34,11 @@ import { getSensorsFromDevice, updateSensor, } from '~/db/models/sensor.server' +import { + detachDeviceSchema, + getDeviceWithoutSensors, +} from '~/db/models/device.server' +import { getSharedDeviceSchemaVersion } from '~/db/models/device-schema.server' import { assignIcon, getIcon, iconsList } from '~/lib/sensoricons' import { getUserId } from '~/services/session-service.server' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -51,9 +56,30 @@ export async function loader({ request, params }: Route.LoaderArgs) { if (typeof deviceID !== 'string') { return 'deviceID not found' } - const rawSensorsData = await getSensorsFromDevice(deviceID) + const device = await getDeviceWithoutSensors({ id: deviceID }) + + if (!device || device.userId !== userId) { + return redirect('/') + } - return rawSensorsData as any + const [rawSensorsData, deviceSchema] = await Promise.all([ + getSensorsFromDevice(deviceID), + device.deviceSchemaVersionId + ? getSharedDeviceSchemaVersion(device.deviceSchemaVersionId, userId) + : Promise.resolve(undefined), + ]) + + return { + sensors: rawSensorsData, + deviceSchema: deviceSchema + ? { + name: device.deviceSchemaName ?? deviceSchema.name, + version: device.deviceSchemaVersion ?? deviceSchema.version, + hash: device.deviceSchemaHash, + sensors: deviceSchema.content.sensors, + } + : null, + } as any } //***************************************************** @@ -62,16 +88,109 @@ export async function action({ request, params }: Route.ActionArgs) { if (!userId) return redirect('/') const formData = await request.formData() - const { updatedSensorsData } = Object.fromEntries(formData) - - if (typeof updatedSensorsData !== 'string') { - return { isUpdated: false } - } + const { intent, updatedSensorsData } = Object.fromEntries(formData) const deviceId = params.deviceId invariant(deviceId, 'deviceID not found!') + const device = await getDeviceWithoutSensors({ id: deviceId }) + if (!device || device.userId !== userId) return redirect('/') + + if (intent === 'detach-schema') { + await detachDeviceSchema({ id: deviceId, userId }) + return { isUpdated: true, isDetached: true } + } + + if (typeof updatedSensorsData !== 'string') { + return { isUpdated: false, message: 'No sensor data submitted.' } + } + const updatedSensorsDataJson = JSON.parse(updatedSensorsData) + const currentSensors = await getSensorsFromDevice(deviceId) + const currentSensorsById = new Map( + currentSensors.map((sensor) => [sensor.id, sensor]), + ) + const deviceSchema = device.deviceSchemaVersionId + ? await getSharedDeviceSchemaVersion(device.deviceSchemaVersionId, userId) + : null + + if (deviceSchema) { + if (currentSensors.length !== deviceSchema.content.sensors.length) { + return { + isUpdated: false, + message: + 'This device no longer matches its schema. Detach it from the schema before editing sensors.', + } + } + + const schemaSensorsById = new Map( + deviceSchema.content.sensors.map((schemaSensor) => [ + schemaSensor.id, + schemaSensor, + ]), + ) + const schemaSensorsByExistingSensorId = new Map( + currentSensors.map((sensor, index) => { + const schemaSensorId = + sensor.data && + typeof sensor.data === 'object' && + !Array.isArray(sensor.data) + ? (sensor.data as { deviceSchemaSensorId?: unknown }) + .deviceSchemaSensorId + : null + + return [ + sensor.id, + typeof schemaSensorId === 'string' + ? (schemaSensorsById.get(schemaSensorId) ?? + deviceSchema.content.sensors[index]) + : deviceSchema.content.sensors[index], + ] + }), + ) + + for (const [index, submittedSensor] of updatedSensorsDataJson.entries()) { + const sensorId = submittedSensor?.id + const existingSensor = + typeof sensorId === 'string' ? currentSensorsById.get(sensorId) : null + const schemaSensor = + typeof sensorId === 'string' + ? schemaSensorsByExistingSensorId.get(sensorId) + : null + + if ( + submittedSensor?.new === true || + submittedSensor?.deleted === true || + !existingSensor || + !schemaSensor + ) { + return { + isUpdated: false, + message: + 'Schema-backed sensors cannot be added or deleted. Detach the device from its schema first.', + } + } + + await updateSensor({ + id: existingSensor.id, + title: schemaSensor.title, + unit: schemaSensor.unit, + sensorType: schemaSensor.sensorType, + icon: submittedSensor.icon ?? existingSensor.icon, + data: { + ...(existingSensor.data && + typeof existingSensor.data === 'object' && + !Array.isArray(existingSensor.data) + ? existingSensor.data + : {}), + deviceSchemaSensorId: schemaSensor.id, + }, + order: index, + }) + } + + return { isUpdated: true } + } for (const [index, sensor] of updatedSensorsDataJson.entries()) { if (sensor?.new === true && sensor?.edited === true) { @@ -79,24 +198,32 @@ export async function action({ request, params }: Route.ActionArgs) { title: sensor.title, unit: sensor.unit, sensorType: sensor.sensorType, + icon: sensor.icon, deviceId, order: index, }) } else if (sensor?.deleted === true) { + if (!currentSensorsById.has(sensor.id)) { + return { isUpdated: false, message: 'Sensor not found.' } + } await deleteSensor(sensor.id) } else if (!sensor?.new) { + if (!currentSensorsById.has(sensor.id)) { + return { isUpdated: false, message: 'Sensor not found.' } + } await updateSensor({ id: sensor.id, title: sensor.title, unit: sensor.unit, sensorType: sensor.sensorType, + icon: sensor.icon, order: index, }) } } - const currentSensors = await getSensorsFromDevice(deviceId) - const validSensorIds = currentSensors + const nextSensors = await getSensorsFromDevice(deviceId) + const validSensorIds = nextSensors .map((sensor: any) => sensor._id || sensor.id) .filter(Boolean) @@ -123,7 +250,12 @@ export default function EditBoxSensors() { const [copiedSensorId, setCopiedSensorId] = React.useState( null, ) - const [sensorsData, setSensorsData] = useState(data) + const originalSensorsData = Array.isArray(data) ? data : data.sensors + const deviceSchema = Array.isArray(data) ? null : data.deviceSchema + const isSchemaBacked = !!deviceSchema + const isSchemaOutOfSync = + isSchemaBacked && originalSensorsData.length !== deviceSchema.sensors.length + const [sensorsData, setSensorsData] = useState(originalSensorsData) /* temp impl. until figuring out how to updating state of nested objects */ const [tepmState, setTepmState] = useState(false) @@ -150,8 +282,18 @@ export default function EditBoxSensors() { delete sensor.editing } } + } else if (actionData?.message) { + toast({ + title: t('save_failed'), + description: actionData.message, + variant: 'destructive', + }) } - }, [actionData, setToastOpen]) // eslint-disable-line react-hooks/exhaustive-deps + }, [actionData, setToastOpen, toast, t]) // eslint-disable-line react-hooks/exhaustive-deps + + React.useEffect(() => { + setSensorsData(originalSensorsData) + }, [originalSensorsData]) const handleCopySensorId = React.useCallback( async (sensorId: string | null) => { @@ -202,6 +344,7 @@ export default function EditBoxSensors() { + {isSchemaBacked && ( + + )} +
+ {isSchemaBacked && ( +
+

+ {t('schema_notice_title', { + name: deviceSchema.name, + version: deviceSchema.version, + })} +

+

{t('schema_notice_text')}

+ {isSchemaOutOfSync && ( +

+ {t('schema_out_of_sync')} +

+ )} +
+ )} +
    {sensorsData?.map((sensor: any, index: number) => { + const isSchemaSensor = isSchemaBacked && !sensor?.new + const canEditSchemaFields = !isSchemaSensor + return (
  • + {isSchemaSensor && ( +
    + {t('schema_fields_locked')} +
    + )}