57 lines
15 KiB
JSON
57 lines
15 KiB
JSON
{
|
|
"$schema": "homarr-widget-package-v3",
|
|
"manifest": {
|
|
"id": "homarr.reference.mqtt",
|
|
"version": "1.0.0",
|
|
"sdkVersion": "1",
|
|
"name": "MQTT live topics",
|
|
"description": "Live broker topics, retained values, recent message history and explicit owner-configured publishes.",
|
|
"entrypoints": {
|
|
"tile": "widget.tsx",
|
|
"advanced": "widget.tsx",
|
|
"server": "server.ts"
|
|
},
|
|
"handlers": {
|
|
"setup": {
|
|
"kind": "query",
|
|
"permission": "view"
|
|
},
|
|
"messages": {
|
|
"kind": "subscription",
|
|
"permission": "view"
|
|
},
|
|
"publish": {
|
|
"kind": "action",
|
|
"permission": "modify"
|
|
}
|
|
}
|
|
},
|
|
"files": {
|
|
"server.ts": "import type { BrokerMessage, BrokerEvent, PublishInput } from \"./mqtt-types\";\nimport type { WidgetJson } from \"@homarr/widget-sdk/server\";\nimport { defineWidgetServer } from \"@homarr/widget-sdk/server\";\nimport { brokerSettings, connectBroker } from \"./mqtt-adapter\";\n\nexport default defineWidgetServer({\n setup: async (_input,context) => {\n const {topics,publishTopics} = await brokerSettings(context);\n return {topics,publishTopics};\n },\n messages: async function* (_input,context):AsyncGenerator<BrokerEvent> {\n const configuration = await brokerSettings(context);\n const {client,close} = await connectBroker(context,configuration);\n const queue:BrokerMessage[] = [];\n let wake = () => {};\n let failure:Error|undefined;\n let dropped = 0;\n const onMessage = (topic:string,buffer:Buffer,packet:{retain:boolean}) => {\n const truncated = buffer.length > 65536;\n const text = buffer.subarray(0,65536).toString(\"utf8\");\n let payload:WidgetJson = text;\n try { payload = JSON.parse(text); } catch {}\n if (queue.length >= 64) {queue.shift();dropped++;}\n queue.push({kind:\"message\",topic,payload,truncated,retained:packet.retain,dropped,receivedAt:new Date().toISOString()});\n wake();\n };\n const onError = (error:Error) => {failure=error;wake();};\n const onClose = () => {if (!context.signal.aborted) onError(new Error(\"Broker connection closed; reconnect to resume\"));};\n const onAbort = () => wake();\n client.on(\"message\",onMessage);client.on(\"error\",onError);client.on(\"close\",onClose);\n context.signal.addEventListener(\"abort\",onAbort,{once:true});\n try {\n const grants = await client.subscribeAsync(configuration.topics,{qos:0});\n if (grants.some((grant) => grant.qos === 128)) throw new Error(\"The broker denied a configured topic subscription\");\n yield {kind:\"connected\",topics:configuration.topics,receivedAt:new Date().toISOString()};\n while (!context.signal.aborted) {\n if (failure) throw failure;\n if (queue.length) {yield queue.shift()!;continue;}\n await new Promise<void>((resolve) => {wake=resolve;if (context.signal.aborted || failure || queue.length) resolve();});\n }\n } finally {\n context.signal.removeEventListener(\"abort\",onAbort);\n client.off(\"message\",onMessage);client.off(\"error\",onError);client.off(\"close\",onClose);\n await close();\n }\n },\n publish: async (input:PublishInput,context) => {\n const configuration = await brokerSettings(context);\n if (!configuration.publishTopics.includes(input?.topic)) throw new Error(\"Choose an owner-configured publish topic\");\n if (typeof input.payload !== \"string\" || Buffer.byteLength(input.payload,\"utf8\") > 4096) throw new Error(\"The payload must be a string under 4 KiB\");\n const {client,close} = await connectBroker(context,configuration);\n try {\n await client.publishAsync(input.topic,input.payload,{qos:1,retain:false});\n return {published:true,topic:input.topic};\n } finally { await close(); }\n },\n});\n",
|
|
"mqtt-types.ts": "import type { WidgetJson } from \"@homarr/widget-sdk/server\";\nexport interface BrokerMessage {kind:\"message\";topic:string;payload:WidgetJson;truncated:boolean;retained:boolean;dropped:number;receivedAt:string}\nexport interface BrokerConnected {kind:\"connected\";topics:string[];receivedAt:string}\nexport type BrokerEvent = BrokerMessage | BrokerConnected;\nexport interface PublishInput {topic:string;payload:string}\n",
|
|
"mqtt.d.ts": "// Portable declarations for the exact MQTT.js 5.15.2 adapter API used here.\n// Draft checking does not fetch npm packages. Runtime code still comes from the pinned dependency.\ndeclare module \"mqtt/lib/client\" {\n import { EventEmitter } from \"node:events\";\n import type { Duplex } from \"node:stream\";\n interface Options {clientId:string;username?:string;password?:string;reconnectPeriod:number;connectTimeout:number;clean:boolean;manualConnect:boolean;log:(...values:unknown[])=>void}\n export default class MqttClient extends EventEmitter {\n constructor(streamBuilder:()=>Duplex,options:Options);\n connect():this;\n end(force?:boolean):this;\n endAsync(force?:boolean):Promise<void>;\n subscribeAsync(topics:string[],options:{qos:0|1|2}):Promise<{qos:0|1|2|128}[]>;\n publishAsync(topic:string,payload:string,options:{qos:0|1|2;retain:boolean}):Promise<void>;\n }\n}\n",
|
|
"README.md": "# MQTT live topics\n\nCreate a Service connection with serviceType `mqtt`. Nonsecret settings:\n\n```json\n{\"url\":\"mqtt://mosquitto:1883\",\"topics\":[\"homelab/status/#\",\"sensors/+/temperature\"],\"publishTopics\":[\"homelab/commands/refresh\"]}\n```\n\nEncrypted credentials: {\"username\":\"homarr\",\"password\":\"YOUR_BROKER_PASSWORD\"}. The browser never receives credentials through SDK connection APIs. TLS brokers use mqtts://. This adapter uses MQTT.js\u2019s client constructor with Node TCP/TLS streams. Optional nonsecret settings ca/cert/rejectUnauthorized configure TLS; keep tlsKey in encrypted credentials. Use a distinct connection when broker or credential sets differ.\n\nThe server adapter opens an authenticated subscription only while observed, disables hidden reconnect loops and closes on cancellation, errors and worker shutdown. The host shares matching subscriptions across surfaces. The browser retains 64 recent events; the worker queue is bounded to 64 messages and each payload to 64 KiB. The tile shows latest topic values; advanced view shows recent messages and a nonretained publish action. Only explicit publishTopics accept publishes, with 4 KiB payloads and QoS 1. Clear publishTopics for a read-only dashboard. Broker permissions still apply. Public guest actions are off by default.\n\nDiagnose verifies TCP reachability for mqtt:// and mqtts://. Run preview to validate TLS, broker credentials and topic authorization; use reconnect after fixing the service. Enabling live SDK actions is required to actually publish from preview. No HTTP URL or proxy is involved. This package pins mqtt@5.15.2 and supports-color@8.1.1 (debug\u2019s optional JavaScript dependency); review and trust installs its dependency tree with lifecycle scripts disabled. Source: https://github.com/mqttjs/MQTT.js\n",
|
|
"mqtt-adapter.ts": "import type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\ninterface LocalMqttSettings {url?:string;topics?:unknown[];publishTopics?:unknown[];ca?:string;cert?:string;rejectUnauthorized?:boolean}\nimport MqttClient from \"mqtt/lib/client\";\nimport { createConnection, isIP } from \"node:net\";\nimport { connect as connectTls } from \"node:tls\";\nimport { randomUUID } from \"node:crypto\";\nimport { getWidgetConnection } from \"@homarr/widget-sdk/server\";\n\nexport async function brokerSettings(context:WidgetServerContext) {\n const {configuration,secrets} = await getWidgetConnection<LocalMqttSettings>(context,{connection:\"broker\",serviceType:\"mqtt\"});\n const settings = configuration.settings ?? {};\n if (typeof settings.url !== \"string\") throw new Error(\"Set the MQTT service url, for example mqtt://mosquitto:1883\");\n const url = new URL(settings.url);\n if (![\"mqtt:\",\"mqtts:\"].includes(url.protocol)) throw new Error(\"Choose an mqtt:// or mqtts:// broker URL\");\n if (url.username || url.password) throw new Error(\"Put the broker username and password in encrypted credentials, not the URL\");\n const topics = Array.isArray(settings.topics) ? settings.topics.filter((topic):topic is string => typeof topic === \"string\" && topic.length > 0).slice(0,32) : [];\n if (!topics.length) throw new Error(\"Set a nonempty topics array on the broker connection\");\n const publishTopics = Array.isArray(settings.publishTopics) ? settings.publishTopics.filter((topic):topic is string => typeof topic === \"string\" && topic.length > 0 && !/[+#]/.test(topic)).slice(0,32) : [];\n return {settings:{...settings,url:settings.url},secrets,topics,publishTopics,timeoutMs:Math.min(configuration.timeoutMs,15000)};\n}\n\nexport async function connectBroker(context:WidgetServerContext,configuration:Awaited<ReturnType<typeof brokerSettings>>) {\n context.signal.throwIfAborted();\n const {settings,secrets,timeoutMs} = configuration;\n const url = new URL(settings.url);\n const hostname = url.hostname.replace(/^\\[|\\]$/g, \"\");\n let port = url.protocol === \"mqtts:\" ? 8883 : 1883;\n if (url.port) port = Number(url.port);\n const stream = () => {\n if (url.protocol !== \"mqtts:\") return createConnection({host:hostname,port});\n return connectTls({host:hostname,port,servername:isIP(hostname) ? undefined : hostname,ca:settings.ca,cert:settings.cert,key:secrets.tlsKey,rejectUnauthorized:settings.rejectUnauthorized !== false});\n };\n const client = new MqttClient(stream,{\n clientId:`homarr-${randomUUID()}`,\n username:secrets.username || undefined,\n password:secrets.password || undefined,\n reconnectPeriod:0,\n connectTimeout:timeoutMs,\n clean:true,\n manualConnect:true,\n log:() => {},\n });\n const ignoreError = () => {};\n client.on(\"error\",ignoreError);\n const stop = () => { client.end(true); };\n context.signal.addEventListener(\"abort\",stop,{once:true});\n const close = async () => {\n context.signal.removeEventListener(\"abort\",stop);\n await client.endAsync(true).catch(() => undefined);\n client.removeListener(\"error\",ignoreError);\n };\n try {\n await new Promise<void>((resolve,reject) => {\n const cleanup = () => {client.off(\"connect\",ready);client.off(\"error\",failed);client.off(\"close\",closed);};\n const ready = () => {cleanup();resolve();};\n const failed = (error:unknown) => {cleanup();reject(error);};\n const closed = () => failed(context.signal.reason ?? new Error(\"Broker connection closed before authentication completed\"));\n client.once(\"connect\",ready);client.once(\"error\",failed);client.once(\"close\",closed);\n if (context.signal.aborted) closed();\n else client.connect();\n });\n context.signal.throwIfAborted();\n return {client,close};\n } catch (error) { await close(); throw error; }\n}\n",
|
|
"widget.tsx": "import type server from \"./server\";\nimport type { BrokerEvent, BrokerMessage, PublishInput } from \"./mqtt-types\";\nimport { Alert, Badge, Button, Group, Select, Stack, Text, Textarea, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetServices, useWidgetOptions, useWidgetQuery, useWidgetState, useWidgetSubscription } from \"@homarr/widget-sdk\";\n\nexport default function MqttWidget() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const options = useWidgetOptions<{title:string}>();\n const setup = useWidgetQuery<Awaited<ReturnType<typeof server.setup>>>(\"setup\",{}, {staleTime:60000});\n const stream = useWidgetSubscription<BrokerEvent>(\"messages\",{}, {maximumEvents:64});\n const publish = useWidgetAction<Awaited<ReturnType<typeof server.publish>>,PublishInput>(\"publish\");\n const capabilities = useWidgetCapabilities();\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const [topic,setTopic] = useWidgetState(\"publishTopic\",\"\");\n const [payload,setPayload] = useWidgetState(\"payload\",\"\");\n const events = stream.events.filter((event):event is BrokerMessage => event.kind === \"message\" && event.topic.includes(filter)).slice().reverse();\n const latest = [...new Map(events.map((event) => [event.topic,event] as const).reverse()).values()].reverse();\n let visible = latest.slice(0,8);\n if (host.displayMode === \"advanced\") visible = events;\n const input = {topic,payload};\n const error = setup.error || stream.error || publish.error;\n return <Stack h=\"100%\" p=\"sm\" gap=\"xs\">\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge color={stream.connected ? \"green\" : \"gray\"}>{stream.connected ? \"Live\" : \"Disconnected\"}</Badge></Group>\n {error && <Alert color=\"red\">{error.message}<Button size=\"compact-xs\" variant=\"subtle\" onClick={() => stream.reconnect()}>Reconnect</Button></Alert>}\n <TextInput size=\"xs\" aria-label=\"Filter MQTT topics\" placeholder=\"Filter topics\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />\n <Stack gap=\"xs\" style={{flex:1,minHeight:0,overflow:\"auto\"}}>\n {!visible.length && <Text size=\"sm\" c=\"dimmed\">Waiting for messages on {setup.data?.topics.join(\", \") || \"configured topics\"}. Retained broker messages appear immediately.</Text>}\n {visible.map((event,index) => <Stack key={`${event.topic}:${event.receivedAt}:${index}`} gap={2} style={{borderBottom:\"1px solid var(--mantine-color-default-border)\",paddingBottom:6}}>\n <Group justify=\"space-between\"><Text size=\"xs\" fw={600}>{event.topic}</Text>{event.retained && <Badge size=\"xs\" variant=\"light\">Retained</Badge>}</Group>\n <Text component=\"pre\" size=\"xs\" style={{whiteSpace:\"pre-wrap\",margin:0}}>{typeof event.payload === \"string\" ? event.payload : JSON.stringify(event.payload,null,2)}</Text>\n <Text size=\"xs\" c=\"dimmed\">{new Date(event.receivedAt).toLocaleTimeString(host.locale)}{event.truncated ? \" \u00b7 payload truncated\" : \"\"}{event.dropped ? ` \u00b7 ${event.dropped} buffered messages dropped` : \"\"}</Text>\n </Stack>)}\n </Stack>\n {host.displayMode !== \"advanced\" && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.openAdvanced()}>Open message history</Button>}\n {host.displayMode === \"advanced\" && (setup.data?.publishTopics.length ?? 0) > 0 && <Stack gap=\"xs\">\n <Select label=\"Publish topic\" data={setup.data?.publishTopics ?? []} value={topic || null} onChange={(value) => setTopic(value || \"\")} />\n <Textarea label=\"Message payload\" value={payload} onChange={(event) => setPayload(event.currentTarget.value)} maxLength={4096} />\n <Button disabled={!topic || !capabilities.canRun(\"publish\",input)} loading={publish.isPending} onClick={async () => {if (await services.confirm({title:\"Publish MQTT message\",message:`Send this payload to ${topic}?`})) await publish.mutateAsync(input).catch(() => undefined);}}>Publish once (not retained)</Button>\n {publish.data?.published && <Text size=\"xs\" c=\"green\">Published to {publish.data.topic}</Text>}\n </Stack>}\n </Stack>;\n}\n"
|
|
},
|
|
"dependencies": {
|
|
"mqtt": "5.15.2",
|
|
"supports-color": "8.1.1"
|
|
},
|
|
"connections": {
|
|
"broker": {
|
|
"kind": "service",
|
|
"serviceType": "mqtt",
|
|
"label": "MQTT broker",
|
|
"description": "Settings: url, topics, optional publishTopics. Put username/password in encrypted credentials."
|
|
}
|
|
},
|
|
"options": {
|
|
"title": {
|
|
"control": "text",
|
|
"label": "Title",
|
|
"default": "MQTT topics"
|
|
}
|
|
}
|
|
}
|