From f9ad3c6ec9b310f00636cbbb6f437d0c43a8b3fc Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 13 Oct 2025 17:05:51 +0900 Subject: [PATCH 01/14] safety --- src/lib/components/RelayControl.svelte | 605 ++++++------------ .../devices/[devEui]/+page.svelte | 47 +- 2 files changed, 187 insertions(+), 465 deletions(-) diff --git a/src/lib/components/RelayControl.svelte b/src/lib/components/RelayControl.svelte index 9658a96a..91052bb0 100644 --- a/src/lib/components/RelayControl.svelte +++ b/src/lib/components/RelayControl.svelte @@ -2,18 +2,28 @@ import { DRAGINO_LT22222L_PAYLOADS } from '$lib/lorawan/dragino'; import { success, error as showError } from '$lib/stores/toast.svelte'; import Spinner from '$lib/components/Spinner.svelte'; - import MaterialIcon from '$lib/components/UI/icons/MaterialIcon.svelte'; import type { DeviceWithType } from '$lib/models/Device'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; - export let device: DeviceWithType; - const devEui = device.dev_eui; - // Track relay busy state per relay (not per payload) and optimistic on/off state + let { device, latestData } = $props(); + + const devEui = $derived(device.dev_eui); type RelayKey = 'relay1' | 'relay2'; - let busy: Record = { relay1: false, relay2: false }; - let relayState: Record = { relay1: false, relay2: false }; // false = OFF - let initialLoaded = false; - let loadingInitial = false; + const relays: Array<{ key: RelayKey; label: string }> = [ + { key: 'relay1', label: 'Relay 1' }, + { key: 'relay2', label: 'Relay 2' } + ]; + + const POLL_INTERVAL_MS = 10_000; + const COOLDOWN_SECONDS = 15; + + let busy: Record = $state({ relay1: false, relay2: false }); + let relayState: Record = $state({ relay1: false, relay2: false }); + let loadingInitial = $state(false); + let cooldownRemaining = $state(0); + + let statusInterval: ReturnType | undefined; + let cooldownTimer: ReturnType | undefined; function setBusy(relay: RelayKey, val: boolean) { busy = { ...busy, [relay]: val }; @@ -26,40 +36,89 @@ if (typeof v === 'string') { const s = v.toLowerCase(); if (['on', 'off'].includes(s)) return s === 'on'; + if (['true', 'false', 't', 'f', 'yes', 'no'].includes(s)) + return ['true', 't', 'yes'].includes(s); if (['1', '0'].includes(s)) return s === '1'; } return undefined; } - async function loadInitialState() { - loadingInitial = true; + function clearCooldownTimer() { + if (cooldownTimer) { + clearInterval(cooldownTimer); + cooldownTimer = undefined; + } + } + + function startCooldown() { + clearCooldownTimer(); + cooldownRemaining = COOLDOWN_SECONDS; + cooldownTimer = setInterval(() => { + cooldownRemaining = Math.max(0, cooldownRemaining - 1); + if (cooldownRemaining === 0) { + clearCooldownTimer(); + } + }, 1000); + } + + async function loadInitialState(showLoader = false) { + if (showLoader) { + loadingInitial = true; + } try { const res = await fetch(`/api/devices/${devEui}/status`); if (res.ok) { const latest = await res.json(); - // Try several possible field names const r1 = coerceBool(latest.relay_1 ?? latest.relay1 ?? latest.r1 ?? latest.relayOne); const r2 = coerceBool(latest.relay_2 ?? latest.relay2 ?? latest.r2 ?? latest.relayTwo); relayState = { relay1: r1 ?? relayState.relay1, relay2: r2 ?? relayState.relay2 }; - initialLoaded = true; - } else { - // Non-fatal if status not available - initialLoaded = true; } } catch (e) { - initialLoaded = true; // proceed with defaults + // ignore and keep defaults } finally { - loadingInitial = false; + if (showLoader) { + loadingInitial = false; + } } } onMount(() => { - void loadInitialState(); + void loadInitialState(true); + statusInterval = setInterval(() => { + void loadInitialState(); + }, POLL_INTERVAL_MS); + + return () => { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = undefined; + } + clearCooldownTimer(); + }; }); + onDestroy(() => { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = undefined; + } + clearCooldownTimer(); + }); + + function buttonDisabled(relay: RelayKey, turnOn: boolean) { + return loadingInitial || busy[relay] || cooldownRemaining > 0 || relayState[relay] === turnOn; + } + + function relayStatusText(relay: RelayKey) { + if (loadingInitial) { + return 'Checking status…'; + } + return relayState[relay] ? 'Currently ON' : 'Currently OFF'; + } + async function sendCommand(relay: RelayKey, turnOn: boolean) { if (busy[relay]) return; setBusy(relay, true); @@ -84,466 +143,164 @@ } } - function toggleRelay(relay: RelayKey) { - sendCommand(relay, !relayState[relay]); - } - - function bothBusy() { - return busy.relay1 || busy.relay2 || loadingInitial; - } - - async function setBoth(turnOn: boolean) { - if (bothBusy()) return; - // Run in parallel - await Promise.all([sendCommand('relay1', turnOn), sendCommand('relay2', turnOn)]); - // Success toasts handled individually; optionally consolidate here. + async function handleRelayPress(relay: RelayKey, turnOn: boolean) { + if (buttonDisabled(relay, turnOn)) return; + startCooldown(); + await sendCommand(relay, turnOn); } -
- -
-
- -
-
-

RELAY CONTROL SYSTEM

-
-
- - {bothBusy() ? 'PROCESSING' : 'READY'} - -
-
-
+
+

Relay control

+

+ Two big buttons for each relay. Tap once and wait for the light to change. +

- -
-
-
SYSTEM OVERRIDE
-
- - -
+ {#if cooldownRemaining > 0} +
+ Next action available in {cooldownRemaining}s
-
- - -
- {#each [{ key: 'relay1', label: 'RELAY 01', channel: 'A' }, { key: 'relay2', label: 'RELAY 02', channel: 'B' }] as relay} -
-
-
CH.{relay.channel}
-
-
- {#if loadingInitial} -
- {:else} -
- {/if} -
- - {relayState[relay.key] ? 'ACTIVE' : 'STANDBY'} - -
-
+ {/if} -
{relay.label}
- - - -
-
- LOAD - {relayState[relay.key] ? '100%' : '0%'} -
-
- V - {relayState[relay.key] ? '24.0' : '0.0'} -
+ {#if loadingInitial} +
+ + Checking relay status… +
+ {/if} + +
+ {#each relays as relay} +
+
+

{relay.label}

+ {relayStatusText(relay.key)} +
+
+ +
-
+

+ {#if busy[relay.key]} + Sending command… + {:else} + {relayStatusText(relay.key)} + {/if} +

+ {/each}
- - -
+
{JSON.stringify(latestData, null, 2)}
+ diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte index 3e3aa5cf..3baa0b32 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte @@ -2,7 +2,6 @@ import { page } from '$app/state'; import CameraStream from '$lib/components/dashboard/CameraStream.svelte'; import DateRangeSelector from '$lib/components/dashboard/DateRangeSelector.svelte'; - import DeviceMap from '$lib/components/dashboard/DeviceMap.svelte'; import DataCard from '$lib/components/DataCard/DataCard.svelte'; import ExportButton from '$lib/components/devices/ExportButton.svelte'; import Spinner from '$lib/components/Spinner.svelte'; @@ -18,16 +17,14 @@ } from '$lib/utilities/helpers'; import { formatNumber, getNumericKeys } from '$lib/utilities/stats'; import type { RealtimeChannel } from '@supabase/supabase-js'; - import { DateTime } from 'luxon'; import { onMount, untrack } from 'svelte'; import { _, locale } from 'svelte-i18n'; import type { PageProps } from './$types'; - import { getDeviceDetailDerived, setupDeviceDetail } from './device-detail.svelte'; + import { setupDeviceDetail } from './device-detail.svelte'; import Header from './Header.svelte'; import { setupRealtimeSubscription } from './realtime.svelte'; import RelayControl from '$lib/components/RelayControl.svelte'; import { browser } from '$app/environment'; - import { afterNavigate } from '$app/navigation'; import { createActiveTimer } from '$lib/utilities/ActiveTimer'; // Get device data from server load function @@ -36,7 +33,6 @@ let { location_id, devEui } = page.params; let basePath = `/app/dashboard/location/${location_id}/devices/${devEui}`; let device = $state(data.device as DeviceWithType); - let dataType = $state(data.dataType); let latestData: DeviceDataRecord | null = $state(null); let historicalData: DeviceDataRecord[] = $state([]); let userId = $state(data.user.id); // User ID for permissions @@ -530,29 +526,14 @@ {$_('No historical data available for the selected date range.')}
{:else if device.cw_device_type?.data_table_v2 === 'cw_relay_data'} - + {:else} - -

{$_('Stats Summary')}

@@ -602,9 +583,11 @@
{#if device.cw_device_type?.data_table_v2 === 'cw_air_data'} - {:else} + {:else if device.cw_device_type?.data_table_v2 === 'traffic_v2'}

{$_('Weather & Data')}

+ {:else} + {/if}
@@ -658,24 +641,6 @@ } } } - - /* ApexCharts style overrides */ - /* .apexcharts-canvas { - background-color: transparent !important; - width: 100% !important; - max-width: 100% !important; - } - - .apexcharts-tooltip { - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important; - border: none !important; - } - - .apexcharts-yaxis-label, - .apexcharts-xaxis-label { - font-size: 12px !important; - } */ - .wrapper { :global { h2 { From c881e585d4af5033bf6f2a68027cdcbf8ccdea07 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 13 Oct 2025 18:09:30 +0900 Subject: [PATCH 02/14] safety --- .../braudcasts/broadcastsetup.sql | 59 +++++++++++++++ src/lib/components/RelayControl.svelte | 45 ++++++++++- .../devices/[devEui]/+page.svelte | 74 ++++++++++--------- 3 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql diff --git a/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql b/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql new file mode 100644 index 00000000..ae436bac --- /dev/null +++ b/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql @@ -0,0 +1,59 @@ +create or replace function public.cw_air_data_changes() +returns trigger +security definer +language plpgsql +set search_path = '' +as $$ +begin + -- Broadcast the change event to the “cw_air_data” topic. + -- TG_OP is the operation (INSERT/UPDATE/DELETE), TG_TABLE_NAME is the table, + -- TG_TABLE_SCHEMA is the schema, NEW is the new row, OLD is the old row. + perform realtime.broadcast_changes( + 'cw_air_data', -- topic name + TG_OP, -- event name (INSERT/UPDATE/DELETE) + TG_OP, -- operation type + TG_TABLE_NAME, -- table name + TG_TABLE_SCHEMA, -- schema name + NEW, -- new record + OLD -- old record + ); + return null; +end; +$$; + +create trigger cw_air_data_broadcast_trigger +after insert or update or delete on public.cw_air_data +for each row +execute function cw_air_data_changes(); + + + + +--------------------- CW_RELAY_DATA TABLE --------------------- +-- Create or replace the function for broadcasting relay data changes +create or replace function public.cw_relay_data_changes() +returns trigger +security definer +language plpgsql +set search_path = '' +as $$ +begin + -- Broadcast the change event to the “cw_relay_data” topic. + perform realtime.broadcast_changes( + 'cw_relay_data', -- topic name + TG_OP, -- event name (INSERT/UPDATE/DELETE) + TG_OP, -- operation type + TG_TABLE_NAME, -- table name + TG_TABLE_SCHEMA, -- schema name + NEW, -- new record + OLD -- old record + ); + return null; +end; +$$; + +-- Create the trigger that calls the function +create trigger cw_relay_data_broadcast_trigger +after insert or update or delete on public.cw_relay_data +for each row +execute function public.cw_relay_data_changes(); diff --git a/src/lib/components/RelayControl.svelte b/src/lib/components/RelayControl.svelte index 91052bb0..8138ccd0 100644 --- a/src/lib/components/RelayControl.svelte +++ b/src/lib/components/RelayControl.svelte @@ -2,10 +2,16 @@ import { DRAGINO_LT22222L_PAYLOADS } from '$lib/lorawan/dragino'; import { success, error as showError } from '$lib/stores/toast.svelte'; import Spinner from '$lib/components/Spinner.svelte'; - import type { DeviceWithType } from '$lib/models/Device'; import { onMount, onDestroy } from 'svelte'; + import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'; + import type { Device } from '$lib/models/Device'; - let { device, latestData } = $props(); + let { + supabase, + device, + latestData + }: { supabase: SupabaseClient | undefined; device: Device | undefined; latestData: any } = + $props(); const devEui = $derived(device.dev_eui); type RelayKey = 'relay1' | 'relay2'; @@ -24,6 +30,39 @@ let statusInterval: ReturnType | undefined; let cooldownTimer: ReturnType | undefined; + let broadcastChannel: RealtimeChannel | undefined = $state(); + + broadcastChannel = supabase.channel('cw_relay_data', { + config: { private: true } + }); + broadcastChannel.on('broadcast', { event: '*' }, (payload) => { + console.log(payload); + debugger; + startCooldown(); + payload.payload.record.relay_1; + payload.payload.record.relay_2; + if (payload.payload.record.dev_eui !== devEui) return; + const r1 = coerceBool(payload.payload.record.relay_1 ?? payload.payload.record.relay1); + const r2 = coerceBool(payload.payload.record.relay_2 ?? payload.payload.record.relay2); + relayState = { + relay1: r1 ?? relayState.relay1, + relay2: r2 ?? relayState.relay2 + }; + latestData = payload.payload.record; + }); + broadcastChannel.subscribe((status, err) => { + console.debug('[Dashboard] Broadcast channel status', { status, err }); + if (status === 'CHANNEL_ERROR') { + console.error('Broadcast channel error', err); + } + if (status === 'TIMED_OUT') { + console.warn('Broadcast channel timed out'); + } + if (status === 'CLOSED') { + console.warn('Broadcast channel closed'); + broadcastChannel = undefined; + } + }); function setBusy(relay: RelayKey, val: boolean) { busy = { ...busy, [relay]: val }; @@ -208,8 +247,6 @@
-
{JSON.stringify(latestData, null, 2)}
- diff --git a/static/build-info.json b/static/build-info.json index 29997e72..8f7c4505 100644 --- a/static/build-info.json +++ b/static/build-info.json @@ -1,9 +1,9 @@ { - "commit": "31359df", - "branch": "325-report-setting-page", + "commit": "efbc4d6", + "branch": "develop", "author": "Kevin Cantrell", - "date": "2025-10-13T13:27:23.328Z", + "date": "2025-10-24T04:18:03.692Z", "builder": "kevin@kevin-desktop", "ipAddress": "192.168.1.100", - "timestamp": 1760362043328 + "timestamp": 1761279483692 } From f0073ebd9f19803564ca0cdb60462bce90093368 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sat, 25 Oct 2025 17:06:05 +0900 Subject: [PATCH 14/14] sorting should now work --- .../components/UI/dashboard/AllDevices.svelte | 27 ++++++++++++++++++- .../UI/dashboard/DashboardCard.svelte | 15 ----------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/lib/components/UI/dashboard/AllDevices.svelte b/src/lib/components/UI/dashboard/AllDevices.svelte index 521c28bc..07c3fe26 100644 --- a/src/lib/components/UI/dashboard/AllDevices.svelte +++ b/src/lib/components/UI/dashboard/AllDevices.svelte @@ -109,6 +109,31 @@ return 'w-full grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'; } } + + function sortDevicesByName(devices: DeviceWithSensorData[] = []): DeviceWithSensorData[] { + const numericPattern = /^[0-9]+$/; + + return [...devices].sort((a, b) => { + const nameA = (a.name ?? '').trim().toLowerCase(); + const nameB = (b.name ?? '').trim().toLowerCase(); + + const isNumericA = numericPattern.test(nameA); + const isNumericB = numericPattern.test(nameB); + + if (isNumericA && isNumericB) { + return Number(nameA) - Number(nameB); + } + + if (isNumericA) return -1; + if (isNumericB) return 1; + + if (nameA === nameB) { + return (a.dev_eui ?? '').localeCompare(b.dev_eui ?? ''); + } + + return nameA.localeCompare(nameB); + }); + }
@@ -137,7 +162,7 @@ loading={hasNullStatus} > {#snippet content()} - {@const locationDevices = location.cw_devices ?? []} + {@const locationDevices = sortDevicesByName(location.cw_devices ?? [])} {@const dragHandlers = createDragHandlers( locationDevices, (newDevices) => { diff --git a/src/lib/components/UI/dashboard/DashboardCard.svelte b/src/lib/components/UI/dashboard/DashboardCard.svelte index 10f228e2..28b91033 100644 --- a/src/lib/components/UI/dashboard/DashboardCard.svelte +++ b/src/lib/components/UI/dashboard/DashboardCard.svelte @@ -26,21 +26,6 @@
- - - - -