From 52fb1d644265f9893b290ce37b9e84e829cb5fd5 Mon Sep 17 00:00:00 2001 From: Markus Tacker Date: Fri, 31 May 2024 12:47:38 +0200 Subject: [PATCH] feat: add LED configuration to card --- src/components/DeviceHeader.tsx | 57 +++++---- src/context/Device.tsx | 124 +++++++++----------- src/model/PCA20065/Card.tsx | 154 +++++++++++++++++++++++++ src/model/PCA20065/Page.tsx | 4 +- src/model/PCA20065/Thingy91XVisual.tsx | 110 ++++++++++++++++++ src/proto/lwm2m.ts | 16 +++ 6 files changed, 363 insertions(+), 102 deletions(-) create mode 100644 src/model/PCA20065/Card.tsx create mode 100644 src/model/PCA20065/Thingy91XVisual.tsx diff --git a/src/components/DeviceHeader.tsx b/src/components/DeviceHeader.tsx index 6a4cba6a..c371c109 100644 --- a/src/components/DeviceHeader.tsx +++ b/src/components/DeviceHeader.tsx @@ -26,39 +26,34 @@ import { Slash, ThermometerIcon } from 'lucide-preact' import { CountryFlag } from './CountryFlag.js' import './DeviceHeader.css' -export const DeviceHeader = ({ device }: { device: Device }) => { - const type = device.model - - return ( -
-

- - Your model:{' '} - {type.name} - -

-
-
-
- -
-
- -
-
- -
-
- -
-
- -
+export const DeviceHeader = ({ device }: { device: Device }) => ( +
+

+ + Your device: {device.id} + +

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
-
- ) -} +
+
+) const SignalQualityInfo = () => { const { reported } = useDevice() diff --git a/src/context/Device.tsx b/src/context/Device.tsx index 2ae3e5db..1edce777 100644 --- a/src/context/Device.tsx +++ b/src/context/Device.tsx @@ -17,19 +17,17 @@ import { import { type Static } from '@sinclair/typebox' import { isObject } from 'lodash-es' import { createContext, type ComponentChildren } from 'preact' -import { - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'preact/hooks' +import { useContext, useEffect, useRef, useState } from 'preact/hooks' export type Device = { id: string model: Model } +type UpdateResult = Promise< + { success: true } | { problem: Static } +> + export const DeviceContext = createContext<{ device?: Device | undefined lastSeen?: Date @@ -44,14 +42,12 @@ export const DeviceContext = createContext<{ remove: () => void } desired: Record - send?: (message: LwM2MObjectInstance) => void + update: (instance: LwM2MObjectInstance) => UpdateResult configuration: Partial<{ desired: Configuration reported: Configuration }> - configure: ( - config: Configuration, - ) => Promise<{ success: true } | { problem: Static }> + configure: (config: Configuration) => UpdateResult debug: boolean setDebug: (debug: boolean) => void hasLiveData: boolean @@ -69,6 +65,7 @@ export const DeviceContext = createContext<{ connectionFailed: false, configuration: {}, configure: async () => Promise.reject(new Error('Not implemented')), + update: async () => Promise.reject(new Error('Not implemented')), debug: false, setDebug: () => undefined, hasLiveData: false, @@ -217,22 +214,6 @@ export const Provider = ({ children }: { children: ComponentChildren }) => { } }, [fingerprint]) - const send = - ws === undefined - ? undefined - : useCallback( - (message: LwM2MObjectInstance) => { - console.log(`[WS] >`, message) - ws.send( - JSON.stringify({ - message: 'message', - payload: message, - }), - ) - }, - [ws], - ) - let hasLiveData = lastSeen !== undefined if ( lastSeen !== undefined && @@ -243,6 +224,40 @@ export const Provider = ({ children }: { children: ComponentChildren }) => { reportedConfig?.updateIntervalSeconds * 1000 } + const update = async (instance: LwM2MObjectInstance): UpdateResult => + new Promise((resolve) => { + onParameters(async ({ helloApiURL }) => { + if (device === undefined) return + if (fingerprint === null) return + try { + await fetch( + new URL( + `./device/${device.id}/state?${new URLSearchParams({ fingerprint }).toString()}`, + helloApiURL, + ), + { + method: 'PATCH', + mode: 'cors', + body: JSON.stringify({ + '@context': Context.lwm2mObjectUpdate.toString(), + ...instance, + }), + }, + ) + resolve({ success: true }) + } catch (err) { + console.error('[DeviceContext]', 'Configuration update failed', err) + resolve({ + problem: { + '@context': Context.problemDetail.toString(), + title: 'Failed to update configuration!', + detail: (err as Error).message, + }, + }) + } + }) + }) + return ( { }, desired, connectionFailed, - send, disconnected, configuration: { reported: reportedConfig, desired: desiredConfig, }, configure: async (config) => - new Promise((resolve) => { - onParameters(async ({ helloApiURL }) => { - if (device === undefined) return - if (fingerprint === null) return - try { - await fetch( - new URL( - `./device/${device.id}/state?${new URLSearchParams({ fingerprint }).toString()}`, - helloApiURL, - ), - { - method: 'PATCH', - mode: 'cors', - body: JSON.stringify({ - '@context': Context.lwm2mObjectUpdate.toString(), - ObjectID: LwM2MObjectID.ApplicationConfiguration_14301, - Resources: { - '0': config.updateIntervalSeconds, - '1': config.gnssEnabled, - '99': Date.now(), - }, - }), - }, - ) - setDesiredConfig(config) - resolve({ success: true }) - } catch (err) { - console.error( - '[DeviceContext]', - 'Configuration update failed', - err, - ) - resolve({ - problem: { - '@context': Context.problemDetail.toString(), - title: 'Failed to update configuration!', - detail: (err as Error).message, - }, - }) - } - }) + update({ + ObjectID: LwM2MObjectID.ApplicationConfiguration_14301, + Resources: { + '0': config.updateIntervalSeconds, + '1': config.gnssEnabled, + '99': Date.now(), + }, + }).then((res) => { + if (!('problem' in res)) { + setDesiredConfig(config) + } + return res }), + update, debug, setDebug, hasLiveData, diff --git a/src/model/PCA20065/Card.tsx b/src/model/PCA20065/Card.tsx new file mode 100644 index 00000000..0b768a98 --- /dev/null +++ b/src/model/PCA20065/Card.tsx @@ -0,0 +1,154 @@ +import { Applied } from '#components/Applied.js' +import { Primary, Transparent } from '#components/Buttons.js' +import { useDevice } from '#context/Device.js' +import type { Model } from '#context/Models.js' +import { Thingy91XVisual } from '#model/PCA20065/Thingy91XVisual.js' +import { isLED, toLED } from '#proto/lwm2m.js' +import { + LwM2MObjectID, + type LwM2MObjectInstance, + type RGBLED_14240, +} from '@hello.nrfcloud.com/proto-map/lwm2m' +import { noop } from 'lodash-es' +import { CircleStop, Lightbulb, X } from 'lucide-preact' +import { useState } from 'preact/hooks' + +export const Card = ({ model }: { model: Model }) => { + const [ledColorPickerVisible, showLEDColorPicker] = useState(false) + const { reported, desired, update } = useDevice() + const reportedLEDColor = Object.values(reported).filter(isLED).map(toLED)[0] + const desiredLEDColor = Object.values(desired).filter(isLED).map(toLED)[0] + return ( +
+
+

{model.title}

+

{model.tagline}

+

+ + + {model.name} + + +

+
+ +
+ showLEDColorPicker(true)} + showLEDHint={!ledColorPickerVisible && desiredLEDColor === undefined} + /> +
+ +
+ {ledColorPickerVisible && ( + { + showLEDColorPicker(false) + const instance: LwM2MObjectInstance = { + ObjectID: LwM2MObjectID.RGBLED_14240, + ObjectInstanceID: 0, + ObjectVersion: '1.0', + Resources: { + 0: color.r, + 1: color.g, + 2: color.b, + 99: Date.now(), + }, + } + update(instance).catch(console.error) + }} + onClose={() => { + showLEDColorPicker(false) + }} + /> + )} + {!ledColorPickerVisible && ( + <> +

Interact with your device

+

+ + + Click the LED above to change the color on your device. + {desiredLEDColor !== undefined && ( + +
+ +
+ )} +
+

+ +

+ + Press the button on your device to receive them here. +

+ + )} +
+
+ ) +} + +export const ColorPicker = ({ + onColor, + onClose, +}: { + onColor: (color: { r: number; g: number; b: number }) => void + onClose: () => void +}) => { + const [selectedColor, setSelectedColor] = useState('') + + const handleColorChange = (event: Event) => { + const colorInput = event.target as HTMLInputElement + setSelectedColor(colorInput.value) + } + + return ( +
+
+

Set LED color

+ + + +
+ +
+ + + { + onColor(hexToRGB(selectedColor)) + }} + > + set + +
+
+ ) +} + +const hexToRGB = (hexColor: string): { r: number; g: number; b: number } => { + const hex = hexColor.replace('#', '') + const r = parseInt(hex.slice(0, 2), 16) + const g = parseInt(hex.slice(2, 4), 16) + const b = parseInt(hex.slice(4, 6), 16) + return { r, g, b } +} diff --git a/src/model/PCA20065/Page.tsx b/src/model/PCA20065/Page.tsx index 03aacc86..5dddc998 100644 --- a/src/model/PCA20065/Page.tsx +++ b/src/model/PCA20065/Page.tsx @@ -8,7 +8,7 @@ import { BatteryChart } from '#model/PCA20065/Chart.js' import { Provider } from '#model/PCA20065/HistoryContext.js' import { Configuration } from '#components/Configuration.js' import { ConnectDevice } from '#components/ConnectDevice.js' -import { ModelCard } from '#model/ModelCard.js' +import { Card } from '#model/PCA20065/Card.js' import { ConnectionSuccess } from './ConnectionSuccess.js' export const Page = ({ device }: { device: TDevice }) => { @@ -25,7 +25,7 @@ export const Page = ({ device }: { device: TDevice }) => { {hasLiveData && }
- +
diff --git a/src/model/PCA20065/Thingy91XVisual.tsx b/src/model/PCA20065/Thingy91XVisual.tsx new file mode 100644 index 00000000..e56762ea --- /dev/null +++ b/src/model/PCA20065/Thingy91XVisual.tsx @@ -0,0 +1,110 @@ +export const Thingy91XVisual = ({ + ledColor, + style, + class: className, + title, + onLEDClick, + showLEDHint, +}: { + ledColor?: { r: number; g: number; b: number } + style?: React.CSSProperties + class?: string + title: string + onLEDClick?: () => void + showLEDHint?: boolean +}) => { + const { r, g, b } = ledColor ?? { r: 0, g: 0, b: 0 } + return ( + + {title} + + + + + + + + + + + + + + + + + {showLEDHint === true && ( + + + + change me! + + + )} + onLEDClick?.()} + > + + + + ) +} + +const rgb = (r: number, g: number, b: number) => `rgb(${r},${g},${b})` diff --git a/src/proto/lwm2m.ts b/src/proto/lwm2m.ts index ce92453b..d3100f76 100644 --- a/src/proto/lwm2m.ts +++ b/src/proto/lwm2m.ts @@ -10,6 +10,7 @@ import { type ButtonPress_14220, type ApplicationConfiguration_14301, timestampResources, + type RGBLED_14240, } from '@hello.nrfcloud.com/proto-map/lwm2m' import { isObject } from 'lodash-es' @@ -41,6 +42,7 @@ export const isSolarCharge = isLwM2MObject( export const isButtonPress = isLwM2MObject( LwM2MObjectID.ButtonPress_14220, ) +export const isLED = isLwM2MObject(LwM2MObjectID.RGBLED_14240) export const isConfig = isLwM2MObject( LwM2MObjectID.ApplicationConfiguration_14301, ) @@ -123,6 +125,20 @@ export const toButtonPress = (message: ButtonPress_14220): ButtonPress => ({ ts: message['Resources'][99], }) +export type LED = WithTimestamp & { + id: number + r: number + g: number + b: number +} +export const toLED = (message: LwM2MObjectInstance): LED => ({ + r: message['Resources'][0], + g: message['Resources'][1], + b: message['Resources'][2], + id: message['ObjectInstanceID'] ?? 0, + ts: message['Resources'][99], +}) + export type DeviceInformation = WithTimestamp & { imei: string iccid?: string