Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1c5bc07
feat: preserve sensor metadata after device creation
jona159 Aug 10, 2026
82908b7
feat: add metadata to sensor definitions
jona159 Aug 10, 2026
2c89a61
feat: add test to persist sensor definition id in sensor data
jona159 Aug 10, 2026
90619e2
feat: turn sensor definitions into routing catalog
jona159 Aug 12, 2026
05f1a44
feat: add nc values to luftdaten.info model definition
jona159 Aug 12, 2026
b81befe
feat: route by definition id
jona159 Aug 12, 2026
563147c
feat: decoder tests
jona159 Aug 12, 2026
3d4ba49
fix: rm generic lufdaten.info device model
jona159 Aug 17, 2026
4f31528
fix: only concrete model definitions
jona159 Aug 17, 2026
3a76349
feat: allow only generic luftdaten.info model, rm concrete models
jona159 Aug 17, 2026
ecb0fd6
fix: adjust example data
jona159 Aug 17, 2026
1aba09d
feat: add sen55 definitions
jona159 Aug 18, 2026
9c23664
feat: pass sensor templates
jona159 Aug 18, 2026
60fbc0d
feat: use id instead of title
jona159 Aug 18, 2026
837d53e
feat: rm concrete models in favor of generic
jona159 Aug 18, 2026
a09a757
feat: use device model zod enum, refine
jona159 Aug 18, 2026
1b78016
feat: check sensor data
jona159 Aug 18, 2026
f3a4d56
feat: warnings instead of throwing
jona159 Aug 18, 2026
34d8daa
feat: use device model zod enum, refine
jona159 Aug 18, 2026
b0fd1e9
fix: variable name
jona159 Aug 20, 2026
09f9f22
feat: add ui feedback for conflicting sensors
jona159 Aug 20, 2026
2a112d4
fix: replace outdated variable name with inline list
jona159 Aug 21, 2026
a179724
Merge branch 'dev' into fix/decoder-substring-matching
jona159 Aug 21, 2026
7dfe96e
fix: improve check
jona159 Aug 21, 2026
1d8eefc
fix: reject infinity values
jona159 Aug 21, 2026
8a4ac2b
fix: tests
jona159 Aug 24, 2026
4afab93
fix: tests
jona159 Aug 24, 2026
f9f6be9
Merge branch 'dev' into fix/decoder-substring-matching
scheidtdav Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions app/components/device/new/device-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,24 @@ export function DeviceSelectionStep() {

const handleClose = () => {
setSelectedDevice(null)
setSelectedConnectionType('')
setValue('model', null)
}

const isConfiguringSenseBoxHome = selectedDevice === 'senseBox:Home'

return (
<div className="overflow-hidden p-4">
<div
className={cn(
'grid gap-6',
selectedDevice === 'senseBox:Home'
isConfiguringSenseBoxHome
? 'grid-cols-1'
: 'grid-cols-1 lg:grid-cols-2',
)}
>
{devices.map((device) => {
if (
selectedDevice === 'senseBox:Home' &&
device.name !== selectedDevice
)
if (isConfiguringSenseBoxHome && device.name !== selectedDevice)
return null

return (
Expand All @@ -132,7 +132,7 @@ export function DeviceSelectionStep() {
'border-primary bg-primary/10 ring-primary/40 shadow-sm ring-2',
)}
onClick={() => {
if (selectedDevice === 'senseBox:Home') {
if (isConfiguringSenseBoxHome) {
return
}
handleDeviceChange(device.name)
Expand All @@ -141,7 +141,7 @@ export function DeviceSelectionStep() {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()

if (selectedDevice === 'senseBox:Home') {
if (isConfiguringSenseBoxHome) {
return
}

Expand All @@ -164,7 +164,7 @@ export function DeviceSelectionStep() {
</div>

<div className="flex min-w-0 flex-1 flex-col justify-center p-3">
{selectedDevice === 'senseBox:Home' && (
{isConfiguringSenseBoxHome && (
<Button
variant="ghost"
size="icon"
Expand Down
91 changes: 67 additions & 24 deletions app/components/device/new/sensors-info.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Cpu } from 'lucide-react'
import { Cpu, TriangleAlert } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useFormContext } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
Expand All @@ -10,10 +10,15 @@ import {
AccordionItem,
AccordionTrigger,
} from '~/components/ui/accordion'
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
import { Badge } from '~/components/ui/badge'
import { Checkbox } from '~/components/ui/checkbox'
import { Label } from '~/components/ui/label'
import { getSensorsForModel } from '~/lib/model-definitions'
import {
findSensorTemplateMappingConflict,
getSensorsForModel,
type SensorTemplateMappingConflict,
} from '~/lib/model-definitions'
import { cn } from '~/lib/utils'
import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1'

Expand Down Expand Up @@ -51,8 +56,11 @@ export function SensorSelectionStep() {
)
const [sensors, setSensors] = useState<Sensor[]>([])
const [selectedSensors, setSelectedSensors] = useState<Sensor[]>([])
const [selectionConflict, setSelectionConflict] =
useState<SensorTemplateMappingConflict>()

useEffect(() => {
setSelectionConflict(undefined)
if (selectedDevice) {
const deviceModel = selectedDevice.startsWith('homeV2')
? 'senseBoxHomeV2'
Expand Down Expand Up @@ -97,9 +105,7 @@ export function SensorSelectionStep() {
const sensorGroups = groupSensorsByType(sensors)

const isSensorSelected = (sensor: Sensor) =>
selectedSensors.some(
(s) => s.title === sensor.title && s.sensorType === sensor.sensorType,
)
selectedSensors.some((selected) => selected.id === sensor.id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isGroupFullySelected = (group: SensorGroup) =>
group.sensors.every((sensor) => isSensorSelected(sensor))
Expand All @@ -111,40 +117,65 @@ export function SensorSelectionStep() {
const getSelectedCountForGroup = (group: SensorGroup) =>
group.sensors.filter((sensor) => isSensorSelected(sensor)).length

const updateSelectedSensors = (updatedSensors: Sensor[]) => {
const sensorDefinitionIds = updatedSensors.flatMap((sensor) =>
sensor.id ? [sensor.id] : [],
)
const conflict = findSensorTemplateMappingConflict(
selectedDevice,
sensorDefinitionIds,
)

if (conflict) {
setSelectionConflict(conflict)
return
}

setSelectionConflict(undefined)
setSelectedSensors(updatedSensors)
setValue('selectedSensors', updatedSensors)
}

const handleSensorToggle = (sensor: Sensor) => {
const isAlreadySelected = isSensorSelected(sensor)

const updatedSensors = isAlreadySelected
? selectedSensors.filter(
(s) =>
!(s.title === sensor.title && s.sensorType === sensor.sensorType),
)
? selectedSensors.filter((selected) => selected.id !== sensor.id)
: [...selectedSensors, sensor]

setSelectedSensors(updatedSensors)
setValue('selectedSensors', updatedSensors)
updateSelectedSensors(updatedSensors)
}

const handleGroupToggle = (group: SensorGroup) => {
const isFullySelected = isGroupFullySelected(group)

const updatedSensors = isFullySelected
? selectedSensors.filter(
(s) =>
!group.sensors.some(
(sensor) =>
s.title === sensor.title && s.sensorType === sensor.sensorType,
),
(selected) =>
!group.sensors.some((sensor) => selected.id === sensor.id),
)
: [
...selectedSensors,
...group.sensors.filter((sensor) => !isSensorSelected(sensor)),
...selectedSensors.filter(
(selected) =>
!group.sensors.some((sensor) => selected.id === sensor.id),
),
...group.sensors,
]

setSelectedSensors(updatedSensors)
setValue('selectedSensors', updatedSensors)
updateSelectedSensors(updatedSensors)
}

const conflictingSensorLabels = selectionConflict?.sensorDefinitionIds.map(
(sensorDefinitionId) => {
const sensor = sensors.find(
(candidate) => candidate.id === sensorDefinitionId,
)
return sensor
? `${sensor.sensorType}: ${sensor.title}`
: sensorDefinitionId
},
)

if (!selectedDevice) {
return <p className="text-center text-lg">{t('device_not_selected')}</p>
}
Expand All @@ -166,15 +197,27 @@ export function SensorSelectionStep() {
type="button"
className="text-destructive text-sm hover:underline"
onClick={() => {
setSelectedSensors([])
setValue('selectedSensors', [])
updateSelectedSensors([])
}}
>
{t('clear_all')}
</button>
)}
</div>

{selectionConflict && (
<Alert variant="destructive" className="sticky top-0 z-10 mb-4">
<TriangleAlert className="h-5 w-5" />
<AlertTitle>{t('sensor_mapping_conflict_title')}</AlertTitle>
<AlertDescription>
{t('sensor_mapping_conflict_description', {
sensors: conflictingSensorLabels?.join(', '),
valueType: selectionConflict.valueType,
})}
</AlertDescription>
</Alert>
)}

<Accordion type="multiple" className="w-full space-y-2">
{sensorGroups.map((group) => {
const isFullySelected = isGroupFullySelected(group)
Expand Down Expand Up @@ -248,11 +291,11 @@ export function SensorSelectionStep() {
<div className="border-muted ml-2 space-y-2 border-l-2 pl-4">
{group.sensors.map((sensor) => {
const isSelected = isSensorSelected(sensor)
const sensorId = `sensor-${group.sensorType}-${sensor.title}`
const sensorId = `sensor-${sensor.id}`

return (
<div
key={sensor.title}
key={sensor.id}
className={cn(
'flex items-center space-x-3 rounded-md p-2 transition-colors',
isSelected
Expand Down
65 changes: 45 additions & 20 deletions app/db/models/device.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
import { messages as NewSenseboxDeviceMessages } from '~/emails/new-device-sensebox'
import { createDeviceApiKey } from '~/lib/jwt'
import { sendMail } from '~/lib/mail.server'
import { getSensorsForModel } from '~/lib/model-definitions'
import {
getSensorsForModel,
getSensorTemplateValidationError,
} from '~/lib/model-definitions'
import {
createOrReusePrivateDeviceSchemaVersionFromUpload,
getVisibleDeviceSchemaVersionForCreation,
Expand Down Expand Up @@ -175,6 +178,7 @@
id: true,
title: true,
sensorType: true,
data: true,
},
},
},
Expand Down Expand Up @@ -979,6 +983,8 @@
let storedDeviceSchemaVersion = null
const isCustomDevice =
!deviceData.model || deviceData.model?.toLowerCase() === 'custom'
const usesSensorDefinitions =
Boolean(deviceData.model) && !isCustomDevice && !deviceData.sensors

// If model and sensors are both specified, reject (backwards compatibility)
if (
Expand All @@ -993,7 +999,16 @@

// If model is specified but sensors are not, get sensors from model layout
if (deviceData.model && !deviceData.sensors) {
const modelSensors = getSensorsForModel(deviceData.model as any)
const sensorTemplateError = getSensorTemplateValidationError(
deviceData.model,
deviceData.sensorTemplates,
)
if (sensorTemplateError) throw new Error(sensorTemplateError)

const modelSensors = getSensorsForModel(
deviceData.model as any,
deviceData.sensorTemplates,
)

if (
!Array.isArray(modelSensors) &&
Expand All @@ -1002,16 +1017,7 @@
throw new Error(`Unknown model: ${deviceData.model}`)
}

if (
Array.isArray(deviceData.sensorTemplates) &&
deviceData.sensorTemplates.length > 0
) {
sensorsToAdd = modelSensors.filter((sensor) =>
deviceData.sensorTemplates.includes(sensor.id),
)
} else {
sensorsToAdd = modelSensors
}
sensorsToAdd = modelSensors
}

if (isCustomDevice && deviceData.sensors) {
Expand Down Expand Up @@ -1092,6 +1098,32 @@
sensorsToAdd.length > 0
) {
for (const [index, sensorData] of sensorsToAdd.entries()) {
const existingSensorData =
sensorData.data &&
typeof sensorData.data === 'object' &&
!Array.isArray(sensorData.data)
? sensorData.data
: {}
const sensorMetadata = storedDeviceSchemaVersion
? {
...existingSensorData,
deviceSchemaSensorId: sensorData.id,
}
: usesSensorDefinitions
? {
...existingSensorData,
sensorDefinitionId: sensorData.id,
}
: sensorData.data &&
typeof sensorData.data === 'object' &&
!Array.isArray(sensorData.data)
? Object.fromEntries(
Object.entries(sensorData.data).filter(
([key]) => key !== 'sensorDefinitionId',
),
)
: sensorData.data

const [newSensor] = await tx
.insert(sensor)
.values({
Expand All @@ -1103,9 +1135,7 @@
sensorWikiPhenomenon: sensorData.sensorWikiPhenomenon,
sensorWikiUnit: sensorData.sensorWikiUnit,
deviceId: createdDevice.id,
data: storedDeviceSchemaVersion
? { deviceSchemaSensorId: sensorData.id }
: sensorData.data,
data: sensorMetadata,
order: sensorData.order ?? index,
})
.returning()
Expand Down Expand Up @@ -1135,11 +1165,6 @@
const lng = (usr.language?.split('_')[0] as 'de' | 'en') ?? 'en'
switch (newDevice.model) {
case 'luftdaten.info':
case 'luftdaten_sds011':
case 'luftdaten_sds011_bme280':
case 'luftdaten_sds011_bmp180':
case 'luftdaten_sds011_dht11':
case 'luftdaten_sds011_dht22':
await sendMail({
recipientAddress: usr.email,
recipientName: usr.name,
Expand Down Expand Up @@ -1190,7 +1215,7 @@
return newDevice
} catch (error) {
console.error('Error creating device with sensors:', error)
throw new Error(

Check failure on line 1218 in app/db/models/device.server.ts

View workflow job for this annotation

GitHub Actions / ⚡ Test

tests/routes/api.users.me.boxes.spec.ts > openSenseMap API Routes: /users > /me/boxes > GET

Error: Failed to create device and its sensors: At least one sensor template is required for model luftdaten.info ❯ createDevice app/db/models/device.server.ts:1218:9 ❯ tests/routes/api.users.me.boxes.spec.ts:69:20

Check failure on line 1218 in app/db/models/device.server.ts

View workflow job for this annotation

GitHub Actions / ⚡ Test

tests/routes/api.users.me.boxes.$deviceId.spec.ts > openSenseMap API Routes: /users > /me/boxes/:deviceId > GET

Error: Failed to create device and its sensors: At least one sensor template is required for model luftdaten.info ❯ createDevice app/db/models/device.server.ts:1218:9 ❯ tests/routes/api.users.me.boxes.$deviceId.spec.ts:71:20

Check failure on line 1218 in app/db/models/device.server.ts

View workflow job for this annotation

GitHub Actions / ⚡ Test

tests/routes/api.tags.spec.ts > openSenseMap API Routes: /tags > should return distinct grouptags of boxes

Error: Failed to create device and its sensors: At least one sensor template is required for model luftdaten.info ❯ createDevice app/db/models/device.server.ts:1218:9 ❯ tests/routes/api.tags.spec.ts:75:18

Check failure on line 1218 in app/db/models/device.server.ts

View workflow job for this annotation

GitHub Actions / ⚡ Test

tests/routes/api.boxes.data.spec.ts > openSenseMap API: /boxes/data

Error: Failed to create device and its sensors: At least one sensor template is required for model luftdaten.info ❯ createDevice app/db/models/device.server.ts:1218:9 ❯ tests/routes/api.boxes.data.spec.ts:58:18
`Failed to create device and its sensors: ${error instanceof Error ? error.message : String(error)}`,
)
}
Expand Down
20 changes: 2 additions & 18 deletions app/db/schema/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { pgEnum } from 'drizzle-orm/pg-core'

import {
DEVICE_EXPOSURE_VALUES,
DEVICE_MODEL_VALUES,
DEVICE_STATUS_VALUES,
} from '~/lib/device-enums'

Expand All @@ -10,24 +11,7 @@ export const DeviceExposureEnum = pgEnum('exposure', DEVICE_EXPOSURE_VALUES)
export const DeviceStatusEnum = pgEnum('status', DEVICE_STATUS_VALUES)

// Enum for device model types
export const DeviceModelEnum = pgEnum('model', [
'homeV2Lora',
'homeV2Ethernet',
'homeV2Wifi',
'homeEthernet',
'homeWifi',
'homeEthernetFeinstaub',
'homeWifiFeinstaub',
'luftdaten_sds011',
'luftdaten_sds011_dht11',
'luftdaten_sds011_dht22',
'luftdaten_sds011_bmp180',
'luftdaten_sds011_bme280',
'hackair_home_v2',
'senseBox:Edu',
'luftdaten.info',
'custom',
])
export const DeviceModelEnum = pgEnum('model', DEVICE_MODEL_VALUES)

export const themePreference = pgEnum('theme_preference', [
'light',
Expand Down
Loading
Loading