Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
13ac36e
feat: device schema db model and related fields
jona159 Jun 15, 2026
1d96b97
feat: upload device schema zod validation
jona159 Jun 15, 2026
2c7a3b5
feat: add optional device schema to create device schema
jona159 Jun 15, 2026
4fe422e
feat: example device schema
jona159 Jun 15, 2026
371fd97
feat: translation
jona159 Jun 15, 2026
5e22eb3
feat: import and save schemas on custom devices
jona159 Jun 15, 2026
007d4db
feat: migrations
jona159 Jun 15, 2026
3c6ef4d
fix: rm draft state, fix migrations
jona159 Jun 17, 2026
b02720b
feat: translations
jona159 Jun 17, 2026
a70b61b
feat: add optional schema version and registry fields to sensor schema
jona159 Jun 17, 2026
65deb54
feat: add db methods to manage schemas
jona159 Jun 17, 2026
9695d80
feat: resource route to load schemas for registry
jona159 Jun 17, 2026
074037a
feat: resource route to download public schema of specific version
jona159 Jun 17, 2026
aa57ce7
feat: allow to donwload device schemas from public profile
jona159 Jun 17, 2026
b3364f3
feat: add model and schema version to custom device creation payload
jona159 Jun 17, 2026
48d9708
feat: adjust add and update sensors
jona159 Jun 17, 2026
88c544d
feat: adjust device creation for sensor schema, allow to detach schema
jona159 Jun 17, 2026
8bb2456
feat: add optional schema version id to zod schema
jona159 Jun 17, 2026
4a2b930
feat: translation
jona159 Jun 17, 2026
e732bc2
feat: adjust hook to track copied string
jona159 Jun 17, 2026
f4e72be
feat: manage own schemas on settings profile
jona159 Jun 17, 2026
dd7be25
feat: restrict editing sensors from schema but allow to detach schema…
jona159 Jun 17, 2026
7b6cc69
feat: tab view for browsing schema registry, upload and manual sensors
jona159 Jun 17, 2026
7a21333
Merge branch 'dev' into feat/device-schema
jona159 Jul 8, 2026
794d176
Merge branch 'dev' into feat/device-schema
jona159 Jul 21, 2026
0512fba
fix: disable state after merge conflict
jona159 Jul 21, 2026
16944a9
fix: use design tokens, download link
jona159 Jul 23, 2026
76a70d2
fix: rm asChild for text node
jona159 Jul 23, 2026
4d9d5b7
fix: use design tokens
jona159 Jul 23, 2026
a57c2ba
fix: use alert dialog instead of default window.confirm
jona159 Jul 23, 2026
3960a4f
feat: add example schema download link
jona159 Jul 23, 2026
a6e3ee4
feat: add translations
jona159 Jul 23, 2026
f6f42a5
fix: use term current instead of publish for device schema version
jona159 Aug 3, 2026
94f0619
fix: rm redundant columns
jona159 Aug 3, 2026
21bf245
feat: generate fuzzy matching candidates and scores
jona159 Aug 5, 2026
31bda3a
feat: deterministic alias table
jona159 Aug 5, 2026
3888d61
feat: tests for normalization and autocomplete suggestions
jona159 Aug 5, 2026
b03b8e2
feat: enrich manually added sensors with aliases,add suggestion list
jona159 Aug 5, 2026
7601f06
feat: add translations
jona159 Aug 5, 2026
8c1a1da
fix: type
jona159 Aug 5, 2026
596d154
feat: group known aliases under canonical phenomena, add fallbacks as…
jona159 Aug 5, 2026
fe3091f
feat: use sensorMatchesAnyPhenomenon method for filtering
jona159 Aug 5, 2026
4c09515
feat: match canonical phenomenon via helper instead of raw sensor.title
jona159 Aug 5, 2026
a4cbde6
feat: fetch from sensor wiki api by default, add cli flags
jona159 Aug 5, 2026
99c1e66
feat: allow object input for combobox
jona159 Aug 5, 2026
b6b4576
feat: add phenomenon filter option type
jona159 Aug 5, 2026
985e754
feat: migration for sensor wiki alias table
jona159 Aug 6, 2026
f4cb142
feat: add aliases from production data
jona159 Aug 6, 2026
83be08d
feat: add admin ui for alias entries
jona159 Aug 6, 2026
7f9635b
feat: add optional sensor wiki alias entries arg
jona159 Aug 6, 2026
01a7e05
feat: get alias entries for filtering
jona159 Aug 6, 2026
4506fb0
feat: add link to alias entries on admin landing page
jona159 Aug 6, 2026
d5ebb69
feat: enrich sensor data with alias entries
jona159 Aug 6, 2026
a5f581f
feat: server methods for alias entries
jona159 Aug 6, 2026
a2f89a8
feat: adjustments for alias entries
jona159 Aug 6, 2026
81e9674
feat: add script to generate candidates
jona159 Aug 6, 2026
6b59450
feat: add hints and user feedback to manual sensor step
jona159 Aug 6, 2026
60f1c71
feat: translations
jona159 Aug 7, 2026
8ddb0d8
Merge branch 'dev' into feat/sensor-wiki-alias-table
jona159 Aug 10, 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
198 changes: 185 additions & 13 deletions app/components/device/new/custom-device-config.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FileJson, Library, Lock, Search, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useFormContext, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useLoaderData } from 'react-router'
import { type CustomDeviceSchemaUpload, type Sensor } from './sensors-info'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
Expand All @@ -12,6 +13,13 @@ import { Label } from '@/components/ui/label'
import { Separator } from '~/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'
import { uploadedDeviceSchemaV1 } from '~/lib/device-schemas/device-schema-v1'
import {
getSensorWikiAliasSuggestions,
matchSensorWikiAlias,
type SensorWikiAliasEntry,
type SensorWikiAliasSuggestion,
} from '~/lib/device-schemas/sensor-wiki-aliases'
import { type loader } from '~/routes/device.new'

type RegistryDeviceSchema = {
id: string
Expand All @@ -33,15 +41,32 @@ type RegistryResponse = {
schemas: RegistryDeviceSchema[]
}

function enrichSensorWithAlias<T extends Sensor>(
sensor: T,
sensorWikiAliasEntries: SensorWikiAliasEntry[],
): T {
const match = matchSensorWikiAlias(sensor, sensorWikiAliasEntries)

if (!match) return sensor

return {
...sensor,
sensorWikiPhenomenon:
sensor.sensorWikiPhenomenon ?? match.sensorWikiPhenomenon,
sensorWikiUnit: sensor.sensorWikiUnit ?? match.sensorWikiUnit,
}
}

export function CustomDeviceConfig() {
const { sensorWikiAliasEntries } = useLoaderData<typeof loader>()
const { control, setValue } = useFormContext()
const sensors =
(useWatch({ control, name: 'selectedSensors' }) as Sensor[] | undefined) ??
[]
const deviceSchema = useWatch({
control,
name: 'deviceSchema',
}) as CustomDeviceSchemaUpload | undefined
}) as CustomDeviceSchemaUpload
const deviceSchemaVersionId = useWatch({
control,
name: 'deviceSchemaVersionId',
Expand All @@ -61,8 +86,20 @@ export function CustomDeviceConfig() {
unit: '',
sensorType: '',
})
const [isSuggestionListOpen, setIsSuggestionListOpen] = useState(false)
const { t } = useTranslation('newdevice')

const sensorSuggestions = useMemo(
() => getSensorWikiAliasSuggestions(newSensor, 5, sensorWikiAliasEntries),
[newSensor, sensorWikiAliasEntries],
)
const sensorWikiMatch = useMemo(
() => matchSensorWikiAlias(newSensor, sensorWikiAliasEntries),
[newSensor, sensorWikiAliasEntries],
)
const hasManualSensorTitle = newSensor.title.trim().length >= 2
const firstSensorSuggestion = sensorSuggestions[0]

useEffect(() => {
const abortController = new AbortController()
const timeout = setTimeout(async () => {
Expand Down Expand Up @@ -100,14 +137,48 @@ export function CustomDeviceConfig() {
}, [registryQuery, t])

const updateNewSensor = (field: keyof Sensor, value: string) => {
setNewSensor((prev) => ({ ...prev, [field]: value }))
setNewSensor((prev) => {
const nextSensor = { ...prev, [field]: value }

if (field === 'title') {
return {
...nextSensor,
sensorWikiPhenomenon: undefined,
sensorWikiUnit: undefined,
}
}

if (field === 'unit') {
return {
...nextSensor,
sensorWikiUnit: undefined,
}
}

return nextSensor
})
}

const applySensorSuggestion = (suggestion: SensorWikiAliasSuggestion) => {
setNewSensor((prev) => ({
...prev,
title: suggestion.title,
unit: prev.unit || suggestion.unit || '',
sensorWikiPhenomenon: suggestion.sensorWikiPhenomenon,
sensorWikiUnit: suggestion.sensorWikiUnit,
}))
setIsSuggestionListOpen(false)
}

const addSensor = () => {
if (deviceSchema || deviceSchemaVersionId) return
if (!newSensor.title || !newSensor.unit || !newSensor.sensorType) return

setValue('selectedSensors', [...sensors, newSensor])
const updatedSensors = [
...sensors,
enrichSensorWithAlias(newSensor, sensorWikiAliasEntries),
]
setValue('selectedSensors', updatedSensors)
setNewSensor({ title: '', unit: '', sensorType: '' })
}

Expand All @@ -124,7 +195,13 @@ export function CustomDeviceConfig() {
try {
const parsedJson = JSON.parse(await file.text())
const parsedSchema = uploadedDeviceSchemaV1.parse(parsedJson)
const schemaSensors = parsedSchema.sensors.map((sensor) => ({
const enrichedSchema = {
...parsedSchema,
sensors: parsedSchema.sensors.map((sensor) =>
enrichSensorWithAlias(sensor, sensorWikiAliasEntries),
),
}
const schemaSensors = enrichedSchema.sensors.map((sensor) => ({
id: sensor.id,
title: sensor.title,
unit: sensor.unit,
Expand All @@ -135,7 +212,7 @@ export function CustomDeviceConfig() {
sensorWikiUnit: sensor.sensorWikiUnit,
}))

setValue('deviceSchema', parsedSchema)
setValue('deviceSchema', enrichedSchema)
setValue('deviceSchemaVersionId', undefined)
setValue('deviceSchemaRegistrySelection', undefined)
setValue('selectedSensors', schemaSensors)
Expand Down Expand Up @@ -339,16 +416,71 @@ export function CustomDeviceConfig() {
)}

<div>
<p className="text-muted-foreground mb-4 text-sm">
{t('manual_sensors_sensor_wiki_hint')}
</p>
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<Label htmlFor="phenomenon">{t('phenomenon')}</Label>
<Input
id="phenomenon"
value={newSensor.title}
onChange={(e) => updateNewSensor('title', e.target.value)}
placeholder="e.g., Temperature"
disabled={userHasSelectedSchema}
/>
<div className="relative">
<Input
id="phenomenon"
value={newSensor.title}
onChange={(e) => {
updateNewSensor('title', e.target.value)
setIsSuggestionListOpen(true)
}}
onFocus={() => setIsSuggestionListOpen(true)}
onBlur={() => setIsSuggestionListOpen(false)}
placeholder="e.g., Temperature"
disabled={userHasSelectedSchema}
autoComplete="off"
aria-autocomplete="list"
aria-expanded={
isSuggestionListOpen && sensorSuggestions.length > 0
}
aria-controls="sensor-wiki-suggestions"
/>
{isSuggestionListOpen && sensorSuggestions.length > 0 && (
<div
id="sensor-wiki-suggestions"
role="listbox"
className="border-border bg-popover text-popover-foreground mt-1 max-h-64 w-full overflow-auto rounded-md border p-1 shadow-md"
>
{sensorSuggestions.map((suggestion) => (
<button
key={`${suggestion.sensorWikiPhenomenon}-${suggestion.sensorWikiUnit ?? 'unitless'}`}
type="button"
role="option"
className="hover:bg-muted focus:bg-muted flex w-full flex-col items-start gap-1 rounded-sm px-3 py-2 text-left text-sm outline-none"
onMouseDown={(event) => {
event.preventDefault()
applySensorSuggestion(suggestion)
}}
>
<span className="font-medium">
{suggestion.title}
</span>
<span className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<span>
{t('device_schema_sensor_wiki_match', {
phenomenon: suggestion.sensorWikiPhenomenon,
})}
</span>
{suggestion.unit && <span>{suggestion.unit}</span>}
<Badge variant="outline">
{t(
suggestion.confidence === 'high'
? 'device_schema_alias_confidence_high'
: 'device_schema_alias_confidence_medium',
)}
</Badge>
</span>
</button>
))}
</div>
)}
</div>
</div>
<div>
<Label htmlFor="unit">{t('unit')}</Label>
Expand All @@ -373,6 +505,46 @@ export function CustomDeviceConfig() {
/>
</div>
</div>
{!userHasSelectedSchema && hasManualSensorTitle && (
<div className="border-border bg-muted/20 mb-4 flex flex-col gap-2 rounded-md border p-3 text-sm md:flex-row md:items-center">
{sensorWikiMatch ? (
<>
<Badge variant="secondary">
{t(
sensorWikiMatch.confidence === 'high'
? 'device_schema_alias_confidence_high'
: 'device_schema_alias_confidence_medium',
)}
</Badge>
<span className="text-muted-foreground">
{t('manual_sensor_wiki_matched', {
phenomenon: sensorWikiMatch.sensorWikiPhenomenon,
})}
</span>
</>
) : firstSensorSuggestion ? (
<>
<Badge variant="outline">
{t('device_schema_alias_confidence_medium')}
</Badge>
<span className="text-muted-foreground">
{t('manual_sensor_wiki_suggestion_available', {
phenomenon: firstSensorSuggestion.sensorWikiPhenomenon,
})}
</span>
</>
) : (
<>
<Badge variant="outline">
{t('manual_sensor_wiki_unmatched')}
</Badge>
<span className="text-muted-foreground">
{t('manual_sensor_wiki_unmatched_text')}
</span>
</>
)}
</div>
)}
<Button
type="button"
onClick={addSensor}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import MultiSelectComboboxFilter from '~/components/ui/combobox-filter'
import { type PhenomenonFilterOption } from '~/lib/phenomenon-filter'
import { useTranslation } from 'react-i18next'

interface FilterPhenomenaProps {
phenomena: string[]
availablePhenomena: string[]
availablePhenomena: PhenomenonFilterOption[]
onPhenomenaChange: (phenomena: string[]) => void
}

Expand Down
40 changes: 31 additions & 9 deletions app/components/ui/combobox-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ import {
import { cn } from '~/lib/utils'
import { useTranslation } from 'react-i18next'

export type ComboboxFilterOption = {
value: string
label: string
description?: string
}

interface MultiSelectComboboxFilterProps {
label: string
values: string[]
options: string[]
options: Array<string | ComboboxFilterOption>
placeholder: string
searchPlaceholder: string
emptyText: string
Expand All @@ -38,6 +44,13 @@ export default function MultiSelectComboboxFilter({
onChange,
}: MultiSelectComboboxFilterProps) {
const { t } = useTranslation('filter')
const normalizedOptions = options.map((option) =>
typeof option === 'string' ? { value: option, label: option } : option,
)
const optionsByValue = new Map(
normalizedOptions.map((option) => [option.value, option]),
)

const toggleValue = (value: string) => {
if (values.includes(value)) {
onChange(values.filter((current) => current !== value))
Expand Down Expand Up @@ -87,14 +100,14 @@ export default function MultiSelectComboboxFilter({
<CommandEmpty>{emptyText}</CommandEmpty>

<CommandGroup>
{options.map((option) => {
const selected = values.includes(option)
{normalizedOptions.map((option) => {
const selected = values.includes(option.value)

return (
<CommandItem
key={option}
value={option}
onSelect={() => toggleValue(option)}
key={option.value}
value={`${option.label} ${option.description ?? ''}`}
onSelect={() => toggleValue(option.value)}
>
<Check
className={cn(
Expand All @@ -103,7 +116,14 @@ export default function MultiSelectComboboxFilter({
)}
/>

<span className="truncate">{option}</span>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (
<span className="text-muted-foreground block truncate text-xs">
{option.description}
</span>
)}
</span>
</CommandItem>
)
})}
Expand All @@ -117,13 +137,15 @@ export default function MultiSelectComboboxFilter({
<div className="flex flex-wrap gap-1.5">
{values.map((value) => (
<Badge key={value} variant="secondary" className="text-xs">
<span className="max-w-40 truncate">{value}</span>
<span className="max-w-40 truncate">
{optionsByValue.get(value)?.label ?? value}
</span>

<button
type="button"
onClick={() => removeValue(value)}
className="hover:text-destructive ml-1 rounded-full"
aria-label={`Remove ${value}`}
aria-label={`Remove ${optionsByValue.get(value)?.label ?? value}`}
>
<X className="h-3 w-3" />
</button>
Expand Down
14 changes: 14 additions & 0 deletions app/db/drizzle/0048_sensor_wiki_aliases.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE "sensor_wiki_alias" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"sensor_wiki_phenomenon" text NOT NULL,
"sensor_wiki_unit" text,
"title" text NOT NULL,
"unit" text,
"title_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"unit_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"sensor_type_aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "sensor_wiki_alias_key_unique" ON "sensor_wiki_alias" USING btree ("key");--> statement-breakpoint
CREATE INDEX "sensor_wiki_alias_phenomenon_idx" ON "sensor_wiki_alias" USING btree ("sensor_wiki_phenomenon");
Loading
Loading