Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dc5e597
feat: add integrations schema
jona159 Jan 4, 2026
30b1d4e
feat: add internal endpoint to post measurements via mqtt
jona159 Jan 4, 2026
53163a2
feat: mqtt server functions
jona159 Jan 4, 2026
cd09724
feat: endpoint for active mqtt configurations
jona159 Jan 4, 2026
2a2869b
fix: make createdAt mandatory to preserver history for batch measurem…
jona159 Jan 8, 2026
4e65d7d
feat: add mqtt client
jona159 Jan 9, 2026
7ac3a31
feat: mqtt routes and db methods
jona159 Jan 9, 2026
24fd9c7
feat: add mqtt service url and key to env
jona159 Jan 9, 2026
92eddcb
feat: install json to form package and define base widgets for styling
jona159 Feb 3, 2026
884a16c
fix: check if measurement is posted by mqtt service
jona159 Feb 3, 2026
0fdec95
feat: form to edit mqtt config from json schema provided by mqtt service
jona159 Feb 3, 2026
a25d0a5
feat: add route to fetch schema from mqtt service
jona159 Feb 4, 2026
9323e3a
feat: replace mqtt form with form generated from json schema
jona159 Feb 4, 2026
6f2bc26
fix: adjust validation for new mqtt form
jona159 Feb 4, 2026
d377ec3
feat: add more widgets
jona159 Feb 4, 2026
460c134
feat: save mqtt config in mqtt service db when creating device#
jona159 Feb 4, 2026
4a7565d
feat: rm mqtt specific apis
jona159 Feb 6, 2026
25e47b3
feat: install openapi-types, seed integrations
jona159 Feb 6, 2026
ffc4d71
feat: adjust integration schema
jona159 Feb 6, 2026
ee9a306
feat: integration db functions
jona159 Feb 6, 2026
5e8c7c5
feat: create integrations via generic api routes
jona159 Feb 6, 2026
f787108
feat: show integrations dynamically in sidebar
jona159 Feb 6, 2026
6d6962a
feat:icon map
jona159 Feb 6, 2026
3c59032
feat: document integrations
jona159 Feb 6, 2026
d05f3d2
fix: minor adjustments
jona159 Feb 11, 2026
7764e12
feat: edit ttn config
jona159 Feb 11, 2026
873d328
Merge branch 'dev' into feat/mqtt-json-schema
jona159 Feb 11, 2026
73c7700
fix: migration and deps conflicts
jona159 Feb 11, 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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ OSEM_API_URL="https://api.opensensemap.org/"
DIRECTUS_URL="https://coelho.opensensemap.org"
SENSORWIKI_API_URL="https://api.sensors.wiki/"

MQTT_SERVICE_URL="http://localhost:3001"
MQTT_SERVICE_KEY="dev-service-key-change-in-production"

MYBADGES_API_URL = "https://api.v2.mybadges.org/"
MYBADGES_URL = "https://mybadges.org/"
MYBADGES_SERVERADMIN_USERNAME = ""
Expand Down
372 changes: 128 additions & 244 deletions app/components/device/new/advanced-info.tsx
Original file line number Diff line number Diff line change
@@ -1,246 +1,130 @@
import { useFormContext } from 'react-hook-form'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card'
import { Input } from '~/components/ui/input'
import { Label } from '~/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select'
import { Switch } from '~/components/ui/switch'
import { Textarea } from '~/components/ui/textarea'
import Form from "@rjsf/core";
import validator from "@rjsf/validator-ajv8";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { CheckboxWidget } from "~/components/rjsf/checkboxWidget";
import { FieldTemplate } from "~/components/rjsf/fieldTemplate";
import { BaseInputTemplate } from "~/components/rjsf/inputTemplate";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { type IntegrationMetadata } from "~/routes/api.integrations";

export function AdvancedStep() {
const { register, setValue, watch, resetField } = useFormContext()

// Watch field states
const isMqttEnabled = watch('mqttEnabled') || false
const isTtnEnabled = watch('ttnEnabled') || false

// Clear corresponding fields when disabling
const handleMqttToggle = (checked: boolean) => {
setValue('mqttEnabled', checked)
if (!checked) {
resetField('url')
resetField('topic')
resetField('messageFormat')
resetField('decodeOptions')
resetField('connectionOptions')
}
}

const handleTtnToggle = (checked: boolean) => {
setValue('ttnEnabled', checked)
if (!checked) {
resetField('dev_id')
resetField('app_id')
resetField('profile')
resetField('decodeOptions')
resetField('port')
}
}

const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = event.target
setValue(name, value)
}

const handleSelectChange = (field: string, value: string) => {
setValue(field, value)
}

return (
<>
{/* MQTT Configuration */}
<Card className="w-full">
<CardHeader>
<CardTitle>MQTT Configuration</CardTitle>
<CardDescription>
Configure your MQTT settings for data streaming
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="mqttEnabled" className="text-base font-semibold">
Enable MQTT
</Label>
<Switch
disabled
id="mqttEnabled"
checked={isMqttEnabled}
onCheckedChange={handleMqttToggle}
/>
</div>

{isMqttEnabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mqtt-url">MQTT URL</Label>
<Input
id="mqtt-url"
placeholder="mqtt://example.com:1883"
{...register('url')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="mqtt-topic">MQTT Topic</Label>
<Input
id="mqtt-topic"
placeholder="my/mqtt/topic"
{...register('topic')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="mqtt-message-format">Message Format</Label>
<Select
onValueChange={(value) =>
handleSelectChange('messageFormat', value)
}
defaultValue={watch('messageFormat')}
>
<SelectTrigger id="mqtt-message-format">
<SelectValue placeholder="Select a message format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="csv">CSV</SelectItem>
</SelectContent>
</Select>
</div>

<div className="space-y-2">
<Label htmlFor="mqtt-decode-options">Decode Options</Label>
<Textarea
id="mqtt-decode-options"
placeholder="Enter decode options as JSON"
className="resize-none"
{...register('decodeOptions')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="mqtt-connection-options">
Connection Options
</Label>
<Textarea
id="mqtt-connection-options"
placeholder="Enter connection options as JSON"
className="resize-none"
{...register('connectionOptions')}
onChange={handleInputChange}
/>
</div>
</div>
)}
</CardContent>
</Card>

{/* TTN Configuration */}
<Card className="mt-6 w-full">
<CardHeader>
<CardTitle>TTN Configuration</CardTitle>
<CardDescription>
Configure your TTN (The Things Network) settings
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="ttnEnabled" className="text-base font-semibold">
Enable TTN
</Label>
<Switch
disabled
id="ttnEnabled"
checked={isTtnEnabled}
onCheckedChange={handleTtnToggle}
/>
</div>

{isTtnEnabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ttn-dev-id">Device ID</Label>
<Input
id="ttn-dev-id"
placeholder="Enter TTN Device ID"
{...register('dev_id')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="ttn-app-id">Application ID</Label>
<Input
id="ttn-app-id"
placeholder="Enter TTN Application ID"
{...register('app_id')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="ttn-profile">Profile</Label>
<Select
onValueChange={(value) =>
handleSelectChange('profile', value)
}
defaultValue={watch('profile')}
>
<SelectTrigger id="ttn-profile">
<SelectValue placeholder="Select a profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="lora-serialization">
Lora Serialization
</SelectItem>
<SelectItem value="sensebox/home">Sensebox/Home</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
<SelectItem value="cayenne-lpp">Cayenne LPP</SelectItem>
</SelectContent>
</Select>
</div>

<div className="space-y-2">
<Label htmlFor="ttn-decode-options">Decode Options</Label>
<Textarea
id="ttn-decode-options"
placeholder="Enter decode options as JSON"
className="resize-none"
{...register('decodeOptions')}
onChange={handleInputChange}
/>
</div>

<div className="space-y-2">
<Label htmlFor="ttn-port">Port</Label>
<Input
id="ttn-port"
placeholder="Enter TTN Port"
type="number"
{...register('port', { valueAsNumber: true })}
onChange={handleInputChange}
/>
</div>
</div>
)}
</CardContent>
</Card>
</>
)
}
const { watch, setValue, resetField } = useFormContext();
const [integrations, setIntegrations] = useState<IntegrationMetadata[]>([]);
const [schemas, setSchemas] = useState<Record<string, { schema: any; uiSchema: any }>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});

// Load available integrations on mount
useEffect(() => {
const loadIntegrations = async () => {
try {
const res = await fetch("/api/integrations");
if (!res.ok) throw new Error("Failed to fetch integrations");
const data = await res.json();
setIntegrations(data);
} catch (err) {
console.error("Failed to load integrations", err);
}
};

void loadIntegrations();
}, []);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner if components don't do data fetching by themselves.
Instead it should be done in the route loader and passed as props to the component, what do you think?

That will also allow to use the loading (and eventually Suspense) mechanisms.


// Load schema when integration is enabled
const loadSchema = async (slug: string, schemaUrl: string) => {
if (schemas[slug]) return; // Already loaded

setLoading((prev) => ({ ...prev, [slug]: true }));

try {
console.log("schema url", schemaUrl)
const res = await fetch(schemaUrl);
if (!res.ok) throw new Error(`Failed to fetch ${slug} schema`);

const data = await res.json();
setSchemas((prev) => ({ ...prev, [slug]: data }));
} catch (err) {
console.error(`Failed to load ${slug} schema`, err);
} finally {
setLoading((prev) => ({ ...prev, [slug]: false }));
}
};

// Toggle handler
const handleToggle = (slug: string, checked: boolean, schemaUrl: string) => {
setValue(`${slug}Enabled`, checked);

if (checked) {
void loadSchema(slug, schemaUrl);
} else {
resetField(`${slug}Config`);
}
};

return (
<>
{integrations.map((intg) => {
const enabled = watch(`${intg.slug}Enabled`) ?? false;
const config = watch(`${intg.slug}Config`) ?? {};
const isLoading = loading[intg.slug] ?? false;
const schema = schemas[intg.slug];

return (
<Card key={intg.id} className="w-full mb-6">
<CardHeader>
<CardTitle>{intg.name} Configuration</CardTitle>
{intg.description && (
<CardDescription>{intg.description}</CardDescription>
)}
</CardHeader>

<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${intg.slug}Enabled`} className="text-base font-semibold">
Enable {intg.name}
</Label>
<Switch
id={`${intg.slug}Enabled`}
checked={enabled}
onCheckedChange={(checked) => handleToggle(intg.slug, checked, intg.schemaUrl)}
/>
</div>

{enabled && (
<>
{isLoading && (
<p className="text-sm text-muted-foreground">
Loading {intg.name} configuration…
</p>
)}

{schema && (
<Form
widgets={{ CheckboxWidget }}
templates={{ FieldTemplate, BaseInputTemplate }}
schema={schema.schema}
uiSchema={schema.uiSchema}
validator={validator}
formData={config}
onChange={(e) => {
setValue(`${intg.slug}Config`, e.formData, {
shouldDirty: true,
shouldValidate: true,
});
}}
onSubmit={() => {}}
>
<></>
</Form>
)}
</>
)}
</CardContent>
</Card>
);
})}
</>
);
}
Loading
Loading