Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/SantaClaas/embedded-fan-control.
Something went wrong. Try again.
13 kB · 341 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342import { For, Show, createMemo, createSignal } from "solid-js";import { devices, noContext, referencesFor, registersIn, type Context, type Device, type Register,} from "../devices";import { BAUD_RATE_WRITE, BAUD_RATE_READ, relay } from "../devices/relay";import { readAll, write } from "../serial/client";import type { Connection } from "../serial/connection";import { registerAddress, toHex } from "./format";
type State = { values: Map<number, number>; refused: { start: number; quantity: number; message: string }[]; at?: Date;};
export default function Devices(props: { connection: Connection }) { const [device, setDevice] = createSignal<Device>(devices[0]!); const [address, setAddress] = createSignal(devices[0]!.defaults.address); const [state, setState] = createSignal<State>({ values: new Map(), refused: [] }); const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal<string | undefined>();
const context = createMemo<Context>(() => ({ holding: state().values }));
function chooseDevice(id: string) { const chosen = devices.find((entry) => entry.id === id); if (!chosen) return;
setDevice(chosen); setAddress(chosen.defaults.address); setState({ values: new Map(), refused: [] }); }
async function readEverything() { setBusy(true); setError(undefined);
try { const values = new Map<number, number>(); const refused: State["refused"] = [];
// The references first, and on their own: almost every RadiCal input register is a fraction // of one of them, so reading them last would mean a first pass that can state nothing const references = referencesFor(device().registers); if (references.length > 0) { const result = await readAll( props.connection, address(), "holding", references.map((reference) => ({ address: reference }) as Register), ); for (const [key, value] of result.values) values.set(key, value); }
for (const space of ["holding", "input", "coil", "discreteInput"] as const) { const registers = registersIn(device(), space); if (registers.length === 0) continue;
const result = await readAll(props.connection, address(), space, registers); for (const [key, value] of result.values) values.set(key, value); refused.push(...result.refused); }
setState({ values, refused, at: new Date() }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { setBusy(false); } }
return ( <section> <header class="panel-header"> <div> <h3>Device</h3> <p class="hint"> Active. This sends requests, so do not use it while the fan controller is driving the same bus — two masters on one RS-485 line collide. </p> </div> </header>
<div class="controls"> <div class="control"> <label for="device-type">Device</label> <select id="device-type" onChange={(event) => chooseDevice(event.currentTarget.value)}> <For each={devices}>{(entry) => <option value={entry.id}>{entry.name}</option>}</For> </select> </div>
<div class="control"> <label for="device-address">Modbus address</label> {/* The hint sits above the field, so an autocomplete popover cannot cover it */} <span id="device-address-hint" class="hint"> {device().defaults.addressRange[0]}–{device().defaults.addressRange[1]}, default{" "} {device().defaults.address} </span> <input id="device-address" type="number" min={device().defaults.addressRange[0]} max={device().defaults.addressRange[1]} step={1} required value={address()} aria-describedby="device-address-hint" onInput={(event) => setAddress(event.currentTarget.valueAsNumber)} /> </div>
<button type="button" onClick={() => void readEverything()} disabled={busy()}> {busy() ? "Reading…" : "Read all registers"} </button> </div>
<p class="hint"> Documented in <code>{device().documentation}</code>. Expects {device().defaults.baudRate} baud,{" "} {device().defaults.parity} parity — set the port to match before reading. </p>
<Show when={error()}>{(message) => <p class="error-msg shown">{message()}</p>}</Show>
<Show when={state().at}> {(at) => <p class="hint">Last read {at().toLocaleTimeString()}</p>} </Show>
<For each={state().refused}> {(refusal) => ( <p class="error-msg shown"> {registerAddress(refusal.start)} +{refusal.quantity}: {refusal.message} </p> )} </For>
<For each={["input", "holding", "coil", "discreteInput"] as const}> {(space) => ( <Show when={registersIn(device(), space).length > 0}> <h4>{spaceHeading[space]}</h4> <div class="table-scroll"> <table class="registers"> <thead> <tr> <th>Address</th> <th>Name</th> <th>Value</th> <th>Write</th> </tr> </thead> <tbody> <For each={registersIn(device(), space)}> {(register) => ( <RegisterRow connection={props.connection} deviceAddress={address()} register={register} raw={state().values.get(register.address)} context={context()} onWritten={() => void readEverything()} /> )} </For> </tbody> </table> </div> </Show> )} </For> </section> );}
const spaceHeading = { input: "Input registers — read only", holding: "Holding registers — read and write", coil: "Coils — read and write", discreteInput: "Discrete inputs — read only",} as const;
function RegisterRow(props: { connection: Connection; deviceAddress: number; register: Register; raw: number | undefined; context: Context; onWritten: () => void;}) { const [pending, setPending] = createSignal<number | undefined>(); const [failure, setFailure] = createSignal<string | undefined>(); const [writing, setWriting] = createSignal(false); // The browser already knows whether the value is within the min/max/step the register declares, // so its judgement is the one used rather than a second copy of the bounds check here const [valid, setValid] = createSignal(false); let field: HTMLInputElement | undefined;
const decoded = createMemo(() => { const raw = props.raw; return raw === undefined ? undefined : props.register.decode(raw, props.context ?? noContext); });
async function send() { const value = pending(); const writable = props.register.write; if (value === undefined || !writable) return;
// Refuse to put a value on the bus that the device is bound to reject anyway if (field && !field.validity.valid) { field.reportValidity(); return; }
setWriting(true); setFailure(undefined);
try { await write(props.connection, props.deviceAddress, props.register, writable.encode(value), { // The relay's manual only ever writes its holding registers with 0x10, and reads its baud // rate from a different address than it writes it to useMultiple: props.register.space === "holding" && isRelayRegister(props.register), writeAddress: props.register.address === BAUD_RATE_READ && isRelayRegister(props.register) ? BAUD_RATE_WRITE : undefined, });
props.onWritten(); } catch (cause) { setFailure(cause instanceof Error ? cause.message : String(cause)); } finally { setWriting(false); } }
const inputId = () => `write-${props.register.space}-${props.register.address}`;
return ( <tr> <td class="address"> {registerAddress(props.register.address)} <span class="hint"> §{props.register.reference.replace(/^docs\/.*, /, "")}</span> </td> <td> <span class="name">{props.register.name}</span> <Show when={props.register.gloss}>{(gloss) => <span class="hint"> — {gloss()}</span>}</Show> </td> <td> <Show when={decoded()} fallback={<span class="hint">not read</span>}> {(value) => ( <> <span class={value().kind === "unavailable" ? "hint" : "value"}>{value().text}</span> <Show when={value().kind === "unavailable" ? value() : undefined}> {(reason) => <span class="hint"> — {(reason() as { because: string }).because}</span>} </Show> <Show when={props.raw !== undefined}> <span class="hint"> raw 0x{toHex(props.raw!, 4)}</span> </Show> </> )} </Show> </td> <td> <Show when={props.register.write} fallback={<span class="hint">—</span>}> {(writable) => ( <div class="field"> <Show when={writable().input.control === "choice" ? writable().input : undefined} fallback={ <Show when={writable().input.control === "number" ? writable().input : undefined} fallback={ <button type="button" disabled={writing()} onClick={() => { setPending(props.raw ? 0 : 1); void send(); }}> {props.raw ? "Open" : "Close"} </button> } > {(numeric) => ( <> <input ref={field} id={inputId()} type="number" required min={(numeric() as { min: number }).min} max={(numeric() as { max: number }).max} step={(numeric() as { step?: number }).step ?? 1} aria-errormessage={`${inputId()}-error`} aria-invalid={pending() !== undefined && !valid() ? "true" : undefined} onInput={(event) => { setPending(event.currentTarget.valueAsNumber); setValid(event.currentTarget.validity.valid); }} /> {/* Revealed by :user-invalid, so nothing is flagged until the field is left */} <span id={`${inputId()}-error`} class="error-msg"> {(numeric() as { min: number }).min} to {(numeric() as { max: number }).max} </span> </> )} </Show> } > {(choice) => ( <select onChange={(event) => { setPending(Number(event.currentTarget.value)); setValid(event.currentTarget.value !== ""); }} > <option value="">Choose…</option> <For each={[...(choice() as { options: ReadonlyMap<number, string> }).options]}> {([value, label]) => <option value={value}>{label}</option>} </For> </select> )} </Show>
<Show when={writable().input.control !== "toggle"}> <button type="button" disabled={writing() || pending() === undefined || (writable().input.control === "number" && !valid())} onClick={() => void send()} > {writing() ? "Writing…" : "Write"} </button> </Show>
<Show when={failure()}>{(message) => <span class="error-msg shown">{message()}</span>}</Show> </div> )} </Show> </td> </tr> );}
function isRelayRegister(register: Register): boolean { return relay.registers.includes(register);}