Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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 NewLufdatenDeviceMessages } from '~/emails/new-device-luftd
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 @@ export function getDeviceForMeasurementWrite({ id }: Pick<Device, 'id'>) {
id: true,
title: true,
sensorType: true,
data: true,
},
},
},
Expand Down Expand Up @@ -979,6 +983,8 @@ export async function createDevice(deviceData: any, userId: string) {
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 @@ export async function createDevice(deviceData: any, userId: string) {

// 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 @@ export async function createDevice(deviceData: any, userId: string) {
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 @@ export async function createDevice(deviceData: any, userId: string) {
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 @@ export async function createDevice(deviceData: any, userId: string) {
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 @@ export async function createDevice(deviceData: any, userId: string) {
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
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