diff --git a/app/components/device/new/device-info.tsx b/app/components/device/new/device-info.tsx index 125733e2..c8da0ad3 100644 --- a/app/components/device/new/device-info.tsx +++ b/app/components/device/new/device-info.tsx @@ -99,24 +99,24 @@ export function DeviceSelectionStep() { const handleClose = () => { setSelectedDevice(null) + setSelectedConnectionType('') setValue('model', null) } + const isConfiguringSenseBoxHome = selectedDevice === 'senseBox:Home' + return (
{devices.map((device) => { - if ( - selectedDevice === 'senseBox:Home' && - device.name !== selectedDevice - ) + if (isConfiguringSenseBoxHome && device.name !== selectedDevice) return null return ( @@ -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) @@ -141,7 +141,7 @@ export function DeviceSelectionStep() { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - if (selectedDevice === 'senseBox:Home') { + if (isConfiguringSenseBoxHome) { return } @@ -164,7 +164,7 @@ export function DeviceSelectionStep() {
- {selectedDevice === 'senseBox:Home' && ( + {isConfiguringSenseBoxHome && (
+ {selectionConflict && ( + + + {t('sensor_mapping_conflict_title')} + + {t('sensor_mapping_conflict_description', { + sensors: conflictingSensorLabels?.join(', '), + valueType: selectionConflict.valueType, + })} + + + )} + {sensorGroups.map((group) => { const isFullySelected = isGroupFullySelected(group) @@ -248,11 +291,11 @@ export function SensorSelectionStep() {
{group.sensors.map((sensor) => { const isSelected = isSensorSelected(sensor) - const sensorId = `sensor-${group.sensorType}-${sensor.title}` + const sensorId = `sensor-${sensor.id}` return (
) { id: true, title: true, sensorType: true, + data: true, }, }, }, @@ -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 ( @@ -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) && @@ -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) { @@ -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({ @@ -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() @@ -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, diff --git a/app/db/schema/enum.ts b/app/db/schema/enum.ts index d02004a6..b3b6c402 100644 --- a/app/db/schema/enum.ts +++ b/app/db/schema/enum.ts @@ -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' @@ -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', diff --git a/app/lib/api-schemas/devices.ts b/app/lib/api-schemas/devices.ts index 66dc3fb2..2e85ae98 100644 --- a/app/lib/api-schemas/devices.ts +++ b/app/lib/api-schemas/devices.ts @@ -1,54 +1,72 @@ import { z } from 'zod' +import { DeviceModelZodEnum } from '~/lib/device-enums' +import { getSensorTemplateValidationError } from '~/lib/model-definitions' -export const CreateDeviceSchema = z.object({ - // public API request shape - name: z.string().min(1).max(100), - description: z - .string() - .max(5000, 'Description should not exceed 5000 characters') - .optional() - .nullable(), - exposure: z - .enum(['indoor', 'outdoor', 'mobile', 'unknown']) - .optional() - .default('unknown'), - location: z - .union([ - z.array(z.number()).min(2).max(3), - z.object({ - lng: z.number(), - lat: z.number(), - height: z.number().optional(), - }), - ]) - .transform((loc) => { - if (Array.isArray(loc)) return loc - return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] - }), - grouptag: z.array(z.string()).optional().default([]), - model: z - .enum([ - 'homeV2Lora', - 'homeV2Ethernet', - 'homeV2Wifi', - 'senseBox:Edu', - 'luftdaten.info', - 'custom', - ]) - .optional() - .default('custom'), - sensors: z - .array( - z.object({ - icon: z.string().optional(), - title: z.string().min(1), - unit: z.string().min(1), - sensorType: z.string().min(1), +export const CreateDeviceSchema = z + .object({ + // public API request shape + name: z.string().min(1).max(100), + description: z + .string() + .max(5000, 'Description should not exceed 5000 characters') + .optional() + .nullable(), + exposure: z + .enum(['indoor', 'outdoor', 'mobile', 'unknown']) + .optional() + .default('unknown'), + location: z + .union([ + z.array(z.number()).min(2).max(3), + z.object({ + lng: z.number(), + lat: z.number(), + height: z.number().optional(), + }), + ]) + .transform((loc) => { + if (Array.isArray(loc)) return loc + return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] }), + grouptag: z.array(z.string()).optional().default([]), + model: DeviceModelZodEnum.optional().default('custom'), + sensorTemplates: z.array(z.string()).optional(), + sensors: z + .array( + z.object({ + icon: z.string().optional(), + title: z.string().min(1), + unit: z.string().min(1), + sensorType: z.string().min(1), + }), + ) + .optional() + .default([]), + }) + .superRefine((data, ctx) => { + if (data.sensors.length > 0 && data.model !== 'custom') { + ctx.addIssue({ + code: 'custom', + path: ['sensors'], + message: + 'Parameters model and sensors cannot be specified at the same time.', + }) + return + } + if (data.sensors.length > 0) return + + const message = getSensorTemplateValidationError( + data.model, + data.sensorTemplates, ) - .optional() - .default([]), -}) + if (message) { + ctx.addIssue({ + code: 'custom', + path: ['sensorTemplates'], + message, + }) + } + }) export const DevicesQuerySchema = z.object({ format: z diff --git a/app/lib/device-enums.ts b/app/lib/device-enums.ts index 112aa013..3fe8b6a5 100644 --- a/app/lib/device-enums.ts +++ b/app/lib/device-enums.ts @@ -9,11 +9,27 @@ export const DEVICE_EXPOSURE_VALUES = [ export const DEVICE_STATUS_VALUES = ['active', 'inactive', 'old'] as const +export const DEVICE_MODEL_VALUES = [ + 'homeV2Lora', + 'homeV2Ethernet', + 'homeV2Wifi', + 'homeEthernet', + 'homeWifi', + 'homeEthernetFeinstaub', + 'homeWifiFeinstaub', + 'luftdaten.info', + 'hackair_home_v2', + 'senseBox:Edu', + 'custom', +] as const + export const DeviceExposureZodEnum = z.enum(DEVICE_EXPOSURE_VALUES) export const DeviceStatusZodEnum = z.enum(DEVICE_STATUS_VALUES) +export const DeviceModelZodEnum = z.enum(DEVICE_MODEL_VALUES) export type DeviceExposureType = z.infer export type DeviceStatusType = z.infer +export type DeviceModelType = z.infer export function parseDeviceExposure(value: unknown): DeviceExposureType | null { const normalized = typeof value === 'string' ? value.toLowerCase() : value diff --git a/app/lib/model-definitions.ts b/app/lib/model-definitions.ts index e3027128..a600bb49 100644 --- a/app/lib/model-definitions.ts +++ b/app/lib/model-definitions.ts @@ -2,6 +2,17 @@ import { sensorDefinitions } from './sensor-definitions' type SensorKey = keyof typeof sensorDefinitions +type SensorWithDefinitionId = (typeof sensorDefinitions)[SensorKey] & { + id: SensorKey +} + +export const luftdatenSensorDefinitionKeys = Object.entries(sensorDefinitions) + .filter(([, definition]) => { + if (!('decoderMappings' in definition)) return false + return Boolean(definition.decoderMappings?.luftdaten?.length) + }) + .map(([id]) => id as SensorKey) + const senseBoxHomeV2: readonly SensorKey[] = [ 'hdc1080_temperature', 'hdc1080_humidity', @@ -58,39 +69,8 @@ export const modelDefinitions = { 'sps30_pm25', ] as const satisfies readonly SensorKey[], - 'luftdaten.info': [ - 'pms1003_pm01', - 'pms1003_pm10', - 'pms1003_pm25', - 'pms3003_pm01', - 'pms3003_pm10', - 'pms3003_pm25', - 'pms5003_pm01', - 'pms5003_pm10', - 'pms5003_pm25', - 'pms7003_pm01', - 'pms7003_pm10', - 'pms7003_pm25', - 'sds011_pm10', - 'sds011_pm25', - 'sps30_pm1', - 'sps30_pm4', - 'sps30_pm10', - 'sps30_pm25', - 'sht3x_humidity', - 'sht3x_temperature', - 'bmp180_temperature', - 'bmp180_pressure_pa', - 'bmp180_pressure_hpa', - 'bme280_humidity', - 'bme280_temperature', - 'bme280_pressure_pa', - 'bme280_pressure_hpa', - 'dht11_humidity', - 'dht11_temperature', - 'dht22_humidity', - 'dht22_temperature', - ] as const satisfies readonly SensorKey[], + 'luftdaten.info': luftdatenSensorDefinitionKeys, + hackair_home_v2: ['sds011_pm10', 'sds011_pm25'] as const, homeEthernet: [ 'hdc1008_temperature', @@ -129,12 +109,115 @@ export const modelDefinitions = { ] as const satisfies readonly SensorKey[], } as const -export const getSensorsForModel = (model: keyof typeof modelDefinitions) => { +export type ModelDefinitionKey = keyof typeof modelDefinitions + +export const getSensorsForModel = ( + model: ModelDefinitionKey, + sensorTemplates?: readonly string[], +): SensorWithDefinitionId[] => { const keys = modelDefinitions[model] if (!keys) return [] - return keys.map((key) => ({ + const sensors = keys.map((key) => ({ id: key, ...sensorDefinitions[key], })) + + if (!sensorTemplates?.length) return sensors + + const normalizedTemplates = new Set( + sensorTemplates.map((template) => template.toLowerCase()), + ) + return sensors.filter( + (sensor) => + normalizedTemplates.has(sensor.id.toLowerCase()) || + normalizedTemplates.has(sensor.sensorType.toLowerCase()), + ) +} + +export function findUnsupportedSensorTemplates( + model: ModelDefinitionKey, + sensorTemplates: readonly string[], +): string[] { + const modelSensors = getSensorsForModel(model) + return sensorTemplates.filter((template) => { + const normalized = template.toLowerCase() + return !modelSensors.some( + (sensor) => + sensor.id.toLowerCase() === normalized || + sensor.sensorType.toLowerCase() === normalized, + ) + }) +} + +export type SensorTemplateMappingConflict = { + valueType: string + sensorDefinitionIds: string[] +} + +export function findSensorTemplateMappingConflict( + model: string | undefined, + sensorTemplates: readonly string[] | undefined, +): SensorTemplateMappingConflict | undefined { + if (model !== 'luftdaten.info' || !sensorTemplates?.length) return undefined + + const mappingsByValueType = new Map< + string, + { valueType: string; sensorDefinitionIds: Set } + >() + + for (const sensor of getSensorsForModel('luftdaten.info', sensorTemplates)) { + if (!('decoderMappings' in sensor)) continue + + for (const mapping of sensor.decoderMappings?.luftdaten ?? []) { + const normalizedValueType = mapping.valueType.toLowerCase() + const destination = mappingsByValueType.get(normalizedValueType) ?? { + valueType: mapping.valueType, + sensorDefinitionIds: new Set(), + } + destination.sensorDefinitionIds.add(sensor.id) + mappingsByValueType.set(normalizedValueType, destination) + } + } + + const conflict = [...mappingsByValueType.values()].find( + ({ sensorDefinitionIds }) => sensorDefinitionIds.size > 1, + ) + if (!conflict) return undefined + + return { + valueType: conflict.valueType, + sensorDefinitionIds: [...conflict.sensorDefinitionIds], + } +} + +export function getSensorTemplateValidationError( + model: string | undefined, + sensorTemplates: readonly string[] | undefined, +): string | undefined { + if (!model || model.toLowerCase() === 'custom') return undefined + if (!Object.hasOwn(modelDefinitions, model)) { + return `Unknown model: ${model}` + } + if (model === 'luftdaten.info' && !sensorTemplates?.length) { + return `At least one sensor template is required for model ${model}` + } + + const definitionModel = model as ModelDefinitionKey + if (sensorTemplates?.length) { + const unsupportedTemplates = findUnsupportedSensorTemplates( + definitionModel, + sensorTemplates, + ) + if (unsupportedTemplates.length > 0) { + return `Unsupported sensor templates for model ${model}: ${unsupportedTemplates.join(', ')}` + } + } + + const conflict = findSensorTemplateMappingConflict(model, sensorTemplates) + if (conflict) { + return `Ambiguous Luftdaten value type ${conflict.valueType} maps to multiple selected sensor definitions: ${conflict.sensorDefinitionIds.join(', ')}` + } + + return undefined } diff --git a/app/lib/sensor-definitions.ts b/app/lib/sensor-definitions.ts index 7ed6f32e..d7a6248b 100644 --- a/app/lib/sensor-definitions.ts +++ b/app/lib/sensor-definitions.ts @@ -1,4 +1,21 @@ -export const sensorDefinitions = { +type SensorDefinition = { + title: string + unit: string + sensorType: string + icon: string + image?: string + phenomenon?: string + decoderMappings?: { + luftdaten?: readonly DecoderValueMapping[] + } +} + +export type DecoderValueMapping = { + valueType: string + multiplier?: number +} + +const sensorDefinitionTemplates = { windspeed: { title: 'Windgeschwindigkeit', unit: 'm/s', @@ -254,6 +271,125 @@ export const sensorDefinitions = { icon: 'osem-cloud', image: '/img/sensor_images/SPS30.jpg', }, + sps30_nc05: { + title: 'NC0.5', + unit: '#/cm³', + sensorType: 'SPS30', + icon: 'osem-cloud', + image: '/img/sensor_images/SPS30.jpg', + }, + sps30_nc1: { + title: 'NC1.0', + unit: '#/cm³', + sensorType: 'SPS30', + icon: 'osem-cloud', + image: '/img/sensor_images/SPS30.jpg', + }, + sps30_nc25: { + title: 'NC2.5', + unit: '#/cm³', + sensorType: 'SPS30', + icon: 'osem-cloud', + image: '/img/sensor_images/SPS30.jpg', + }, + sps30_nc4: { + title: 'NC4.0', + unit: '#/cm³', + sensorType: 'SPS30', + icon: 'osem-cloud', + image: '/img/sensor_images/SPS30.jpg', + }, + sps30_nc10: { + title: 'NC10', + unit: '#/cm³', + sensorType: 'SPS30', + icon: 'osem-cloud', + image: '/img/sensor_images/SPS30.jpg', + }, + sen55_pm1: { + title: 'PM1', + unit: 'µg/m³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_pm25: { + title: 'PM2.5', + unit: 'µg/m³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_pm4: { + title: 'PM4', + unit: 'µg/m³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_pm10: { + title: 'PM10', + unit: 'µg/m³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_nc05: { + title: 'NC0.5', + unit: '#/cm³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_nc1: { + title: 'NC1.0', + unit: '#/cm³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_nc25: { + title: 'NC2.5', + unit: '#/cm³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_nc4: { + title: 'NC4.0', + unit: '#/cm³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_nc10: { + title: 'NC10', + unit: '#/cm³', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_typical_particle_size: { + title: 'Typische Partikelgröße', + unit: 'µm', + sensorType: 'SEN55', + icon: 'osem-cloud', + }, + sen55_temperature: { + title: 'Temperatur', + unit: '°C', + sensorType: 'SEN55', + icon: 'osem-thermometer', + }, + sen55_humidity: { + title: 'rel. Luftfeuchte', + unit: '%', + sensorType: 'SEN55', + icon: 'osem-humidity', + }, + sen55_voc: { + title: 'VOC-Index', + unit: 'Index', + sensorType: 'SEN55', + icon: 'osem-gauge', + }, + sen55_nox: { + title: 'NOx-Index', + unit: 'Index', + sensorType: 'SEN55', + icon: 'osem-gauge', + }, pms1003_pm10: { title: 'PM10', unit: 'µg/m³', @@ -338,4 +474,358 @@ export const sensorDefinitions = { icon: 'osem-humidity', image: '/img/sensor_images/HDC1008.png', }, +} as const satisfies Record + +type SensorDefinitionMetadata = { + phenomenon: string + decoderMappings?: SensorDefinition['decoderMappings'] +} + +const sensorDefinitionMetadata = { + windspeed: { phenomenon: 'wind-speed' }, + dht22_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { + luftdaten: [ + { valueType: 'temperature' }, + { valueType: 'DHT22_temperature' }, + ], + }, + }, + bme680_temperature: { phenomenon: 'air-temperature' }, + smt50_soilmoisture: { phenomenon: 'soil-moisture' }, + sht3x_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { luftdaten: [{ valueType: 'SHT3X_temperature' }] }, + }, + pms5003_pm01: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P0' }] }, + }, + pms5003_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P2' }] }, + }, + bme280_pressure_pa: { + phenomenon: 'atmospheric-pressure', + decoderMappings: { luftdaten: [{ valueType: 'BME280_pressure' }] }, + }, + bme680_humidity: { phenomenon: 'relative-humidity' }, + bme280_humidity: { + phenomenon: 'relative-humidity', + decoderMappings: { luftdaten: [{ valueType: 'BME280_humidity' }] }, + }, + pms5003_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P1' }] }, + }, + bme280_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { luftdaten: [{ valueType: 'BME280_temperature' }] }, + }, + veml6070_uvintensity: { phenomenon: 'ultraviolet-intensity' }, + sht3x_humidity: { + phenomenon: 'relative-humidity', + decoderMappings: { luftdaten: [{ valueType: 'SHT3X_humidity' }] }, + }, + bme680_pressure: { phenomenon: 'atmospheric-pressure' }, + tsl45315_lightintensity: { phenomenon: 'illuminance' }, + bmp180_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { luftdaten: [{ valueType: 'BMP180_temperature' }] }, + }, + sds011_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'SDS_P2' }] }, + }, + sps30_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_P1' }] }, + }, + soundlevelmeter: { phenomenon: 'sound-pressure-level' }, + pms7003_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P2' }] }, + }, + bme680_voc: { phenomenon: 'volatile-organic-compounds' }, + dnms_la_min: { phenomenon: 'sound-pressure-level-minimum' }, + dht22_humidity: { + phenomenon: 'relative-humidity', + decoderMappings: { + luftdaten: [{ valueType: 'humidity' }, { valueType: 'DHT22_humidity' }], + }, + }, + pms7003_pm01: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P0' }] }, + }, + dps310_temperature: { phenomenon: 'air-temperature' }, + pms7003_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P1' }] }, + }, + sps30_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_P2' }] }, + }, + dps310_pressure: { phenomenon: 'atmospheric-pressure' }, + bme280_pressure_hpa: { + phenomenon: 'atmospheric-pressure', + decoderMappings: { + luftdaten: [{ valueType: 'BME280_pressure', multiplier: 0.01 }], + }, + }, + bmp180_pressure_pa: { + phenomenon: 'atmospheric-pressure', + decoderMappings: { luftdaten: [{ valueType: 'BMP180_pressure' }] }, + }, + sds011_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'SDS_P1' }] }, + }, + sps30_pm1: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_P0' }] }, + }, + pms1003_pm01: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P0' }] }, + }, + hdc1080_temperature: { phenomenon: 'air-temperature' }, + pms1003_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P2' }] }, + }, + scd30_co2: { + phenomenon: 'carbon-dioxide-concentration', + decoderMappings: { + luftdaten: [{ valueType: 'SCD30_co2' }, { valueType: 'SCD30_co2_ppm' }], + }, + }, + hdc1008_temperature: { phenomenon: 'air-temperature' }, + sps30_pm4: { + phenomenon: 'particulate-matter-mass-concentration-4um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_P4' }] }, + }, + sps30_nc05: { + phenomenon: 'particle-number-concentration-0.5um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_N05' }] }, + }, + sps30_nc1: { + phenomenon: 'particle-number-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_N1' }] }, + }, + sps30_nc25: { + phenomenon: 'particle-number-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_N25' }] }, + }, + sps30_nc4: { + phenomenon: 'particle-number-concentration-4um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_N4' }] }, + }, + sps30_nc10: { + phenomenon: 'particle-number-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'SPS30_N10' }] }, + }, + sen55_pm1: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_P0' }, + { valueType: 'SEN5X_P0' }, + { valueType: 'SEN55_P0' }, + ], + }, + }, + sen55_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_P2' }, + { valueType: 'SEN5X_P2' }, + { valueType: 'SEN55_P2' }, + ], + }, + }, + sen55_pm4: { + phenomenon: 'particulate-matter-mass-concentration-4um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_P4' }, + { valueType: 'SEN5X_P4' }, + { valueType: 'SEN55_P4' }, + ], + }, + }, + sen55_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_P1' }, + { valueType: 'SEN5X_P1' }, + { valueType: 'SEN55_P1' }, + ], + }, + }, + sen55_nc05: { + phenomenon: 'particle-number-concentration-0.5um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_N05' }, + { valueType: 'SEN5X_N05' }, + { valueType: 'SEN55_N05' }, + ], + }, + }, + sen55_nc1: { + phenomenon: 'particle-number-concentration-1um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_N1' }, + { valueType: 'SEN5X_N1' }, + { valueType: 'SEN55_N1' }, + ], + }, + }, + sen55_nc25: { + phenomenon: 'particle-number-concentration-2.5um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_N25' }, + { valueType: 'SEN5X_N25' }, + { valueType: 'SEN55_N25' }, + ], + }, + }, + sen55_nc4: { + phenomenon: 'particle-number-concentration-4um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_N4' }, + { valueType: 'SEN5X_N4' }, + { valueType: 'SEN55_N4' }, + ], + }, + }, + sen55_nc10: { + phenomenon: 'particle-number-concentration-10um', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_N10' }, + { valueType: 'SEN5X_N10' }, + { valueType: 'SEN55_N10' }, + ], + }, + }, + sen55_typical_particle_size: { + phenomenon: 'typical-particle-size', + decoderMappings: { + luftdaten: [ + { valueType: 'SPS30_TS' }, + { valueType: 'SEN5X_TS' }, + { valueType: 'SEN55_TS' }, + ], + }, + }, + sen55_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { + luftdaten: [ + { valueType: 'SHT3X_temperature' }, + { valueType: 'SEN5X_temperature' }, + { valueType: 'SEN55_temperature' }, + ], + }, + }, + sen55_humidity: { + phenomenon: 'relative-humidity', + decoderMappings: { + luftdaten: [ + { valueType: 'SHT3X_humidity' }, + { valueType: 'SEN5X_humidity' }, + { valueType: 'SEN55_humidity' }, + ], + }, + }, + sen55_voc: { + phenomenon: 'volatile-organic-compounds-index', + decoderMappings: { + luftdaten: [{ valueType: 'SEN5X_voc' }, { valueType: 'SEN55_voc' }], + }, + }, + sen55_nox: { + phenomenon: 'nitrogen-oxides-index', + decoderMappings: { + luftdaten: [ + { valueType: 'SHT3X_co2_ppm' }, + { valueType: 'SEN5X_nox' }, + { valueType: 'SEN55_nox' }, + { valueType: 'SEN55_co2_ppm' }, + ], + }, + }, + pms1003_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P1' }] }, + }, + pms3003_pm25: { + phenomenon: 'particulate-matter-mass-concentration-2.5um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P2' }] }, + }, + bmp180_pressure_hpa: { + phenomenon: 'atmospheric-pressure', + decoderMappings: { + luftdaten: [{ valueType: 'BMP180_pressure', multiplier: 0.01 }], + }, + }, + dnms_la_max: { phenomenon: 'sound-pressure-level-maximum' }, + hdc1080_humidity: { phenomenon: 'relative-humidity' }, + pms3003_pm01: { + phenomenon: 'particulate-matter-mass-concentration-1um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P0' }] }, + }, + dht11_temperature: { + phenomenon: 'air-temperature', + decoderMappings: { + luftdaten: [ + { valueType: 'temperature' }, + { valueType: 'DHT11_temperature' }, + ], + }, + }, + smt50_soiltemperature: { phenomenon: 'soil-temperature' }, + dht11_humidity: { + phenomenon: 'relative-humidity', + decoderMappings: { + luftdaten: [{ valueType: 'humidity' }, { valueType: 'DHT11_humidity' }], + }, + }, + pms3003_pm10: { + phenomenon: 'particulate-matter-mass-concentration-10um', + decoderMappings: { luftdaten: [{ valueType: 'PMS_P1' }] }, + }, + dnms_la_eq: { + phenomenon: 'equivalent-continuous-sound-pressure-level', + decoderMappings: { luftdaten: [{ valueType: 'DNMS_noise_LAeq' }] }, + }, + bmp280_pressure: { phenomenon: 'atmospheric-pressure' }, + hdc1008_humidity: { phenomenon: 'relative-humidity' }, +} as const satisfies Record< + keyof typeof sensorDefinitionTemplates, + SensorDefinitionMetadata +> + +type SensorDefinitions = { + [Key in keyof typeof sensorDefinitionTemplates]: (typeof sensorDefinitionTemplates)[Key] & + (typeof sensorDefinitionMetadata)[Key] } + +export const sensorDefinitions = Object.fromEntries( + Object.entries(sensorDefinitionTemplates).map(([id, definition]) => [ + id, + { + ...definition, + ...sensorDefinitionMetadata[id as keyof typeof sensorDefinitionMetadata], + }, + ]), +) as SensorDefinitions diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 8d6753cb..62081271 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -233,6 +233,9 @@ async function post(request: Request, user: User) { name: validatedData.name, exposure: validatedData.exposure, model: sensorsProvided ? undefined : validatedData.model, + sensorTemplates: sensorsProvided + ? undefined + : validatedData.sensorTemplates, latitude: latitude, longitude: longitude, tags: validatedData.grouptag, diff --git a/app/routes/device.dashboard.$deviceId.tsx b/app/routes/device.dashboard.$deviceId.tsx index ab34a953..d92ae8b7 100644 --- a/app/routes/device.dashboard.$deviceId.tsx +++ b/app/routes/device.dashboard.$deviceId.tsx @@ -69,7 +69,7 @@ let deviceData = { }, }, ], - model: 'luftdaten_sds011_dht22', + model: 'luftdaten.info', description: 'Mounted at the street side of my house. Traffic: approx. 8.000 vehicles/d', image: '5b411d0e5dc1ec001b4f11c8_pblauf.jpg', diff --git a/app/services/decoding-service.server.ts b/app/services/decoding-service.server.ts index 691fd658..a41a13e8 100644 --- a/app/services/decoding-service.server.ts +++ b/app/services/decoding-service.server.ts @@ -1,7 +1,13 @@ +import { + type DecoderValueMapping, + sensorDefinitions, +} from '~/lib/sensor-definitions' + type SensorLike = { id: string title?: string sensorType?: string + data?: unknown } const luftdatenMatchings: Record = { @@ -30,7 +36,21 @@ const luftdatenMatchings: Record = { noise_laeq: ['schallpegel', 'geräuschpegel'], } -function findLuftdatenSensorId( +function getSensorDefinitionId(sensor: SensorLike): string | undefined { + if ( + !sensor.data || + typeof sensor.data !== 'object' || + Array.isArray(sensor.data) + ) { + return undefined + } + + const sensorDefinitionId = (sensor.data as Record) + .sensorDefinitionId + return typeof sensorDefinitionId === 'string' ? sensorDefinitionId : undefined +} + +function findLuftdatenSensorIdByTitle( sensors: SensorLike[], value_type: string, ): string | undefined { @@ -67,27 +87,87 @@ function findLuftdatenSensorId( if (!luftdatenMatchings[vt_phenomenon]) return undefined - for (const sensor of sensors) { - if (!sensor?.id) continue - if (!sensor.title) continue + const aliases = luftdatenMatchings[vt_phenomenon] + const compatibleSensors = sensors.filter((sensor) => { + if (!sensor?.id || !sensor.title) return false + if (!sensor.sensorType) return true - const title = sensor.title.toLowerCase() + return sensor.sensorType.toLowerCase().startsWith(vt_sensortype) + }) - if (sensor.sensorType) { - const type = sensor.sensorType.toLowerCase() - if (!type.startsWith(vt_sensortype)) continue - } + const exactMatches = compatibleSensors.filter((sensor) => { + const title = sensor.title!.toLowerCase() + return title === vt_phenomenon || aliases.includes(title) + }) - const aliases = luftdatenMatchings[vt_phenomenon] - const titleMatches = - title === vt_phenomenon || - aliases.includes(title) || - aliases.some((alias) => title.includes(alias)) + if (exactMatches.length > 1) { + console.warn( + `Ambiguous Luftdaten sensor mapping for value type ${value_type}`, + ) + return undefined + } + if (exactMatches.length === 1) return exactMatches[0].id + + const substringMatches = compatibleSensors.filter((sensor) => { + const title = sensor.title!.toLowerCase() + return aliases.some((alias) => title.includes(alias)) + }) + + if (substringMatches.length > 1) { + console.warn( + `Ambiguous Luftdaten sensor mapping for value type ${value_type}`, + ) + return undefined + } - if (titleMatches) return sensor.id + return substringMatches[0]?.id +} + +type LuftdatenSensorMapping = { + sensorId: string + mapping: DecoderValueMapping +} + +function findLuftdatenSensorMapping( + sensors: SensorLike[], + valueType: string, +): LuftdatenSensorMapping | undefined { + const normalizedValueType = valueType?.toLowerCase() + if (!normalizedValueType) return undefined + + const definitionMatches = sensors.flatMap( + (sensor) => { + const definitionId = getSensorDefinitionId(sensor) + if (!definitionId || !(definitionId in sensorDefinitions)) return [] + + const definition = + sensorDefinitions[definitionId as keyof typeof sensorDefinitions] + if (!('decoderMappings' in definition)) return [] + + const mapping = definition.decoderMappings.luftdaten?.find( + (candidate) => + candidate.valueType.toLowerCase() === normalizedValueType, + ) + + return mapping ? [{ sensorId: sensor.id, mapping }] : [] + }, + ) + + if (definitionMatches.length > 1) { + console.warn( + `Ambiguous Luftdaten sensor definition mapping for value type ${valueType}`, + ) + return undefined } + if (definitionMatches.length === 1) return definitionMatches[0] - return undefined + const legacySensors = sensors.filter( + (sensor) => getSensorDefinitionId(sensor) === undefined, + ) + const legacySensorId = findLuftdatenSensorIdByTitle(legacySensors, valueType) + return legacySensorId + ? { sensorId: legacySensorId, mapping: { valueType } } + : undefined } const hackairMatchings: Record = { @@ -108,22 +188,32 @@ function findHackairSensorId( if (!hackairMatchings[vt_sensortype]) return undefined - for (const sensor of sensors) { - if (!sensor?.id) continue - if (!sensor.title) continue - - const title = sensor.title.toLowerCase() - const aliases = hackairMatchings[vt_sensortype] + const aliases = hackairMatchings[vt_sensortype] + const titledSensors = sensors.filter( + (sensor) => Boolean(sensor?.id) && Boolean(sensor.title), + ) + const exactMatches = titledSensors.filter((sensor) => { + const title = sensor.title!.toLowerCase() + return title === vt_sensortype || aliases.includes(title) + }) + + if (exactMatches.length > 1) { + console.warn(`Ambiguous hackAIR sensor mapping for key ${readingKey}`) + return undefined + } + if (exactMatches.length === 1) return exactMatches[0].id - const titleMatches = - title === vt_sensortype || - aliases.includes(title) || - aliases.some((alias) => title.includes(alias)) + const substringMatches = titledSensors.filter((sensor) => { + const title = sensor.title!.toLowerCase() + return aliases.some((alias) => title.includes(alias)) + }) - if (titleMatches) return sensor.id + if (substringMatches.length > 1) { + console.warn(`Ambiguous hackAIR sensor mapping for key ${readingKey}`) + return undefined } - return undefined + return substringMatches[0]?.id } function parseLocation( @@ -192,7 +282,7 @@ const decodeHandlers: { [key: string]: { decodeMessage: (data: any, options: any) => any[] } } = { 'application/json': { - decodeMessage: (body: any, {}: { sensors: any[] }) => { + decodeMessage: (body: any) => { if (Array.isArray(body)) { return body.map((measurement) => ({ sensor_id: measurement.sensor_id ?? measurement.sensor, @@ -228,7 +318,7 @@ const decodeHandlers: { }, 'text/csv': { - decodeMessage: (body: string, {}: { sensors: any[] }) => { + decodeMessage: (body: string) => { const lines = body.trim().split('\n') return lines.map((line) => { const parts = line.split(',').map((part) => part.trim()) @@ -264,22 +354,34 @@ const decodeHandlers: { throw new Error('Invalid luftdaten json. Missing `sensordatavalues`') } - const out = body.sensordatavalues - .map((sdv: any) => { - const sensor_id = findLuftdatenSensorId(sensors, sdv.value_type) - if (!sensor_id) return null + const createdAt = new Date() + const destinationSensorIds = new Set() + const out = [] + + for (const sdv of body.sensordatavalues) { + const resolved = findLuftdatenSensorMapping(sensors, sdv.value_type) + if (!resolved) continue + + const rawValue = parseFloat(sdv.value) + if (!Number.isFinite(rawValue)) continue + if (destinationSensorIds.has(resolved.sensorId)) { + console.warn( + `Multiple Luftdaten values resolved to sensor ${resolved.sensorId}; keeping the first value`, + ) + continue + } - const value = parseFloat(sdv.value) - if (Number.isNaN(value)) return null + const value = rawValue * (resolved.mapping.multiplier ?? 1) + if (!Number.isFinite(value)) continue - return { - sensor_id, - value, - createdAt: new Date(), - location: null, - } + destinationSensorIds.add(resolved.sensorId) + out.push({ + sensor_id: resolved.sensorId, + value, + createdAt, + location: null, }) - .filter(Boolean) as any[] + } if (out.length === 0) { throw new Error('No applicable values found') diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 86c2a038..3d840b00 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -11,6 +11,8 @@ import { 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 { DeviceModelZodEnum } from '~/lib/device-enums' +import { getSensorTemplateValidationError } from '~/lib/model-definitions' import { deleteDeviceImage } from '~/lib/s3.server' export const CreateDeviceServiceSchema = z @@ -29,16 +31,7 @@ export const CreateDeviceServiceSchema = z tags: z.array(z.string()).optional().default([]), latitude: z.number(), longitude: z.number(), - model: z - .enum([ - 'homeV2Lora', - 'homeV2Ethernet', - 'homeV2Wifi', - 'senseBox:Edu', - 'luftdaten.info', - 'custom', - ]) - .optional(), + model: DeviceModelZodEnum.optional(), sensorTemplates: z.array(z.string()).optional(), sensors: z .array( @@ -57,6 +50,19 @@ export const CreateDeviceServiceSchema = z message: 'Model and sensors cannot be specified at the same time.', path: ['sensors'], }) + .superRefine((data, ctx) => { + const message = getSensorTemplateValidationError( + data.model, + data.sensorTemplates, + ) + if (message) { + ctx.addIssue({ + code: 'custom', + path: ['sensorTemplates'], + message, + }) + } + }) export type CreateDeviceServiceInput = z.infer diff --git a/public/locales/de/newdevice.json b/public/locales/de/newdevice.json index ba624034..024de46d 100644 --- a/public/locales/de/newdevice.json +++ b/public/locales/de/newdevice.json @@ -53,6 +53,8 @@ "no_selected_sensors": "Noch keine Sensoren ausgewählt.", "selectedSensors_one": "{{count}} Sensor ausgewählt", "selectedSensors_other": "{{count}} Sensoren ausgewählt", + "sensor_mapping_conflict_title": "Sensorkombination nicht möglich", + "sensor_mapping_conflict_description": "{{sensors}} können nicht gemeinsam ausgewählt werden, weil sie denselben eingehenden Sensor.Community-Wert ({{valueType}}) akzeptieren.", "add_sensor": "Sensor hinzufügen", "device_schema_upload": "Geräteschema hochladen", "device_schema_upload_text": "Importiere ein JSON-Schema, um einen festen Sensorsatz für dieses Gerät zu verwenden.", diff --git a/public/locales/en/newdevice.json b/public/locales/en/newdevice.json index 72d00e80..f8377b12 100644 --- a/public/locales/en/newdevice.json +++ b/public/locales/en/newdevice.json @@ -53,6 +53,8 @@ "no_selected_sensors": "No sensors selected yet.", "selectedSensors_one": "{{count}} sensor selected", "selectedSensors_other": "{{count}} sensors selected", + "sensor_mapping_conflict_title": "Sensor combination not possible", + "sensor_mapping_conflict_description": "{{sensors}} cannot be selected together because they accept the same incoming Sensor.Community value ({{valueType}}).", "add_sensor": "Add Sensor", "device_schema_upload": "Upload device schema", "device_schema_upload_text": "Import a JSON schema to use a fixed set of sensors for this device.", diff --git a/tests/db/models/device.server.spec.ts b/tests/db/models/device.server.spec.ts index 5a222f1a..9df2b1a7 100644 --- a/tests/db/models/device.server.spec.ts +++ b/tests/db/models/device.server.spec.ts @@ -111,6 +111,26 @@ describe('Device Model: createDevice', () => { expect(result.sensors).toHaveLength(0) }) + it('should persist the sensor definition ID in sensor data', async () => { + const result = await createDevice( + { + name: 'Device with catalog sensor', + latitude: 52.0, + longitude: 8.0, + exposure: 'indoor', + model: 'homeV2Wifi', + sensorTemplates: ['sps30_pm1'], + }, + userId, + ) + + createdDeviceIds.push(result.id) + expect(result.sensors).toHaveLength(1) + expect(result.sensors[0].data).toEqual({ + sensorDefinitionId: 'sps30_pm1', + }) + }) + it('should create device with tags/grouptag', async () => { const deviceData = { name: 'Tagged Device', diff --git a/tests/routes/api.boxes.data.spec.ts b/tests/routes/api.boxes.data.spec.ts index d17ab4a0..45be82cf 100644 --- a/tests/routes/api.boxes.data.spec.ts +++ b/tests/routes/api.boxes.data.spec.ts @@ -21,6 +21,7 @@ const TEST_BOX = { latitude: 51.5, longitude: 7.5, model: 'luftdaten.info' as const, + sensorTemplates: ['sds011_pm10'], mqttEnabled: false, ttnEnabled: false, } diff --git a/tests/routes/api.device.sensors.spec.ts b/tests/routes/api.device.sensors.spec.ts index adb772cc..e48a94d8 100644 --- a/tests/routes/api.device.sensors.spec.ts +++ b/tests/routes/api.device.sensors.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { type Route } from '../../.react-router/types/app/routes/+types/api.boxes.$deviceId' import { BASE_URL } from '../../vitest.setup' import { @@ -12,11 +13,7 @@ import { createToken } from '~/lib/jwt' import { action as deviceUpdateAction } from '~/routes/api.boxes.$deviceId' import { registerUser } from '~/services/user-service.server' -const DEVICE_TEST_USER = { - name: 'deviceUpdateDeviceSensorsTest123', - email: 'test@deviceSensorsTest123.endpoint', - password: 'highlySecurePasswordForTesting', -} +const DEVICE_TEST_USER = generateTestUserCredentials() let user: User let jwt: string diff --git a/tests/routes/api.tags.spec.ts b/tests/routes/api.tags.spec.ts index f5cbadfa..d2f4d3a0 100644 --- a/tests/routes/api.tags.spec.ts +++ b/tests/routes/api.tags.spec.ts @@ -7,14 +7,20 @@ import { loader } from '~/routes/api.tags' import { registerUser } from '~/services/user-service.server' const TAGS_TEST_USER = generateTestUserCredentials() +const TEST_TAGS = [ + `tag-${TAGS_TEST_USER.name}-1`, + `tag-${TAGS_TEST_USER.name}-2`, + `tag-${TAGS_TEST_USER.name}-3`, +] const TEST_TAG_BOX = { name: `'${TAGS_TEST_USER.name}'s Box`, exposure: 'outdoor', expiresAt: null, - tags: ['tag1', 'tag2', 'testgrouptag'], + tags: TEST_TAGS, latitude: 0, longitude: 0, model: 'luftdaten.info', + sensorTemplates: ['sds011_pm10'], mqttEnabled: false, ttnEnabled: false, } @@ -43,7 +49,7 @@ describe('openSenseMap API Routes: /tags', () => { userId = user.id }) - it('should return empty array of tags when none are there', async () => { + it('should not return tags from a device that has not been created', async () => { // Arrange const request = new Request(`${BASE_URL}/tags`, { method: 'GET', @@ -63,7 +69,7 @@ describe('openSenseMap API Routes: /tags', () => { 'application/json; charset=utf-8', ) expect(Array.isArray(body.data)).toBe(true) - expect(body.data).toHaveLength(0) + expect(body.data).not.toEqual(expect.arrayContaining(TEST_TAGS)) }) it('should return distinct grouptags of boxes', async () => { @@ -88,9 +94,7 @@ describe('openSenseMap API Routes: /tags', () => { 'application/json; charset=utf-8', ) expect(Array.isArray(body.data)).toBe(true) - expect( - body.data.filter((t: string) => TEST_TAG_BOX.tags.includes(t)), - ).toHaveLength(3) + expect(body.data).toEqual(expect.arrayContaining(TEST_TAGS)) }) afterAll(async () => { diff --git a/tests/routes/api.users.me.boxes.$deviceId.spec.ts b/tests/routes/api.users.me.boxes.$deviceId.spec.ts index 181686e4..40d0cd91 100644 --- a/tests/routes/api.users.me.boxes.$deviceId.spec.ts +++ b/tests/routes/api.users.me.boxes.$deviceId.spec.ts @@ -18,6 +18,7 @@ const BOX_TEST_USER_BOX = { latitude: 0, longitude: 0, model: 'luftdaten.info', + sensorTemplates: ['sds011_pm10'], mqttEnabled: false, ttnEnabled: false, } diff --git a/tests/routes/api.users.me.boxes.spec.ts b/tests/routes/api.users.me.boxes.spec.ts index a2339ac3..0415be8f 100644 --- a/tests/routes/api.users.me.boxes.spec.ts +++ b/tests/routes/api.users.me.boxes.spec.ts @@ -18,6 +18,7 @@ const TEST_BOX = { latitude: 0, longitude: 0, model: 'luftdaten.info', + sensorTemplates: ['sds011_pm10'], } describe('openSenseMap API Routes: /users', () => { diff --git a/tests/services/decoding-service.server.spec.ts b/tests/services/decoding-service.server.spec.ts new file mode 100644 index 00000000..6db90496 --- /dev/null +++ b/tests/services/decoding-service.server.spec.ts @@ -0,0 +1,194 @@ +import { decodeMeasurements } from '~/services/decoding-service.server' + +const payload = { + sensordatavalues: [ + { value_type: 'SPS30_N1', value: '1.0' }, + { value_type: 'SPS30_N10', value: '2.0' }, + ], +} + +const nc1 = { id: 'nc1-id', title: 'NC1.0', sensorType: 'SPS30' } +const nc10 = { id: 'nc10-id', title: 'NC10', sensorType: 'SPS30' } + +describe('Luftdaten decoding', () => { + it.each([ + [nc10, nc1], + [nc1, nc10], + ])( + 'prefers exact aliases independently of sensor order', + async (...sensors) => { + const measurements = await decodeMeasurements(payload, { + contentType: 'luftdaten', + sensors, + }) + + expect( + measurements.map(({ sensor_id, value }) => ({ sensor_id, value })), + ).toEqual([ + { sensor_id: nc1.id, value: 1 }, + { sensor_id: nc10.id, value: 2 }, + ]) + }, + ) + + it('uses one timestamp for the complete batch', async () => { + const measurements = await decodeMeasurements(payload, { + contentType: 'luftdaten', + sensors: [nc10, nc1], + }) + + expect(measurements[0].createdAt).toBe(measurements[1].createdAt) + }) + + it('uses substring matching as a legacy fallback', async () => { + const measurements = await decodeMeasurements( + { sensordatavalues: [{ value_type: 'SPS30_N1', value: '1.0' }] }, + { + contentType: 'luftdaten', + sensors: [ + { + id: 'decorated-nc1-id', + title: 'Particles (NC1.0)', + sensorType: 'SPS30', + }, + ], + }, + ) + + expect(measurements[0].sensor_id).toBe('decorated-nc1-id') + }) + + it('routes catalog sensors by definition ID after their titles are renamed', async () => { + const measurements = await decodeMeasurements(payload, { + contentType: 'luftdaten', + sensors: [ + { + id: nc10.id, + title: 'Large particles outside', + sensorType: 'SPS30', + data: { sensorDefinitionId: 'sps30_nc10' }, + }, + { + id: nc1.id, + title: 'Small particles outside', + sensorType: 'SPS30', + data: { sensorDefinitionId: 'sps30_nc1' }, + }, + ], + }) + + expect(measurements.map(({ sensor_id }) => sensor_id)).toEqual([ + nc1.id, + nc10.id, + ]) + }) + + it('does not title-match a catalog sensor with a different definition', async () => { + await expect( + decodeMeasurements( + { sensordatavalues: [{ value_type: 'SPS30_N1', value: '1.0' }] }, + { + contentType: 'luftdaten', + sensors: [ + { + id: nc10.id, + title: 'NC1.0', + sensorType: 'SPS30', + data: { sensorDefinitionId: 'sps30_nc10' }, + }, + ], + }, + ), + ).rejects.toThrow('No applicable values found') + }) + + it.each([ + ['bme280_pressure_pa', 101517.36], + ['bme280_pressure_hpa', 1015.1736], + ])( + 'applies the catalog multiplier for %s', + async (sensorDefinitionId, expectedValue) => { + const measurements = await decodeMeasurements( + { + sensordatavalues: [ + { value_type: 'BME280_pressure', value: '101517.36' }, + ], + }, + { + contentType: 'luftdaten', + sensors: [ + { + id: 'pressure-id', + title: 'Pressure outside', + sensorType: 'BME280', + data: { sensorDefinitionId }, + }, + ], + }, + ) + + expect(measurements[0].value).toBeCloseTo(expectedValue) + }, + ) + + it('keeps the first value when multiple values resolve to one sensor', async () => { + const measurements = await decodeMeasurements( + { + sensordatavalues: [ + { value_type: 'SPS30_N1', value: '1.0' }, + { value_type: 'SPS30_N1', value: '2.0' }, + ], + }, + { contentType: 'luftdaten', sensors: [nc1] }, + ) + + expect(measurements).toHaveLength(1) + expect(measurements[0]).toMatchObject({ sensor_id: nc1.id, value: 1 }) + }) + + it('skips ambiguous legacy mappings but preserves other values', async () => { + const measurements = await decodeMeasurements(payload, { + contentType: 'luftdaten', + sensors: [nc1, { ...nc1, id: 'other-nc1-id' }, nc10], + }) + + expect(measurements).toHaveLength(1) + expect(measurements[0]).toMatchObject({ sensor_id: nc10.id, value: 2 }) + }) + + it('rejects when every mapping is ambiguous', async () => { + await expect( + decodeMeasurements( + { sensordatavalues: [{ value_type: 'SPS30_N1', value: '1.0' }] }, + { + contentType: 'luftdaten', + sensors: [nc1, { ...nc1, id: 'other-nc1-id' }], + }, + ), + ).rejects.toThrow('No applicable values found') + }) + + it('skips ambiguous definition mappings but preserves other values', async () => { + const measurements = await decodeMeasurements(payload, { + contentType: 'luftdaten', + sensors: [ + { + ...nc1, + data: { sensorDefinitionId: 'sps30_nc1' }, + }, + { + ...nc1, + id: 'other-nc1-id', + data: { sensorDefinitionId: 'sps30_nc1' }, + }, + { + ...nc10, + data: { sensorDefinitionId: 'sps30_nc10' }, + }, + ], + }) + + expect(measurements).toHaveLength(1) + expect(measurements[0]).toMatchObject({ sensor_id: nc10.id, value: 2 }) + }) +}) diff --git a/tests/services/hackair-decoding-service.server.spec.ts b/tests/services/hackair-decoding-service.server.spec.ts new file mode 100644 index 00000000..e9111c84 --- /dev/null +++ b/tests/services/hackair-decoding-service.server.spec.ts @@ -0,0 +1,64 @@ +import { decodeMeasurements } from '~/services/decoding-service.server' + +const payload = { reading: { pm10: '10.1' } } +const pm10 = { id: 'pm10-id', title: 'PM10' } +const overlappingTitle = { id: 'pm1-id', title: 'Outdoor PM1 sensor' } + +describe('hackAIR decoding', () => { + it.each([ + [overlappingTitle, pm10], + [pm10, overlappingTitle], + ])( + 'prefers exact aliases independently of sensor order', + async (...sensors) => { + const measurements = await decodeMeasurements(payload, { + contentType: 'hackair', + sensors, + }) + + expect(measurements).toHaveLength(1) + expect(measurements[0]).toMatchObject({ + sensor_id: pm10.id, + value: 10.1, + }) + }, + ) + + it('skips ambiguous mappings but preserves other readings', async () => { + const measurements = await decodeMeasurements( + { reading: { pm10: '10.1', temperature: '21.5' } }, + { + contentType: 'hackair', + sensors: [ + pm10, + { ...pm10, id: 'other-pm10-id' }, + { id: 'temperature-id', title: 'Temperatur' }, + ], + }, + ) + + expect(measurements).toHaveLength(1) + expect(measurements[0]).toMatchObject({ + sensor_id: 'temperature-id', + value: 21.5, + }) + }) + + it('rejects when every mapping is ambiguous', async () => { + await expect( + decodeMeasurements(payload, { + contentType: 'hackair', + sensors: [pm10, { ...pm10, id: 'other-pm10-id' }], + }), + ).rejects.toThrow('No applicable values found') + }) + + it('uses a unique substring match as a legacy fallback', async () => { + const measurements = await decodeMeasurements(payload, { + contentType: 'hackair', + sensors: [{ id: 'decorated-pm10-id', title: 'Outdoor PM10 sensor' }], + }) + + expect(measurements[0].sensor_id).toBe('decorated-pm10-id') + }) +})