Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 10 additions & 92 deletions src/lib/components/rule-actions/Relay-Actions.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
<script lang="ts">
import { CwDropdown, CwInput } from '@cropwatchdevelopment/cwui';
import { m } from '$lib/paraglide/messages.js';
import {
MAX_TIMED_RELAY_SECONDS,
buildRelayPayload,
hexToBase64,
type ActionValue
} from './relay-payload';

interface DeviceOption {
label: string;
value: string;
disabled?: boolean;
}

type ActionValue =
| 'ro1_on_timed'
| 'ro2_on_timed'
| 'both_on_timed'
| 'ro1_on_permanent'
| 'ro1_off_permanent'
| 'ro2_on_permanent'
| 'ro2_off_permanent';

interface ActionOption {
label: string;
value: ActionValue;
Expand Down Expand Up @@ -105,7 +102,9 @@
const isTimedAction = $derived(selectedAction.endsWith('_timed'));

const safeOnTimeSeconds = $derived(
Number.isFinite(onTimeSeconds) ? Math.max(1, Math.min(65, Math.trunc(onTimeSeconds))) : 5
Number.isFinite(onTimeSeconds)
? Math.max(1, Math.min(MAX_TIMED_RELAY_SECONDS, Math.trunc(onTimeSeconds)))
: 5
);

const payloadHex = $derived(buildRelayPayload(selectedAction, safeOnTimeSeconds));
Expand Down Expand Up @@ -138,87 +137,6 @@
}
});

function buildRelayPayload(action: ActionValue, seconds: number): string {
if (action.endsWith('_timed')) {
return buildTimedRelayPayload(action, seconds);
}
return buildPermanentRelayPayload(action);
}

function buildTimedRelayPayload(action: ActionValue, seconds: number): string {
const command = 0x05;

// Timeout behavior byte:
// 0x00 = after timeout, relay(s) switch to the inverted/opposite state
// 0x01 = after timeout, relay(s) return to their original pre-command state
// Use 0x00 so a timed "ON for N seconds" action always returns the relay to OFF,
// even if it was already ON when the rule fired.
const timeoutBehavior = 0x00;

// Dragino timed relay format uses one byte for RO1/RO2 state.
// 0b10 = Relay 1 ON/NC, Relay 2 OFF/NO
// 0b01 = Relay 1 OFF/NO, Relay 2 ON/NC
// 0b11 = Both ON/NC
const relayState = action === 'ro1_on_timed' ? 0b10 : action === 'ro2_on_timed' ? 0b01 : 0b11;

const milliseconds = seconds * 1000;

// Use 2-byte latch time for compatibility with older firmware.
// Max = 65535 ms, so this UI clamps to 65 seconds.
if (milliseconds > 0xffff) {
throw new Error('Timed relay payload exceeds 2-byte latch time limit.');
}

const timeHigh = (milliseconds >> 8) & 0xff;
const timeLow = milliseconds & 0xff;

return bytesToHex([command, timeoutBehavior, relayState, timeHigh, timeLow]);
}

function buildPermanentRelayPayload(action: ActionValue): string {
// Dragino fixed relay command: [0x03, relay1Byte, relay2Byte]
// 0x00 = OFF, 0x01 = ON, 0x11 = no action on that relay.
const command = 0x03;
const NO_ACTION = 0x11;

let relay1: number;
let relay2: number;
switch (action) {
case 'ro1_on_permanent':
relay1 = 0x01;
relay2 = NO_ACTION;
break;
case 'ro1_off_permanent':
relay1 = 0x00;
relay2 = NO_ACTION;
break;
case 'ro2_on_permanent':
relay1 = NO_ACTION;
relay2 = 0x01;
break;
case 'ro2_off_permanent':
relay1 = NO_ACTION;
relay2 = 0x00;
break;
default:
return '';
}

return bytesToHex([command, relay1, relay2]);
}

function bytesToHex(bytes: number[]): string {
return bytes
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}

function hexToBase64(hex: string): string {
const bytes = hex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? [];
return btoa(String.fromCharCode(...bytes));
}

function handleSecondsInput(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const value = Number(input.value);
Expand Down Expand Up @@ -248,7 +166,7 @@
placeholder="Enter time in seconds"
type="numeric"
min="1"
max="65"
max={String(MAX_TIMED_RELAY_SECONDS)}
value={String(onTimeSeconds)}
oninput={handleSecondsInput}
/>
Expand Down
67 changes: 67 additions & 0 deletions src/lib/components/rule-actions/relay-payload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
buildPermanentRelayPayload,
buildRelayPayload,
buildTimedRelayPayload,
hexToBase64
} from './relay-payload';

describe('buildTimedRelayPayload', () => {
it('builds the tested v1.7 payload for Relay 1 ON for 15 seconds', () => {
expect(buildTimedRelayPayload('ro1_on_timed', 15)).toBe('05011000003A98');
});

it('builds the payload for Relay 2 ON for 15 seconds', () => {
expect(buildTimedRelayPayload('ro2_on_timed', 15)).toBe('05010100003A98');
});

it('builds the payload for both relays ON for 15 seconds', () => {
expect(buildTimedRelayPayload('both_on_timed', 15)).toBe('05011100003A98');
});

it('uses timeoutBehavior 0x01 and never the old bad 0x00 payload', () => {
const payload = buildTimedRelayPayload('ro1_on_timed', 15);
expect(payload.slice(2, 4)).toBe('01');
expect(payload).not.toBe('0500023A98');
});

it('emits a 7-byte (4-byte latch time) payload', () => {
expect(buildTimedRelayPayload('ro1_on_timed', 15)).toHaveLength(14);
});

it('handles long latch times beyond the old 2-byte 65-second limit', () => {
// 24 hours = 86_400_000 ms = 0x05265C00
expect(buildTimedRelayPayload('ro1_on_timed', 86400)).toBe('05011005265C00');
});

it('throws on a non-positive latch time', () => {
expect(() => buildTimedRelayPayload('ro1_on_timed', 0)).toThrow();
});
});

describe('buildPermanentRelayPayload', () => {
it('builds permanent relay commands with the 0x03 command byte', () => {
expect(buildPermanentRelayPayload('ro1_on_permanent')).toBe('030111');
expect(buildPermanentRelayPayload('ro1_off_permanent')).toBe('030011');
expect(buildPermanentRelayPayload('ro2_on_permanent')).toBe('031101');
expect(buildPermanentRelayPayload('ro2_off_permanent')).toBe('031100');
});
});

describe('buildRelayPayload', () => {
it('routes timed actions to the timed builder', () => {
expect(buildRelayPayload('ro1_on_timed', 15)).toBe('05011000003A98');
});

it('routes permanent actions to the permanent builder', () => {
expect(buildRelayPayload('ro1_on_permanent', 15)).toBe('030111');
});
});

describe('hexToBase64', () => {
it('encodes the tested timed payloads to base64', () => {
expect(hexToBase64('05011000003A98')).toBe('BQEQAAA6mA==');
expect(hexToBase64('05010100003A98')).toBe('BQEBAAA6mA==');
expect(hexToBase64('05011100003A98')).toBe('BQERAAA6mA==');
});
});
107 changes: 107 additions & 0 deletions src/lib/components/rule-actions/relay-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Dragino LT-22222-L relay downlink payload builders.
//
// Reference: Dragino LT-22222-L LoRa I/O Controller User Manual (firmware v1.7).
// - 0x05 = timed relay control, 0x03 = permanent relay control.
// - Timed command layout: [0x05, timeoutBehavior, relayState, time1..time4].
// - timeoutBehavior 0x01 = return relays to original state after the latch time;
// 0x00 = invert relays after the latch time.
// - relayState is one hex digit per relay (1 = NC/closed/ON, 0 = NO/open/OFF).
// - v1.6+ firmware uses a 4-byte big-endian latch time in milliseconds.

export type ActionValue =
| 'ro1_on_timed'
| 'ro2_on_timed'
| 'both_on_timed'
| 'ro1_on_permanent'
| 'ro1_off_permanent'
| 'ro2_on_permanent'
| 'ro2_off_permanent';

// 4-byte latch time (v1.6+ firmware) supports far more than the old 2-byte limit;
// cap the UI at 24 hours rather than the previous 65-second clamp.
export const MAX_TIMED_RELAY_SECONDS = 86400;

export function bytesToHex(bytes: number[]): string {
return bytes
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}

export function hexToBase64(hex: string): string {
const bytes = hex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? [];
return btoa(String.fromCharCode(...bytes));
}

export function buildTimedRelayPayload(action: ActionValue, seconds: number): string {
const command = 0x05;

// Use 0x01 so the relay returns to its original pre-command state after the
// latch time. Do NOT use 0x00 here — 0x00 means invert/opposite after timeout.
const timeoutBehavior = 0x01;

// The relay-state byte is not binary bit flags. It is Dragino's relay-state
// command byte, one hex digit per relay (1 = NC/closed/ON, 0 = NO/open/OFF):
// 0x10 = RO1 ON, RO2 OFF
// 0x01 = RO1 OFF, RO2 ON
// 0x11 = RO1 ON, RO2 ON
const relayState =
action === 'ro1_on_timed' ? 0x10 : action === 'ro2_on_timed' ? 0x01 : 0x11;

const milliseconds = seconds * 1000;

if (!Number.isFinite(milliseconds) || milliseconds < 1) {
throw new Error('Timed relay payload requires a positive latch time.');
}

if (milliseconds > 0xffffffff) {
throw new Error('Timed relay payload exceeds 4-byte latch time limit.');
}

// v1.6+ firmware (LT-22222-L v1.7) uses a 4-byte big-endian latch time.
const time1 = (milliseconds >>> 24) & 0xff;
const time2 = (milliseconds >>> 16) & 0xff;
const time3 = (milliseconds >>> 8) & 0xff;
const time4 = milliseconds & 0xff;

return bytesToHex([command, timeoutBehavior, relayState, time1, time2, time3, time4]);
}

export function buildPermanentRelayPayload(action: ActionValue): string {
// Dragino fixed relay command: [0x03, relay1Byte, relay2Byte]
// 0x00 = OFF, 0x01 = ON, 0x11 = no action on that relay.
const command = 0x03;
const NO_ACTION = 0x11;

let relay1: number;
let relay2: number;
switch (action) {
case 'ro1_on_permanent':
relay1 = 0x01;
relay2 = NO_ACTION;
break;
case 'ro1_off_permanent':
relay1 = 0x00;
relay2 = NO_ACTION;
break;
case 'ro2_on_permanent':
relay1 = NO_ACTION;
relay2 = 0x01;
break;
case 'ro2_off_permanent':
relay1 = NO_ACTION;
relay2 = 0x00;
break;
default:
return '';
}

return bytesToHex([command, relay1, relay2]);
}

export function buildRelayPayload(action: ActionValue, seconds: number): string {
if (action.endsWith('_timed')) {
return buildTimedRelayPayload(action, seconds);
}
return buildPermanentRelayPayload(action);
}
59 changes: 53 additions & 6 deletions src/lib/utils/recaptcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const SCRIPT_ID = 'cw-recaptcha-enterprise';
const SCRIPT_SRC_MATCHER = 'script[src*="recaptcha/enterprise.js"]';
const READY_TIMEOUT_MS = 8_000;
const EXECUTE_TIMEOUT_MS = 12_000;
// A cold browser frequently fails the very first request to google.com with
// `ERR_NAME_NOT_RESOLVED` before DNS is warm. Retry the script load a few
// times with a short backoff so a transient failure doesn't hard-block login.
const SCRIPT_LOAD_ATTEMPTS = 3;
const SCRIPT_RETRY_BASE_DELAY_MS = 400;
// `window.grecaptcha.enterprise` can lag slightly behind the script's `onload`.
// Poll for it briefly rather than declaring failure on the first synchronous check.
const API_AVAILABILITY_TIMEOUT_MS = 4_000;
const API_POLL_INTERVAL_MS = 100;

let status: RecaptchaStatus = 'idle';
let inflightLoad: Promise<void> | null = null;
Expand Down Expand Up @@ -74,6 +83,12 @@ function removeRecaptchaArtifacts() {
}
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}

async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
Expand Down Expand Up @@ -153,6 +168,42 @@ async function loadRecaptchaScriptElement(siteKey: string): Promise<void> {
});
}

async function loadRecaptchaScriptWithRetry(siteKey: string): Promise<void> {
let lastError: Error | null = null;

for (let attempt = 1; attempt <= SCRIPT_LOAD_ATTEMPTS; attempt += 1) {
try {
await loadRecaptchaScriptElement(siteKey);
return;
} catch (error) {
lastError = toError(error, 'reCAPTCHA script failed to load');
// Drop the failed <script> so the next attempt starts from a clean slate.
removeRecaptchaArtifacts();
if (attempt < SCRIPT_LOAD_ATTEMPTS) {
await delay(SCRIPT_RETRY_BASE_DELAY_MS * attempt);
}
}
}

throw lastError ?? new Error('reCAPTCHA script failed to load');
}

async function waitForEnterpriseApi(): Promise<void> {
if (hasUsableEnterpriseApi()) {
return;
}

const deadline = Date.now() + API_AVAILABILITY_TIMEOUT_MS;
while (Date.now() < deadline) {
await delay(API_POLL_INTERVAL_MS);
if (hasUsableEnterpriseApi()) {
return;
}
}

throw new Error('reCAPTCHA script loaded without a usable Enterprise API');
}

async function ensureRecaptchaReady(): Promise<void> {
const siteKey = getPublicSiteKey();
if (!siteKey) {
Expand All @@ -170,12 +221,8 @@ async function ensureRecaptchaReady(): Promise<void> {

inflightLoad = (async () => {
try {
await loadRecaptchaScriptElement(siteKey);

if (!hasUsableEnterpriseApi()) {
throw new Error('reCAPTCHA script loaded without a usable Enterprise API');
}

await loadRecaptchaScriptWithRetry(siteKey);
await waitForEnterpriseApi();
await waitForEnterpriseReady();
setStatus('ready');
} catch (error) {
Expand Down
Loading