From 5a7819933b5f2790cc6db6234454bc4c9f2f80d1 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 16:28:00 +0200 Subject: [PATCH 01/72] fix: a malformed request no longer reaches the socket Three write channels validate their payload before the handler runs: write, add_replace_server_register and set_bool. A payload that fails comes back as a message and a console entry, and the handler is never called. The schemas were missing rather than unwired. Twelve of the thirteen IPC argument types were hand-written interfaces, so WriteParameters, AddRegisterParams and SetBooleanParameters are now inferred from Zod like everything else that crosses a process boundary. A guarded handler is given the parsed payload, so unknown keys are stripped before anything acts on them. Nothing throws across the boundary, because an error there surfaces in the renderer as an unhandled rejection carrying the channel name and nothing else. A schema is only accepted on a channel that returns void. create_server answers with the port it actually bound and the renderer writes that into the port field, so those validate in their own handler instead. A write of an unparseable value used to send NaN to modbus-serial and now stops with a message. --- .gitignore | 4 +- src/main/__tests__/ipc.test.ts | 187 +++++++++++++++++++++++++++++++++ src/main/index.ts | 2 +- src/main/ipc.ts | 90 +++++++++++++--- src/shared/migrations/index.ts | 1 + src/shared/types/client.ts | 34 +++--- src/shared/types/server.ts | 28 ++--- 7 files changed, 303 insertions(+), 43 deletions(-) create mode 100644 src/main/__tests__/ipc.test.ts diff --git a/.gitignore b/.gitignore index 7f0c115..3ba7e73 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ test-results e2e/presentation-output # Claude Code -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +# TypeScript incremental build info +*.tsbuildinfo diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts new file mode 100644 index 0000000..da92e76 --- /dev/null +++ b/src/main/__tests__/ipc.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const handle = vi.fn() +vi.mock('electron', () => ({ + ipcMain: { + handle: (...args: unknown[]): unknown => handle(...args), + on: vi.fn(), + removeAllListeners: vi.fn() + } +})) + +import { + AddRegisterParamsSchema, + SetBooleanParametersSchema, + WriteParametersSchema, + type BackendMessage, + type Windows +} from '@shared' +import { createIpcHandle } from '../ipc' + +const createWindows = (): { windows: Windows; sent: BackendMessage[] } => { + const sent: BackendMessage[] = [] + const windows = { + send: (_event: string, payload: BackendMessage) => sent.push(payload) + } as unknown as Windows + return { windows, sent } +} + +/** Invokes the listener that was registered for `channel`. */ +const invoke = async (channel: string, payload?: unknown): Promise => { + const call = handle.mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`nothing registered for ${channel}`) + return (call[1] as (e: unknown, p?: unknown) => unknown)({}, payload) +} + +beforeEach(() => handle.mockClear()) + +describe('createIpcHandle', () => { + it('registers an unguarded channel and passes the payload straight through', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('update_connection_config', listener) + await invoke('update_connection_config', { unitId: 3 }) + + expect(listener).toHaveBeenCalledWith({}, { unitId: 3 }) + expect(sent).toEqual([]) + }) + + it('calls the listener when a guarded payload parses', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + await invoke('set_bool', { + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true + }) + + expect(listener).toHaveBeenCalledOnce() + expect(sent).toEqual([]) + }) + + it('never calls the listener when the payload is rejected', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + // unitId 300 is not a Modbus unit id + const returned = await invoke('set_bool', { + uuid: 'server-1', + unitId: '300', + registerType: 'coils', + address: 12, + state: true + }) + + expect(listener).not.toHaveBeenCalled() + expect(returned).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0].variant).toBe('error') + expect(String(sent[0].error)).toContain('set_bool') + expect(String(sent[0].error)).toContain('unitId') + }) + + it('reports rather than throws, so the renderer never sees a rejected invoke', async () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + ipcHandle('set_bool', vi.fn(), SetBooleanParametersSchema) + await expect(invoke('set_bool', undefined)).resolves.toBeUndefined() + }) + + it('hands the listener the parsed payload, so unknown keys never reach the socket', async () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + await invoke('set_bool', { + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true, + __proto__polluted: 'nope', + extra: 'stripped' + }) + + expect(listener.mock.calls[0][1]).toEqual({ + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true + }) + }) + + it('refuses a schema on a channel that has to return a value', () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + // create_server answers with the port it actually bound, so there is no + // honest value to return when the payload is rejected. + // @ts-expect-error a schema is only accepted on a channel returning void + ipcHandle('create_server', vi.fn(), SetBooleanParametersSchema) + }) +}) + +describe('write-path schemas', () => { + it('accepts a coil write and a register write', () => { + expect( + WriteParametersSchema.safeParse({ + address: 4, + single: true, + type: 'coils', + value: [true, false] + }).success + ).toBe(true) + + expect( + WriteParametersSchema.safeParse({ + address: 4, + single: false, + type: 'holding_registers', + value: 1234, + dataType: 'uint16' + }).success + ).toBe(true) + }) + + it('rejects an address outside the Modbus range', () => { + const result = WriteParametersSchema.safeParse({ + address: 70000, + single: true, + type: 'coils', + value: [true] + }) + expect(result.success).toBe(false) + }) + + it('rejects a register write with no data type', () => { + const result = WriteParametersSchema.safeParse({ + address: 4, + single: true, + type: 'holding_registers', + value: 1234 + }) + expect(result.success).toBe(false) + }) + + it('rejects an add-register payload whose params are incomplete', () => { + const result = AddRegisterParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + littleEndian: false, + params: { address: 0, registerType: 'holding_registers' } + }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index b5ed96a..1c0a6ac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,7 +26,7 @@ const client = new ModbusClient({ appState, windows }) const server = new ModbusServer({ windows }) // IPC -initIpc(app, appState, client, server) +initIpc(app, appState, client, server, windows) /** * Say which path took the app down. diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9491f45..bb26c57 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,32 +7,88 @@ import { defaultClientState, IpcHandlerMap, IpcEvent, - IpcEventPayloadMap + IpcEventPayloadMap, + Windows, + formatZodError, + WriteParametersSchema, + AddRegisterParamsSchema, + SetBooleanParametersSchema } from '@shared' import { ModbusClient } from './modules/modbusClient' import { ModbusServer } from './modules/mobusServer' import { applyPrivilegedPortFix, getPrivilegedPortStatus } from './modules/privilegedPort' import { applySerialGroupFix, getSerialGroupStatus, requestLogout } from './modules/serialGroup' import { IpcMainEvent, IpcMainInvokeEvent, ipcMain } from 'electron' +import type { ZodType } from 'zod' -export const ipcHandle = ( - channel: C, - listener: ( - event: IpcMainInvokeEvent, - ...args: IpcHandlerMap[C]['args'] - ) => Promise | IpcHandlerMap[C]['return'] -): void => { - ipcMain.handle(channel, listener) -} +type IpcListener = ( + event: IpcMainInvokeEvent, + ...args: IpcHandlerMap[C]['args'] +) => Promise | IpcHandlerMap[C]['return'] + +/** + * A schema may only guard a channel that returns nothing. + * + * A channel that returns a value has no honest answer to give when the payload + * is rejected. `create_server` returns the port it actually bound and the + * renderer writes that straight into the port field, so a stand-in number would + * appear in the UI as a real one. Those validate inside their own handler. + */ +type PayloadSchema = IpcHandlerMap[C]['return'] extends void + ? ZodType + : never + +/** + * Builds the `ipcHandle` used below, bound to the windows it reports through. + * + * A guarded channel hands the handler the *parsed* payload, not the one that + * arrived, so anything the schema does not describe is stripped before it can + * reach a Modbus socket. + * + * A rejected payload comes back as a `backend_message`, never as a throw. An + * error crossing the IPC boundary surfaces in the renderer as an unhandled + * rejection carrying the channel name and nothing else, which is exactly the + * failure the Linux helpers avoid by returning results instead of throwing. + */ +export const createIpcHandle = + (windows: Windows) => + ( + channel: C, + listener: IpcListener, + schema?: PayloadSchema + ): void => { + if (!schema) { + ipcMain.handle(channel, listener) + return + } + + ipcMain.handle(channel, (event: IpcMainInvokeEvent, ...args: unknown[]) => { + const result = schema.safeParse(args[0]) + + if (!result.success) { + windows.send('backend_message', { + message: 'Invalid request, nothing was changed', + variant: 'error', + error: `${channel}: ${formatZodError(result.error)}` + }) + return undefined + } + + return listener(event, ...([result.data] as IpcHandlerMap[C]['args'])) + }) + } type InitIpcFn = ( app: Electron.App, state: AppState, client: ModbusClient, - server: ModbusServer + server: ModbusServer, + windows: Windows ) => void -export const initIpc: InitIpcFn = (app, state, client, server) => { +export const initIpc: InitIpcFn = (app, state, client, server, windows) => { + const ipcHandle = createIpcHandle(windows) + // Connnection config ipcHandle('get_connection_config', () => { // Validate and return the current connection config, or default if invalid @@ -65,7 +121,7 @@ export const initIpc: InitIpcFn = (app, state, client, server) => { ipcHandle('stop_polling', () => client.stopPolling()) // Write Actions - ipcHandle('write', (_, writeParameters) => client.write(writeParameters)) + ipcHandle('write', (_, writeParameters) => client.write(writeParameters), WriteParametersSchema) // Scan Unit ID Actions ipcHandle('scan_unit_ids', (_, scanUnitIdParameters) => client.scanUnitIds(scanUnitIdParameters)) @@ -78,11 +134,15 @@ export const initIpc: InitIpcFn = (app, state, client, server) => { ipcHandle('stop_scanning_registers', () => client.stopScanningRegisters()) // Server - ipcHandle('add_replace_server_register', (_, params) => server.addRegister(params)) + ipcHandle( + 'add_replace_server_register', + (_, params) => server.addRegister(params), + AddRegisterParamsSchema + ) ipcHandle('remove_server_register', (_, params) => server.removeRegister(params)) ipcHandle('sync_server_register', (_, params) => server.syncServerRegisters(params)) ipcHandle('reset_registers', (_, params) => server.resetRegisters(params)) - ipcHandle('set_bool', (_, params) => server.setBool(params)) + ipcHandle('set_bool', (_, params) => server.setBool(params), SetBooleanParametersSchema) ipcHandle('reset_bools', (_, params) => server.resetBools(params)) ipcHandle('sync_bools', (_, params) => server.syncBools(params)) ipcHandle('reset_server', (_, uuid) => server.resetServer(uuid)) diff --git a/src/shared/migrations/index.ts b/src/shared/migrations/index.ts index 1004314..0c68b7f 100644 --- a/src/shared/migrations/index.ts +++ b/src/shared/migrations/index.ts @@ -1,4 +1,5 @@ export type { MigrationResult } from './types' +export { formatZodError } from './shared' export { migrateServerConfig, CURRENT_SERVER_CONFIG_VERSION } from './server/config' export { migrateServerRegistersState, diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index 1666195..b79759c 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -1,5 +1,5 @@ import z from 'zod' -import { BaseDataType, DataTypeSchema } from './datatype' +import { BaseDataTypeSchema, DataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' import { BooleanRegisters, NumberRegisters, UnitIdString } from './server' @@ -140,18 +140,26 @@ export type ConnectionConfig = z.infer // // WriteParameters -export type WriteParameters = { address: number; single: boolean } & ( - | { - type: 'coils' - value: boolean[] - dataType?: never - } - | { - type: 'holding_registers' - value: number - dataType: BaseDataType - } -) +export const WriteParametersSchema = z + .object({ + address: z.number().int().min(0).max(65535), + single: z.boolean() + }) + .and( + z.union([ + z.object({ + type: z.literal('coils'), + value: z.array(z.boolean()), + dataType: z.undefined() + }), + z.object({ + type: z.literal('holding_registers'), + value: z.number(), + dataType: BaseDataTypeSchema + }) + ]) + ) +export type WriteParameters = z.infer // // diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index f2637b4..8356ced 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -124,12 +124,13 @@ export type ServerConfig = z.infer // // Regular types -export type AddRegisterParams = { - uuid: string - unitId: UnitIdString - params: RegisterParams - littleEndian: boolean -} +export const AddRegisterParamsSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + params: RegisterParamsSchema, + littleEndian: z.boolean() +}) +export type AddRegisterParams = z.infer export interface RemoveRegisterParams { uuid: string unitId: UnitIdString @@ -151,13 +152,14 @@ export interface ResetRegistersParams { registerType: NumberRegisters } -export interface SetBooleanParameters { - uuid: string - unitId: UnitIdString - registerType: BooleanRegisters - address: number - state: boolean -} +export const SetBooleanParametersSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + registerType: BooleanRegistersSchema, + address: z.number().int().min(0).max(65535), + state: z.boolean() +}) +export type SetBooleanParameters = z.infer export interface ResetBoolsParams { uuid: string From 7e7fcb952491fabc514b55c541bb3865b33c3b1c Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 16:30:48 +0200 Subject: [PATCH 02/72] refactor: shared no longer reaches into main for a type ValueGenerators described its maps with the ValueGenerator class itself, which meant shared imported from main. shared is imported by all three processes and is the one layer that may not do that. Only dispose is ever called on a generator from outside, so the interface says exactly that and ValueGenerator implements it. Type-only either way, so no bundle changes. --- src/main/modules/modbusServer/valueGenerator.ts | 3 ++- src/shared/types/server.ts | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 98ebd58..e481d86 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -7,6 +7,7 @@ import { ServerData, BaseDataType, RegisterParams, + RegisterValueGenerator, UnitIdString } from '@shared' import { round } from 'lodash' @@ -25,7 +26,7 @@ type ValueGeneratorParams = { * ValueGenerator generates and updates Modbus register values at a set interval. * It supports various data types and updates the server data and notifies the frontend. */ -export class ValueGenerator { +export class ValueGenerator implements RegisterValueGenerator { private _uuid: string private _unitId: UnitIdString private _windows: Windows diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index 8356ced..84b947f 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -2,7 +2,6 @@ import { z } from 'zod' import { BaseDataType, BaseDataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' import { RegisterType, SerialPortOptionsSchema } from './client' -import { ValueGenerator } from '../../main/modules/modbusServer/valueGenerator' import { unitIds } from './unitid' // Server mode (global: TCP or RTU) @@ -191,7 +190,18 @@ export interface ServerData { holding_registers: number[] } +/** + * What the server needs of a running generator, which is only the teardown. + * + * shared is imported by all three processes, so it may not reach into main for + * a type. ValueGenerator implements this instead, which leaves the dependency + * pointing the one way it is allowed to point. + */ +export interface RegisterValueGenerator { + dispose: () => void +} + export interface ValueGenerators { - input_registers: Map - holding_registers: Map + input_registers: Map + holding_registers: Map } From e0ce7734bbeb484f19f564030136d2761407e3db Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 16:35:10 +0200 Subject: [PATCH 03/72] refactor: one side per IPC call, where it was actually two Two channels were being called from a store and a component for the same reason, which put two writers on one piece of state. setRegisterMapping had a debounced sync in the store and an undebounced one in RegisterConfig, there because turning on read configuration reads immediately and cannot wait 150 ms. The store now exposes that flush, so the component asks for it instead of sending the mapping itself. getAppVersion was read into the store at startup and then asked for again by both save paths. They read the store, which already holds it. UpdateBanner keeps its own call on purpose: it is tested on its own, and coupling it to the root store to save one read of a value that cannot change is a bad trade. The other two the audit flagged are not duplicates. In the store, read is a consequence of flipping endianness and stopScanningUnitIds is a reload cleanup; in the components, both are the user pressing a button. Same channel, different concerns. --- src/renderer/src/components/UpdateBanner.tsx | 3 +++ .../RegisterGridToolbar/SaveButton/SaveButton.tsx | 4 ++-- .../client/RegisterConfig/RegisterConfig.tsx | 4 ++-- .../server/OpenSaveClear/OpenSaveClear.tsx | 5 +++-- src/renderer/src/context/root.zustand.ts | 13 +++++++++++++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 93369d8..ea814d7 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -35,6 +35,9 @@ const UpdateBanner = (): JSX.Element | null => { const release: GitHubRelease = await response.json() const latestTag = release.tag_name.replace(/^v/, '') // Remove 'v' prefix if present + // Asked for directly rather than read off the root store, so the + // banner stays testable on its own. The version cannot change while + // the app runs, so a second read costs nothing. const currentVersion = await window.api.getAppVersion() // Compare versions diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx index 9c2929e..020132a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx @@ -21,8 +21,8 @@ const SaveButton = meme(() => { }) }) - // Get app version - const modbuxVersion = await window.api.getAppVersion() + // The store reads the version once at startup; it cannot change after that + const modbuxVersion = z.version const registerMapConfig: RegisterMapConfig = { version: 2, diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 97eaa54..955dc51 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -15,7 +15,7 @@ import LengthInput from '@renderer/components/shared/inputs/LengthInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { flushRegisterMappingToMain, useRootZustand } from '@renderer/context/root.zustand' import { RegisterType } from '@shared' import { showMapping } from '@renderer/context/data.zustand' import { ElementType, useCallback, useEffect } from 'react' @@ -109,7 +109,7 @@ const ReadConfiguration = meme(() => { // When read configuration is enabled, send the configuration to the backend API // and immediately show the configured registers in the grid if (toggleState) { - window.api.setRegisterMapping(useRootZustand.getState().registerMapping) + flushRegisterMappingToMain() showMapping() } useRootZustand.getState().setReadConfiguration(toggleState) diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx index 911f7f9..94a46fd 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx @@ -1,6 +1,7 @@ import { FileOpen, Save, Delete } from '@mui/icons-material' import { Box, IconButton } from '@mui/material' import { meme } from '@renderer/components/shared/inputs/meme' +import { useRootZustand } from '@renderer/context/root.zustand' import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, migrateServerConfig } from '@shared' import { ServerConfig, ServerRegistersPerUnit, UnitIdStringSchema } from '@shared' @@ -136,8 +137,8 @@ const useSave: UseSaveHook = () => { serverRegistersPerUnit[unitId] = registers }) - // Get app version - const modbuxVersion = await window.api.getAppVersion() + // The store reads the version once at startup; it cannot change after that + const modbuxVersion = useRootZustand.getState().version const config: ServerConfig = { version: 2, diff --git a/src/renderer/src/context/root.zustand.ts b/src/renderer/src/context/root.zustand.ts index d7ae612..484888a 100644 --- a/src/renderer/src/context/root.zustand.ts +++ b/src/renderer/src/context/root.zustand.ts @@ -22,6 +22,19 @@ function syncRegisterMappingToMain(): void { }, 150) } +/** + * Sends the mapping now instead of in 150 ms. + * + * For a caller that needs the backend to hold the mapping before its next + * request. Turning on read configuration reads straight afterwards, and the + * debounce would let that read go out against the mapping from before. + */ +export const flushRegisterMappingToMain = (): void => { + if (_ipcTimer) clearTimeout(_ipcTimer) + _ipcTimer = null + window.api.setRegisterMapping(useRootZustand.getState().registerMapping) +} + export const useRootZustand = create< RootZusand, [['zustand/persist', PersistedRootZustand], ['zustand/mutative', never]] From 94d93048d9449b703b87380c5a5c1add596e8b40 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 17:12:28 +0200 Subject: [PATCH 04/72] refactor: five components move into their own folder ServerRtuConfig, AddRegister, ServerBitMapDetail, BitIndicator and BitSettingsPopover sat next to a parent instead of in a folder of their own. AddRegister takes its store, its helpers and their test along, so everything that belongs to it is in one place. Nothing but import paths changed: eight renames, eight lines, and no content edit in any moved file. The wrapping in meme comes next and would be unreviewable on top of a move this wide. --- .../BitMapDetailPanel/{ => BitIndicator}/BitIndicator.tsx | 2 +- .../RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx | 2 +- .../{ => BitSettingsPopover}/BitSettingsPopover.tsx | 0 .../src/components/server/ServerConfig/ServerConfig.tsx | 2 +- .../ServerConfig/{ => ServerRtuConfig}/ServerRtuConfig.tsx | 0 src/renderer/src/components/server/ServerGrid/ServerGrid.tsx | 2 +- .../server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx | 2 +- .../ServerRegisters/{ => AddRegister}/AddRegister.tsx | 0 .../__tests__/addRegister.zustand.helpers.test.ts | 0 .../{ => AddRegister}/addRegister.zustand.helpers.ts | 0 .../ServerRegisters/{ => AddRegister}/addRegister.zustand.ts | 0 .../{ => ServerBitMapDetail}/ServerBitMapDetail.tsx | 2 +- .../server/ServerGrid/ServerRegisters/ServerRegisters.tsx | 4 ++-- 13 files changed, 8 insertions(+), 8 deletions(-) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/{ => BitIndicator}/BitIndicator.tsx (98%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/{ => BitSettingsPopover}/BitSettingsPopover.tsx (100%) rename src/renderer/src/components/server/ServerConfig/{ => ServerRtuConfig}/ServerRtuConfig.tsx (100%) rename src/renderer/src/components/server/ServerGrid/ServerRegisters/{ => AddRegister}/AddRegister.tsx (100%) rename src/renderer/src/components/server/ServerGrid/ServerRegisters/{ => AddRegister}/__tests__/addRegister.zustand.helpers.test.ts (100%) rename src/renderer/src/components/server/ServerGrid/ServerRegisters/{ => AddRegister}/addRegister.zustand.helpers.ts (100%) rename src/renderer/src/components/server/ServerGrid/ServerRegisters/{ => AddRegister}/addRegister.zustand.ts (100%) rename src/renderer/src/components/server/ServerGrid/ServerRegisters/{ => ServerBitMapDetail}/ServerBitMapDetail.tsx (98%) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx similarity index 98% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx index 48123f4..425c0ee 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx @@ -4,7 +4,7 @@ import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useState } from 'react' import { BitColor } from '@shared' -import BitSettingsPopover from './BitSettingsPopover' +import BitSettingsPopover from '../BitSettingsPopover/BitSettingsPopover' interface BitIndicatorProps { bitIndex: number diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx index 7e26b94..26551b4 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx @@ -4,7 +4,7 @@ import { useRootZustand } from '@renderer/context/root.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' import { BitColor, BitMapConfig } from '@shared' -import BitIndicator from './BitIndicator' +import BitIndicator from './BitIndicator/BitIndicator' interface BitMapDetailPanelProps { address: number diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index c84dc25..3f0ad57 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -17,7 +17,7 @@ import Select from '@mui/material/Select' import { UnitIdString, UnitIdStringSchema } from '@shared' import MenuItem from '@mui/material/MenuItem' import React, { useState } from 'react' -import ServerRtuConfig from './ServerRtuConfig' +import ServerRtuConfig from './ServerRtuConfig/ServerRtuConfig' const ModeToggle = meme(() => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx similarity index 100% rename from src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx rename to src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx diff --git a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx index 4102d84..433eb0d 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx @@ -1,6 +1,6 @@ import ServerBooleans from './ServerBooleans/ServerBooleans' import ServerRegisters from './ServerRegisters/ServerRegisters' -import AddRegister from './ServerRegisters/AddRegister' +import AddRegister from './ServerRegisters/AddRegister/AddRegister' import Box from '@mui/material/Box' const ServerGrid = (): JSX.Element => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx index 682032d..1954a51 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx @@ -4,7 +4,7 @@ import { RegisterType } from '@shared' import { useCallback } from 'react' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' -import { useAddRegisterZustand } from '../ServerRegisters/addRegister.zustand' +import { useAddRegisterZustand } from '../ServerRegisters/AddRegister/addRegister.zustand' import useServerGridZustand from '../serverGrid.zustand' const AddButton = meme(({ type }: { type: RegisterType }) => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx similarity index 100% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister.tsx rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/addRegister.zustand.helpers.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts similarity index 100% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/addRegister.zustand.helpers.test.ts rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.helpers.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts similarity index 100% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.helpers.ts rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts similarity index 100% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.ts rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx similarity index 98% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx index ef22d99..c1154ed 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx @@ -3,7 +3,7 @@ import { ServerRegisterEntry, BitMapConfig, getBit } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' -import ServerBit from '../shared/ServerBit' +import ServerBit from '../../shared/ServerBit' interface ServerBitMapDetailProps { register: ServerRegisterEntry diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 046ac63..d306ae0 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -4,10 +4,10 @@ import { NumberRegisters, ServerRegister } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useAddRegisterZustand } from './addRegister.zustand' +import { useAddRegisterZustand } from './AddRegister/addRegister.zustand' import ServerPartTitle from '../ServerPartTitle/ServerPartTitle' import useServerGridZustand from '../serverGrid.zustand' -import ServerBitMapDetail from './ServerBitMapDetail' +import ServerBitMapDetail from './ServerBitMapDetail/ServerBitMapDetail' import { DateTime } from 'luxon' interface RowProps { From 58f1cff56309f182c8b20eed0ce2dcdbbddea29b Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 17:45:37 +0200 Subject: [PATCH 05/72] refactor: AddRegister splits into its four subjects At 900 lines and 27 components it was more than twice the next file in the renderer on both counts, and the only file the size rule actually catches. The split is by subject, not one file per component: AddRegister.tsx the dialog and its layout registerFields.tsx where the register lives and what it is called valueParameters.tsx what value it produces, fixed or generated addRegisterActions.tsx the buttons and the submit they share maskedInputs.tsx the six IMask wrappers The masked inputs sit together because they are six near-identical pairs of a forwardRef wrapper and its memo. Whatever gets done to one of them should be done to all six, which is easier to see in one file than spread through the dialog. Whether they want to be one parameterised input, next to the ones shared/inputs already has, is a separate question. Every declaration moved unchanged; only the import blocks are new. --- .../AddRegister/AddRegister.tsx | 804 +----------------- .../AddRegister/addRegisterActions.tsx | 238 ++++++ .../AddRegister/maskedInputs.tsx | 180 ++++ .../AddRegister/registerFields.tsx | 89 ++ .../AddRegister/valueParameters.tsx | 328 +++++++ 5 files changed, 841 insertions(+), 798 deletions(-) create mode 100644 src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx create mode 100644 src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx create mode 100644 src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx create mode 100644 src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx index 1434ca7..2d55049 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx @@ -1,804 +1,11 @@ -import { - Box, - Button, - FormControl, - FormHelperText, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography -} from '@mui/material' +import { Box, Modal, Paper, Typography } from '@mui/material' import { useAddRegisterZustand } from './addRegister.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { ElementType, forwardRef, useCallback, useEffect, useState } from 'react' -import { IMask, IMaskInput } from 'react-imask' -import { AddRegisterParams, BaseDataType, notEmpty, RegisterParamsBasePart } from '@shared' -import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' -import { useMinMaxInteger } from '@renderer/hooks' -import { useServerZustand } from '@renderer/context/server.zustand' -import { Delete } from '@mui/icons-material' -import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' -import { DateTime } from 'luxon' +import { useEffect } from 'react' +import { FixedOrGenerator, ValueParameters } from './valueParameters' +import { AddressField, DataTypeSelect, CommentField } from './registerFields' +import { AddButtons, DeleteButton } from './addRegisterActions' -// -// -// -// -// Address -const AddressInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - - // Set maximum address based on data type - const maxAddress = useAddRegisterZustand((z) => { - if (['int32', 'uint32', 'float', 'unix'].includes(z.dataType)) return 65534 - if (['int64', 'uint64', 'double', 'datetime'].includes(z.dataType)) return 65532 - if (z.dataType === 'utf8') return Math.max(0, 65535 - (Number(z.registerLength) || 10) + 1) - return 65535 - }) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -AddressInputForward.displayName = 'AddressInput' -const AddressInput = meme(AddressInputForward) - -const AddressField = meme(() => { - const address = useAddRegisterZustand((z) => String(z.address)) - const addressInUse = useAddRegisterZustand((z) => z.addressInUse) - const addressFitError = useAddRegisterZustand((z) => z.addressFitError) - const valid = useAddRegisterZustand((z) => z.valid.address) - const setAddress = useAddRegisterZustand((z) => z.setAddress) - - return ( - - , - inputProps: maskInputProps({ set: setAddress }) - } - }} - /> - {addressInUse && In use} - {addressFitError && Data type does not fit at this address} - - ) -}) - -// -// -// -// -// Data Type -const DataTypeSelect = meme(() => { - const dataType = useAddRegisterZustand((z) => z.dataType) - const setDataType = useAddRegisterZustand((z) => z.setDataType) - return -}) - -// -// -// -// -// Fixed Or Generator -const FixedOrGenerator = meme(() => { - const fixed = useAddRegisterZustand((z) => z.fixed) - const setFixed = useAddRegisterZustand((z) => z.setFixed) - const dataType = useAddRegisterZustand((z) => z.dataType) - - // UTF-8 and BITMAP are always fixed — hide toggle - if (dataType === 'utf8' || dataType === 'bitmap') return null - - return ( - v !== null && setFixed(v)} - sx={{ flex: 1 }} - > - - Fixed - - - Generator - - - ) -}) - -// -// -// -// -// Value Input -const ValueInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const { min, max, integer } = useMinMaxInteger(dataType) - - return ( - { - set(value, notEmpty(value)) - }} - /> - ) -}) - -ValueInputForward.displayName = 'ValueInput' -const ValueInput = meme(ValueInputForward) - -const ValueInputComponent = meme(() => { - const value = useAddRegisterZustand((z) => z.value) - const valid = useAddRegisterZustand((z) => z.valid.value) - const setValue = useAddRegisterZustand((z) => z.setValue) - - return ( - , - inputProps: maskInputProps({ set: setValue }) - } - }} - /> - ) -}) - -// -// -// -// -// Min/Max Masks - -const MinInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const maxValue = useAddRegisterZustand((z) => z.max) - const { min, max, integer } = useMinMaxInteger(dataType, 'min', maxValue) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -MinInputForward.displayName = 'MinInput' -const MinInput = meme(MinInputForward) - -const MaxInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const minValue = useAddRegisterZustand((z) => z.min) - const { min, integer, max } = useMinMaxInteger(dataType, 'max', minValue) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -MaxInputForward.displayName = 'MaxInput' -const MaxInput = meme(MaxInputForward) - -// -// -// Min Max components -const MinTextField = meme(() => { - const min = useAddRegisterZustand((z) => String(z.min)) - const valid = useAddRegisterZustand((z) => z.valid.min) - const setMin = useAddRegisterZustand((z) => z.setMin) - - return ( - , - inputProps: maskInputProps({ set: setMin }) - } - }} - /> - ) -}) - -const MaxTextField = meme(() => { - const max = useAddRegisterZustand((z) => String(z.max)) - const valid = useAddRegisterZustand((z) => z.valid.max) - const setMax = useAddRegisterZustand((z) => z.setMax) - - return ( - , - inputProps: maskInputProps({ set: setMax }) - } - }} - /> - ) -}) - -// -// -// -// -// Interval - -const IntervalInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -IntervalInputForward.displayName = 'IntervalInput' -const IntervalInput = meme(IntervalInputForward) - -const IntervalTextField = meme(() => { - const interval = useAddRegisterZustand((z) => String(z.interval)) - const valid = useAddRegisterZustand((z) => z.valid.interval) - const setInterval = useAddRegisterZustand((z) => z.setInterval) - - return ( - , - inputProps: maskInputProps({ set: setInterval }) - } - }} - /> - ) -}) - -// -// -// -// -// DateTimePicker for unix/datetime fixed mode -const DateTimeField = meme(() => { - const value = useAddRegisterZustand((z) => z.value) - const showDatePickerUtc = useAddRegisterZustand((z) => z.showDatePickerUtc) - const setValue = useAddRegisterZustand((z) => z.setValue) - const setShowDatePickerUtc = useAddRegisterZustand((z) => z.setShowDatePickerUtc) - - const dateValue = value && value !== '0' ? DateTime.fromMillis(Number(value)) : DateTime.now() - - return ( - - { - if (dt && dt.isValid) { - setValue(String(dt.toMillis()), true) - } - }} - ampm={false} - slotProps={{ - textField: { - size: 'small', - sx: { minWidth: 220 }, - // v9 renders a PickersTextField here, not a Material TextField, so - // the html input is reached through its own nested slotProps. - slotProps: { htmlInput: { 'data-testid': 'add-reg-datetime-input' } } - } - }} - /> - - setShowDatePickerUtc(!showDatePickerUtc)} - > - UTC - - - - ) -}) - -// -// -// -// -// String value input for utf8 -const StringValueField = meme(() => { - const stringValue = useAddRegisterZustand((z) => z.stringValue) - const setStringValue = useAddRegisterZustand((z) => z.setStringValue) - const maxBytes = useAddRegisterZustand((z) => (Number(z.registerLength) || 10) * 2) - const valid = useAddRegisterZustand((z) => z.valid.stringValue) - - useEffect(() => { - // Reevaluate string length when changing register Length - setStringValue(useAddRegisterZustand.getState().stringValue) - }, [maxBytes, setStringValue]) - - const helperText = `${new TextEncoder().encode(stringValue).length} / ${maxBytes} bytes` - - return ( - setStringValue(e.target.value)} - helperText={helperText} - error={!valid} - /> - ) -}) - -// -// -// -// -// Register length input for utf8 -const RegisterLengthForward = forwardRef((props, ref) => { - const { set, ...other } = props - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -RegisterLengthForward.displayName = 'RegisterLengthInput' -const RegisterLengthInput = meme(RegisterLengthForward) - -const RegisterLengthField = meme(() => { - const registerLength = useAddRegisterZustand((z) => z.registerLength) - const valid = useAddRegisterZustand((z) => z.valid.registerLength) - const setRegisterLength = useAddRegisterZustand((z) => z.setRegisterLength) - - return ( - , - inputProps: maskInputProps({ set: setRegisterLength }) - } - }} - /> - ) -}) - -// -// -// -// -// ValueParameters -const ValueParameters = meme(() => { - const fixed = useAddRegisterZustand((z) => z.fixed) - const dataType = useAddRegisterZustand((z) => z.dataType) - - // UTF-8: string input + register length - if (dataType === 'utf8') { - return ( - <> - - - - ) - } - - // Unix/datetime fixed: date picker - if (['unix', 'datetime'].includes(dataType) && fixed) { - return - } - - // Unix/datetime generator: only interval - if (['unix', 'datetime'].includes(dataType) && !fixed) { - return - } - - // Numeric fixed: value input - if (fixed) { - return - } - - // Numeric generator: min/max/interval - return ( - <> - - - - - ) -}) - -// -// -// -// -// Comment -const CommentField = meme(() => { - const comment = useAddRegisterZustand((z) => z.comment) - const setComment = useAddRegisterZustand((z) => z.setComment) - - return ( - setComment(e.target.value)} - /> - ) -}) - -// -// -// -// -// Toggle endianness button removed - now global per server - -// -// -// -// -// Shared submit logic — adds or edits the register, returns the address and dataType used -function submitRegister(isEdit: boolean): { address: number; dataType: BaseDataType } | undefined { - const { - fixed, - address, - value, - dataType, - registerType, - min, - max, - interval, - comment, - stringValue, - registerLength, - serverRegisterEdit - } = useAddRegisterZustand.getState() - if (!registerType) return undefined - - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) - - const littleEndian = z.littleEndian[uuid] ?? false - const commonParams: Omit = { uuid, unitId, littleEndian } - const baseRegisterParams: RegisterParamsBasePart = { - address: Number(address), - dataType, - comment, - registerType - } - - if (isEdit && serverRegisterEdit) { - const oldAddress = serverRegisterEdit.params.address - if (oldAddress !== Number(address)) { - z.removeRegister({ - uuid, - unitId, - address: oldAddress, - registerType, - dataType: serverRegisterEdit.params.dataType - }) - } - } - - if (dataType === 'utf8') { - // UTF-8: always fixed, pass stringValue and length - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - value: 0, - stringValue, - length: Number(registerLength) || 10 - } - }) - } else if (['unix', 'datetime'].includes(dataType)) { - if (fixed) { - // Fixed timestamp from date picker (value stored as ms) - const timestamp = dataType === 'unix' ? Math.floor(Number(value) / 1000) : Number(value) - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: timestamp } }) - } else { - // Generator: system time, only interval matters - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: 0, - max: 0, - interval: Number(interval) * 1000 - } - }) - } - } else if (fixed) { - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: Number(value) } }) - } else { - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: Number(min), - max: Number(max), - interval: Number(interval) * 1000 - } - }) - } - - return { address: Number(address), dataType } -} - -// Add buttons -const AddButtons = meme(() => { - const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) - const valid = useAddRegisterZustand((z) => { - if (z.dataType === 'utf8') { - return z.valid.address && z.valid.stringValue && z.valid.registerLength - } - if (['unix', 'datetime'].includes(z.dataType)) { - return z.fixed ? z.valid.address : z.valid.address && z.valid.interval - } - if (z.fixed) return z.valid.address && z.valid.value - return z.valid.address && z.valid.min && z.valid.max && z.valid.interval - }) - - const handleAddAndClose = useCallback(() => { - const result = submitRegister(edit) - if (!result) return - const state = useAddRegisterZustand.getState() - state.resetToDefaults() - state.setRegisterType(undefined) - }, [edit]) - - const handleAddAndNext = useCallback(() => { - const result = submitRegister(false) - if (!result) return - const { address, dataType } = result - const state = useAddRegisterZustand.getState() - const size = ['double', 'uint64', 'int64', 'datetime'].includes(dataType) - ? 4 - : ['uint32', 'int32', 'float', 'unix'].includes(dataType) - ? 2 - : dataType === 'utf8' - ? Number(state.registerLength) || 10 - : 1 - // Reset value and comment, keep dataType/LE/fixed/min/max/interval - state.setValue('0', true) - state.setComment('') - if (dataType === 'utf8') state.setStringValue('') - state.initNextUnusedAddress(address + size) - }, []) - - const handleEditSubmit = useCallback(() => { - const result = submitRegister(true) - if (!result) return - const state = useAddRegisterZustand.getState() - state.setRegisterType(undefined) - state.setEditRegister(undefined) - }, []) - - if (edit) { - return ( - - ) - } - - return ( - <> - - - - ) -}) - -const DeleteButton = meme(() => { - const [over, setOver] = useState(false) - const handleClick = useCallback(() => { - const { address, registerType, setRegisterType, setEditRegister } = - useAddRegisterZustand.getState() - if (!registerType) return - - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) - - const numericAddress = Number(address) - const entry = z.serverRegisters[uuid]?.[unitId]?.[registerType]?.[numericAddress] - const dataType = entry?.params?.dataType ?? 'uint16' - - z.removeRegister({ - uuid, - unitId, - address: numericAddress, - registerType, - dataType - }) - - setRegisterType(undefined) - setEditRegister(undefined) - }, []) - - return ( - - ) -}) - -// -// -// -// -// MAIN const AddRegister = meme(() => { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) const registerType = useAddRegisterZustand((z) => z.registerType) @@ -896,4 +103,5 @@ const AddRegister = meme(() => { ) }) + export default AddRegister diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx new file mode 100644 index 0000000..bdf062a --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx @@ -0,0 +1,238 @@ +/** + * The dialog's buttons, and the submit they share. + */ +import { Button } from '@mui/material' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useCallback, useState } from 'react' +import { AddRegisterParams, BaseDataType, RegisterParamsBasePart } from '@shared' +import { useServerZustand } from '@renderer/context/server.zustand' +import { Delete } from '@mui/icons-material' + +function submitRegister(isEdit: boolean): { address: number; dataType: BaseDataType } | undefined { + const { + fixed, + address, + value, + dataType, + registerType, + min, + max, + interval, + comment, + stringValue, + registerLength, + serverRegisterEdit + } = useAddRegisterZustand.getState() + if (!registerType) return undefined + + const z = useServerZustand.getState() + const uuid = z.selectedUuid + const unitId = z.getUnitId(uuid) + + const littleEndian = z.littleEndian[uuid] ?? false + const commonParams: Omit = { uuid, unitId, littleEndian } + const baseRegisterParams: RegisterParamsBasePart = { + address: Number(address), + dataType, + comment, + registerType + } + + if (isEdit && serverRegisterEdit) { + const oldAddress = serverRegisterEdit.params.address + if (oldAddress !== Number(address)) { + z.removeRegister({ + uuid, + unitId, + address: oldAddress, + registerType, + dataType: serverRegisterEdit.params.dataType + }) + } + } + + if (dataType === 'utf8') { + // UTF-8: always fixed, pass stringValue and length + z.addRegister({ + ...commonParams, + params: { + ...baseRegisterParams, + value: 0, + stringValue, + length: Number(registerLength) || 10 + } + }) + } else if (['unix', 'datetime'].includes(dataType)) { + if (fixed) { + // Fixed timestamp from date picker (value stored as ms) + const timestamp = dataType === 'unix' ? Math.floor(Number(value) / 1000) : Number(value) + z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: timestamp } }) + } else { + // Generator: system time, only interval matters + z.addRegister({ + ...commonParams, + params: { + ...baseRegisterParams, + min: 0, + max: 0, + interval: Number(interval) * 1000 + } + }) + } + } else if (fixed) { + z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: Number(value) } }) + } else { + z.addRegister({ + ...commonParams, + params: { + ...baseRegisterParams, + min: Number(min), + max: Number(max), + interval: Number(interval) * 1000 + } + }) + } + + return { address: Number(address), dataType } +} + +// Add buttons + +export const AddButtons = meme(() => { + const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) + const valid = useAddRegisterZustand((z) => { + if (z.dataType === 'utf8') { + return z.valid.address && z.valid.stringValue && z.valid.registerLength + } + if (['unix', 'datetime'].includes(z.dataType)) { + return z.fixed ? z.valid.address : z.valid.address && z.valid.interval + } + if (z.fixed) return z.valid.address && z.valid.value + return z.valid.address && z.valid.min && z.valid.max && z.valid.interval + }) + + const handleAddAndClose = useCallback(() => { + const result = submitRegister(edit) + if (!result) return + const state = useAddRegisterZustand.getState() + state.resetToDefaults() + state.setRegisterType(undefined) + }, [edit]) + + const handleAddAndNext = useCallback(() => { + const result = submitRegister(false) + if (!result) return + const { address, dataType } = result + const state = useAddRegisterZustand.getState() + const size = ['double', 'uint64', 'int64', 'datetime'].includes(dataType) + ? 4 + : ['uint32', 'int32', 'float', 'unix'].includes(dataType) + ? 2 + : dataType === 'utf8' + ? Number(state.registerLength) || 10 + : 1 + // Reset value and comment, keep dataType/LE/fixed/min/max/interval + state.setValue('0', true) + state.setComment('') + if (dataType === 'utf8') state.setStringValue('') + state.initNextUnusedAddress(address + size) + }, []) + + const handleEditSubmit = useCallback(() => { + const result = submitRegister(true) + if (!result) return + const state = useAddRegisterZustand.getState() + state.setRegisterType(undefined) + state.setEditRegister(undefined) + }, []) + + if (edit) { + return ( + + ) + } + + return ( + <> + + + + ) +}) + +export const DeleteButton = meme(() => { + const [over, setOver] = useState(false) + const handleClick = useCallback(() => { + const { address, registerType, setRegisterType, setEditRegister } = + useAddRegisterZustand.getState() + if (!registerType) return + + const z = useServerZustand.getState() + const uuid = z.selectedUuid + const unitId = z.getUnitId(uuid) + + const numericAddress = Number(address) + const entry = z.serverRegisters[uuid]?.[unitId]?.[registerType]?.[numericAddress] + const dataType = entry?.params?.dataType ?? 'uint16' + + z.removeRegister({ + uuid, + unitId, + address: numericAddress, + registerType, + dataType + }) + + setRegisterType(undefined) + setEditRegister(undefined) + }, []) + + return ( + + ) +}) + +// +// +// +// +// MAIN diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx new file mode 100644 index 0000000..e1077f3 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx @@ -0,0 +1,180 @@ +/** + * The six masked inputs, each an IMask wrapper behind a forwardRef. + * + * Six near-identical pairs, which is why they sit together: whatever is done + * to one of them should be done to all six, and that is easier to see here + * than spread through the dialog. + */ +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { MaskInputProps } from '@renderer/components/shared/inputs/types' +import { forwardRef } from 'react' +import { IMask, IMaskInput } from 'react-imask' +import { notEmpty } from '@shared' +import { useMinMaxInteger } from '@renderer/hooks' + +const AddressInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + + // Set maximum address based on data type + const maxAddress = useAddRegisterZustand((z) => { + if (['int32', 'uint32', 'float', 'unix'].includes(z.dataType)) return 65534 + if (['int64', 'uint64', 'double', 'datetime'].includes(z.dataType)) return 65532 + if (z.dataType === 'utf8') return Math.max(0, 65535 - (Number(z.registerLength) || 10) + 1) + return 65535 + }) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +AddressInputForward.displayName = 'AddressInput' + +export const AddressInput = meme(AddressInputForward) + +const ValueInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const { min, max, integer } = useMinMaxInteger(dataType) + + return ( + { + set(value, notEmpty(value)) + }} + /> + ) +}) + +ValueInputForward.displayName = 'ValueInput' + +export const ValueInput = meme(ValueInputForward) + +const MinInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const maxValue = useAddRegisterZustand((z) => z.max) + const { min, max, integer } = useMinMaxInteger(dataType, 'min', maxValue) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +MinInputForward.displayName = 'MinInput' + +export const MinInput = meme(MinInputForward) + +const MaxInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const minValue = useAddRegisterZustand((z) => z.min) + const { min, integer, max } = useMinMaxInteger(dataType, 'max', minValue) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +MaxInputForward.displayName = 'MaxInput' + +export const MaxInput = meme(MaxInputForward) + +// +// +// Min Max components + +const IntervalInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +IntervalInputForward.displayName = 'IntervalInput' + +export const IntervalInput = meme(IntervalInputForward) + +const RegisterLengthForward = forwardRef((props, ref) => { + const { set, ...other } = props + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +RegisterLengthForward.displayName = 'RegisterLengthInput' + +export const RegisterLengthInput = meme(RegisterLengthForward) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx new file mode 100644 index 0000000..fb84732 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx @@ -0,0 +1,89 @@ +/** + * What the register is: where it lives, how it is read, what it is called. + */ +import { FormControl, FormHelperText, InputBaseComponentProps, TextField } from '@mui/material' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { maskInputProps } from '@renderer/components/shared/inputs/types' +import { ElementType } from 'react' +import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' +import { AddressInput } from './maskedInputs' + +export const AddressField = meme(() => { + const address = useAddRegisterZustand((z) => String(z.address)) + const addressInUse = useAddRegisterZustand((z) => z.addressInUse) + const addressFitError = useAddRegisterZustand((z) => z.addressFitError) + const valid = useAddRegisterZustand((z) => z.valid.address) + const setAddress = useAddRegisterZustand((z) => z.setAddress) + + return ( + + , + inputProps: maskInputProps({ set: setAddress }) + } + }} + /> + {addressInUse && In use} + {addressFitError && Data type does not fit at this address} + + ) +}) + +// +// +// +// +// Data Type + +export const DataTypeSelect = meme(() => { + const dataType = useAddRegisterZustand((z) => z.dataType) + const setDataType = useAddRegisterZustand((z) => z.setDataType) + return +}) + +// +// +// +// +// Fixed Or Generator + +export const CommentField = meme(() => { + const comment = useAddRegisterZustand((z) => z.comment) + const setComment = useAddRegisterZustand((z) => z.setComment) + + return ( + setComment(e.target.value)} + /> + ) +}) + +// +// +// +// +// Toggle endianness button removed - now global per server + +// +// +// +// +// Shared submit logic — adds or edits the register, returns the address and dataType used diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx new file mode 100644 index 0000000..2296a22 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -0,0 +1,328 @@ +/** + * What value the register produces: a fixed one, or a generator. + * + * The fields swap with the data type, so the nine of them are one subject. + */ +import { InputBaseComponentProps, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { maskInputProps } from '@renderer/components/shared/inputs/types' +import { ElementType, useEffect } from 'react' +import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' +import { DateTime } from 'luxon' +import { ValueInput, MinInput, MaxInput, IntervalInput, RegisterLengthInput } from './maskedInputs' + +export const FixedOrGenerator = meme(() => { + const fixed = useAddRegisterZustand((z) => z.fixed) + const setFixed = useAddRegisterZustand((z) => z.setFixed) + const dataType = useAddRegisterZustand((z) => z.dataType) + + // UTF-8 and BITMAP are always fixed — hide toggle + if (dataType === 'utf8' || dataType === 'bitmap') return null + + return ( + v !== null && setFixed(v)} + sx={{ flex: 1 }} + > + + Fixed + + + Generator + + + ) +}) + +// +// +// +// +// Value Input + +const ValueInputComponent = meme(() => { + const value = useAddRegisterZustand((z) => z.value) + const valid = useAddRegisterZustand((z) => z.valid.value) + const setValue = useAddRegisterZustand((z) => z.setValue) + + return ( + , + inputProps: maskInputProps({ set: setValue }) + } + }} + /> + ) +}) + +// +// +// +// +// Min/Max Masks + +const MinTextField = meme(() => { + const min = useAddRegisterZustand((z) => String(z.min)) + const valid = useAddRegisterZustand((z) => z.valid.min) + const setMin = useAddRegisterZustand((z) => z.setMin) + + return ( + , + inputProps: maskInputProps({ set: setMin }) + } + }} + /> + ) +}) + +const MaxTextField = meme(() => { + const max = useAddRegisterZustand((z) => String(z.max)) + const valid = useAddRegisterZustand((z) => z.valid.max) + const setMax = useAddRegisterZustand((z) => z.setMax) + + return ( + , + inputProps: maskInputProps({ set: setMax }) + } + }} + /> + ) +}) + +// +// +// +// +// Interval + +const IntervalTextField = meme(() => { + const interval = useAddRegisterZustand((z) => String(z.interval)) + const valid = useAddRegisterZustand((z) => z.valid.interval) + const setInterval = useAddRegisterZustand((z) => z.setInterval) + + return ( + , + inputProps: maskInputProps({ set: setInterval }) + } + }} + /> + ) +}) + +// +// +// +// +// DateTimePicker for unix/datetime fixed mode + +const DateTimeField = meme(() => { + const value = useAddRegisterZustand((z) => z.value) + const showDatePickerUtc = useAddRegisterZustand((z) => z.showDatePickerUtc) + const setValue = useAddRegisterZustand((z) => z.setValue) + const setShowDatePickerUtc = useAddRegisterZustand((z) => z.setShowDatePickerUtc) + + const dateValue = value && value !== '0' ? DateTime.fromMillis(Number(value)) : DateTime.now() + + return ( + + { + if (dt && dt.isValid) { + setValue(String(dt.toMillis()), true) + } + }} + ampm={false} + slotProps={{ + textField: { + size: 'small', + sx: { minWidth: 220 }, + // v9 renders a PickersTextField here, not a Material TextField, so + // the html input is reached through its own nested slotProps. + slotProps: { htmlInput: { 'data-testid': 'add-reg-datetime-input' } } + } + }} + /> + + setShowDatePickerUtc(!showDatePickerUtc)} + > + UTC + + + + ) +}) + +// +// +// +// +// String value input for utf8 + +const StringValueField = meme(() => { + const stringValue = useAddRegisterZustand((z) => z.stringValue) + const setStringValue = useAddRegisterZustand((z) => z.setStringValue) + const maxBytes = useAddRegisterZustand((z) => (Number(z.registerLength) || 10) * 2) + const valid = useAddRegisterZustand((z) => z.valid.stringValue) + + useEffect(() => { + // Reevaluate string length when changing register Length + setStringValue(useAddRegisterZustand.getState().stringValue) + }, [maxBytes, setStringValue]) + + const helperText = `${new TextEncoder().encode(stringValue).length} / ${maxBytes} bytes` + + return ( + setStringValue(e.target.value)} + helperText={helperText} + error={!valid} + /> + ) +}) + +// +// +// +// +// Register length input for utf8 + +const RegisterLengthField = meme(() => { + const registerLength = useAddRegisterZustand((z) => z.registerLength) + const valid = useAddRegisterZustand((z) => z.valid.registerLength) + const setRegisterLength = useAddRegisterZustand((z) => z.setRegisterLength) + + return ( + , + inputProps: maskInputProps({ set: setRegisterLength }) + } + }} + /> + ) +}) + +// +// +// +// +// ValueParameters + +export const ValueParameters = meme(() => { + const fixed = useAddRegisterZustand((z) => z.fixed) + const dataType = useAddRegisterZustand((z) => z.dataType) + + // UTF-8: string input + register length + if (dataType === 'utf8') { + return ( + <> + + + + ) + } + + // Unix/datetime fixed: date picker + if (['unix', 'datetime'].includes(dataType) && fixed) { + return + } + + // Unix/datetime generator: only interval + if (['unix', 'datetime'].includes(dataType) && !fixed) { + return + } + + // Numeric fixed: value input + if (fixed) { + return + } + + // Numeric generator: min/max/interval + return ( + <> + + + + + ) +}) + +// +// +// +// +// Comment From 2ea0b9e91a9d2425db09a3f453eb069ec5d4dff6 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 18:25:34 +0200 Subject: [PATCH 06/72] refactor: submitRegister finds its home It was 89 lines in a component file doing two unrelated things, and the only function declaration in the renderer, which is a fair hint it never landed anywhere on purpose. The pure half is a translation: what the dialog holds, all of it strings, into the params the server stores. That is toRegisterParams in the helpers, next to getRegisterSize and isAddressInUse, with eight tests over the conversions that were carrying real rules and no coverage. An interval is typed in seconds and stored in milliseconds. A unix timestamp is stored in seconds while a datetime picked in the same field is stored in milliseconds. A utf8 register falls back to ten registers. A generated timestamp reads the clock, so its min and max are pinned to zero. The other half writes through the server store, which makes it a state mutation, so it is now a submit action on the form store rather than something a button does for itself. While there: the next-address button was calculating a register size inline with the same four branches getRegisterSize already holds. --- .../addRegister.zustand.helpers.test.ts | 78 ++++++++++++- .../addRegister.zustand.helpers.ts | 68 ++++++++++- .../AddRegister/addRegister.zustand.ts | 60 +++++++++- .../AddRegister/addRegisterActions.tsx | 106 +----------------- 4 files changed, 208 insertions(+), 104 deletions(-) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts index ab47797..ae620e8 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { getRegisterSize, isAddressInUse } from '../addRegister.zustand.helpers' +import { + getRegisterSize, + isAddressInUse, + toRegisterParams, + type RegisterFormValues +} from '../addRegister.zustand.helpers' // ─── getRegisterSize ──────────────────────────────────────────────── @@ -127,3 +132,74 @@ describe('isAddressInUse', () => { expect(isAddressInUse(used, 'int32', 10, undefined, edit)).toBe(true) }) }) + +describe('toRegisterParams', () => { + const form: RegisterFormValues = { + fixed: true, + address: '40', + value: '1234', + dataType: 'uint16', + registerType: 'holding_registers', + min: '0', + max: '100', + interval: '5', + comment: 'flow rate', + stringValue: '', + registerLength: '' + } + + it('carries the address, type and comment through unchanged', () => { + expect(toRegisterParams(form)).toMatchObject({ + address: 40, + dataType: 'uint16', + registerType: 'holding_registers', + comment: 'flow rate' + }) + }) + + it('a fixed register keeps its value and gets no generator fields', () => { + const params = toRegisterParams(form) + expect(params).toMatchObject({ value: 1234 }) + expect(params).not.toHaveProperty('min') + expect(params).not.toHaveProperty('interval') + }) + + it('a generator turns the interval from seconds into milliseconds', () => { + expect(toRegisterParams({ ...form, fixed: false })).toMatchObject({ + min: 0, + max: 100, + interval: 5000 + }) + }) + + it('a fixed unix timestamp is stored in seconds', () => { + // The picker hands back milliseconds + expect(toRegisterParams({ ...form, dataType: 'unix', value: '1756742400000' })).toMatchObject({ + value: 1756742400 + }) + }) + + it('a fixed datetime keeps the milliseconds the picker gave it', () => { + expect( + toRegisterParams({ ...form, dataType: 'datetime', value: '1756742400000' }) + ).toMatchObject({ value: 1756742400000 }) + }) + + it('a generated timestamp reads the clock, so min and max are pinned to zero', () => { + expect( + toRegisterParams({ ...form, dataType: 'unix', fixed: false, min: '7', max: '9' }) + ).toMatchObject({ min: 0, max: 0, interval: 5000 }) + }) + + it('utf8 is always fixed and carries its string', () => { + expect( + toRegisterParams({ ...form, dataType: 'utf8', stringValue: 'PUMP-01', registerLength: '4' }) + ).toMatchObject({ value: 0, stringValue: 'PUMP-01', length: 4 }) + }) + + it('utf8 falls back to ten registers when no length was given', () => { + expect(toRegisterParams({ ...form, dataType: 'utf8', registerLength: '' })).toMatchObject({ + length: 10 + }) + }) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts index 1b5add3..8b0c967 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts @@ -1,4 +1,10 @@ -import type { DataType } from '@shared' +import type { + BaseDataType, + DataType, + NumberRegisters, + RegisterParams, + RegisterParamsBasePart +} from '@shared' /** * Returns the number of Modbus registers a data type occupies. @@ -34,3 +40,63 @@ export const isAddressInUse = ( return addressesNeeded.some((a) => usedAddresses.includes(Number(a))) } + +/** What the add-register dialog holds, before any of it means anything. */ +export interface RegisterFormValues { + fixed: boolean + address: string + value: string + dataType: BaseDataType + registerType: NumberRegisters + min: string + max: string + interval: string + comment: string + stringValue: string + registerLength: string +} + +/** + * Turns what the dialog holds into the params the server stores. + * + * Everything in the dialog is a string, and the conversions out of it are not + * uniform. An interval is typed in seconds and stored in milliseconds. A unix + * timestamp is stored in seconds while a datetime, picked in the same field, + * is stored in milliseconds. A utf8 register falls back to ten registers when + * no length was given. A generated timestamp reads the system clock, so its + * min and max carry nothing and are pinned to zero. + */ +export const toRegisterParams = (form: RegisterFormValues): RegisterParams => { + const base: RegisterParamsBasePart = { + address: Number(form.address), + dataType: form.dataType, + comment: form.comment, + registerType: form.registerType + } + + if (form.dataType === 'utf8') { + return { + ...base, + value: 0, + stringValue: form.stringValue, + length: Number(form.registerLength) || 10 + } + } + + if (['unix', 'datetime'].includes(form.dataType)) { + if (form.fixed) { + const picked = Number(form.value) + return { ...base, value: form.dataType === 'unix' ? Math.floor(picked / 1000) : picked } + } + return { ...base, min: 0, max: 0, interval: Number(form.interval) * 1000 } + } + + if (form.fixed) return { ...base, value: Number(form.value) } + + return { + ...base, + min: Number(form.min), + max: Number(form.max), + interval: Number(form.interval) * 1000 + } +} diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index 2a78b4f..433ee9b 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -11,7 +11,7 @@ import { } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' -import { getRegisterSize, isAddressInUse } from './addRegister.zustand.helpers' +import { getRegisterSize, isAddressInUse, toRegisterParams } from './addRegister.zustand.helpers' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -144,6 +144,12 @@ interface AddRegisterZustand { setShowDatePickerUtc: (utc: boolean) => void initNextUnusedAddress: (startFrom?: number) => void resetToDefaults: () => void + /** + * Writes what the dialog holds to the server, and answers where it landed. + * + * Undefined when there is no register type, which means nothing was written. + */ + submit: (isEdit: boolean) => { address: number; dataType: BaseDataType } | undefined } // ─── Store ─────────────────────────────────────────────────────────────────── @@ -303,6 +309,58 @@ export const useAddRegisterZustand = create { + const form = getState() + const { registerType, serverRegisterEdit } = form + if (!registerType) return undefined + + const server = useServerZustand.getState() + const uuid = server.selectedUuid + const unitId = server.getUnitId(uuid) + + const params = toRegisterParams({ + fixed: form.fixed, + address: form.address, + value: form.value, + dataType: form.dataType, + registerType, + min: form.min, + max: form.max, + interval: form.interval, + comment: form.comment, + stringValue: form.stringValue, + registerLength: form.registerLength + }) + + // Moving an existing register means the old address has to go first + if (isEdit && serverRegisterEdit) { + const oldAddress = serverRegisterEdit.params.address + if (oldAddress !== params.address) { + server.removeRegister({ + uuid, + unitId, + address: oldAddress, + registerType, + dataType: serverRegisterEdit.params.dataType + }) + } + } + + server.addRegister({ + uuid, + unitId, + littleEndian: server.littleEndian[uuid] ?? false, + params + }) + + return { address: params.address, dataType: form.dataType } + }, resetToDefaults: () => set((state) => { state.address = '0' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx index bdf062a..dd6efb0 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx @@ -3,102 +3,12 @@ */ import { Button } from '@mui/material' import { useAddRegisterZustand } from './addRegister.zustand' +import { getRegisterSize } from './addRegister.zustand.helpers' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useState } from 'react' -import { AddRegisterParams, BaseDataType, RegisterParamsBasePart } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { Delete } from '@mui/icons-material' -function submitRegister(isEdit: boolean): { address: number; dataType: BaseDataType } | undefined { - const { - fixed, - address, - value, - dataType, - registerType, - min, - max, - interval, - comment, - stringValue, - registerLength, - serverRegisterEdit - } = useAddRegisterZustand.getState() - if (!registerType) return undefined - - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) - - const littleEndian = z.littleEndian[uuid] ?? false - const commonParams: Omit = { uuid, unitId, littleEndian } - const baseRegisterParams: RegisterParamsBasePart = { - address: Number(address), - dataType, - comment, - registerType - } - - if (isEdit && serverRegisterEdit) { - const oldAddress = serverRegisterEdit.params.address - if (oldAddress !== Number(address)) { - z.removeRegister({ - uuid, - unitId, - address: oldAddress, - registerType, - dataType: serverRegisterEdit.params.dataType - }) - } - } - - if (dataType === 'utf8') { - // UTF-8: always fixed, pass stringValue and length - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - value: 0, - stringValue, - length: Number(registerLength) || 10 - } - }) - } else if (['unix', 'datetime'].includes(dataType)) { - if (fixed) { - // Fixed timestamp from date picker (value stored as ms) - const timestamp = dataType === 'unix' ? Math.floor(Number(value) / 1000) : Number(value) - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: timestamp } }) - } else { - // Generator: system time, only interval matters - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: 0, - max: 0, - interval: Number(interval) * 1000 - } - }) - } - } else if (fixed) { - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: Number(value) } }) - } else { - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: Number(min), - max: Number(max), - interval: Number(interval) * 1000 - } - }) - } - - return { address: Number(address), dataType } -} - -// Add buttons - export const AddButtons = meme(() => { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) const valid = useAddRegisterZustand((z) => { @@ -113,7 +23,7 @@ export const AddButtons = meme(() => { }) const handleAddAndClose = useCallback(() => { - const result = submitRegister(edit) + const result = useAddRegisterZustand.getState().submit(edit) if (!result) return const state = useAddRegisterZustand.getState() state.resetToDefaults() @@ -121,17 +31,11 @@ export const AddButtons = meme(() => { }, [edit]) const handleAddAndNext = useCallback(() => { - const result = submitRegister(false) + const result = useAddRegisterZustand.getState().submit(false) if (!result) return const { address, dataType } = result const state = useAddRegisterZustand.getState() - const size = ['double', 'uint64', 'int64', 'datetime'].includes(dataType) - ? 4 - : ['uint32', 'int32', 'float', 'unix'].includes(dataType) - ? 2 - : dataType === 'utf8' - ? Number(state.registerLength) || 10 - : 1 + const size = getRegisterSize(dataType, Number(state.registerLength) || 10) // Reset value and comment, keep dataType/LE/fixed/min/max/interval state.setValue('0', true) state.setComment('') @@ -140,7 +44,7 @@ export const AddButtons = meme(() => { }, []) const handleEditSubmit = useCallback(() => { - const result = submitRegister(true) + const result = useAddRegisterZustand.getState().submit(true) if (!result) return const state = useAddRegisterZustand.getState() state.setRegisterType(undefined) From aa734dee21d79b3eb417b40c619116980fb1287f Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 20:23:58 +0200 Subject: [PATCH 07/72] fix: the loose ends this branch left behind Two save handlers stayed async after reading the version off the store took their only await away. Both hang on an onClick, which discards what they return, so nothing else had to change. The split left its section banners behind. They sit above the component they introduce, and the splitter cut on the const line, so each banner stayed with the component before it. Min Max components ended up over the interval mask, Fixed Or Generator over the comment field, and three named things that had moved to another file or stopped existing: MAIN, Comment, Shared submit logic, and a note about an endianness button that was removed a while ago. Every banner now introduces something that is actually under it. --- .../RegisterGridToolbar/SaveButton/SaveButton.tsx | 2 +- .../server/OpenSaveClear/OpenSaveClear.tsx | 2 +- .../AddRegister/addRegisterActions.tsx | 6 ------ .../ServerRegisters/AddRegister/maskedInputs.tsx | 2 +- .../ServerRegisters/AddRegister/registerFields.tsx | 14 +------------- .../AddRegister/valueParameters.tsx | 6 ------ 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx index 020132a..af5821d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx @@ -7,7 +7,7 @@ import { snakeCase } from 'lodash' import { useCallback } from 'react' const SaveButton = meme(() => { - const saveRegisterConfig = useCallback(async () => { + const saveRegisterConfig = useCallback(() => { const z = useRootZustand.getState() const { name } = z diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx index 94a46fd..01ea939 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx @@ -122,7 +122,7 @@ type UseSaveHook = () => { } const useSave: UseSaveHook = () => { - const save = useCallback(async () => { + const save = useCallback(() => { const z = useServerZustand.getState() const { serverRegisters, selectedUuid, littleEndian } = z const name = z.name[selectedUuid] ?? '' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx index dd6efb0..be19236 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx @@ -134,9 +134,3 @@ export const DeleteButton = meme(() => { ) }) - -// -// -// -// -// MAIN diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx index e1077f3..263c128 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx @@ -131,7 +131,7 @@ export const MaxInput = meme(MaxInputForward) // // -// Min Max components +// Interval const IntervalInputForward = forwardRef((props, ref) => { const { set, ...other } = props diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx index fb84732..bd172f4 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx @@ -58,7 +58,7 @@ export const DataTypeSelect = meme(() => { // // // -// Fixed Or Generator +// Comment export const CommentField = meme(() => { const comment = useAddRegisterZustand((z) => z.comment) @@ -75,15 +75,3 @@ export const CommentField = meme(() => { /> ) }) - -// -// -// -// -// Toggle endianness button removed - now global per server - -// -// -// -// -// Shared submit logic — adds or edits the register, returns the address and dataType used diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx index 2296a22..5a229cb 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -320,9 +320,3 @@ export const ValueParameters = meme(() => { ) }) - -// -// -// -// -// Comment From a9f0439c9b3efe2517abf1706c33ae668b7adf8d Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:05:20 +0200 Subject: [PATCH 08/72] chore: a CLAUDE.md and the prose skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from ~/rust/ploxc, which has ten skills and a CLAUDE.md that fits on a screen. Most of that is about Rust and about a compiler's own documents; this is the half that is not. CLAUDE.md holds the writing rule, the four invariants a session breaks when it does not know them, and what the two test loops cost. It imports CONTRIBUTING.md rather than repeating it. /prose is the rule with its trigger table: a sentence shape on the left, the command that settles it on the right. Its gate is not "did you measure this" but "which set did the command count, and is that the noun in the sentence" — the failures it is built from all passed the first question. Its two reference files carry this branch's own failures rather than the ones it was ported with. A component census that counted `^const` declarations under a sentence about components. A step sized as wiring when twelve of thirteen argument types had no schema to wire. Six section banners that stopped naming what was under them after a split, none of which failed anything. The measurement in CLAUDE.md was itself written from memory first, at fifteen seconds, and the loop takes seventy-five. --- .claude/skills/prose/SKILL.md | 151 ++++++++++++++++++ .claude/skills/prose/references/figures.md | 42 +++++ .../references/the-block-not-the-sentence.md | 33 ++++ CLAUDE.md | 53 ++++++ 4 files changed, 279 insertions(+) create mode 100644 .claude/skills/prose/SKILL.md create mode 100644 .claude/skills/prose/references/figures.md create mode 100644 .claude/skills/prose/references/the-block-not-the-sentence.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md new file mode 100644 index 0000000..16fa3a0 --- /dev/null +++ b/.claude/skills/prose/SKILL.md @@ -0,0 +1,151 @@ +--- +name: prose +description: Measure a sentence you just wrote against the thing it describes, then prune the block it lands in. Use after writing or editing a code comment, a commit message, a CHANGELOG entry, an issue reply, or any markdown paragraph, and before adding to one that already exists — reflowing a sentence that states a measurement counts as writing it. Do NOT use as a tone pass — a sentence that passed its check is finished. +--- + +# Prose + +Is it code? Skip. Everything else gets read back sentence by sentence before it +stands, and **a claim you did not measure does not stay.** + +## The trigger + +You just wrote a sentence containing one of these. Run its command now. + +| the sentence contains | the command | +| --- | --- | +| a quoted message — a snackbar, an error, test output | run it and copy the line out of the output | +| a number — including "both", "all three", "each", "the only remaining" | the command that counts it, pasted with its output | +| a reference — a file, a symbol, a commit | `git show :` and `grep -rn '' src/` | +| a cause — "because", "this closes", "it is missing X" | `grep -rn '' src/` over every caller, and show both sides | +| a date or an order — "pre-existing", "added after", "still" | `git log -S '' --format='%h %ad %s' --date=short` | +| a qualifier — "mostly", "except", a parenthesis | read the hedge back; ask whether the claim in front survives | +| the shape of the code — "X now calls Y", "the copy is gone" | `grep` and `git diff`, never a green suite | +| **a command the reader is told to run** | run it, and read its output the way its reader will | +| **you are adding to a comment or a section that already exists** | read the whole block first — see *read the block* below | + +**A fact from outside this repository has no command here.** Cite the source, or +cut the sentence. + +To quote something the app says, find it rather than remember it: + +```sh +grep -rn "message:" src/main src/renderer/src --include=*.ts --include=*.tsx | grep -v __tests__ +``` + +## The command you paste + +Four ways it is still wrong: + +- **It did not run.** Read the exit code. `yarn lint | tail -5` prints nothing + useful when lint failed on a file you did not open. +- **It answered a different question.** Read your sentence's noun, read what came + back, and say whether they are the same set. +- **It could not have contradicted you.** Searching for the fix never returns a + site that needs it. Search the population — every call, every caller. +- **It matched the sentence you were writing.** Run the search before you paste + it into a file and again after, and see whether the number moved. + +## Figures + +→ [WHY: figures](./references/figures.md) + +**No hand-written figure goes into prose.** Not a careful one, not a checked one, +not one you just measured. + +**The default is not to count.** A sentence with no number in it is the one to +write unless counting earns its place. *"The suite is green"*, *"its callers are +`AddButtons`, `DeleteButton` and the edit submit"* — neither can go stale. + +**"Did you measure it" is the wrong gate**, and it passes the failures. A +measured figure fails when **the command counted one set and the sentence names +another**. So the question is not *did I run it* but **which set did the command +count, and is that the noun in the sentence?** + +**A figure about work in progress does not go in at all** — steps done, files +left, lines in your own diff. It is a prediction, and it is wrong before the +commit lands. + +**Editing a sentence that states a measurement is writing it.** Reflowing or +trimming does not make the measurement true again. Run the command again, or cut +the sentence. + +**The reader has the diff.** Files, functions, call sites — `git show` answers all +of it, correctly, forever. + +Naming a mechanism is a count too: *"the linter would catch it"*, *"nothing else +reads this"* — a claim about a set you did not enumerate. + +**Check the last item in any list of three.** The first two get verified and the +third rides along on the pattern they set. + +## Then: read the block your sentence lands in, and cut it + +**The unit is the block, not the sentence you just wrote.** Every sentence in a +long comment was justified on the day it was added, and nobody reads the whole +thing — so a comment grows by accretion and never shrinks. + +Before the sentence stands, read the **whole** comment block, the whole section: + +- **Does your addition make something above it redundant?** A correction + supersedes what it corrects. Delete the superseded half; do not leave both and + let the reader work out which is current. +- **Is any of it now held by something that cannot go stale?** A Zod schema says + what a shape is, a test says what the code does, `CONTRIBUTING.md` says what + the rules are. Prose repeating one of those is a second copy that drifts. +- **What would a reader lose if the block were three sentences?** Write those + three. If nothing is lost, that is the block. + +**A comment that survives a move has not been re-read.** A section banner +introduces the thing under it; after any split or reorder, check that it still +names what follows. + +**What it costs is paid by a reviewer, and it is more than one reading.** Two +comments in one block that disagree cost a *second measurement*, because the only +way to tell which is the false claim is to go and run the thing. + +**In a test file, the sentence naming what the test discriminates stays and the +incident that produced it goes.** + +## Then: can this be written with fewer sentences? + +Ask of each sentence: + +- Cover it. Does anything change for the reader? No — cut it. +- Is it narration? `CLAUDE.md` has the test. +- Does a test or a schema already hold this fact? Then it needs no prose. +- Is this the third rewrite of this paragraph? Delete it. + +| what | how long | +| --- | --- | +| a commit message | what changed and why. The evidence is in the diff, not here | +| a CHANGELOG entry | what a user can now do. Grouped by feature, never by code change | +| a code comment | what the reader cannot see from the code. If it argues, cut it | +| an issue reply | casual, brief, first person. No release-notes formatting | + +## References + +**No line number.** It goes wrong on the next edit above it, and the reader has +to search for the symbol anyway. Name the file and the symbol. + +## The form + +**No em dash in anything a person reads** — product copy, README, release notes, +CHANGELOG, issue replies. Never search and replace: each sentence gets its own +fix, a comma, a colon, a full stop, or a rewrite. An em dash usually marks a +sentence that wants restructuring anyway. + +English in the repository, Dutch in the chat. + +## Sentences with nothing to measure + +- **Description, not assertion** — "this maps the register list into rows" + describes code the reader can see. +- **Reasoning about a decision** — "one selector per field is easier to read" + can be disagreed with; it cannot be wrong. + +## Stop + +Two passes, then stop: the commands, and the shortening. Do not read it a third +time to make it sound better. Do not soften a sentence that survived, and do not +add a hedge to one you now feel less sure about — go and measure it instead. diff --git a/.claude/skills/prose/references/figures.md b/.claude/skills/prose/references/figures.md new file mode 100644 index 0000000..c3ce2cb --- /dev/null +++ b/.claude/skills/prose/references/figures.md @@ -0,0 +1,42 @@ +# Figures + +Why the prohibition is flat rather than conditional. History, not state. + +**A measured figure that counted the wrong set.** The component census behind +the `meme` decision was written as *"102 wrapped, 81 not, 183 total"*. The +command had run. It counted declarations matching `^const [A-Z]`, and the +sentence named *components* — two different sets, because a component declared +`export const` is invisible to that pattern. + +```sh +grep -rn "^const [A-Z]" src/renderer/src --include=*.tsx | wc -l # what ran +grep -rn "^\(export \)\?const [A-Z]" src/renderer/src --include=*.tsx | wc -l # what the sentence meant +``` + +The figure survived three retellings, two artifacts and a decision, because +every retelling was a reflow rather than a re-run. The repair was not a better +count: it was running the same meter over `main` and over the branch, so the +difference could be attributed to the meter rather than to the work. + +**A cause asserted from a neighbouring fact.** *"The schemas already exist; this +is wiring, not authoring"* was written about the IPC validation step. Thirty-seven +Zod schemas did exist, and that was the neighbouring fact. None of them described +an IPC argument: twelve of the thirteen argument types were hand-written +interfaces. + +```sh +grep -rn "export const .*Schema" src/shared --include=*.ts | wc -l # the fact that was true +grep -rn "export interface WriteParameters\|export type WriteParameters" src/shared # the one that mattered +``` + +The sentence sized a step. It was wrong by the whole authoring half. + +**A grep that matched names instead of concerns.** *"Four channels are called +from both a store and a component"* came from intersecting two lists of channel +names. Two of the four were not duplicates at all: in the store, `read` follows +an endianness flip and `stopScanningUnitIds` is a reload cleanup; in the +components both are a button. Same channel, different reason, both correct. + +The command answered *"which names appear on both sides"*. The sentence claimed +*"which concerns are duplicated"*. Nothing about running it again would have +caught that. diff --git a/.claude/skills/prose/references/the-block-not-the-sentence.md b/.claude/skills/prose/references/the-block-not-the-sentence.md new file mode 100644 index 0000000..32b60e0 --- /dev/null +++ b/.claude/skills/prose/references/the-block-not-the-sentence.md @@ -0,0 +1,33 @@ +# What a block nobody re-read cost + +Why `/prose` prunes the block and not the sentence. + +**A comment that survived a move stops being true without being edited.** +`AddRegister.tsx` was split into five files by a script that cut on each `const` +line. A section banner sits *above* the component it introduces, so every banner +stayed behind with the component before it. + +Jens found the first one by reading the diff: a `// MAIN` at the end of +`addRegisterActions.tsx`, introducing nothing. A sweep found five more of the +same shape, one of them mislabelling a live component: + +| banner | what was under it | +| --- | --- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | end of file | +| `// Toggle endianness button removed` | end of file, and already dead before the split | + +Nothing failed. Lint passed, typecheck passed, 591 tests passed, and 86 E2E +specs passed. A banner is prose, and prose has no suite. + +**The same day, an edit left a keyword behind.** Reading the app version off the +store instead of over IPC removed the only `await` from two handlers, and left +`async` on both. Jens found those by reading too. + +```sh +yarn lint # passes: require-await is not enabled +``` + +Both are the same failure: the sentence that was edited was checked, and the +block it lived in was not re-read. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0427f16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# How to write here + +Every sentence is a **claim**, an **order**, or a **measurement**. Anything else +is narration — cut it before writing it. This holds in chat, in code comments, in +commit messages, in the changelog, and in every markdown file here. + +- **claim** — can be contested, and the reader acts differently if it does not + hold. +- **order** — something to run or to do. +- **measurement** — a command and what it printed. + +Before you write anything that is not code, run `/prose`. + +# What this is + +An Electron app: Modbus TCP and RTU, client and server, in one window. + +``` +src/main/ the Electron main process — Modbus client, servers, device state +src/preload/ the bridge; window.api is generated from IPC_CHANNELS +src/shared/ types, Zod schemas, pure helpers, migrations +src/renderer/ the React UI +``` + +`@renderer/*` and `@shared` are the import aliases. There are no others. + +# Four things that break if you do not know them + +- **Nothing in `src/shared` may import from `src/main`.** All three processes + import shared; it is the one layer that may not reach back. +- **One store selector per field.** `useRootZustand((z) => z.a)` and then + `((z) => z.b)`, never one selector returning an object. The renderer has zero + whole-store subscriptions and zero `useShallow`, and that is why it renders a + two-thousand-row grid without either. +- **`window.api` is generated.** A channel is a name in `IPC_CHANNELS`, a type in + `IpcHandlerSpec`, and a one-line handler in `main/ipc.ts`. The camelCase method + appears by itself. +- **Every interactive element carries a `data-testid`.** The e2e suite addresses + the UI through them. + +# The rules + +@CONTRIBUTING.md + +# What is slow, and what that means + +```sh +yarn lint && yarn typecheck && yarn test # about 75 seconds, most of it typecheck +yarn test:e2e # builds first, then minutes +``` + +Run the e2e specs a change touches while you work, and the suite once, at the +end. `e2e/specs/01-main/` is numbered in the order it runs. From 736fbd60f6d171c801b2111a013f6649c925bdfd Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:16:40 +0200 Subject: [PATCH 09/72] chore: fire /prose from a write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill runs only when someone remembers it, and the moment prose needs checking is a write, which no description can name. So the trigger is the write itself: a markdown file, or an edit that adds a comment. It is a reminder and never a block. The first firing of a session states the rule and every one after asks two questions, because a hook that argues on every edit becomes wallpaper. The hooks live under .claude/ rather than in tools/, which holds scripts that belong to the app. Nothing under .claude/ is part of the build: no package.json entry, no postinstall, no CI. They are .mjs on node for the same reason — the least important thing here should not be what decides that this project has a second runtime. Both directions are pinned in the suite. The negative cases matter as much as the positive ones: a matcher narrowed to kill a false positive is how the false negatives get made. The payloads are built rather than typed, because a hand-escaped one in a shell went through unparseable and the hook read that as nothing to say, which looks exactly like a matcher declining. --- .../hooks/__tests__/prose-trigger.test.mjs | 65 +++++++++++++++++++ .claude/hooks/payload.mjs | 24 +++++++ .claude/hooks/prose-trigger.mjs | 46 +++++++++++++ .claude/hooks/session-marker.mjs | 57 ++++++++++++++++ .claude/settings.json | 16 +++++ 5 files changed, 208 insertions(+) create mode 100644 .claude/hooks/__tests__/prose-trigger.test.mjs create mode 100644 .claude/hooks/payload.mjs create mode 100755 .claude/hooks/prose-trigger.mjs create mode 100644 .claude/hooks/session-marker.mjs create mode 100644 .claude/settings.json diff --git a/.claude/hooks/__tests__/prose-trigger.test.mjs b/.claude/hooks/__tests__/prose-trigger.test.mjs new file mode 100644 index 0000000..b01f62d --- /dev/null +++ b/.claude/hooks/__tests__/prose-trigger.test.mjs @@ -0,0 +1,65 @@ +/** + * Both directions, in one run. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case here names what must fire and what must not. The payloads + * are built rather than typed: a hand-escaped one in a shell went through as + * unparseable, and the hook read that as nothing to say — which looks exactly + * like a matcher declining. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'prose-trigger.mjs') + +/** What the hook says, or '' when it declined. Throws when it exits non-zero. */ +const fire = (toolInput, sessionId = `test-${Math.random()}`) => { + const payload = JSON.stringify({ session_id: sessionId, ...(toolInput ? { tool_input: toolInput } : {}) }) + const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('prose-trigger fires on', () => { + it('a markdown write', () => expect(fire({ file_path: 'a.md', content: 'x' })).not.toBe('')) + it('an edit adding a line comment', () => + expect(fire({ file_path: 'a.ts', new_string: ' // why\nconst x = 1' })).not.toBe('')) + it('an edit adding a block comment', () => + expect(fire({ file_path: 'a.tsx', new_string: '/* why */\nconst y = 2' })).not.toBe('')) +}) + +describe('prose-trigger stays quiet on', () => { + it('code with no comment', () => + expect(fire({ file_path: 'a.ts', new_string: 'const x = 1' })).toBe('')) + it('a // that is inside a string', () => + expect(fire({ file_path: 'a.ts', new_string: "const u = 'http://x'" })).toBe('')) + it('a file that is neither markdown nor commented code', () => + expect(fire({ file_path: 'a.json', content: '{"a":1}' })).toBe('')) + it('a payload with no tool_input', () => expect(fire(null)).toBe('')) +}) + +describe('prose-trigger says it once', () => { + it('states the rule first and asks a question after', () => { + const session = `once-${Math.random()}` + const first = fire({ file_path: 'a.md', content: 'x' }, session) + const second = fire({ file_path: 'b.md', content: 'y' }, session) + expect(first.length).toBeGreaterThan(second.length) + expect(second).toBe('prose: measured? whole block read and cut?') + }) + + it('starts over in a new session', () => { + const a = fire({ file_path: 'a.md', content: 'x' }, `s-${Math.random()}`) + const b = fire({ file_path: 'a.md', content: 'x' }, `s-${Math.random()}`) + expect(a).toBe(b) + }) +}) + +describe('prose-trigger never interrupts', () => { + it('exits 0 on unparseable stdin', () => { + expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe('') + }) + it('exits 0 on the JSON null that reaches the try and not the catch', () => { + expect(execFileSync('node', [HOOK], { input: 'null', encoding: 'utf8' })).toBe('') + }) +}) diff --git a/.claude/hooks/payload.mjs b/.claude/hooks/payload.mjs new file mode 100644 index 0000000..4be2012 --- /dev/null +++ b/.claude/hooks/payload.mjs @@ -0,0 +1,24 @@ +/** + * Reading the hook payload off stdin. + * + * `JSON.parse` returns `null` for the valid JSON `null`, reaching the `try` and + * not the `catch`, so the shape is checked rather than assumed. + * + * **A hook exits 0 or it is broken.** The harness reads a non-zero code as + * something to show the user, and 2 as a refusal. + */ + +/** The payload, or `{}` when stdin holds anything else. */ +export async function readPayload() { + if (process.stdin.isTTY) return {} + try { + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + return parsed + } catch { + // A payload this cannot read is not a reason to interrupt anyone. + return {} + } +} diff --git a/.claude/hooks/prose-trigger.mjs b/.claude/hooks/prose-trigger.mjs new file mode 100755 index 0000000..c1bd1e4 --- /dev/null +++ b/.claude/hooks/prose-trigger.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * The trigger for `/prose` that does not depend on anyone remembering it. + * + * The moment a sentence needs checking is the moment before it is written, and + * no user words announce it. So the trigger is the *write*: a markdown file, or + * an edit that adds a comment. + * + * It is a reminder, never a block, and it fires once per session. A hook that + * argues on every edit becomes wallpaper. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** A comment opener at the start of a line, in TypeScript and JavaScript. */ +const ADDS_COMMENT = /(^|\n)\s*(\/\/|\/\*)/ + +/** The first firing of a session explains the rule. */ +const FULL = + 'This write is prose, not code. Every sentence is a claim, an order, or a measurement — ' + + 'anything else is narration, so cut it. Then read back what you wrote: for every sentence ' + + 'that quotes a message, states a number, names a file or symbol, asserts a cause, or dates ' + + 'an event, run the command for that shape in `/prose` and paste what it returned. A claim ' + + 'you did not measure does not stay. Then read the whole block you are writing into, not the ' + + 'sentence alone: a correction supersedes what it corrects, and a comment nobody reads end ' + + 'to end only ever grows. No em dash in anything a person reads.' + +/** Every firing after it asks the two questions instead of repeating the rule. */ +const SHORT = 'prose: measured? whole block read and cut?' + +const payload = await readPayload() + +const path = payload.tool_input?.file_path ?? '' +const written = payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' +if (!path.endsWith('.md') && !ADDS_COMMENT.test(written)) process.exit(0) + +const first = firstThisSession('prose-trigger', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? FULL : SHORT } + }) +) diff --git a/.claude/hooks/session-marker.mjs b/.claude/hooks/session-marker.mjs new file mode 100644 index 0000000..757766e --- /dev/null +++ b/.claude/hooks/session-marker.mjs @@ -0,0 +1,57 @@ +/** + * The once-per-session rule the reminder hooks share. + * + * A hook that says the same thing on every tool call becomes wallpaper, so each + * states its rule on the first firing and asks a short question after that. The + * marker is an empty file in the temp directory, named after the session. + */ + +import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * A day is too short: a session left open over a weekend would lose its marker + * and fire twice. A week is longer than any session and still cleans up. + */ +const WEEK = 7 * 24 * 60 * 60 * 1000 + +/** + * Where one hook's claim for one session is recorded. + * + * Path-safe: a session id is a UUID today, and a `/` in one would otherwise + * write the marker somewhere else or fail silently. + */ +function markerPath(hook, sessionId) { + const session = String(sessionId ?? 'unknown').replace(/[^A-Za-z0-9._-]/g, '_') + return join(tmpdir(), `modbux-${hook}-${session}`) +} + +export function firstThisSession(hook, sessionId) { + const marker = markerPath(hook, sessionId) + + // `wx` fails when the file exists, which makes the check and the claim one + // step. Two tool calls arriving together would both pass a separate `exists` + // test and both take the full text. + try { + writeFileSync(marker, '', { flag: 'wx' }) + } catch { + return false + } + + // Only on the first firing — after that the marker is this session's own. + try { + const prefix = `modbux-${hook}-` + for (const name of readdirSync(tmpdir())) { + if (!name.startsWith(prefix)) continue + const stale = join(tmpdir(), name) + if (existsSync(stale) && Date.now() - statSync(stale).mtimeMs > WEEK) { + rmSync(stale, { force: true }) + } + } + } catch { + // Tidying is not worth a failure. + } + + return true +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..54be0b6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs"] + } + ] + } + ] + } +} From 9bca511a51e537357d382b02ef2a9f0921186a39 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:22:00 +0200 Subject: [PATCH 10/72] chore: the precommit checklist, with this project's commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routine this branch has been running by hand: read the diff, lint, typecheck, the unit suite, the e2e specs the change reaches, report, then commit. It was in nobody's file. Ordered by what gets skipped rather than by when it happens, so reading the diff comes before running anything and the prose pass is a step rather than an afterthought. The e2e rule is split in two on purpose: the specs a change touches while you work, the suite once at the end of a branch. Running it per commit is how a branch stops being worked on. Two references carry this branch's own incidents. A build artefact that rode in on `git add -A` and was caught a commit later while reading a file list for another reason. Six section banners a split left naming the wrong thing, where lint, typecheck, 591 unit tests and 86 e2e specs were all green — a banner is prose, and prose has no suite. The claim that `yarn test` does not typecheck is pasted as what the two commands print on the same file rather than asserted. --- .claude/skills/precommit/SKILL.md | 141 ++++++++++++++++++ .../references/what-a-script-moved.md | 30 ++++ .../references/what-git-add-a-took.md | 22 +++ 3 files changed, 193 insertions(+) create mode 100644 .claude/skills/precommit/SKILL.md create mode 100644 .claude/skills/precommit/references/what-a-script-moved.md create mode 100644 .claude/skills/precommit/references/what-git-add-a-took.md diff --git a/.claude/skills/precommit/SKILL.md b/.claude/skills/precommit/SKILL.md new file mode 100644 index 0000000..91cfc21 --- /dev/null +++ b/.claude/skills/precommit/SKILL.md @@ -0,0 +1,141 @@ +--- +name: precommit +description: Run the checklist before committing or merging — read the diff, lint, typecheck, the unit suite, the e2e specs the change touches, then report and commit. Use when the user says "commit", "committen", "precommit", "merge" or "mergen", and when you run yarn lint, yarn typecheck, yarn test or yarn test:e2e to find out whether your work is finished. Do NOT use to decide which tests a change needs — that is test. +--- + +# Precommit + +## While you work + +**`git add` before you mutate anything.** `git checkout` naming a path restores +from the *index*, which on an unstaged file is HEAD — so the command undoing one +mutation deletes everything else you wrote in that file. + +**A scripted edit leaves no name behind.** After any edit you did not type line +by line: + +```sh +git diff --stat -- # empty means nothing happened +git diff | grep '^-' | grep -E 'const |function |export ' # what left, by name +git diff | grep -E '^[-+][[:space:]]*(//|\*)' # comments it ate or spliced +``` + +A name in the second list you did not decide to remove is one you did not decide +to remove. A `+` comment that does not follow its `-` neighbour is a banner that +now names something else. → [WHY: what a script moved](./references/what-a-script-moved.md) + +**Writing or changing a test?** Its own skill: **`test`**. + +--- + +## 1. Read the diff + +```sh +git status --porcelain # untracked files too — they are in neither diff +git diff +git diff --staged +``` + +Read **every** changed file, and untracked files in full: a new file has no diff, +and is where a fresh copy of something the project already owns lands. + +Then, against `CONTRIBUTING.md` *Code style* and `CLAUDE.md`: + +- **A store selector returning an object** is a whole-store subscription wearing + a selector's clothes. One selector per field. +- **`src/shared` importing from `src/main`** — the one layer that may not reach + back. +- **An interactive element with no `data-testid`** — the e2e suite addresses the + UI through them. +- **A new IPC channel** — a name in `IPC_CHANNELS`, a type in `IpcHandlerSpec`, a + one-line handler. A handler carrying logic belongs in the module it calls. +- **Changed a persisted shape?** It needs a version and a migration. `partialize` + in the store says whether the shape is persisted at all. + +## 2. `yarn lint && yarn typecheck && yarn test` + +About 75 seconds together, most of it typecheck. Run all three: `yarn test` does +not typecheck, so a wrong annotation passes it. + +``` +const n: number = 'a string' # vitest: 1 passed + # tsc: error TS2322 +``` + +## 3. The e2e specs this change touches + +```sh +npx electron-vite build && npx playwright test e2e/specs/01-main/-.spec.ts +``` + +Pick them by what the change reaches, not by name. A change to the server grid +touches `03-server-config`, `04-add-register-modal` and `08-polling-generators`; +a change to writing touches `09-write-operations`; a change to config shapes +touches `05-file-io` and `14-client-config-io`. + +## 4. The full suite, once + +`yarn test:e2e` at the end of a branch, not per commit. It builds first and runs +for minutes, and running it per commit is how a branch stops being worked on. + +**A packaging or dependency change is measured on the artefact**, never on +`package.json`: `asar list` says what ships. + +## 5. Report, then decide what needs asking + +**Report what every step above produced.** A waiver covers the permission, never +the checklist. + +| what you are about to do | ask first? | +| --- | --- | +| `git commit` | no | +| `git push`, `gh` | **yes** — the line is whether it leaves the machine | +| merge | **yes**, and show the squash message first | + +## 6. Commit + +Commit the files you changed. **Never `git add -A` without reading +`git status --porcelain` first** — it takes build output, editor droppings and +anything a tool left behind. +→ [WHY: what git add -A took](./references/what-git-add-a-took.md) + +Conventional Commits, lowercase, no full stop. `feat` is new functionality, +`fix` is something that was broken, `refactor` is the same behaviour in +different code, `test` is test-only, `docs` is docs-only, `chore` is tooling. +Mean what you say. + +Explain **why**, and if something was fixed, what the defect was and how it was +verified. **No `Claude-Session:` trailer** — the message ends at the prose. + +`git commit -F -` reads stdin, which is how a multi-paragraph message gets in +without a shell mangling it. + +**Re-run every pasted command after the last edit, immediately before +`git commit`.** A figure is only true of the tree it ran against, and the way to +get this wrong is to measure, keep working, and commit the measurement beside the +change that moved it. + +## Splitting one change into several commits + +**Never split through the working tree.** `git stash` then `git checkout -- .` +restores every unstashed file from HEAD, and that work is gone. `git stash` with +nothing to stash is a no-op that still succeeds, so the `pop` after it applies +whatever was already on the stack. + +Build the commit in the **index**: `git add -p`, or `git apply --cached` for a +hunk. Both write only the index, so a mistake cannot destroy anything. + +To move uncommitted work to another branch you need no stash at all: +`git checkout -b ` carries it. + +## The prose pass + +Its own skill: **`prose`**. + +**Every commit hands over to it, once, after the message is drafted and before +`git commit`.** Unconditionally — no "if it makes a claim", because deciding +whether your own sentence makes a claim is the judgement that fails. + +**What it covers is the whole diff, not the message.** A false sentence in a code +comment and a false sentence in a commit message are the same defect, and the +comment is the one that survives. diff --git a/.claude/skills/precommit/references/what-a-script-moved.md b/.claude/skills/precommit/references/what-a-script-moved.md new file mode 100644 index 0000000..e61e562 --- /dev/null +++ b/.claude/skills/precommit/references/what-a-script-moved.md @@ -0,0 +1,30 @@ +# What a script moved without saying so + +**A split left six section banners naming the wrong thing.** `AddRegister.tsx` +was 900 lines and 27 components; a script cut it into five files on each `const` +line. A banner sits *above* the component it introduces, so every one of them +stayed behind with the component before it. + +| the banner | what ended up under it | +| --- | --- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file | + +Jens found the first by reading the diff. The sweep that found the other five +was a script too: + +```sh +python3 - <<'PY' +import re, pathlib +for f in pathlib.Path('').glob('*.tsx'): + s = f.read_text() + for m in re.finditer(r'^(?://\n)+// (.+)$', s, re.M): + nxt = re.search(r'^(?:export )?(?:const|function) (\w+)', s[m.end():], re.M) + print(f.name, m.group(1), '->', nxt.group(1) if nxt else 'NOTHING') +PY +``` + +**Nothing failed.** Lint passed, typecheck passed, 591 unit tests and 86 e2e +specs passed. A banner is prose, and prose has no suite — which is why the check +has to be a command run against the diff rather than a suite waited on. diff --git a/.claude/skills/precommit/references/what-git-add-a-took.md b/.claude/skills/precommit/references/what-git-add-a-took.md new file mode 100644 index 0000000..5f3bcde --- /dev/null +++ b/.claude/skills/precommit/references/what-git-add-a-took.md @@ -0,0 +1,22 @@ +# What `git add -A` took + +**A build artefact rode into a commit on this branch.** `tsconfig.node.tsbuildinfo` +is written by `tsc --composite` and had never been tracked. It went in because +the commit was staged with `git add -A` and the status was not read first. + +```sh +git ls-tree main --name-only | grep tsbuildinfo # nothing: it was never on main +grep -n tsbuildinfo .gitignore # nothing: it was not ignored either +``` + +Two things had to be false at once for it to land, and both were: it was not in +`.gitignore`, and nobody looked at what was being staged. `.gitignore` now +carries `*.tsbuildinfo`. + +**Nothing failed.** Lint, typecheck and the suite all passed with it in the tree, +because it is not code. It was caught while consolidating branches, one commit +later, by reading a file list for a different reason. + +The rule is not "never use `-A`". It is that `git status --porcelain` is read +first, every time, and that a name in it you did not expect gets answered before +it is staged. From 18ec98485a31eb0bd856e3dffd778785b758ce84 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:27:09 +0200 Subject: [PATCH 11/72] chore: the test skill, and what it caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which tests a change needs, and the proof each one can fail. Two rules carry it: every fix ships a pair, and a test written after the fix proves nothing until you have watched it go red. Its own rule was applied to work from earlier on this branch. The eight tests over toRegisterParams were written against code that had just been extracted, and passed on the first run — which proves only that they agree with what they were copied from. Mutating each rule separately turned exactly one test red per mutation, which is what makes the set discriminating rather than overlapping. The e2e section is about this suite rather than about testing. One app, one worker, no retries, every spec serial and cleaning up before it starts rather than after it ends. The number in the filename is the order, and a spec that needs what an earlier one built breaks when run alone. The table of what each suite can see is there because the fast loop cannot protect a behaviour only Playwright reaches, and that is worth saying while the test is being written rather than when it breaks. --- .claude/skills/test/SKILL.md | 111 ++++++++++++++++++ .../references/five-rules-five-mutations.md | 35 ++++++ 2 files changed, 146 insertions(+) create mode 100644 .claude/skills/test/SKILL.md create mode 100644 .claude/skills/test/references/five-rules-five-mutations.md diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 0000000..004f3d0 --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,111 @@ +--- +name: test +description: Decide which tests a change needs and prove each one can fail. Use before changing code, while writing or editing a test, and after a review or a user hands you a defect. Do NOT use to run the suites before a commit — that is precommit. +--- + +# Test + +## Before you change anything + +Test the **blast radius**, not the bug. The bug's own test goes green and the +regression lands in a neighbouring path of the same function. + +Two steps, no transitive closure: + +1. **The callers** of what you are changing. `grep -rn '' src/` — then ask + per caller whether the rule is true of *it*. +2. **The input forms** that reach it. A register type, a data type, an endianness, + a unit id, an address at the top of its range. Enumerate the axes; do not + assume the value you have in mind is the shape. + +Then: what of that radius is covered — not whether tests exist, whether they +touch *this* — what the behaviour should be across all of it, write those, run +them. Anything already failing that your change does not turn green goes to the +user with the output. Never absorbed, never left because it was there first. + +## Which suite can see it + +| | sees | cannot see | +| --- | --- | --- | +| **vitest** | pure functions, schemas, migrations, stores, a component in isolation | Electron, IPC, the real DataGrid, anything across two windows | +| **Playwright** | the app as a user drives it, both windows, a real Modbus socket | anything without a `data-testid` to address it | + +`yarn test` strips types rather than checking them, so a wrong annotation passes +it and only `yarn typecheck` says so. + +**A behaviour that only the e2e suite can see is a behaviour with one test.** +Say so when you write it, because the fast loop will not protect it. + +## Every fix ships a pair + +| the test | what it guards | when it is red | +| --- | --- | --- | +| the **state that must not recur** | the exact input the finding named | before the fix | +| the **state that must keep working** | the behaviour beside it, which the fix could break | never | + +Apply the fix and run both; revert it and run both again. The first must go red +and the second must stay green. If both stay green, the pair does not test the +fix. + +**A repair changes behaviour in two directions and you will test one.** The +finding names what was wrong; nothing in it names what was right. Name the input +the old behaviour handled correctly, and put it in the list. + +## Then prove it can fail + +A test written after the fix proves nothing until you have watched it go red. +→ [WHY: five rules, five mutations](./references/five-rules-five-mutations.md) + +**Mutate the rule, not the function.** One rule per run, restored between: + +```sh +cp /tmp/orig # then edit one rule +npx vitest run # want: exactly the test for that rule, red +cp /tmp/orig # and green again +git status --porcelain # empty, or the restore did not take +``` + +- **Break the rule you are claiming, not something it shares.** A change to a + helper two rules call goes red for whichever is load-bearing, which reads + exactly like proof for the other. +- **A condition is as many mutations as it has clauses.** Per clause: delete it, + which asks whether it is load-bearing, and put the neighbouring rule in its + place, which asks whether it is the *right* one. The second finds the + survivors — a rival that refuses the same input you happened to write is + invisible to a deletion. +- **Edit by line number or by a unique string, not by the first match.** `sed`, + `replace(old, new, 1)` and a first-hit search all take the first one, and a + codebase repeats lines. If a mutation reports *no tests* rather than a failure, + it broke the file: that is your quoting, not the code. +- **Assert what the code did, not what it said.** The value in the store, the + cell in the grid, the bytes on the wire. Not that a handler was called. +- **Assert what must appear, not what must be absent.** A thing never produced + and a thing correctly produced empty read identically, and most mutations stop + something happening. +- **When the output is a message, assert which one, never how many.** A count + passes with the guard removed, because the input still earns exactly one + message and it is the wrong one. +- **Name the wrong behaviour it rules out.** If you cannot name an input that + answers differently under the rival rule, the test does not discriminate. + +## Write it as the difficult user + +Not the well-behaved one. An address at 65535 with a data type that needs four +registers. A unit id of 0, and of 248. An empty comment, and one with a newline +in it. A config file from two versions ago. A serial port that disappears +mid-read. Everything that "nobody would do" — someone with a field device does. + +## The e2e suite is one app, in order + +`playwright.config.ts` sets `workers: 1` and `retries: 0`, and every spec is a +`test.describe.serial`. The specs share one running app and one server, so: + +- **A spec cleans up before it starts**, not after it ends. `cleanServerState` + is the first test in the ones that configure a server, because the spec before + it may have failed halfway. +- **The number in the filename is the order.** A spec that needs what an earlier + one built is a spec that breaks when run alone. +- **The DataGrid virtualises both axes**, so a column far enough right or a row + far enough down is not in the DOM. `MODBUX_E2E=1` is set by the fixture for + exactly that, and it is never set in a shipped build. +- **A failure that does not reproduce is reported, not re-run into silence.** diff --git a/.claude/skills/test/references/five-rules-five-mutations.md b/.claude/skills/test/references/five-rules-five-mutations.md new file mode 100644 index 0000000..24f993c --- /dev/null +++ b/.claude/skills/test/references/five-rules-five-mutations.md @@ -0,0 +1,35 @@ +# Five rules, five mutations + +**Eight tests were written and none of them had been seen to fail.** +`toRegisterParams` was extracted out of a 89-line function, and its tests were +written against the extracted code and passed on the first run. That is the +shape the rule exists for: a test written after the thing it tests, green from +the start, proves only that it agrees with the code it was copied from. + +Run afterwards, one rule at a time: + +``` + baseline: 27 passed +interval: drop the seconds-to-ms conversion 1 failed | 26 passed +unix: stop converting to seconds 1 failed | 26 passed +utf8: fall back to 1 instead of 10 1 failed | 26 passed +generated timestamp: honour min and max 1 failed | 26 passed +drop the comment 1 failed | 26 passed + restored: 27 passed +``` + +**Exactly one test red per mutation** is what makes the set discriminating +rather than overlapping. A mutation that reddens four tests has found a shared +helper, not a covered rule. + +**A sixth run reported `Tests no tests`**, which looks like a mutation nothing +covers and was a quoting error in the script doing the mutating: the file no +longer parsed, so vitest collected nothing. A mutation run that reports *no +tests* rather than a failure has broken the file, and says nothing about +coverage. + +The restore is checked rather than assumed: + +```sh +git status --porcelain # empty, or the file is still mutated +``` From d3bbe4233c2ae7f63b24c5b17c25f194db32fde3 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:38:45 +0200 Subject: [PATCH 12/72] chore: ignore TODO.md A working list for whatever is in flight. Modbux ships, so a todo here is tied to the branch being worked rather than to a roadmap, and anything that should outlive the branch belongs in a GitHub issue or the changelog. Not tracked, for the same reason tmp/ is not. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3ba7e73..e78a782 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ out .pnp.* tmp +TODO.md temp # Test coverage From 918afdbbcd9aeac1744bd5b0d63d39a152987bb8 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:42:18 +0200 Subject: [PATCH 13/72] chore: the prose rule fires every time, and reaches a commit message Two gaps, both found by using it. The reminder degraded to a one-line question after its first firing. That short form is the one that gets read past, and the sentence being written is what is at stake, so it states the whole rule every time now. The session marker it needed for that is no longer imported here. And it never saw a commit message. Those are written with `git commit -F -` through Bash, and the matcher was `Write|Edit`, so five commit messages on this branch went past it while precommit says every commit hands over unconditionally. Bash is on the matcher now. The commit match is anchored to the start of a command or to a separator rather than found anywhere in the string. Matching anywhere fired on the very run that added it: the command was a heredoc writing a test *about* `git commit`. That shape is now one of the cases. --- .../hooks/__tests__/prose-trigger.test.mjs | 35 ++++++++++++++----- .claude/hooks/prose-trigger.mjs | 29 +++++++++------ .claude/settings.json | 6 ++-- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/.claude/hooks/__tests__/prose-trigger.test.mjs b/.claude/hooks/__tests__/prose-trigger.test.mjs index b01f62d..06d42c7 100644 --- a/.claude/hooks/__tests__/prose-trigger.test.mjs +++ b/.claude/hooks/__tests__/prose-trigger.test.mjs @@ -39,19 +39,36 @@ describe('prose-trigger stays quiet on', () => { it('a payload with no tool_input', () => expect(fire(null)).toBe('')) }) -describe('prose-trigger says it once', () => { - it('states the rule first and asks a question after', () => { - const session = `once-${Math.random()}` +describe('prose-trigger says the whole rule', () => { + it('every time, in the same session', () => { + const session = `same-${Math.random()}` const first = fire({ file_path: 'a.md', content: 'x' }, session) const second = fire({ file_path: 'b.md', content: 'y' }, session) - expect(first.length).toBeGreaterThan(second.length) - expect(second).toBe('prose: measured? whole block read and cut?') + expect(second).toBe(first) + expect(first).toContain('claim, an order, or a measurement') }) +}) - it('starts over in a new session', () => { - const a = fire({ file_path: 'a.md', content: 'x' }, `s-${Math.random()}`) - const b = fire({ file_path: 'a.md', content: 'x' }, `s-${Math.random()}`) - expect(a).toBe(b) +describe('prose-trigger reaches a commit message', () => { + it('fires on git commit, which is written through Bash and not a Write', () => + expect(fire({ command: 'git commit -F -' })).not.toBe('')) + it('fires on git merge for the same reason', () => + expect(fire({ command: 'git merge --no-ff feature' })).not.toBe('')) + it('stays quiet on other git commands', () => { + expect(fire({ command: 'git status --porcelain' })).toBe('') + expect(fire({ command: 'git diff --staged' })).toBe('') + expect(fire({ command: 'git log --oneline -5' })).toBe('') + }) + it('fires when a commit follows another command', () => + expect(fire({ command: 'yarn test && git commit -F -' })).not.toBe('')) + it('stays quiet on a command that merely mentions the word', () => { + expect(fire({ command: "grep -rn 'commit' docs/" })).toBe('') + expect(fire({ command: "rg 'git commit' .claude/" })).toBe('') + }) + it('stays quiet on a heredoc that writes about a commit', () => { + // This is the false positive that fired while the Bash matcher was added. + const heredoc = "python3 - <<'PY'\ns = \"expect(fire({ command: 'git commit -F -' }))\"\nPY" + expect(fire({ command: heredoc })).toBe('') }) }) diff --git a/.claude/hooks/prose-trigger.mjs b/.claude/hooks/prose-trigger.mjs index c1bd1e4..59babc2 100755 --- a/.claude/hooks/prose-trigger.mjs +++ b/.claude/hooks/prose-trigger.mjs @@ -6,20 +6,19 @@ * no user words announce it. So the trigger is the *write*: a markdown file, or * an edit that adds a comment. * - * It is a reminder, never a block, and it fires once per session. A hook that - * argues on every edit becomes wallpaper. + * It is a reminder, never a block, and it states the whole rule every time. The + * short form it used to degrade to after the first firing is the form that gets + * read past, and the sentence being written is the thing at stake. * * Reads the hook payload on stdin, writes hook JSON on stdout. */ import { readPayload } from './payload.mjs' -import { firstThisSession } from './session-marker.mjs' /** A comment opener at the start of a line, in TypeScript and JavaScript. */ const ADDS_COMMENT = /(^|\n)\s*(\/\/|\/\*)/ -/** The first firing of a session explains the rule. */ -const FULL = +const RULE = 'This write is prose, not code. Every sentence is a claim, an order, or a measurement — ' + 'anything else is narration, so cut it. Then read back what you wrote: for every sentence ' + 'that quotes a message, states a number, names a file or symbol, asserts a cause, or dates ' + @@ -28,19 +27,27 @@ const FULL = 'sentence alone: a correction supersedes what it corrects, and a comment nobody reads end ' + 'to end only ever grows. No em dash in anything a person reads.' -/** Every firing after it asks the two questions instead of repeating the rule. */ -const SHORT = 'prose: measured? whole block read and cut?' - const payload = await readPayload() const path = payload.tool_input?.file_path ?? '' const written = payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' -if (!path.endsWith('.md') && !ADDS_COMMENT.test(written)) process.exit(0) -const first = firstThisSession('prose-trigger', payload.session_id) +/** + * A commit message is prose, and it is written through Bash rather than a Write. + * + * Anchored to the start of a command rather than matched anywhere in the string: + * a heredoc writing a test about `git commit`, or a grep for it, is not a commit. + * That false positive fired on the run that added this line. + */ +const command = payload.tool_input?.command ?? '' +const IS_COMMIT = /(?:^|[;&|]\s*|&&\s*|\|\|\s*)git\s+(?:commit|merge)\b/ + +const isProse = + path.endsWith('.md') || ADDS_COMMENT.test(written) || IS_COMMIT.test(command) +if (!isProse) process.exit(0) console.log( JSON.stringify({ - hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? FULL : SHORT } + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: RULE } }) ) diff --git a/.claude/settings.json b/.claude/settings.json index 54be0b6..a1dc391 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,12 +2,14 @@ "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit", + "matcher": "Write|Edit|Bash", "hooks": [ { "type": "command", "command": "node", - "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs"] + "args": [ + "${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs" + ] } ] } From cb93ed2aace91856ae23545d022ac40f058fd8cf Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 21:44:21 +0200 Subject: [PATCH 14/72] chore: the todo skill, and where a thing lands here Four destinations, and the first step is not one of them: is the fix small enough that it belongs in this commit instead. Writing a defect down converts it into a backlog item, and backlog items feel handled. The routing differs from the repo it came from because the destinations do. There is no docs/decisions/ here, so a reason goes to the memory directory, which is where every decision this branch made already lives. TODO.md is untracked, so anything another person needs is a GitHub issue rather than a note in it. The tense is the test. "Enable require-await" is a task; the paragraph explaining how two handlers came to be async without an await is a record. If the sentence explains, it is not a task. Its reference carries the gauge that caught the same drift in ploxc, with both figures attributed rather than restated as measurements: a fact from outside this repository cites its source or it goes. --- .claude/skills/todo/SKILL.md | 66 +++++++++++++++++++ .../the-explanation-that-ate-the-task.md | 25 +++++++ 2 files changed, 91 insertions(+) create mode 100644 .claude/skills/todo/SKILL.md create mode 100644 .claude/skills/todo/references/the-explanation-that-ate-the-task.md diff --git a/.claude/skills/todo/SKILL.md b/.claude/skills/todo/SKILL.md new file mode 100644 index 0000000..ff5f406 --- /dev/null +++ b/.claude/skills/todo/SKILL.md @@ -0,0 +1,66 @@ +--- +name: todo +description: Route something you want to record to the place that holds it — TODO.md, a GitHub issue, the memory directory, or the plan in flight. Use before writing down a task, a reason or a defect, and whenever the user says "note that", "write that down" or "add a TODO". Do NOT use to decide whether a change needs a test — that is test. +--- + +# Todo + +## First: is it yours to fix instead? + +Writing a defect down converts it into a backlog item, and backlog items feel +handled. Before choosing a place, choose whether there is one: + +- **the fix is small** — do it in this commit, and record nothing +- **larger** — tell the user, and let them decide +- **the user deferred it** — now it goes somewhere, and the rest of this decides where + +**The tell is the sentence you are about to write.** *"Pre-existing"*, *"older +than this branch"*, *"not mine"*, *"pulled in by proximity"*. Each of those can +be true, and none of them answers whether the fix is small. + +## Then: state, or history? + +| what you are writing | where it goes | +| --- | --- | +| something to **do**, on this branch or the next | `TODO.md`, one line | +| something a **user** would recognise as a bug or a request | a GitHub issue | +| **why** — a decision, a measurement, an approach that failed | the memory directory | +| a **preference** the user stated, or how they want to work | the memory directory, never the repository | +| something already in the **plan being executed** | there, and not twice | +| what a user can now **do** | `CHANGELOG.md`, at the release | + +**The tell is the tense.** *"Enable `require-await`"* is a task. *"Two handlers +stayed async because reading the version off the store took their only await, +and lint has the rule off"* is a record. If your sentence explains, it is not a +task, however true it is. + +**The other tell is length.** A task is one line, maybe three. The moment you +reach for a table, a code block, or a paragraph beginning "the cause is", you +are writing a record, and the memory directory is where records go. + +## `TODO.md` is not a record and not shared + +It is untracked. It does not survive a clone, it does not reach anyone else, and +it does not move with a branch. So: + +- **Anything another person needs** goes to a GitHub issue instead. A note to + yourself is the only thing this file holds. +- **A symbol name is authoritative, a line number is a hint.** Name the symbol; + a line number goes stale on the next edit above it. +- **A finished item is deleted, never ticked.** A ticked box is history, and + history in a task list is what makes a task list stop being read. + +## Before adding a paragraph to something already there + +The destination looks settled, so the test above gets skipped — and that is how +a task list turns into a history one paragraph at a time. + +Read the whole entry first. If your addition explains rather than instructs, +the entry stays one line and the explanation goes to memory. +→ [WHY: the explanation that ate the task](./references/the-explanation-that-ate-the-task.md) + +## What the user says goes where they say + +"Note that" and "write that down" name the act, not the destination. Route it by +the table, then say in one line where it landed and why, so a wrong call is +cheap to correct. diff --git a/.claude/skills/todo/references/the-explanation-that-ate-the-task.md b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md new file mode 100644 index 0000000..25a46ff --- /dev/null +++ b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md @@ -0,0 +1,25 @@ +# The explanation that ate the task + +Why the routing test runs again when you are only adding a paragraph. + +**Ploxc's `TODO.md` went back to being a history one appended paragraph at a +time, and nobody noticed until it was unusable.** The gauge that caught it is +lines per open item. Its own `todo` skill records **10.6** on the morning of the +split and **4.7** after it; both figures are quoted from there, not measured +here. + +```sh +awk 'END{printf "%.1f lines per open item\n", NR/o} /^[[:space:]]*- \[ \]/{o++}' TODO.md +``` + +**Read a rise, not a level.** Anything shorter than the current mean lowers it, +so six one-line tasks move the number as far as a large cut does. What it +measures is *prose per task*, and the only thing that raises it is prose. + +The shape is the same at document scale. Ploxc's `precommit` skill records +going 5398, 5523, 5699 and 5799 words over four review rounds, one correct step +added per round, until the round was stopped rather than the steps refused. + +Modbux's `TODO.md` is untracked and small enough that the gauge is not worth +running. The rule it produced is the part that transfers: **an addition that +explains belongs where records go, not on the task.** From 5e7db134e468bfb6a3bcf9ef9e5eac3add68afac Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 22:28:15 +0200 Subject: [PATCH 15/72] refactor: every component is wrapped in meme The checkpoint settled one rule: every component, props or not. A meter that accepts a declaration rendered as JSX somewhere or exported as its file default reads 104 wrapped and 86 bare, identically on main and on this branch, and a half-applied rule is what produced that split. The 86 are wrapped here, in one commit, because doing it in several is how the split happens again. The comparator is untouched. The four forwardRef inputs take the shape the rest of the codebase already uses: a named Forward component carrying the displayName, wrapped by meme. Assigning displayName to the memo instead would have left the inner component anonymous. One test changed. SerialGroupModal's store stub was a plain object read during render, so the test for a newly plugged adapter drove it by re-rendering the parent with identical props, which memo refuses. The stub is a real zustand store now, and the change reaches the component the way it does in the app. Dropping ports from that effect's dependency list turns that one test red and no other. --- src/renderer/src/App.tsx | 5 +- src/renderer/src/components/UpdateBanner.tsx | 5 +- .../client/ClientGrids/ClientGrids.tsx | 5 +- .../BitSettingsPopover/BitSettingsPopover.tsx | 155 +++++++++--------- .../RegisterGrid/BitMapRow/BitMapRow.tsx | 5 +- .../ClientGrids/RegisterGrid/RegisterGrid.tsx | 8 +- .../ClearButton/ClearButton.tsx | 5 +- .../ClearConfigButton/ClearConfigButton.tsx | 5 +- .../ClearFiltersButton/ClearFiltersButton.tsx | 5 +- .../MenuConnectionOptions.tsx | 5 +- .../MenuRegisterOptions.tsx | 5 +- .../ScanRegisters/ScanRegisters.tsx | 32 ++-- .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 32 ++-- .../PollButton/PollButton.tsx | 5 +- .../RawButton/RawButton.tsx | 5 +- .../ReadButton/ReadButton.tsx | 5 +- .../ShowLogButton/ShowLogButton.tsx | 5 +- .../TimeSettings/TimeSettings.tsx | 8 +- .../ToggleEndianButton/ToggleEndianButton.tsx | 5 +- .../RegisterGrid/columns/bitmapExpand.tsx | 5 +- .../RegisterGrid/columns/interpolation.tsx | 4 +- .../RegisterGrid/columns/write.tsx | 5 +- .../TransactionGrid/TransactionGrid.tsx | 12 +- .../ConnectionConfig/RtuConfig/RtuConfig.tsx | 8 +- .../ConnectionConfig/TcpConfig/TcpConfig.tsx | 4 +- .../SerialGroupModal/SerialGroupModal.tsx | 45 ++--- .../__tests__/SerialGroupModal.test.tsx | 26 +-- .../PrivilegedPortModal.tsx | 37 +++-- .../server/ServerConfig/ServerConfig.tsx | 4 +- .../ServerRtuConfig/ServerRtuConfig.tsx | 8 +- .../server/ServerGrid/ServerGrid.tsx | 5 +- .../ServerRegisters/ServerRegisters.tsx | 4 +- .../src/components/shared/CommandBlock.tsx | 93 ++++++----- .../src/components/shared/HomeButton.tsx | 5 +- .../src/components/shared/MessageReceiver.tsx | 5 +- .../src/components/shared/SliderComponent.tsx | 5 +- .../shared/inputs/AddressBaseInput.tsx | 111 ++++++------- .../components/shared/inputs/EndianTable.tsx | 149 +++++++++-------- .../components/shared/inputs/HostInput.tsx | 7 +- .../components/shared/inputs/LengthInput.tsx | 7 +- .../components/shared/inputs/UintInput.tsx | 7 +- .../components/shared/inputs/UnitIdInput.tsx | 7 +- src/renderer/src/containers/Home.tsx | 8 +- src/renderer/src/svg/Client.tsx | 5 +- src/renderer/src/svg/GithubCat.tsx | 5 +- src/renderer/src/svg/Ploxc.tsx | 5 +- src/renderer/src/svg/Server.tsx | 5 +- 47 files changed, 474 insertions(+), 422 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d9d9881..53ebfc0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,11 +1,12 @@ import { Box } from '@mui/material' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from './context/layout.zustand' import Home from './containers/Home' import Client from './containers/Client' import Server from './containers/Server' import UpdateBanner from './components/UpdateBanner' -const App = (): JSX.Element => { +const App = meme((): JSX.Element => { const appType = useLayoutZustand((z) => z.appType) return ( @@ -24,6 +25,6 @@ const App = (): JSX.Element => { ) -} +}) export default App diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index ea814d7..5bc2073 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -1,5 +1,6 @@ import { Alert, AlertTitle, IconButton, Link, Collapse } from '@mui/material' import CloseIcon from '@mui/icons-material/Close' +import { meme } from '@renderer/components/shared/inputs/meme' import { useEffect, useState } from 'react' const FORCE_SHOW_BANNER = false // Set to true for testing @@ -9,7 +10,7 @@ interface GitHubRelease { html_url: string } -const UpdateBanner = (): JSX.Element | null => { +const UpdateBanner = meme((): JSX.Element | null => { const [showBanner, setShowBanner] = useState(false) const [latestVersion, setLatestVersion] = useState(null) const [releaseUrl, setReleaseUrl] = useState(null) @@ -116,6 +117,6 @@ const UpdateBanner = (): JSX.Element | null => { ) -} +}) export default UpdateBanner diff --git a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx index e6155cf..5ae3544 100644 --- a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx +++ b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx @@ -1,5 +1,6 @@ import Box from '@mui/material/Box' import TransactionGrid from '@renderer/components/client/ClientGrids/TransactionGrid/TransactionGrid' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import RegisterGrid from './RegisterGrid/RegisterGrid' @@ -12,7 +13,7 @@ import RegisterGrid from './RegisterGrid/RegisterGrid' * the scan dialog puts it back the old way for anyone who would rather not * watch. */ -const ClientGrids = (): JSX.Element | null => { +const ClientGrids = meme((): JSX.Element | null => { const showLog = useLayoutZustand((z) => z.showLog) const showWhileScanning = useLayoutZustand((z) => z.showGridWhileScanning) const scanning = useRootZustand((z) => z.clientState.scanningRegisters) @@ -36,6 +37,6 @@ const ClientGrids = (): JSX.Element | null => { {showLog && !scanning && } ) -} +}) export default ClientGrids diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx index 590d01b..f30d3f2 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx @@ -1,5 +1,6 @@ import { Box, Paper, Popover, ToggleButton, ToggleButtonGroup } from '@mui/material' import { alpha } from '@mui/material/styles' +import { meme } from '@renderer/components/shared/inputs/meme' import { BitColor } from '@shared' interface BitSettingsPopoverProps { @@ -17,85 +18,87 @@ const COLOR_OPTIONS: { value: BitColor; palette: 'success' | 'warning' | 'error' { value: 'error', palette: 'error' } ] -const BitSettingsPopover = ({ - anchorEl, - onClose, - color, - invert, - onColorChange, - onInvertChange -}: BitSettingsPopoverProps): JSX.Element => { - const selected = color ?? 'default' +const BitSettingsPopover = meme( + ({ + anchorEl, + onClose, + color, + invert, + onColorChange, + onInvertChange + }: BitSettingsPopoverProps): JSX.Element => { + const selected = color ?? 'default' - return ( - - - {/* Invert toggle */} - - onInvertChange(!invert)} - sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} - > - Invert - - + + {/* Invert toggle */} + + onInvertChange(!invert)} + sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} + > + Invert + + - {/* Color swatches */} - - {COLOR_OPTIONS.map(({ value, palette }) => { - const isSelected = selected === value - return ( - onColorChange(value === 'default' ? undefined : value)} - sx={(theme) => ({ - width: 16, - height: 16, - borderRadius: '50%', - bgcolor: theme.palette[palette].main, - cursor: 'pointer', - outline: isSelected - ? `2px solid ${theme.palette[palette].main}` - : '2px solid transparent', - outlineOffset: 2, - boxShadow: isSelected - ? `0 0 8px ${alpha(theme.palette[palette].main, 0.5)}` - : 'none', - transition: 'outline 0.15s, box-shadow 0.15s, transform 0.1s', - '&:hover': { - transform: 'scale(1.15)', - boxShadow: `0 0 8px ${alpha(theme.palette[palette].main, 0.4)}` - } - })} - /> - ) - })} - - - - ) -} + {/* Color swatches */} + + {COLOR_OPTIONS.map(({ value, palette }) => { + const isSelected = selected === value + return ( + onColorChange(value === 'default' ? undefined : value)} + sx={(theme) => ({ + width: 16, + height: 16, + borderRadius: '50%', + bgcolor: theme.palette[palette].main, + cursor: 'pointer', + outline: isSelected + ? `2px solid ${theme.palette[palette].main}` + : '2px solid transparent', + outlineOffset: 2, + boxShadow: isSelected + ? `0 0 8px ${alpha(theme.palette[palette].main, 0.5)}` + : 'none', + transition: 'outline 0.15s, box-shadow 0.15s, transform 0.1s', + '&:hover': { + transform: 'scale(1.15)', + boxShadow: `0 0 8px ${alpha(theme.palette[palette].main, 0.4)}` + } + })} + /> + ) + })} + + + + ) + } +) export default BitSettingsPopover diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx index 1c88d70..2fbbc59 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx @@ -1,4 +1,5 @@ import { GridRow, GridRowProps } from '@mui/x-data-grid' +import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { BITMAP_DATATYPE } from '@shared' @@ -11,7 +12,7 @@ import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' // the virtual-scroller height slot when expanded. // ───────────────────────────────────────────────────────────────────────────── -const BitMapRow = (props: GridRowProps): JSX.Element => { +const BitMapRow = meme((props: GridRowProps): JSX.Element => { const address = props.rowId as number const expandedAddress = useBitMapZustand((z) => z.expandedAddress) @@ -50,6 +51,6 @@ const BitMapRow = (props: GridRowProps): JSX.Element => { )} ) -} +}) export default BitMapRow diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index c521ec8..f37d7ab 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -43,7 +43,7 @@ const Footer = meme(() => { // // // DataGrid -const RegisterGridContent = (): JSX.Element => { +const RegisterGridContent = meme((): JSX.Element => { const registerData = useDataZustand((z) => z.registerData) const registerMapping = useRootZustand((z) => z.registerMapping[z.registerConfig.type]) const columns = useRegisterGridColumns() @@ -209,19 +209,19 @@ const RegisterGridContent = (): JSX.Element => { }} /> ) -} +}) // // // // // DataGrid paper -const RegisterGrid = (): JSX.Element => { +const RegisterGrid = meme((): JSX.Element => { return ( ) -} +}) export default RegisterGrid diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx index 71bfe0b..ea09080 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx @@ -1,9 +1,10 @@ import Button from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback } from 'react' -const ClearButton = (): JSX.Element => { +const ClearButton = meme((): JSX.Element => { const noData = useDataZustand((z) => z.registerData.length === 0) const polling = useRootZustand((z) => z.clientState.polling) const disabled = noData || polling @@ -24,6 +25,6 @@ const ClearButton = (): JSX.Element => { Clear ) -} +}) export default ClearButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx index d9357a0..1c6a64b 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx @@ -1,10 +1,11 @@ import { Delete } from '@mui/icons-material' import IconButton from '@mui/material/IconButton' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback, useState } from 'react' -const ClearConfigButton = (): JSX.Element => { +const ClearConfigButton = meme((): JSX.Element => { const [warn, setWarn] = useState(false) const handleClick = useCallback(() => { @@ -27,6 +28,6 @@ const ClearConfigButton = (): JSX.Element => { ) -} +}) export default ClearConfigButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx index 6382845..72de78e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx @@ -6,6 +6,7 @@ import { useGridApiContext, useGridSelector } from '@mui/x-data-grid' +import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' // The grid sets a filter of its own while read configuration is on, to keep @@ -14,7 +15,7 @@ import { useCallback } from 'react' // survives a clear. Dropping it would fill the list with empty rows. const INTERNAL_FILTER_ID = 1 -const ClearFiltersButton = (): JSX.Element | null => { +const ClearFiltersButton = meme((): JSX.Element | null => { const apiRef = useGridApiContext() // Active items rather than the model: opening the filter panel already puts // an empty item in the model, and a form nobody has typed in yet is not a @@ -44,6 +45,6 @@ const ClearFiltersButton = (): JSX.Element | null => { ) -} +}) export default ClearFiltersButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx index c4e265e..4499266 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx @@ -1,12 +1,13 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' // RTU over TCP (encapsulated RTU) is a niche, TCP-family transport, so it lives // here in the options menu rather than as a third connection toggle. Only shown // when TCP is selected; serial RTU has no use for it. -const MenuConnectionOptions = (): JSX.Element | null => { +const MenuConnectionOptions = meme((): JSX.Element | null => { const protocol = useRootZustand((z) => z.connectionConfig.protocol) const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') @@ -39,6 +40,6 @@ const MenuConnectionOptions = (): JSX.Element | null => { ) -} +}) export default MenuConnectionOptions diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx index 9c5d2c3..da32dad 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx @@ -1,9 +1,10 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' -const MenuRegisterOptions = (): JSX.Element | null => { +const MenuRegisterOptions = meme((): JSX.Element | null => { const type = useRootZustand((z) => z.registerConfig.type) const advanceMode = useRootZustand((z) => z.registerConfig.advancedMode) @@ -40,6 +41,6 @@ const MenuRegisterOptions = (): JSX.Element | null => { ) -} +}) export default MenuRegisterOptions diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index e0475f9..a68c796 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -65,7 +65,7 @@ export const useScanRegistersZustand = create { +const UnitIdField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const unitId = useRootZustand((z) => String(z.connectionConfig.unitId)) const setUnitId = useRootZustand((z) => z.setUnitId) @@ -87,12 +87,12 @@ const UnitIdField = (): JSX.Element => { }} /> ) -} +}) // // // Address field with base toggle -const AddressField = (): JSX.Element => { +const AddressField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const address = useScanRegistersZustand((z) => z.address) const setAddress = useScanRegistersZustand((z) => z.setAddress) @@ -106,12 +106,12 @@ const AddressField = (): JSX.Element => { baseTestId="scan-base" /> ) -} +}) // // // Scan Length field -const ScanLengthField = (): JSX.Element => { +const ScanLengthField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const scanLength = useScanRegistersZustand((z) => String(z.scanLength)) const setScanLength = useScanRegistersZustand((z) => z.setScanLength) @@ -133,12 +133,12 @@ const ScanLengthField = (): JSX.Element => { }} /> ) -} +}) // // // Chunk Size field -const ChunkSizeField = (): JSX.Element => { +const ChunkSizeField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const chunkSize = useScanRegistersZustand((z) => String(z.chunkSize)) const setChunkSize = useScanRegistersZustand((z) => z.setChunkSize) @@ -163,12 +163,12 @@ const ChunkSizeField = (): JSX.Element => { }} /> ) -} +}) // // // Timeout field -const TimeoutField = (): JSX.Element => { +const TimeoutField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const timeout = useScanRegistersZustand((z) => z.timeout) const setTimeout = useScanRegistersZustand((z) => z.setTimeout) @@ -181,7 +181,7 @@ const TimeoutField = (): JSX.Element => { testId="scan-timeout-input" /> ) -} +}) // // @@ -192,29 +192,29 @@ const TimeoutField = (): JSX.Element => { // zero. So the length of the grid data is the count of what the scan turned // up, and it means that while a scan is running, since the same list holds // polled data the rest of the time. -const FoundCount = (): JSX.Element | null => { +const FoundCount = meme((): JSX.Element | null => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const count = useDataZustand((z) => z.registerData.length) if (!scanning) return null return -} +}) // // // Show the grid while scanning -const GridToggle = (): JSX.Element => { +const GridToggle = meme((): JSX.Element => { const shown = useLayoutZustand((z) => z.showGridWhileScanning) const toggle = useLayoutZustand((z) => z.toggleShowGridWhileScanning) return -} +}) // // // Scan button -const ScanButton = (): JSX.Element => { +const ScanButton = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningRegisters) const scan = useCallback(async () => { @@ -254,7 +254,7 @@ const ScanButton = (): JSX.Element => { {text} ) -} +}) // // diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index 39a2a15..932111a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -24,7 +24,7 @@ import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' // // // Start Unit ID field -const StartUnitIdField = (): JSX.Element => { +const StartUnitIdField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) const setStartUnitId = useScanUnitIdZustand((z) => z.setStartUnitId) @@ -46,12 +46,12 @@ const StartUnitIdField = (): JSX.Element => { }} /> ) -} +}) // // // Count field -const CountField = (): JSX.Element => { +const CountField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const count = useScanUnitIdZustand((z) => String(z.count)) const setCount = useScanUnitIdZustand((z) => z.setCount) @@ -73,12 +73,12 @@ const CountField = (): JSX.Element => { }} /> ) -} +}) // // // Address field with base toggle -const AddressField = (): JSX.Element => { +const AddressField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const address = useScanUnitIdZustand((z) => z.address) const setAddress = useScanUnitIdZustand((z) => z.setAddress) @@ -92,12 +92,12 @@ const AddressField = (): JSX.Element => { baseTestId="scan-unitid-base" /> ) -} +}) // // // Length field -const LengthField = (): JSX.Element => { +const LengthField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const length = useScanUnitIdZustand((z) => String(z.length)) const setLength = useScanUnitIdZustand((z) => z.setLength) @@ -119,12 +119,12 @@ const LengthField = (): JSX.Element => { }} /> ) -} +}) // // // Timeout field -const TimeoutField = (): JSX.Element => { +const TimeoutField = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const timeout = useScanUnitIdZustand((z) => z.timeout) const setTimeout = useScanUnitIdZustand((z) => z.setTimeout) @@ -137,12 +137,12 @@ const TimeoutField = (): JSX.Element => { testId="scan-unitid-timeout-input" /> ) -} +}) // // // Select register types -const SelectRegisterTypes = (): JSX.Element => { +const SelectRegisterTypes = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const setRegisterTypes = useScanUnitIdZustand((z) => z.setRegisterTypes) @@ -185,11 +185,11 @@ const SelectRegisterTypes = (): JSX.Element => { ) -} +}) // // Scan button -const ScanButton = (): JSX.Element => { +const ScanButton = meme((): JSX.Element => { const scanning = useRootZustand((z) => z.clientState.scanningUniId) const polling = useRootZustand((z) => z.clientState.polling) const disabled = useScanUnitIdZustand((z) => z.registerTypes.length === 0) @@ -232,7 +232,7 @@ const ScanButton = (): JSX.Element => { {text} ) -} +}) // // @@ -292,7 +292,7 @@ const ScanResultGrid = meme(() => { // // // Scan unit ids button -export const ScanUnitIdsButton = ({ setAnchor }: SetAnchorProps): JSX.Element => { +export const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') // Close the menu behind it, the way scanning registers does. Otherwise it is @@ -314,7 +314,7 @@ export const ScanUnitIdsButton = ({ setAnchor }: SetAnchorProps): JSX.Element => Scan Unit ID{`'`}s ) -} +}) // // diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx index b790273..d1b00ac 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx @@ -1,8 +1,9 @@ import Button, { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback } from 'react' -const PollButton = (): JSX.Element => { +const PollButton = meme((): JSX.Element => { const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') const polling = useRootZustand((z) => z.clientState.polling) @@ -25,6 +26,6 @@ const PollButton = (): JSX.Element => { Poll ) -} +}) export default PollButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx index 85969c2..eeb0d70 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx @@ -1,9 +1,10 @@ import Button from '@mui/material/Button' import { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' -const RawButton = (): JSX.Element | null => { +const RawButton = meme((): JSX.Element | null => { const type = useRootZustand((z) => z.registerConfig.type) const showRawValues = useLayoutZustand((z) => z.showClientRawValues) @@ -23,6 +24,6 @@ const RawButton = (): JSX.Element | null => { RAW ) -} +}) export default RawButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx index 156be64..5eeb6e8 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx @@ -1,8 +1,9 @@ import Button, { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback, useRef, useState } from 'react' -const ReadButton = (): JSX.Element => { +const ReadButton = meme((): JSX.Element => { const disabled = useRootZustand( (z) => z.clientState.connectState !== 'connected' || z.clientState.polling ) @@ -34,6 +35,6 @@ const ReadButton = (): JSX.Element => { Read ) -} +}) export default ReadButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx index f58b1f5..a687824 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx @@ -1,7 +1,8 @@ import Button, { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -const ShowLogButton = (): JSX.Element => { +const ShowLogButton = meme((): JSX.Element => { const showLog = useLayoutZustand((z) => z.showLog) const toggleShowLog = useLayoutZustand((z) => z.toggleShowLog) @@ -13,6 +14,6 @@ const ShowLogButton = (): JSX.Element => { {text} ) -} +}) export default ShowLogButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx index b8ad2e8..aeebbef 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx @@ -9,20 +9,20 @@ import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback, useState } from 'react' // Polling interval slider -const PollRate = (): JSX.Element => { +const PollRate = meme((): JSX.Element => { const value = useRootZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) const setValue = useRootZustand((z) => z.setPollRate) return setValue(v * 1000)} /> -} +}) // Read Timeout slider -const Timeout = (): JSX.Element => { +const Timeout = meme((): JSX.Element => { const value = useRootZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) const setValue = useRootZustand((z) => z.setTimeout) return setValue(v * 1000)} /> -} +}) const TimeSettings = meme(() => { const polling = useRootZustand((z) => z.clientState.polling) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx index 42de1f6..5398fbd 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx @@ -2,9 +2,10 @@ import ToggleButton from '@mui/material/ToggleButton' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import Tooltip from '@mui/material/Tooltip' import EndianTable from '@renderer/components/shared/inputs/EndianTable' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' -const ToggleEndianButton = (): JSX.Element | null => { +const ToggleEndianButton = meme((): JSX.Element | null => { const type = useRootZustand((z) => z.registerConfig.type) const littleEndian = useRootZustand((z) => z.registerConfig.littleEndian) const setLittleEndian = useRootZustand((z) => z.setLittleEndian) @@ -40,6 +41,6 @@ const ToggleEndianButton = (): JSX.Element | null => { ) -} +}) export default ToggleEndianButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx index 42c14ca..8d12335 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx @@ -1,5 +1,6 @@ import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material' import { Box } from '@mui/material' +import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' interface ExpandCellProps { @@ -7,7 +8,7 @@ interface ExpandCellProps { isBitmap: boolean } -export const ExpandCell = ({ address, isBitmap }: ExpandCellProps): JSX.Element => { +export const ExpandCell = meme(({ address, isBitmap }: ExpandCellProps): JSX.Element => { const expandedAddress = useBitMapZustand((z) => z.expandedAddress) const toggleExpanded = useBitMapZustand((z) => z.toggleExpanded) const isExpanded = expandedAddress === address @@ -44,4 +45,4 @@ export const ExpandCell = ({ address, isBitmap }: ExpandCellProps): JSX.Element ) -} +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index f21d3f6..ca9c442 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -163,7 +163,7 @@ interface ActionProps { address: number } -const Action = ({ type, address }: ActionProps): JSX.Element => { +const Action = meme(({ type, address }: ActionProps): JSX.Element => { const [open, setOpen] = useState(false) const actionCellRef = useRef(null) @@ -213,7 +213,7 @@ const Action = ({ type, address }: ActionProps): JSX.Element => { /> ) -} +}) export const interpolationColumn = (type: RegisterType): GridColDef => ({ field: 'interpolation', diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx index c4a0cb7..3383d5e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx @@ -2,6 +2,7 @@ import { Edit } from '@mui/icons-material' import { GridActionsColDef, useGridApiContext } from '@mui/x-data-grid' import { GridActionsCellItem } from '@mui/x-data-grid/components' import WriteModal from '@renderer/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { RegisterType, RegisterData } from '@shared' @@ -12,7 +13,7 @@ interface ActionProps { type: RegisterType } -const Action = ({ address, type }: ActionProps): JSX.Element => { +const Action = meme(({ address, type }: ActionProps): JSX.Element => { const [open, setOpen] = useState(false) const text = type === 'coils' ? 'Write Coil' : 'Write Register' @@ -57,7 +58,7 @@ const Action = ({ address, type }: ActionProps): JSX.Element => { )} ) -} +}) export const writeActionColumn = (type: RegisterType): GridActionsColDef => ({ field: 'actions', diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index d39df39..5bb9c3f 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -16,7 +16,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' // // // Log export button exports the transaction log as a CSV file -const ExportButton = (): JSX.Element => { +const ExportButton = meme((): JSX.Element => { const api = useGridApiContext() return ( @@ -32,21 +32,21 @@ const ExportButton = (): JSX.Element => { Export ) -} +}) // // // // // Clears the transaction log -const ClearButton = (): JSX.Element => { +const ClearButton = meme((): JSX.Element => { const clear = useRootZustand((z) => z.clearTransactions) return ( ) -} +}) // // @@ -114,7 +114,7 @@ const TransactionGridContent = meme(() => { // // // DataGrid paper -const TransactionGrid = (): JSX.Element => { +const TransactionGrid = meme((): JSX.Element => { return ( { ) -} +}) export default TransactionGrid diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index 6256a2d..022a830 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -116,7 +116,7 @@ const ComActions = meme(() => { // // // COM Port (composite) -const Com = (): JSX.Element => { +const Com = meme((): JSX.Element => { const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') useEffect(() => { @@ -129,7 +129,7 @@ const Com = (): JSX.Element => { ) -} +}) // // @@ -172,7 +172,7 @@ const ClientStopBitsSelect = meme(() => { return }) -const RtuConfig = (): JSX.Element => { +const RtuConfig = meme((): JSX.Element => { return ( @@ -186,5 +186,5 @@ const RtuConfig = (): JSX.Element => { ) -} +}) export default RtuConfig diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index f9941b7..2915783 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -59,7 +59,7 @@ const Port = meme(() => { ) }) -const TcpConfig = (): JSX.Element => { +const TcpConfig = meme((): JSX.Element => { return ( @@ -67,5 +67,5 @@ const TcpConfig = (): JSX.Element => { ) -} +}) export default TcpConfig diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 4fbb169..2707fe9 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -8,6 +8,7 @@ import { Typography } from '@mui/material' import CommandBlock from '@renderer/components/shared/CommandBlock' +import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { SerialGroupStatus, serialGroupCommandDisplay } from '@shared' import { useSnackbar } from 'notistack' @@ -59,7 +60,7 @@ const decline = (): void => { // // // The command, built from whoever is logged in -const Command = (): JSX.Element => { +const Command = meme((): JSX.Element => { const username = useSerialGroupZustand((z) => z.status?.username) // The group the refusing device actually belongs to, not an assumed dialout. const group = useSerialGroupZustand((z) => z.status?.group) @@ -69,12 +70,12 @@ const Command = (): JSX.Element => { testId="serial-group-command" /> ) -} +}) // // // After the command has run: in the file, not yet in the session -const PendingLogin = (): JSX.Element => { +const PendingLogin = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) return ( { in before Modbux can open a port. ) -} +}) // // // Before it has run: what is wrong, and what will fix it -const Explanation = (): JSX.Element => { +const Explanation = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) const username = useSerialGroupZustand((z) => z.status?.username) // A string or null, so it compares by value like any other primitive. @@ -123,29 +124,29 @@ const Explanation = (): JSX.Element => { ) -} +}) // // // Body -const Body = (): JSX.Element => { +const Body = meme((): JSX.Element => { const done = useSerialGroupZustand((z) => z.done) return done ? : -} +}) // // // Buttons -const NotNowButton = (): JSX.Element => { +const NotNowButton = meme((): JSX.Element => { const busy = useSerialGroupZustand((z) => z.busy) return ( ) -} +}) -const RunCommandButton = (): JSX.Element | null => { +const RunCommandButton = meme((): JSX.Element | null => { const busy = useSerialGroupZustand((z) => z.busy) const blocked = useSerialGroupZustand((z) => blockedReason(z.status)) const { enqueueSnackbar } = useSnackbar() @@ -171,18 +172,18 @@ const RunCommandButton = (): JSX.Element | null => { {busy ? 'Waiting for authorization…' : 'Run command'} ) -} +}) -const LaterButton = (): JSX.Element => { +const LaterButton = meme((): JSX.Element => { const setOpen = useSerialGroupZustand((z) => z.setOpen) return ( ) -} +}) -const LogoutButton = (): JSX.Element => { +const LogoutButton = meme((): JSX.Element => { const setOpen = useSerialGroupZustand((z) => z.setOpen) const { enqueueSnackbar } = useSnackbar() @@ -202,9 +203,9 @@ const LogoutButton = (): JSX.Element => { Log out now ) -} +}) -const Actions = (): JSX.Element => { +const Actions = meme((): JSX.Element => { const done = useSerialGroupZustand((z) => z.done) return ( @@ -221,15 +222,15 @@ const Actions = (): JSX.Element => { )} ) -} +}) // // // Title -const Title = (): JSX.Element => { +const Title = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) return Serial ports need the {group} group -} +}) // // @@ -239,7 +240,7 @@ interface Props { active: boolean } -const SerialGroupModal = ({ active }: Props): JSX.Element | null => { +const SerialGroupModal = meme(({ active }: Props): JSX.Element | null => { const open = useSerialGroupZustand((z) => z.open) const hasStatus = useSerialGroupZustand((z) => z.status !== null) @@ -284,6 +285,6 @@ const SerialGroupModal = ({ active }: Props): JSX.Element | null => { ) -} +}) export default SerialGroupModal diff --git a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx index 206f14c..3351ac5 100644 --- a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx @@ -1,21 +1,28 @@ // @vitest-environment happy-dom /// -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { create } from 'zustand' import type { SerialGroupStatus, SerialGroupFixResult } from '@shared' // ─── Store stub ────────────────────────────────────────────────────── // The real root store registers ipcRenderer listeners on import, which is far // more machinery than this component needs. Only the port list matters here. -const rootState = { serialPorts: [] as { path: string }[] } +interface RootStub { + serialPorts: { path: string }[] +} + +// A real store, not a plain object: a plugged-in adapter reaches the component +// by the store notifying it, and a stub that is only read during a render can +// only be driven by re-rendering the parent, which memo refuses. +const useRootStub = create(() => ({ serialPorts: [] })) vi.mock('@renderer/context/root.zustand', () => ({ - useRootZustand: Object.assign( - (selector: (state: typeof rootState) => unknown) => selector(rootState), - { getState: () => rootState } - ) + useRootZustand: Object.assign((selector: (state: RootStub) => unknown) => useRootStub(selector), { + getState: (): RootStub => useRootStub.getState() + }) })) const mockEnqueueSnackbar = vi.fn() @@ -75,7 +82,7 @@ describe('SerialGroupModal', () => { done: false, declined: false }) - rootState.serialPorts = [] + useRootStub.setState({ serialPorts: [] }) mockGetStatus.mockResolvedValue(needsMembership) mockApplyFix.mockResolvedValue(okResult) mockRequestLogout.mockResolvedValue(true) @@ -206,14 +213,13 @@ describe('SerialGroupModal', () => { it('checks again when an adapter is plugged in while RTU is already selected', async () => { // Nothing plugged in: every port opens, so there is nothing to say. mockGetStatus.mockResolvedValue({ ...needsMembership, needsMembership: false }) - const { rerender } = render() + render() await waitFor(() => expect(mockGetStatus).toHaveBeenCalledTimes(1)) expect(screen.queryByTestId('serial-group-modal')).not.toBeInTheDocument() // Refreshing the list is how a newly plugged adapter shows up. mockGetStatus.mockResolvedValue(needsMembership) - rootState.serialPorts = [{ path: '/dev/ttyACM0' }] - rerender() + act(() => useRootStub.setState({ serialPorts: [{ path: '/dev/ttyACM0' }] })) expect(await screen.findByTestId('serial-group-modal')).toBeInTheDocument() }) diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index eb1069e..e847464 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -12,6 +12,7 @@ import { Typography } from '@mui/material' import CommandBlock from '@renderer/components/shared/CommandBlock' +import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' import { PrivilegedPortFixMode, @@ -63,15 +64,15 @@ const close = (): void => { // // // Title -const Title = (): JSX.Element => { +const Title = meme((): JSX.Element => { const port = usePrivilegedPortZustand((z) => z.status?.port) return Port {port} needs a system setting -} +}) // // // What is in the way -const Explanation = (): JSX.Element => { +const Explanation = meme((): JSX.Element => { const port = usePrivilegedPortZustand((z) => z.status?.port) const floor = usePrivilegedPortZustand((z) => z.status?.unprivilegedPortStart) @@ -88,12 +89,12 @@ const Explanation = (): JSX.Element => { : `Until that floor is lowered, Modbux cannot use it and clients looking for ${port} will not find it.`} ) -} +}) // // // Permanently or until reboot, driving both the command shown and the one run -const ModeToggle = (): JSX.Element => { +const ModeToggle = meme((): JSX.Element => { const mode = usePrivilegedPortZustand((z) => z.mode) const setMode = usePrivilegedPortZustand((z) => z.setMode) @@ -114,12 +115,12 @@ const ModeToggle = (): JSX.Element => { ) -} +}) // // // The command, which follows the toggle so the two cannot drift apart -const Command = (): JSX.Element => { +const Command = meme((): JSX.Element => { const mode = usePrivilegedPortZustand((z) => z.mode) const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) @@ -130,12 +131,12 @@ const Command = (): JSX.Element => { testId="privileged-port-command" /> ) -} +}) // // // Don't ask again -const DontAskCheckbox = (): JSX.Element => { +const DontAskCheckbox = meme((): JSX.Element => { const dontAsk = usePrivilegedPortZustand((z) => z.dontAsk) const setDontAsk = usePrivilegedPortZustand((z) => z.setDontAsk) @@ -153,12 +154,12 @@ const DontAskCheckbox = (): JSX.Element => { label={Don't ask again} /> ) -} +}) // // // Body -const Body = (): JSX.Element => { +const Body = meme((): JSX.Element => { const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) return ( @@ -186,21 +187,21 @@ const Body = (): JSX.Element => { ) -} +}) // // // Buttons -const CancelButton = (): JSX.Element => { +const CancelButton = meme((): JSX.Element => { const busy = usePrivilegedPortZustand((z) => z.busy) return ( ) -} +}) -const RunCommandButton = (): JSX.Element | null => { +const RunCommandButton = meme((): JSX.Element | null => { const busy = usePrivilegedPortZustand((z) => z.busy) const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) const { enqueueSnackbar } = useSnackbar() @@ -231,12 +232,12 @@ const RunCommandButton = (): JSX.Element | null => { {busy ? 'Waiting for authorization…' : 'Run command'} ) -} +}) // // // MAIN -const PrivilegedPortModal = (): JSX.Element | null => { +const PrivilegedPortModal = meme((): JSX.Element | null => { const open = usePrivilegedPortZustand((z) => z.open) const hasStatus = usePrivilegedPortZustand((z) => z.status !== null) const ready = useServerZustand((z) => !!z.ready[z.selectedUuid]) @@ -286,6 +287,6 @@ const PrivilegedPortModal = (): JSX.Element | null => { ) -} +}) export default PrivilegedPortModal diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index 3f0ad57..931023b 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -210,7 +210,7 @@ const Port = meme(() => { // // // Server Config -const ServerConfig = (): JSX.Element => { +const ServerConfig = meme((): JSX.Element => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') return ( @@ -221,6 +221,6 @@ const ServerConfig = (): JSX.Element => { ) -} +}) export default ServerConfig diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index 3e94e1e..d8c0414 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -159,7 +159,7 @@ const ComActions = meme(() => { // // // COM Port (composite) -const Com = (): JSX.Element => { +const Com = meme((): JSX.Element => { useEffect(() => { useServerZustand.getState().refreshServerSerialPorts() }, []) @@ -171,7 +171,7 @@ const Com = (): JSX.Element => { ) -} +}) // // @@ -224,7 +224,7 @@ const ServerStopBitsSelect = meme(() => { ) }) -const ServerRtuConfig = (): JSX.Element => { +const ServerRtuConfig = meme((): JSX.Element => { return ( @@ -238,6 +238,6 @@ const ServerRtuConfig = (): JSX.Element => { ) -} +}) export default ServerRtuConfig diff --git a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx index 433eb0d..f64f9f7 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx @@ -2,8 +2,9 @@ import ServerBooleans from './ServerBooleans/ServerBooleans' import ServerRegisters from './ServerRegisters/ServerRegisters' import AddRegister from './ServerRegisters/AddRegister/AddRegister' import Box from '@mui/material/Box' +import { meme } from '@renderer/components/shared/inputs/meme' -const ServerGrid = (): JSX.Element => { +const ServerGrid = meme((): JSX.Element => { return ( { ) -} +}) export default ServerGrid diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index d306ae0..4dc037e 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -63,7 +63,7 @@ const getDisplayValue = (register: ServerRegister[number]): string | number => { return register.value } -const ServerRegisterValue = ({ register }: RowProps): JSX.Element => { +const ServerRegisterValue = meme(({ register }: RowProps): JSX.Element => { const [displayValue, setDisplayValue] = useState(() => getDisplayValue(register)) useEffect(() => { @@ -76,7 +76,7 @@ const ServerRegisterValue = ({ register }: RowProps): JSX.Element => { }, [register.value, register.params.stringValue, register]) return {displayValue} -} +}) const ServerRegisterRow = meme(({ register }: RowProps) => { const isBitmap = register.params.dataType === 'bitmap' diff --git a/src/renderer/src/components/shared/CommandBlock.tsx b/src/renderer/src/components/shared/CommandBlock.tsx index 0de3a84..222426e 100644 --- a/src/renderer/src/components/shared/CommandBlock.tsx +++ b/src/renderer/src/components/shared/CommandBlock.tsx @@ -1,5 +1,6 @@ import { Box, IconButton, Tooltip, Typography } from '@mui/material' import { Check, ContentCopy } from '@mui/icons-material' +import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useState } from 'react' /** @@ -9,53 +10,55 @@ import { useCallback, useState } from 'react' * screen rather than describing it. `copied` is local on purpose: two seconds * of a changed icon belongs to this element and nothing else reads it. */ -const CommandBlock = ({ command, testId }: { command: string; testId: string }): JSX.Element => { - const [copied, setCopied] = useState(false) +const CommandBlock = meme( + ({ command, testId }: { command: string; testId: string }): JSX.Element => { + const [copied, setCopied] = useState(false) - const handleCopy = useCallback(async (): Promise => { - try { - await navigator.clipboard.writeText(command) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch { - // Clipboard can be unavailable; the command stays selectable on screen. - } - }, [command]) + const handleCopy = useCallback(async (): Promise => { + try { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard can be unavailable; the command stays selectable on screen. + } + }, [command]) - return ( - ({ - display: 'flex', - alignItems: 'center', - gap: 1, - p: 1, - pl: 1.5, - borderRadius: 1, - border: `1px solid ${theme.palette.divider}`, - // A shade up from the dialog surface, as the scan modals nest theirs. - background: theme.palette.background.paper - })} - > - ({ + display: 'flex', + alignItems: 'center', + gap: 1, + p: 1, + pl: 1.5, + borderRadius: 1, + border: `1px solid ${theme.palette.divider}`, + // A shade up from the dialog surface, as the scan modals nest theirs. + background: theme.palette.background.paper + })} > - {command} - - - - {copied ? : } - - - - ) -} + + {command} + + + + {copied ? : } + + + + ) + } +) export default CommandBlock diff --git a/src/renderer/src/components/shared/HomeButton.tsx b/src/renderer/src/components/shared/HomeButton.tsx index fe39495..688e538 100644 --- a/src/renderer/src/components/shared/HomeButton.tsx +++ b/src/renderer/src/components/shared/HomeButton.tsx @@ -1,8 +1,9 @@ import Button from '@mui/material/Button' import { Home } from '@mui/icons-material' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -const HomeButton = (): JSX.Element | null => { +const HomeButton = meme((): JSX.Element | null => { const setAppType = useLayoutZustand((z) => z.setAppType) const hideHomeButton = useLayoutZustand((z) => z.hideHomeButton) @@ -19,6 +20,6 @@ const HomeButton = (): JSX.Element | null => { ) -} +}) export default HomeButton diff --git a/src/renderer/src/components/shared/MessageReceiver.tsx b/src/renderer/src/components/shared/MessageReceiver.tsx index 6eed740..ec7fae8 100644 --- a/src/renderer/src/components/shared/MessageReceiver.tsx +++ b/src/renderer/src/components/shared/MessageReceiver.tsx @@ -1,10 +1,11 @@ +import { meme } from '@renderer/components/shared/inputs/meme' import { onEvent } from '@renderer/events' import { BackendMessage } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' // Receives message and shows them in a snackbar -const MessageReceiver = (): null => { +const MessageReceiver = meme((): null => { const { enqueueSnackbar } = useSnackbar() const handleMessage = useCallback( @@ -23,5 +24,5 @@ const MessageReceiver = (): null => { }, [handleMessage]) return null -} +}) export default MessageReceiver diff --git a/src/renderer/src/components/shared/SliderComponent.tsx b/src/renderer/src/components/shared/SliderComponent.tsx index 03e0d26..b494bc1 100644 --- a/src/renderer/src/components/shared/SliderComponent.tsx +++ b/src/renderer/src/components/shared/SliderComponent.tsx @@ -1,6 +1,7 @@ import Box from '@mui/material/Box' import Slider from '@mui/material/Slider' import Typography from '@mui/material/Typography' +import { meme } from '@renderer/components/shared/inputs/meme' interface Props { label: string @@ -8,7 +9,7 @@ interface Props { setValue: (value: number) => void } -const SliderComponent = ({ label, value, setValue }: Props): JSX.Element => { +const SliderComponent = meme(({ label, value, setValue }: Props): JSX.Element => { const labelWidth = 70 const valueWidth = 25 @@ -46,6 +47,6 @@ const SliderComponent = ({ label, value, setValue }: Props): JSX.Element => { ) -} +}) export default SliderComponent diff --git a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx index 047a632..8d36f5d 100644 --- a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx +++ b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx @@ -4,6 +4,7 @@ import { MaskSetFn } from '@renderer/context/root.zustand.types' import { ElementType, useCallback } from 'react' import { maskInputProps } from './types' import UIntInput from './UintInput' +import { meme } from './meme' interface AddressBaseInputProps { disabled?: boolean @@ -13,66 +14,62 @@ interface AddressBaseInputProps { baseTestId: string } -const AddressBaseInput = ({ - disabled, - address, - setAddress, - testId, - baseTestId -}: AddressBaseInputProps): JSX.Element => { - const addressBase = useRootZustand((z) => z.registerConfig.addressBase) - const setAddressBase = useRootZustand((z) => z.setAddressBase) +const AddressBaseInput = meme( + ({ disabled, address, setAddress, testId, baseTestId }: AddressBaseInputProps): JSX.Element => { + const addressBase = useRootZustand((z) => z.registerConfig.addressBase) + const setAddressBase = useRootZustand((z) => z.setAddressBase) - const base = Number(addressBase) - const displayValue = String(address + base) + const base = Number(addressBase) + const displayValue = String(address + base) - const handleSetAddress = useCallback( - (v: string) => setAddress(String(Math.max(0, Number(v) - base))), - [setAddress, base] - ) + const handleSetAddress = useCallback( + (v: string) => setAddress(String(Math.max(0, Number(v) - base))), + [setAddress, base] + ) - return ( - , - inputProps: maskInputProps({ set: handleSetAddress, max: 65535 + base }), - endAdornment: ( - v !== null && setAddressBase(v)} - > - , + inputProps: maskInputProps({ set: handleSetAddress, max: 65535 + base }), + endAdornment: ( + v !== null && setAddressBase(v)} > - 0 - - - 1 - - - ) - } - }} - /> - ) -} + + 0 + + + 1 + + + ) + } + }} + /> + ) + } +) export default AddressBaseInput diff --git a/src/renderer/src/components/shared/inputs/EndianTable.tsx b/src/renderer/src/components/shared/inputs/EndianTable.tsx index 15738ad..e8d34a7 100644 --- a/src/renderer/src/components/shared/inputs/EndianTable.tsx +++ b/src/renderer/src/components/shared/inputs/EndianTable.tsx @@ -1,5 +1,6 @@ import { Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' import { tableCellClasses } from '@mui/material/TableCell' +import { meme } from './meme' /** * What BE and LE do to one value, shown rather than described. @@ -13,82 +14,86 @@ import { tableCellClasses } from '@mui/material/TableCell' * register grid, so a value looks the same wherever it appears. */ -const Hex = ({ children }: { children: string }): JSX.Element => ( - ({ - fontFamily: 'monospace', - color: theme.palette.primary.light, - fontSize: '0.9em' - })} - > - {children} - +const Hex = meme( + ({ children }: { children: string }): JSX.Element => ( + ({ + fontFamily: 'monospace', + color: theme.palette.primary.light, + fontSize: '0.9em' + })} + > + {children} + + ) ) -const EndianTable = (): JSX.Element => ( - - - 32 bit value: 0x12345678 - +const EndianTable = meme( + (): JSX.Element => ( + + + 32 bit value: 0x12345678 + - {/* - Set once here rather than per cell. The size comes down from the table, - and Hex sizes itself against it in em rather than in pixels, so one - number governs the lot. Padding does not come down: a cell brings its - own, so the outer two are cleared with pseudo classes, which is the only - way to reach first and last. - */} - - - - - Register 0 - Register 1 - ST - - - - - Big-Endian - - 0x1234 high - - - 0x5678 low - - - reg[0] := dWord.W1; reg[1] := dWord.W0; - - - - Little-Endian - - 0x5678 low - - - 0x1234 high - - - reg[0] := dWord.W0; reg[1] := dWord.W1; - - - -
+ {/* + Set once here rather than per cell. The size comes down from the table, + and Hex sizes itself against it in em rather than in pixels, so one + number governs the lot. Padding does not come down: a cell brings its + own, so the outer two are cleared with pseudo classes, which is the only + way to reach first and last. + */} + + + + + Register 0 + Register 1 + ST + + + + + Big-Endian + + 0x1234 high + + + 0x5678 low + + + reg[0] := dWord.W1; reg[1] := dWord.W0; + + + + Little-Endian + + 0x5678 low + + + 0x1234 high + + + reg[0] := dWord.W0; reg[1] := dWord.W1; + + + +
- - Big-Endian puts the high word first and is what most devices use. Pick the one your device - uses, or every 32-bit value reads as nonsense. - -
+ + Big-Endian puts the high word first and is what most devices use. Pick the one your device + uses, or every 32-bit value reads as nonsense. + +
+ ) ) export default EndianTable diff --git a/src/renderer/src/components/shared/inputs/HostInput.tsx b/src/renderer/src/components/shared/inputs/HostInput.tsx index 841ebac..8d981c7 100644 --- a/src/renderer/src/components/shared/inputs/HostInput.tsx +++ b/src/renderer/src/components/shared/inputs/HostInput.tsx @@ -1,7 +1,8 @@ import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const HostInput = forwardRef((props, ref) => { +const HostInputForward = forwardRef((props, ref) => { const { set, ...other } = props return ( ((props, ref) => { ) }) -HostInput.displayName = 'HostInput' +HostInputForward.displayName = 'HostInput' + +const HostInput = meme(HostInputForward) export default HostInput diff --git a/src/renderer/src/components/shared/inputs/LengthInput.tsx b/src/renderer/src/components/shared/inputs/LengthInput.tsx index f68682a..231b6e7 100644 --- a/src/renderer/src/components/shared/inputs/LengthInput.tsx +++ b/src/renderer/src/components/shared/inputs/LengthInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const LengthInput = forwardRef((props, ref) => { +const LengthInputForward = forwardRef((props, ref) => { const { set, max = 125, ...other } = props return ( ((props, ref) => ) }) -LengthInput.displayName = 'LengthInput' +LengthInputForward.displayName = 'LengthInput' + +const LengthInput = meme(LengthInputForward) export default LengthInput diff --git a/src/renderer/src/components/shared/inputs/UintInput.tsx b/src/renderer/src/components/shared/inputs/UintInput.tsx index 1118e26..a508975 100644 --- a/src/renderer/src/components/shared/inputs/UintInput.tsx +++ b/src/renderer/src/components/shared/inputs/UintInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const UIntInput = forwardRef((props, ref) => { +const UIntInputForward = forwardRef((props, ref) => { const { set, max = 65535, ...other } = props return ( ((props, ref) => { ) }) -UIntInput.displayName = 'UIntInput' +UIntInputForward.displayName = 'UIntInput' + +const UIntInput = meme(UIntInputForward) export default UIntInput diff --git a/src/renderer/src/components/shared/inputs/UnitIdInput.tsx b/src/renderer/src/components/shared/inputs/UnitIdInput.tsx index 65bdbbd..a40a1a3 100644 --- a/src/renderer/src/components/shared/inputs/UnitIdInput.tsx +++ b/src/renderer/src/components/shared/inputs/UnitIdInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const UnitIdInput = forwardRef((props, ref) => { +const UnitIdInputForward = forwardRef((props, ref) => { const { set, ...other } = props return ( ((props, ref) => ) }) -UnitIdInput.displayName = 'UnitIdInput' +UnitIdInputForward.displayName = 'UnitIdInput' + +const UnitIdInput = meme(UnitIdInputForward) export default UnitIdInput diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 9036945..681a5d0 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -121,7 +121,7 @@ const bottomElementsCommonSx: SxProps = { '&:hover': { opacity: 1 } } -const PloxcLogo = (): JSX.Element => { +const PloxcLogo = meme((): JSX.Element => { return ( { Ploxc ) -} +}) -const Version = (): JSX.Element => { +const Version = meme((): JSX.Element => { const version = useRootZustand((z) => z.version) return ( @@ -156,7 +156,7 @@ const Version = (): JSX.Element => { ) -} +}) // // diff --git a/src/renderer/src/svg/Client.tsx b/src/renderer/src/svg/Client.tsx index eeade82..891872e 100644 --- a/src/renderer/src/svg/Client.tsx +++ b/src/renderer/src/svg/Client.tsx @@ -1,6 +1,7 @@ +import { meme } from '@renderer/components/shared/inputs/meme' import { StyledSvg, StyledSvgProps } from './util' -const Client = ({ sx }: StyledSvgProps): JSX.Element => { +const Client = meme(({ sx }: StyledSvgProps): JSX.Element => { return ( { ) -} +}) export default Client diff --git a/src/renderer/src/svg/GithubCat.tsx b/src/renderer/src/svg/GithubCat.tsx index 881009a..0efd425 100644 --- a/src/renderer/src/svg/GithubCat.tsx +++ b/src/renderer/src/svg/GithubCat.tsx @@ -1,6 +1,7 @@ +import { meme } from '@renderer/components/shared/inputs/meme' import { StyledSvg, StyledSvgProps } from './util' -const GithubCat = ({ sx }: StyledSvgProps): JSX.Element => { +const GithubCat = meme(({ sx }: StyledSvgProps): JSX.Element => { return ( { /> ) -} +}) export default GithubCat diff --git a/src/renderer/src/svg/Ploxc.tsx b/src/renderer/src/svg/Ploxc.tsx index d226884..215548e 100644 --- a/src/renderer/src/svg/Ploxc.tsx +++ b/src/renderer/src/svg/Ploxc.tsx @@ -1,6 +1,7 @@ +import { meme } from '@renderer/components/shared/inputs/meme' import { StyledSvg, StyledSvgProps } from './util' -const Ploxc = ({ sx }: StyledSvgProps): JSX.Element => { +const Ploxc = meme(({ sx }: StyledSvgProps): JSX.Element => { return ( { /> ) -} +}) export default Ploxc diff --git a/src/renderer/src/svg/Server.tsx b/src/renderer/src/svg/Server.tsx index aed6ffb..4bb1758 100644 --- a/src/renderer/src/svg/Server.tsx +++ b/src/renderer/src/svg/Server.tsx @@ -1,6 +1,7 @@ +import { meme } from '@renderer/components/shared/inputs/meme' import { StyledSvg, StyledSvgProps } from './util' -const Server = ({ sx }: StyledSvgProps): JSX.Element => { +const Server = meme(({ sx }: StyledSvgProps): JSX.Element => { return ( @@ -12,6 +13,6 @@ const Server = ({ sx }: StyledSvgProps): JSX.Element => { ) -} +}) export default Server From 509da5dbbeaa72f5e12acc759adec3236a5e65f8 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 22:41:12 +0200 Subject: [PATCH 16/72] refactor: the local stores answer to one name The checkpoint settled the filename: .zustand.ts, matching the global stores in context/, and named after the component rather than the folder. Three files were still called _zustand.ts. They are serialGroupModal, privilegedPortModal and scanUnitIds now, and their seven importers say so. WriteModal carried its store inline, which made it the exception the rule would have had to remember. It comes out to writeModal.zustand.ts, and the eslint-disable header goes with it: it was there for the store's setters, and WriteModal.tsx lints clean without it. The typo in ValueInputZusand moves unchanged. Renaming it is step 8's, and doing it here would hide a rename inside a move. --- .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 2 +- .../MenuButton/ScanUnitIds/_columns.tsx | 2 +- .../{_zustand.ts => scanUnitIds.zustand.ts} | 0 .../columns/WriteModal/WriteModal.tsx | 58 +------------------ .../columns/WriteModal/writeModal.zustand.ts | 56 ++++++++++++++++++ .../ConnectionConfig/ConnectionConfig.tsx | 2 +- .../SerialGroupModal/SerialGroupModal.tsx | 2 +- .../__tests__/SerialGroupModal.test.tsx | 2 +- ...zustand.ts => serialGroupModal.zustand.ts} | 0 .../PrivilegedPortModal.tsx | 2 +- .../__tests__/PrivilegedPortModal.test.tsx | 2 +- ...tand.ts => privilegedPortModal.zustand.ts} | 0 12 files changed, 65 insertions(+), 63 deletions(-) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/{_zustand.ts => scanUnitIds.zustand.ts} (100%) create mode 100644 src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts rename src/renderer/src/components/client/SerialGroupModal/{_zustand.ts => serialGroupModal.zustand.ts} (100%) rename src/renderer/src/components/server/PrivilegedPortModal/{_zustand.ts => privilegedPortModal.zustand.ts} (100%) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index 932111a..19e087b 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -16,7 +16,7 @@ import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useRootZustand } from '@renderer/context/root.zustand' import { ElementType, useCallback, useMemo } from 'react' import useScanUnitIdColumns from './_columns' -import { useScanUnitIdZustand } from './_zustand' +import { useScanUnitIdZustand } from './scanUnitIds.zustand' import { ScanCloseButton, ScanProgress, ScanTimeoutField } from '../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx index ec27620..eb631a4 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx @@ -1,7 +1,7 @@ import { GridColDef } from '@mui/x-data-grid' import { RegisterType, ScanUnitIDResult } from '@shared' import { useMemo } from 'react' -import { useScanUnitIdZustand } from './_zustand' +import { useScanUnitIdZustand } from './scanUnitIds.zustand' import { Box } from '@mui/material' /** diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_zustand.ts rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index 8659e32..a7ffe0e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { Publish } from '@mui/icons-material' import { Box, @@ -15,64 +14,11 @@ import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSele import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' import { useMinMaxInteger } from '@renderer/hooks' -import { BaseDataType, BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' +import { BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { IMaskInput, IMask } from 'react-imask' -import { create } from 'zustand' -import { mutative } from 'zustand-mutative' - -interface ValueInputZusand { - dataType: BaseDataType - setDataType: (dataType: BaseDataType) => void - value: string - valid: boolean - setValue: MaskSetFn - address: number - setAddress: (address: number) => void - coilFunction: 5 | 15 - setCoilFunction: (coilFunction: 5 | 15) => void - coils: boolean[] - initCoils: (coils: boolean[]) => void - setCoils: (coil: boolean, index: number) => void -} - -const useValueInputZustand = create( - mutative((set) => ({ - dataType: 'int16', - setDataType: (dataType) => - set((state) => { - state.dataType = dataType - }), - value: '0', - valid: true, - setValue: (value, valid) => - set((state) => { - state.value = value - state.valid = !!valid - }), - address: 0, - setAddress: (address: number) => - set((state) => { - state.address = address - }), - coilFunction: 5, - setCoilFunction: (coilFunction: 5 | 15) => - set((state) => { - state.coilFunction = coilFunction - }), - coils: [], - initCoils: (coils) => - set((state) => { - state.coils = coils - }), - setCoils: (coil, index) => - set((state) => { - state.coils[index] = coil - }) - })) -) +import { useValueInputZustand } from './writeModal.zustand' const ValueInputForward = forwardRef((props, ref) => { const { set, ...other } = props diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts new file mode 100644 index 0000000..24b0ca8 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { BaseDataType } from '@shared' +import { create } from 'zustand' +import { mutative } from 'zustand-mutative' + +interface ValueInputZusand { + dataType: BaseDataType + setDataType: (dataType: BaseDataType) => void + value: string + valid: boolean + setValue: MaskSetFn + address: number + setAddress: (address: number) => void + coilFunction: 5 | 15 + setCoilFunction: (coilFunction: 5 | 15) => void + coils: boolean[] + initCoils: (coils: boolean[]) => void + setCoils: (coil: boolean, index: number) => void +} + +export const useValueInputZustand = create( + mutative((set) => ({ + dataType: 'int16', + setDataType: (dataType) => + set((state) => { + state.dataType = dataType + }), + value: '0', + valid: true, + setValue: (value, valid) => + set((state) => { + state.value = value + state.valid = !!valid + }), + address: 0, + setAddress: (address: number) => + set((state) => { + state.address = address + }), + coilFunction: 5, + setCoilFunction: (coilFunction: 5 | 15) => + set((state) => { + state.coilFunction = coilFunction + }), + coils: [], + initCoils: (coils) => + set((state) => { + state.coils = coils + }), + setCoils: (coil, index) => + set((state) => { + state.coils[index] = coil + }) + })) +) diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 6d89cf9..f479070 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -11,7 +11,7 @@ import { } from '@mui/material' import RtuConfig from './RtuConfig/RtuConfig' import SerialGroupModal from '@renderer/components/client/SerialGroupModal/SerialGroupModal' -import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/_zustand' +import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/serialGroupModal.zustand' import TcpConfig from './TcpConfig/TcpConfig' import { useRootZustand } from '@renderer/context/root.zustand' import { Protocol } from '@shared' diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 2707fe9..5f3ec03 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -13,7 +13,7 @@ import { useRootZustand } from '@renderer/context/root.zustand' import { SerialGroupStatus, serialGroupCommandDisplay } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' -import { useSerialGroupZustand } from './_zustand' +import { useSerialGroupZustand } from './serialGroupModal.zustand' /** * Linux serial group modal diff --git a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx index 3351ac5..b5b1ef3 100644 --- a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx @@ -33,7 +33,7 @@ vi.mock('notistack', () => ({ })) import SerialGroupModal from '../SerialGroupModal' -import { useSerialGroupZustand } from '../_zustand' +import { useSerialGroupZustand } from '../serialGroupModal.zustand' // ─── window.api stub ───────────────────────────────────────────────── diff --git a/src/renderer/src/components/client/SerialGroupModal/_zustand.ts b/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts similarity index 100% rename from src/renderer/src/components/client/SerialGroupModal/_zustand.ts rename to src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index e847464..a937a8d 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -22,7 +22,7 @@ import { } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' -import { usePrivilegedPortZustand } from './_zustand' +import { usePrivilegedPortZustand } from './privilegedPortModal.zustand' /** * Linux privileged port modal diff --git a/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx b/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx index ea76976..0354e89 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx @@ -32,7 +32,7 @@ vi.mock('notistack', () => ({ })) import PrivilegedPortModal from '../PrivilegedPortModal' -import { usePrivilegedPortZustand } from '../_zustand' +import { usePrivilegedPortZustand } from '../privilegedPortModal.zustand' // ─── window.api stub ───────────────────────────────────────────────── diff --git a/src/renderer/src/components/server/PrivilegedPortModal/_zustand.ts b/src/renderer/src/components/server/PrivilegedPortModal/privilegedPortModal.zustand.ts similarity index 100% rename from src/renderer/src/components/server/PrivilegedPortModal/_zustand.ts rename to src/renderer/src/components/server/PrivilegedPortModal/privilegedPortModal.zustand.ts From 5eb0b6ee95d04abc811285f1b394857afcc8c5a5 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 22:54:21 +0200 Subject: [PATCH 17/72] refactor: MUI comes in by the door it lives behind The checkpoint settled deep imports. It counted 42 sites, which was @mui/material alone; the sweep also takes @mui/icons-material, @mui/x-data-grid and @mui/x-date-pickers, because the rule is about barrels and those are barrels. 80 barrel imports across 57 files. Every target was chosen by compiling it rather than by the shape of the name, and three of them would have gone wrong the other way: GridRowProps lives in components, not models, and DateTimePicker and LocalizationProvider are named exports rather than defaults. Five sites keep the barrel. The exports map in @mui/x-data-grid/package.json declares thirteen subpaths besides the root, and useGridApiContext and useGridApiRef are exported by none of them, so the root is the only place they can be had. --- src/renderer/src/App.tsx | 2 +- src/renderer/src/components/UpdateBanner.tsx | 6 ++++- .../BitIndicator/BitIndicator.tsx | 8 +++++-- .../BitMapDetailPanel/BitMapDetailPanel.tsx | 2 +- .../BitSettingsPopover/BitSettingsPopover.tsx | 6 ++++- .../RegisterGrid/BitMapRow/BitMapRow.tsx | 2 +- .../ClientGrids/RegisterGrid/RegisterGrid.tsx | 15 +++++------- .../ClearConfigButton/ClearConfigButton.tsx | 2 +- .../ClearFiltersButton/ClearFiltersButton.tsx | 6 ++--- .../LoadButton/LoadButton.tsx | 2 +- .../MenuButton/MenuButton.tsx | 2 +- .../MenuButton/ScanProgress/ScanProgress.tsx | 19 +++++++-------- .../ScanRegisters/ScanRegisters.tsx | 7 +++++- .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 22 ++++++++--------- .../MenuButton/ScanUnitIds/_columns.tsx | 4 ++-- .../RegisterGridToolbar.tsx | 2 +- .../SaveButton/SaveButton.tsx | 2 +- .../TimeSettings/TimeSettings.tsx | 2 +- .../columns/WriteModal/WriteModal.tsx | 22 ++++++++--------- .../RegisterGrid/columns/address.tsx | 2 +- .../RegisterGrid/columns/bitmapExpand.tsx | 5 ++-- .../RegisterGrid/columns/groupEnd.tsx | 3 ++- .../RegisterGrid/columns/index.tsx | 2 +- .../RegisterGrid/columns/interpolation.tsx | 12 +++++++--- .../RegisterGrid/columns/value.tsx | 2 +- .../RegisterGrid/columns/write.tsx | 5 ++-- .../TransactionGrid/TransactionGrid.tsx | 14 +++++------ .../ClientGrids/TransactionGrid/_columns.tsx | 4 ++-- .../ConnectionConfig/ConnectionConfig.tsx | 20 +++++++--------- .../ConnectionConfig/RtuConfig/RtuConfig.tsx | 9 +++++-- .../ConnectionConfig/TcpConfig/TcpConfig.tsx | 4 +++- .../client/RegisterConfig/RegisterConfig.tsx | 22 ++++++++--------- .../SerialGroupModal/SerialGroupModal.tsx | 16 ++++++------- .../server/OpenSaveClear/OpenSaveClear.tsx | 7 ++++-- .../PrivilegedPortModal.tsx | 24 +++++++++---------- .../server/SelectServer/SelectServer.tsx | 3 ++- .../server/ServerConfig/ServerConfig.tsx | 12 ++++------ .../ServerRtuConfig/ServerRtuConfig.tsx | 18 +++++++------- .../ServerBooleans/ServerBooleans.tsx | 7 +++++- .../ServerPartTitle/ServerPartTitle.tsx | 4 +++- .../AddRegister/AddRegister.tsx | 5 +++- .../AddRegister/addRegisterActions.tsx | 4 ++-- .../AddRegister/registerFields.tsx | 5 +++- .../AddRegister/valueParameters.tsx | 8 +++++-- .../ServerBitMapDetail/ServerBitMapDetail.tsx | 2 +- .../ServerRegisters/ServerRegisters.tsx | 9 +++++-- .../server/ServerGrid/shared/ServerBit.tsx | 5 +++- .../src/components/shared/CommandBlock.tsx | 8 +++++-- .../src/components/shared/HomeButton.tsx | 2 +- .../shared/inputs/AddressBaseInput.tsx | 5 +++- .../shared/inputs/DataTypeSelectInput.tsx | 5 +++- .../components/shared/inputs/EndianTable.tsx | 8 ++++++- .../shared/inputs/SerialPortInputs.tsx | 18 +++++++------- src/renderer/src/containers/Home.tsx | 8 +++++-- src/renderer/src/main.tsx | 5 ++-- src/renderer/src/svg/util.tsx | 2 +- src/renderer/src/theme/index.ts | 2 +- 57 files changed, 245 insertions(+), 184 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 53ebfc0..a09e9b2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from './context/layout.zustand' import Home from './containers/Home' diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 5bc2073..9559a43 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -1,4 +1,8 @@ -import { Alert, AlertTitle, IconButton, Link, Collapse } from '@mui/material' +import Alert from '@mui/material/Alert' +import AlertTitle from '@mui/material/AlertTitle' +import Collapse from '@mui/material/Collapse' +import IconButton from '@mui/material/IconButton' +import Link from '@mui/material/Link' import CloseIcon from '@mui/icons-material/Close' import { meme } from '@renderer/components/shared/inputs/meme' import { useEffect, useState } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx index 425c0ee..ed18aac 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx @@ -1,5 +1,9 @@ -import { SettingsOutlined } from '@mui/icons-material' -import { Box, Paper, TextField, Theme, Typography } from '@mui/material' +import SettingsOutlined from '@mui/icons-material/SettingsOutlined' +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { Theme } from '@mui/material/styles' import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useState } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx index 26551b4..8725763 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { useDataZustand } from '@renderer/context/data.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx index f30d3f2..5621d72 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx @@ -1,4 +1,8 @@ -import { Box, Paper, Popover, ToggleButton, ToggleButtonGroup } from '@mui/material' +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import Popover from '@mui/material/Popover' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { BitColor } from '@shared' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx index 2fbbc59..feb68c1 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx @@ -1,4 +1,4 @@ -import { GridRow, GridRowProps } from '@mui/x-data-grid' +import { GridRow, GridRowProps } from '@mui/x-data-grid/components' import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index f37d7ab..175fa2c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -1,4 +1,5 @@ -import { Paper, Typography } from '@mui/material' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' import { useRootZustand } from '@renderer/context/root.zustand' import { DateTime } from 'luxon' import { meme } from '@renderer/components/shared/inputs/meme' @@ -6,14 +7,10 @@ import { useDataZustand } from '@renderer/context/data.zustand' import { useEffect, useRef } from 'react' import useRegisterGridColumns from './columns' import RegisterGridToolbar from './RegisterGridToolbar/RegisterGridToolbar' -import { - DataGrid, - GridFilterModel, - GridFooterContainer, - GridLogicOperator, - GridPagination, - useGridApiRef -} from '@mui/x-data-grid' +import { useGridApiRef } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid/DataGrid' +import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' +import { GridFilterModel, GridLogicOperator } from '@mui/x-data-grid/models' import { DataType, RegisterData } from '@shared' import { alpha } from '@mui/material/styles' import { showMapping } from '@renderer/context/data.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx index 1c6a64b..5ef9977 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx @@ -1,4 +1,4 @@ -import { Delete } from '@mui/icons-material' +import Delete from '@mui/icons-material/Delete' import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx index 72de78e..dc909ca 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx @@ -1,11 +1,11 @@ -import { FilterAltOff } from '@mui/icons-material' +import FilterAltOff from '@mui/icons-material/FilterAltOff' import IconButton from '@mui/material/IconButton' +import { useGridApiContext } from '@mui/x-data-grid' import { gridFilterActiveItemsSelector, gridFilterModelSelector, - useGridApiContext, useGridSelector -} from '@mui/x-data-grid' +} from '@mui/x-data-grid/hooks' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx index 1f57d88..c5511b9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx @@ -1,4 +1,4 @@ -import { FileOpen } from '@mui/icons-material' +import FileOpen from '@mui/icons-material/FileOpen' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx index ce71c7c..3faf14c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx @@ -8,7 +8,7 @@ import ScanRegistersButton, { SetAnchorProps } from './ScanRegistersButton/ScanR import { ScanUnitIdsButton } from './ScanUnitIds/ScanUnitIds' import FormGroup from '@mui/material/FormGroup' import Button from '@mui/material/Button' -import { Settings } from '@mui/icons-material' +import Settings from '@mui/icons-material/Settings' import Popover from '@mui/material/Popover' const MenuContent = meme(({ setAnchor }: SetAnchorProps) => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx index c95be19..ccc1bdf 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx @@ -1,13 +1,12 @@ -import { - Button, - IconButton, - InputBaseComponentProps, - LinearProgress, - TextField, - Tooltip, - Typography -} from '@mui/material' -import { Visibility, VisibilityOff } from '@mui/icons-material' +import Button from '@mui/material/Button' +import IconButton from '@mui/material/IconButton' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import LinearProgress from '@mui/material/LinearProgress' +import TextField from '@mui/material/TextField' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import Visibility from '@mui/icons-material/Visibility' +import VisibilityOff from '@mui/icons-material/VisibilityOff' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index a68c796..eaf76eb 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { Box, Button, InputBaseComponentProps, Modal, Paper, TextField } from '@mui/material' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import Modal from '@mui/material/Modal' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { ElementType, useCallback, useMemo } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index 19e087b..a0a06e9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -1,15 +1,13 @@ -import { - alpha, - Box, - Button, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' -import { DataGrid } from '@mui/x-data-grid' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import Modal from '@mui/material/Modal' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { alpha } from '@mui/material/styles' +import { DataGrid } from '@mui/x-data-grid/DataGrid' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx index eb631a4..5f23022 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx @@ -1,8 +1,8 @@ -import { GridColDef } from '@mui/x-data-grid' +import { GridColDef } from '@mui/x-data-grid/models' import { RegisterType, ScanUnitIDResult } from '@shared' import { useMemo } from 'react' import { useScanUnitIdZustand } from './scanUnitIds.zustand' -import { Box } from '@mui/material' +import Box from '@mui/material/Box' /** * What a unit ID did with one request. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx index eddf447..8a1d08f 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { meme } from '@renderer/components/shared/inputs/meme' import PollButton from './PollButton/PollButton' import ReadButton from './ReadButton/ReadButton' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx index af5821d..0a0345c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx @@ -1,4 +1,4 @@ -import { Save } from '@mui/icons-material' +import Save from '@mui/icons-material/Save' import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx index aeebbef..6c12666 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx @@ -1,4 +1,4 @@ -import { Timer } from '@mui/icons-material' +import Timer from '@mui/icons-material/Timer' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import Paper from '@mui/material/Paper' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index a7ffe0e..ee03007 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -1,15 +1,13 @@ -import { Publish } from '@mui/icons-material' -import { - Box, - Button, - ButtonGroup, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' +import Publish from '@mui/icons-material/Publish' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import ButtonGroup from '@mui/material/ButtonGroup' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import Modal from '@mui/material/Modal' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/address.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/address.tsx index de17131..447c008 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/address.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/address.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { GridColDef } from '@mui/x-data-grid/models' import { RegisterData } from '@shared' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx index 8d12335..54cde71 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx @@ -1,5 +1,6 @@ -import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material' -import { Box } from '@mui/material' +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown' +import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight' +import Box from '@mui/material/Box' import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/groupEnd.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/groupEnd.tsx index 6c9c6d9..47d0d89 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/groupEnd.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/groupEnd.tsx @@ -1,4 +1,5 @@ -import { CheckCircle, CircleOutlined } from '@mui/icons-material' +import CheckCircle from '@mui/icons-material/CheckCircle' +import CircleOutlined from '@mui/icons-material/CircleOutlined' import { GridColDef } from '@mui/x-data-grid/models' import { RegisterData, RegisterMapObject } from '@shared' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx index b6dbf67..db74fd8 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx @@ -1,4 +1,4 @@ -import { GridColDef } from '@mui/x-data-grid' +import { GridColDef } from '@mui/x-data-grid/models' import { useRootZustand } from '@renderer/context/root.zustand' import { RegisterData } from '@shared' import { useMemo } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index ca9c442..2345129 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -1,10 +1,16 @@ -import { ArrowRightAlt, Functions, Refresh } from '@mui/icons-material' -import { FormLabel, IconButton, InputBaseComponentProps } from '@mui/material' +import ArrowRightAlt from '@mui/icons-material/ArrowRightAlt' +import Functions from '@mui/icons-material/Functions' +import Refresh from '@mui/icons-material/Refresh' +import FormLabel from '@mui/material/FormLabel' +import IconButton from '@mui/material/IconButton' +import { InputBaseComponentProps } from '@mui/material/InputBase' import Box from '@mui/material/Box' import Modal from '@mui/material/Modal' import Paper from '@mui/material/Paper' import TextField from '@mui/material/TextField' -import { GridActionsCellItem, GridColDef, useGridApiContext } from '@mui/x-data-grid' +import { useGridApiContext } from '@mui/x-data-grid' +import { GridActionsCellItem } from '@mui/x-data-grid/components' +import { GridColDef } from '@mui/x-data-grid/models' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx index 88ad3b5..8c867fc 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { GridColDef } from '@mui/x-data-grid/models' import { DataType, RegisterData, RegisterDataWords } from '@shared' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx index 3383d5e..f833e0b 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx @@ -1,5 +1,6 @@ -import { Edit } from '@mui/icons-material' -import { GridActionsColDef, useGridApiContext } from '@mui/x-data-grid' +import Edit from '@mui/icons-material/Edit' +import { useGridApiContext } from '@mui/x-data-grid' +import { GridActionsColDef } from '@mui/x-data-grid/models' import { GridActionsCellItem } from '@mui/x-data-grid/components' import WriteModal from '@renderer/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index 5bb9c3f..bd9d163 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -1,11 +1,9 @@ -import { Box, Button, Paper } from '@mui/material' -import { - DataGrid, - GridFooterContainer, - GridPagination, - useGridApiContext, - useGridApiRef -} from '@mui/x-data-grid' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Paper from '@mui/material/Paper' +import { useGridApiContext, useGridApiRef } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid/DataGrid' +import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' import { useRootZustand } from '@renderer/context/root.zustand' import useTransactionGridColumns from './_columns' import { DateTime } from 'luxon' diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx index b293ddb..08d31c9 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx @@ -1,5 +1,5 @@ -import { Box } from '@mui/material' -import { GridColDef } from '@mui/x-data-grid' +import Box from '@mui/material/Box' +import { GridColDef } from '@mui/x-data-grid/models' import { Transaction } from '@shared' import { DateTime } from 'luxon' import { useMemo } from 'react' diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index f479070..e946d17 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -1,14 +1,12 @@ -import { - Box, - Button, - ButtonProps, - CircularProgress, - InputBaseComponentProps, - TextField, - ToggleButton, - ToggleButtonGroup, - Tooltip -} from '@mui/material' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import { ButtonProps } from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import Tooltip from '@mui/material/Tooltip' import RtuConfig from './RtuConfig/RtuConfig' import SerialGroupModal from '@renderer/components/client/SerialGroupModal/SerialGroupModal' import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/serialGroupModal.zustand' diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index 022a830..39d821f 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -1,5 +1,10 @@ -import { Autocomplete, Box, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material' -import { CheckCircleOutlined, Refresh } from '@mui/icons-material' +import Autocomplete from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' +import Refresh from '@mui/icons-material/Refresh' import { meme } from '@renderer/components/shared/inputs/meme' import { BaudRateSelect, diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index 2915783..15a4780 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -1,4 +1,6 @@ -import { TextField, Box, InputBaseComponentProps } from '@mui/material' +import Box from '@mui/material/Box' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' import HostInput from '@renderer/components/shared/inputs/HostInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 955dc51..a627c69 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -1,15 +1,13 @@ -import { List } from '@mui/icons-material' -import { - MenuItem, - FormControl, - InputLabel, - Select, - TextField, - Box, - ToggleButtonGroup, - ToggleButton, - InputBaseComponentProps -} from '@mui/material' +import List from '@mui/icons-material/List' +import Box from '@mui/material/Box' +import FormControl from '@mui/material/FormControl' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' import LengthInput from '@renderer/components/shared/inputs/LengthInput' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 5f3ec03..69c4b74 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -1,12 +1,10 @@ -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Typography -} from '@mui/material' +import Alert from '@mui/material/Alert' +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx index 01ea939..05b646b 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx @@ -1,5 +1,8 @@ -import { FileOpen, Save, Delete } from '@mui/icons-material' -import { Box, IconButton } from '@mui/material' +import Delete from '@mui/icons-material/Delete' +import FileOpen from '@mui/icons-material/FileOpen' +import Save from '@mui/icons-material/Save' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { useServerZustand } from '@renderer/context/server.zustand' diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index a937a8d..e739ba8 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -1,16 +1,14 @@ -import { - Alert, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - ToggleButton, - ToggleButtonGroup, - Typography -} from '@mui/material' +import Alert from '@mui/material/Alert' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import FormControlLabel from '@mui/material/FormControlLabel' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' diff --git a/src/renderer/src/components/server/SelectServer/SelectServer.tsx b/src/renderer/src/components/server/SelectServer/SelectServer.tsx index fc966af..9f0a8a0 100644 --- a/src/renderer/src/components/server/SelectServer/SelectServer.tsx +++ b/src/renderer/src/components/server/SelectServer/SelectServer.tsx @@ -1,4 +1,5 @@ -import { Add, Delete } from '@mui/icons-material' +import Add from '@mui/icons-material/Add' +import Delete from '@mui/icons-material/Delete' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' import { findAvailablePort, MAIN_SERVER_UUID } from '@shared' diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index 931023b..be93d0b 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -1,11 +1,9 @@ import FormControl from '@mui/material/FormControl' -import { - TextField, - Box, - InputBaseComponentProps, - ToggleButtonGroup, - ToggleButton -} from '@mui/material' +import Box from '@mui/material/Box' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import InputLabel from '@mui/material/InputLabel' import { meme } from '@renderer/components/shared/inputs/meme' import { MaskInputProps, maskInputProps } from '@renderer/components/shared/inputs/types' diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index d8c0414..1026d51 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -1,12 +1,12 @@ -import { - alpha, - Autocomplete, - Box, - CircularProgress, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' -import { Refresh, Usb, UsbOff } from '@mui/icons-material' +import Autocomplete from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { alpha } from '@mui/material/styles' +import Refresh from '@mui/icons-material/Refresh' +import Usb from '@mui/icons-material/Usb' +import UsbOff from '@mui/icons-material/UsbOff' import { meme } from '@renderer/components/shared/inputs/meme' import { BaudRateSelect, diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx index 15dbb6c..27afd27 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx @@ -1,5 +1,10 @@ import { DeleteFilled, PlusCircleOutlined } from '@ant-design/icons' -import { Box, IconButton, InputBaseComponentProps, Paper, TextField, alpha } from '@mui/material' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' +import { alpha } from '@mui/material/styles' import { useServerZustand } from '@renderer/context/server.zustand' import { BooleanRegisters, ServerBoolEntry } from '@shared' import { deepEqual } from 'fast-equals' diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx index 1954a51..4926595 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx @@ -1,5 +1,7 @@ import { DeleteFilled, PlusCircleFilled } from '@ant-design/icons' -import { alpha, Box, IconButton } from '@mui/material' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import { alpha } from '@mui/material/styles' import { RegisterType } from '@shared' import { useCallback } from 'react' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx index 2d55049..cc6c379 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx @@ -1,4 +1,7 @@ -import { Box, Modal, Paper, Typography } from '@mui/material' +import Box from '@mui/material/Box' +import Modal from '@mui/material/Modal' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' import { useAddRegisterZustand } from './addRegister.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useEffect } from 'react' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx index be19236..7da0aef 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx @@ -1,13 +1,13 @@ /** * The dialog's buttons, and the submit they share. */ -import { Button } from '@mui/material' +import Button from '@mui/material/Button' import { useAddRegisterZustand } from './addRegister.zustand' import { getRegisterSize } from './addRegister.zustand.helpers' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useState } from 'react' import { useServerZustand } from '@renderer/context/server.zustand' -import { Delete } from '@mui/icons-material' +import Delete from '@mui/icons-material/Delete' export const AddButtons = meme(() => { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx index bd172f4..8d5d56e 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx @@ -1,7 +1,10 @@ /** * What the register is: where it lives, how it is read, what it is called. */ -import { FormControl, FormHelperText, InputBaseComponentProps, TextField } from '@mui/material' +import FormControl from '@mui/material/FormControl' +import FormHelperText from '@mui/material/FormHelperText' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' import { useAddRegisterZustand } from './addRegister.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx index 5a229cb..38ae65e 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -3,12 +3,16 @@ * * The fields swap with the data type, so the nine of them are one subject. */ -import { InputBaseComponentProps, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { useAddRegisterZustand } from './addRegister.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import { ElementType, useEffect } from 'react' -import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' import { DateTime } from 'luxon' import { ValueInput, MinInput, MaxInput, IntervalInput, RegisterLengthInput } from './maskedInputs' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx index c1154ed..99d769c 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { ServerRegisterEntry, BitMapConfig, getBit } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 4dc037e..223d872 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -1,5 +1,10 @@ -import { Edit, ExpandLess, ExpandMore } from '@mui/icons-material' -import { Paper, Box, IconButton, alpha } from '@mui/material' +import Edit from '@mui/icons-material/Edit' +import ExpandLess from '@mui/icons-material/ExpandLess' +import ExpandMore from '@mui/icons-material/ExpandMore' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import Paper from '@mui/material/Paper' +import { alpha } from '@mui/material/styles' import { NumberRegisters, ServerRegister } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' diff --git a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx index 4617b56..a830201 100644 --- a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx +++ b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx @@ -1,4 +1,7 @@ -import { Box, TextField, Typography, alpha } from '@mui/material' +import Box from '@mui/material/Box' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useState } from 'react' diff --git a/src/renderer/src/components/shared/CommandBlock.tsx b/src/renderer/src/components/shared/CommandBlock.tsx index 222426e..a9cea5b 100644 --- a/src/renderer/src/components/shared/CommandBlock.tsx +++ b/src/renderer/src/components/shared/CommandBlock.tsx @@ -1,5 +1,9 @@ -import { Box, IconButton, Tooltip, Typography } from '@mui/material' -import { Check, ContentCopy } from '@mui/icons-material' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import Check from '@mui/icons-material/Check' +import ContentCopy from '@mui/icons-material/ContentCopy' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useState } from 'react' diff --git a/src/renderer/src/components/shared/HomeButton.tsx b/src/renderer/src/components/shared/HomeButton.tsx index 688e538..ff40fd2 100644 --- a/src/renderer/src/components/shared/HomeButton.tsx +++ b/src/renderer/src/components/shared/HomeButton.tsx @@ -1,5 +1,5 @@ import Button from '@mui/material/Button' -import { Home } from '@mui/icons-material' +import Home from '@mui/icons-material/Home' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' diff --git a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx index 8d36f5d..986c505 100644 --- a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx +++ b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx @@ -1,4 +1,7 @@ -import { InputBaseComponentProps, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { useRootZustand } from '@renderer/context/root.zustand' import { MaskSetFn } from '@renderer/context/root.zustand.types' import { ElementType, useCallback } from 'react' diff --git a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx index 62ed59a..ccef04c 100644 --- a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx +++ b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx @@ -1,4 +1,7 @@ -import { FormControl, InputLabel, Select, MenuItem } from '@mui/material' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' import { BaseDataType } from '@shared' import { meme } from './meme' diff --git a/src/renderer/src/components/shared/inputs/EndianTable.tsx b/src/renderer/src/components/shared/inputs/EndianTable.tsx index e8d34a7..fb40f1b 100644 --- a/src/renderer/src/components/shared/inputs/EndianTable.tsx +++ b/src/renderer/src/components/shared/inputs/EndianTable.tsx @@ -1,4 +1,10 @@ -import { Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' +import Paper from '@mui/material/Paper' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Typography from '@mui/material/Typography' import { tableCellClasses } from '@mui/material/TableCell' import { meme } from './meme' diff --git a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx index 780a5b0..37fba00 100644 --- a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx +++ b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx @@ -1,13 +1,11 @@ -import { - AutocompleteRenderInputParams, - Box, - CircularProgress, - FormControl, - InputLabel, - MenuItem, - Select, - TextField -} from '@mui/material' +import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import TextField from '@mui/material/TextField' import { meme } from './meme' import { ModbusBaudRate, ModbusBaudRateSchema } from '@shared' import React, { useMemo } from 'react' diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 681a5d0..652c79c 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -1,5 +1,9 @@ -import { CallSplit } from '@mui/icons-material' -import { Fade, Box, Button, Typography, SxProps } from '@mui/material' +import CallSplit from '@mui/icons-material/CallSplit' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Fade from '@mui/material/Fade' +import Typography from '@mui/material/Typography' +import { SxProps } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useCallback, useEffect } from 'react' diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6962ad3..f054b62 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -8,11 +8,12 @@ import './index.css' import React from 'react' import ReactDOM from 'react-dom/client' -import { CssBaseline, IconButton } from '@mui/material' +import CssBaseline from '@mui/material/CssBaseline' +import IconButton from '@mui/material/IconButton' import { styled, ThemeProvider } from '@mui/material/styles' import { theme } from './theme' import { closeSnackbar, SnackbarProvider, MaterialDesignContent } from 'notistack' -import { Close } from '@mui/icons-material' +import Close from '@mui/icons-material/Close' import App from './App' const StyledMaterialDesignContent = styled(MaterialDesignContent)(() => ({ diff --git a/src/renderer/src/svg/util.tsx b/src/renderer/src/svg/util.tsx index bd884e7..62231d0 100644 --- a/src/renderer/src/svg/util.tsx +++ b/src/renderer/src/svg/util.tsx @@ -1,4 +1,4 @@ -import { styled, SxProps, Theme } from '@mui/material' +import { SxProps, Theme, styled } from '@mui/material/styles' export const StyledSvg = styled('svg')({}) diff --git a/src/renderer/src/theme/index.ts b/src/renderer/src/theme/index.ts index 3cd7b27..02b719f 100644 --- a/src/renderer/src/theme/index.ts +++ b/src/renderer/src/theme/index.ts @@ -1,6 +1,6 @@ // Brings the palette.DataGrid tokens into the type system. import '@mui/x-data-grid/themeAugmentation' -import { createTheme } from '@mui/material' +import { createTheme } from '@mui/material/styles' const base = createTheme({ breakpoints: { From ed8200ccfe651c68ef2271df211c6955c5ad586c Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 23:51:44 +0200 Subject: [PATCH 18/72] chore: the paper cuts Eight frozen typos: RootZusand, ValueInputZusand, typestampColumn, Connnection, Remeber, mobusServer.ts, lillteBigEndian.md, and the one a user reads, "Single register only supported fot 16 bit values". Six interfaces called Props are named after the component they belong to. Ten interactive elements carry a data-testid that did not. @main, @preload and @backend are gone from the vite config. All three resolved nothing: no file imports through any of them. src/backend was real between ec4898c and 3dea261, and the alias outlived the directory by more than a year. #2A2A2A was not a colour someone picked. The grid computes its own row background in dark mode as color-mix(in srgb, #1F1F1F 95%, #fff), which is 0.95 * 0x1F + 0.05 * 0xFF = 42.2, so #2A2A2A, and the two panels behind the server lists were a hand-copy of that. The value is named once as gridSurface and pinned into palette.DataGrid.bg, so the grid and the panels move together. x-data-grid augments PaletteOptions but not Palette, which is why the panels read the export rather than the theme. Both #cccccc sites read palette.info.main now, and the Ploxc logo's other fill reads palette.primary.main beside it. Found while measuring: the DateTimePicker already had add-reg-datetime-input, nested in slotProps where a meter looking at JSX attributes does not see it. Anything counting data-testid has to look there too. --- electron.vite.config.ts | 8 +- src/main/index.ts | 2 +- src/main/ipc.ts | 4 +- .../modules/__tests__/modbusServer.test.ts | 2 +- ...{lillteBigEndian.md => littleBigEndian.md} | 0 src/main/modules/modbusClient.ts | 4 +- .../{mobusServer.ts => modbusServer.ts} | 0 .../BitIndicator/BitIndicator.tsx | 1 + .../TimeSettings/TimeSettings.tsx | 18 ++- .../RegisterGridToolbar/components/index.tsx | 0 .../columns/WriteModal/WriteModal.tsx | 4 +- .../columns/WriteModal/writeModal.zustand.ts | 4 +- .../RegisterGrid/columns/binary.tsx | 4 +- .../RegisterGrid/columns/interpolation.tsx | 125 +++++++++--------- .../TransactionGrid/TransactionGrid.tsx | 3 +- .../ClientGrids/TransactionGrid/_columns.tsx | 4 +- .../SerialGroupModal/SerialGroupModal.tsx | 4 +- .../ServerBooleans/ServerBooleans.tsx | 3 +- .../ServerRegisters/ServerRegisters.tsx | 3 +- .../server/ServerGrid/shared/ServerBit.tsx | 1 + .../src/components/shared/CommandBlock.tsx | 7 +- .../src/components/shared/SliderComponent.tsx | 84 ++++++------ .../shared/inputs/DataTypeSelectInput.tsx | 62 ++++----- src/renderer/src/containers/Home.tsx | 4 +- src/renderer/src/context/root.zustand.ts | 4 +- .../src/context/root.zustand.types.ts | 2 +- src/renderer/src/main.tsx | 2 +- src/renderer/src/svg/Ploxc.tsx | 9 +- src/renderer/src/theme/index.ts | 14 +- 29 files changed, 216 insertions(+), 166 deletions(-) rename src/main/modules/{lillteBigEndian.md => littleBigEndian.md} (100%) rename src/main/modules/{mobusServer.ts => modbusServer.ts} (100%) delete mode 100644 src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/components/index.tsx diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ae3fb9e..e12a636 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,9 +7,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], resolve: { alias: { - '@main': resolve('src/main'), - '@shared': resolve('src/shared'), - '@backend': resolve('src/backend') + '@shared': resolve('src/shared') } } }, @@ -17,9 +15,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], resolve: { alias: { - '@preload': resolve('src/preload'), - '@shared': resolve('src/shared'), - '@backend': resolve('src/backend') + '@shared': resolve('src/shared') } } }, diff --git a/src/main/index.ts b/src/main/index.ts index 1c0a6ac..0b7fc47 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ import { initIpc, onIpcEvent } from './ipc' import { AppState } from './state' import { ModbusClient } from './modules/modbusClient' import os from 'os' -import { ModbusServer } from './modules/mobusServer' +import { ModbusServer } from './modules/modbusServer' import { Windows } from '@shared' if (is.dev && os.platform() === 'darwin') { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bb26c57..b742bf8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -15,7 +15,7 @@ import { SetBooleanParametersSchema } from '@shared' import { ModbusClient } from './modules/modbusClient' -import { ModbusServer } from './modules/mobusServer' +import { ModbusServer } from './modules/modbusServer' import { applyPrivilegedPortFix, getPrivilegedPortStatus } from './modules/privilegedPort' import { applySerialGroupFix, getSerialGroupStatus, requestLogout } from './modules/serialGroup' import { IpcMainEvent, IpcMainInvokeEvent, ipcMain } from 'electron' @@ -89,7 +89,7 @@ type InitIpcFn = ( export const initIpc: InitIpcFn = (app, state, client, server, windows) => { const ipcHandle = createIpcHandle(windows) - // Connnection config + // Connection config ipcHandle('get_connection_config', () => { // Validate and return the current connection config, or default if invalid const result = ConnectionConfigSchema.safeParse(state.connectionConfig) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 79d94cd..7c3d982 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -50,7 +50,7 @@ vi.mock('net', () => ({ } })) -import { ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS } from '../mobusServer' +import { ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS } from '../modbusServer' import { ServerTCP, ServerSerial } from 'modbus-serial' const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows diff --git a/src/main/modules/lillteBigEndian.md b/src/main/modules/littleBigEndian.md similarity index 100% rename from src/main/modules/lillteBigEndian.md rename to src/main/modules/littleBigEndian.md diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 2b9a855..c5d1d8d 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -105,7 +105,7 @@ export class ModbusClient { .on('close', () => { // If we were connected, go to 'connecting' and try to reconnect if (this._shouldAutoReconnect) { - // Remeber polling state before trying to reconnect + // Remember polling state before trying to reconnect this._reconnectWasPolling = this._clientState.polling // Only emit reconnecting message if not already in connecting state @@ -667,7 +667,7 @@ export class ModbusClient { if (single && !['int16', 'uint16'].includes(dataType)) { this._emitMessage({ - message: 'Single register only supported fot 16 bit values', + message: 'Single register only supported for 16 bit values', variant: 'warning', error: undefined }) diff --git a/src/main/modules/mobusServer.ts b/src/main/modules/modbusServer.ts similarity index 100% rename from src/main/modules/mobusServer.ts rename to src/main/modules/modbusServer.ts diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx index ed18aac..80d51e9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx @@ -125,6 +125,7 @@ const BitIndicator = meme( {/* Comment — inline beside the circle and index */} {editing ? ( { const value = useRootZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) const setValue = useRootZustand((z) => z.setPollRate) - return setValue(v * 1000)} /> + return ( + setValue(v * 1000)} + /> + ) }) // Read Timeout slider @@ -21,7 +28,14 @@ const Timeout = meme((): JSX.Element => { const value = useRootZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) const setValue = useRootZustand((z) => z.setTimeout) - return setValue(v * 1000)} /> + return ( + setValue(v * 1000)} + /> + ) }) const TimeSettings = meme(() => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/components/index.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/components/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index ee03007..1f0b1ac 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -267,7 +267,7 @@ const Coils = meme(() => { ) }) -interface Props { +interface WriteModalProps { address: number open: boolean onClose: () => void @@ -275,7 +275,7 @@ interface Props { type: RegisterType } -const WriteModal = meme(({ open, onClose, address, actionCellRef, type }: Props) => { +const WriteModal = meme(({ open, onClose, address, actionCellRef, type }: WriteModalProps) => { const rect = actionCellRef.current?.getBoundingClientRect() const right = (rect?.right ? window.innerWidth - rect.right : 0) + 38 const setValue = useValueInputZustand((z) => z.setValue) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts index 24b0ca8..9e8ff5f 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts @@ -4,7 +4,7 @@ import { BaseDataType } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' -interface ValueInputZusand { +interface ValueInputZustand { dataType: BaseDataType setDataType: (dataType: BaseDataType) => void value: string @@ -19,7 +19,7 @@ interface ValueInputZusand { setCoils: (coil: boolean, index: number) => void } -export const useValueInputZustand = create( +export const useValueInputZustand = create( mutative((set) => ({ dataType: 'int16', setDataType: (dataType) => diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx index 55f11b3..dadfbb9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx @@ -3,11 +3,11 @@ import Box from '@mui/material/Box' import { RegisterData } from '@shared' import { meme } from '@renderer/components/shared/inputs/meme' -interface Props { +interface WordLedDisplayProps { value: number | undefined } -const WordLedDisplay = meme(({ value = 0 }: Props): JSX.Element => { +const WordLedDisplay = meme(({ value = 0 }: WordLedDisplayProps): JSX.Element => { // Zorg dat we exact 16 bits hebben const bits = value .toString(2) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index 2345129..edd7425 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -66,6 +66,7 @@ interface InputFieldProps { const InputField = meme(({ interpolateKey, value, set }: InputFieldProps) => { return ( { ) }) -interface Props { +interface InterpolationModalProps { address: number open: boolean onClose: () => void @@ -98,71 +99,74 @@ const useInterpolateValue = ( return interpolate !== undefined ? interpolate[key] : defaultInterpolation[key] }) -const InterpolationModal = meme(({ open, onClose, actionCellRef, type, address }: Props) => { - const rect = actionCellRef.current?.getBoundingClientRect() - - const x1 = useInterpolateValue('x1', type, address) - const x2 = useInterpolateValue('x2', type, address) - const y1 = useInterpolateValue('y1', type, address) - const y2 = useInterpolateValue('y2', type, address) - - const handleChange = useCallback( - (key: keyof RegisterLinearInterpolation, value: string) => { - const state = useRootZustand.getState() - const interpolate: RegisterLinearInterpolation = state.registerMapping[type][address] - ?.interpolate || { ...defaultInterpolation } - state.setRegisterMapping(address, 'interpolate', { ...interpolate, [key]: value }) - }, - [type, address] - ) +const InterpolationModal = meme( + ({ open, onClose, actionCellRef, type, address }: InterpolationModalProps) => { + const rect = actionCellRef.current?.getBoundingClientRect() + + const x1 = useInterpolateValue('x1', type, address) + const x2 = useInterpolateValue('x2', type, address) + const y1 = useInterpolateValue('y1', type, address) + const y2 = useInterpolateValue('y2', type, address) + + const handleChange = useCallback( + (key: keyof RegisterLinearInterpolation, value: string) => { + const state = useRootZustand.getState() + const interpolate: RegisterLinearInterpolation = state.registerMapping[type][address] + ?.interpolate || { ...defaultInterpolation } + state.setRegisterMapping(address, 'interpolate', { ...interpolate, [key]: value }) + }, + [type, address] + ) - return ( - open && ( - - - + - Linear Interpolation - { - useRootZustand - .getState() - .setRegisterMapping(address, 'interpolate', { ...defaultInterpolation }) - }} + - - - - - - handleChange('x1', v)} /> - handleChange('x2', v)} /> + Linear Interpolation + { + useRootZustand + .getState() + .setRegisterMapping(address, 'interpolate', { ...defaultInterpolation }) + }} + > + + - - - handleChange('y1', v)} /> - handleChange('y2', v)} /> + + + handleChange('x1', v)} /> + handleChange('x2', v)} /> + + + + handleChange('y1', v)} /> + handleChange('y2', v)} /> + - - - + + + ) ) - ) -}) + } +) interface ActionProps { type: RegisterType @@ -195,6 +199,7 @@ const Action = meme(({ type, address }: ActionProps): JSX.Element => { return ( <> } diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index bd9d163..c1493ec 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -19,6 +19,7 @@ const ExportButton = meme((): JSX.Element => { return ( ) diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx index 08d31c9..9604fd5 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx @@ -4,7 +4,7 @@ import { Transaction } from '@shared' import { DateTime } from 'luxon' import { useMemo } from 'react' -const typestampColumn: GridColDef = { +const timestampColumn: GridColDef = { field: 'timestamp', headerName: 'Timestamp', hideable: false, @@ -97,7 +97,7 @@ const errorMessageColumn: GridColDef = { const useTransactionGridColumns = (): GridColDef[] => { return useMemo(() => { return [ - typestampColumn, + timestampColumn, unitIdColumn, addressColumn, // lengthColumn, diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 69c4b74..cb6a61b 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -233,12 +233,12 @@ const Title = meme((): JSX.Element => { // // // MAIN -interface Props { +interface SerialGroupModalProps { /** True while RTU is the selected transport. The check runs then, and only then. */ active: boolean } -const SerialGroupModal = meme(({ active }: Props): JSX.Element | null => { +const SerialGroupModal = meme(({ active }: SerialGroupModalProps): JSX.Element | null => { const open = useSerialGroupZustand((z) => z.open) const hasStatus = useSerialGroupZustand((z) => z.status !== null) diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx index 27afd27..8b4aec2 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx @@ -15,6 +15,7 @@ import useServerGridZustand from '../serverGrid.zustand' import ServerBit from '../shared/ServerBit' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { maskInputProps } from '@renderer/components/shared/inputs/types' +import { gridSurface } from '@renderer/theme' interface ServerBooleanProps { name: string @@ -227,7 +228,7 @@ const ServerBooleans = meme(({ name, type }: ServerBooleanProps) => { flex: 1, width: '100%', height: '100%', - backgroundColor: '#2A2A2A', + backgroundColor: gridSurface, fontSize: '0.95em', position: 'relative' }} diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 223d872..3a1b5f1 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -8,6 +8,7 @@ import { alpha } from '@mui/material/styles' import { NumberRegisters, ServerRegister } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' +import { gridSurface } from '@renderer/theme' import { useCallback, useEffect, useMemo, useState } from 'react' import { useAddRegisterZustand } from './AddRegister/addRegister.zustand' import ServerPartTitle from '../ServerPartTitle/ServerPartTitle' @@ -188,7 +189,7 @@ const ServerRegisters = meme(({ name, type }: ServerRegistersProps) => { flex: 1, width: '100%', height: '100%', - backgroundColor: '#2A2A2A', + backgroundColor: gridSurface, fontSize: '0.95em', position: 'relative' }} diff --git a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx index a830201..c75894f 100644 --- a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx +++ b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx @@ -106,6 +106,7 @@ const ServerBit = meme( {/* Comment — inline editable */} {!readOnly && editing ? ( - + {copied ? : } diff --git a/src/renderer/src/components/shared/SliderComponent.tsx b/src/renderer/src/components/shared/SliderComponent.tsx index b494bc1..9856d52 100644 --- a/src/renderer/src/components/shared/SliderComponent.tsx +++ b/src/renderer/src/components/shared/SliderComponent.tsx @@ -3,50 +3,58 @@ import Slider from '@mui/material/Slider' import Typography from '@mui/material/Typography' import { meme } from '@renderer/components/shared/inputs/meme' -interface Props { +interface SliderComponentProps { label: string value: number setValue: (value: number) => void + testId: string } -const SliderComponent = meme(({ label, value, setValue }: Props): JSX.Element => { - const labelWidth = 70 - const valueWidth = 25 +const SliderComponent = meme( + ({ label, value, setValue, testId }: SliderComponentProps): JSX.Element => { + const labelWidth = 70 + const valueWidth = 25 - return ( - - - {label} - - - { - const value = Array.isArray(v) ? v.at(0) : v - if (value === undefined) return - setValue(value) - }} - /> + return ( + + + {label} + + + { + const value = Array.isArray(v) ? v.at(0) : v + if (value === undefined) return + setValue(value) + }} + /> + + + {value} s + - - {value} s - - - ) -}) + ) + } +) export default SliderComponent diff --git a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx index ccef04c..0fb99f1 100644 --- a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx +++ b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx @@ -5,42 +5,44 @@ import Select from '@mui/material/Select' import { BaseDataType } from '@shared' import { meme } from './meme' -interface Props { +interface DataTypeSelectInputProps { disabled?: boolean dataType: BaseDataType setDataType: (dataType: BaseDataType) => void } -const DataTypeSelectInput = meme(({ disabled, dataType, setDataType }: Props) => { - const labelId = 'data-type-select' - return ( - - Type - setDataType(e.target.value as BaseDataType)} + > + INT16 + UINT16 + INT32 + UINT32 + FLOAT - INT64 - UINT64 - DOUBLE + INT64 + UINT64 + DOUBLE - UNIX - DATETIME - UTF-8 - BITMAP - - - ) -}) + UNIX + DATETIME + UTF-8 + BITMAP + + + ) + } +) export default DataTypeSelectInput diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 652c79c..678044d 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -137,7 +137,9 @@ const PloxcLogo = meme((): JSX.Element => { sx={{ left: 16, ...bottomElementsCommonSx }} > - Ploxc + ({ fontWeight: 800, color: theme.palette.info.main })}> + Ploxc + ) }) diff --git a/src/renderer/src/context/root.zustand.ts b/src/renderer/src/context/root.zustand.ts index 484888a..d604a35 100644 --- a/src/renderer/src/context/root.zustand.ts +++ b/src/renderer/src/context/root.zustand.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { mutative } from 'zustand-mutative' import { persist } from 'zustand/middleware' -import { PersistedRootZustand, PersistedRootZustandSchema, RootZusand } from './root.zustand.types' +import { PersistedRootZustand, PersistedRootZustandSchema, RootZustand } from './root.zustand.types' import { defaultConnectionConfig, defaultRegisterConfig, @@ -36,7 +36,7 @@ export const flushRegisterMappingToMain = (): void => { } export const useRootZustand = create< - RootZusand, + RootZustand, [['zustand/persist', PersistedRootZustand], ['zustand/mutative', never]] >( persist( diff --git a/src/renderer/src/context/root.zustand.types.ts b/src/renderer/src/context/root.zustand.types.ts index 3eb52eb..0f71ccf 100644 --- a/src/renderer/src/context/root.zustand.types.ts +++ b/src/renderer/src/context/root.zustand.types.ts @@ -30,7 +30,7 @@ export const PersistedRootZustandSchema = z.object({ }) export type PersistedRootZustand = z.infer -export type RootZusand = { +export type RootZustand = { transactions: Transaction[] version: string clientState: ClientState diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index f054b62..c2829ee 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -60,7 +60,7 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( default: StyledMaterialDesignContent }, action: (snackbarId) => ( - closeSnackbar(snackbarId)}> + closeSnackbar(snackbarId)}> ) diff --git a/src/renderer/src/svg/Ploxc.tsx b/src/renderer/src/svg/Ploxc.tsx index 215548e..ac3afe4 100644 --- a/src/renderer/src/svg/Ploxc.tsx +++ b/src/renderer/src/svg/Ploxc.tsx @@ -1,10 +1,17 @@ +import { CSSObject, Theme } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { StyledSvg, StyledSvgProps } from './util' const Ploxc = meme(({ sx }: StyledSvgProps): JSX.Element => { return ( ({ + '& #dot': { fill: theme.palette.info.main }, + '& #logo': { fill: theme.palette.primary.main } + }), + ...(Array.isArray(sx) ? sx : [sx]) + ]} viewBox="0 0 180 180" version="1.1" xmlns="http://www.w3.org/2000/svg" diff --git a/src/renderer/src/theme/index.ts b/src/renderer/src/theme/index.ts index 02b719f..6d15249 100644 --- a/src/renderer/src/theme/index.ts +++ b/src/renderer/src/theme/index.ts @@ -53,13 +53,19 @@ const base = createTheme({ } }) -// The Data Grid paints its own surfaces from palette.DataGrid. Left alone it -// lightens the whole grid in dark mode (color-mix of paper with white); pinning -// headerBg puts just the column headers back on the app background, leaving the -// rows and footer on the grid's own base. +// What the Data Grid lifts its rows to in dark mode, left alone: +// color-mix(in srgb, #1F1F1F 95%, #fff), which lands here. The panels behind the +// server lists sit on the same slab, and x-data-grid augments PaletteOptions but +// not Palette, so the value cannot be read back off the theme. It is named here +// instead, and both sides read the name. +export const gridSurface = '#2A2A2A' + +// headerBg puts just the column headers back on the app background. bg is the +// value the grid already computed, pinned so the panels can share it. export const theme = createTheme(base, { palette: { DataGrid: { + bg: gridSurface, headerBg: base.palette.background.default } } From 4891e2f694f714299c68be406b14c48816f3ae2d Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 1 Sep 2026 23:56:00 +0200 Subject: [PATCH 19/72] chore: scanningUniId is scanningUnitIds 32 sites, all of them in src. It reads a flag beside scanningRegisters and is set by the same scan that stopScanningUnitIds ends, so the plural is what the neighbours already say. No migration. partialize in root.zustand.ts persists name, connectionConfig, registerConfig and registerMapping, and clientState is not among them, so the old key was never written to storage and no saved config carries it. --- .../modules/__tests__/modbusClient.test.ts | 6 +++--- src/main/modules/modbusClient.ts | 18 +++++++++--------- .../MenuButton/ScanProgress/ScanProgress.tsx | 2 +- .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 18 +++++++++--------- .../MenuButton/__tests__/MenuOptions.test.tsx | 4 ++-- src/renderer/src/context/root.zustand.ts | 2 +- src/shared/__tests__/windows.test.ts | 10 +++++----- src/shared/default.ts | 2 +- src/shared/types/client.ts | 2 +- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 490dbe6..dd29e54 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -114,7 +114,7 @@ describe('ModbusClient', () => { it('starts in disconnected state', () => { expect(client.state.connectState).toBe('disconnected') expect(client.state.polling).toBe(false) - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) expect(client.state.scanningRegisters).toBe(false) }) }) @@ -521,7 +521,7 @@ describe('ModbusClient', () => { describe('scanning', () => { it('stopScanningUnitIds sets flag to false', () => { client.stopScanningUnitIds() - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) }) it('stopScanningRegisters sets flag to false', () => { @@ -1266,7 +1266,7 @@ describe('ModbusClient', () => { // Should have scanned far fewer than 100 units const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBeLessThan(100) - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) }) it('scans all four register types', async () => { diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index c5d1d8d..ab7b77a 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -68,7 +68,7 @@ export class ModbusClient { private _clientState: ClientState = { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } @@ -724,7 +724,7 @@ export class ModbusClient { } this._client.setTimeout(params.timeout) - this._clientState.scanningUniId = true + this._clientState.scanningUnitIds = true this._sendClientState() const { range } = params @@ -734,14 +734,14 @@ export class ModbusClient { for (let id = range[0]; id <= range[1]; id++) await this._scanUnitIds({ id, ...params }) - this._clientState.scanningUniId = false + this._clientState.scanningUnitIds = false this._sendClientState() } public stopScanningUnitIds = (): void => { // Set scanning unit id to false so the scanning is stopped // after the last asynchonous operation has completed. - this._clientState.scanningUniId = false + this._clientState.scanningUnitIds = false } private _scanUnitIds: ScanUnitIdFn = async ({ address, id, length, registerTypes }) => { @@ -760,7 +760,7 @@ export class ModbusClient { } } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -777,7 +777,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -793,7 +793,7 @@ export class ModbusClient { } await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -810,7 +810,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -827,7 +827,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx index ccc1bdf..74c9c47 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx @@ -17,7 +17,7 @@ import { IMaskInput, IMask } from 'react-imask' // Scan progress export const ScanProgress = meme(() => { const scanning = useRootZustand( - (z) => z.clientState.scanningUniId || z.clientState.scanningRegisters + (z) => z.clientState.scanningUnitIds || z.clientState.scanningRegisters ) const scanProgress = useRootZustand((z) => z.scanProgress) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index a0a06e9..ff0773b 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -23,7 +23,7 @@ import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' // // Start Unit ID field const StartUnitIdField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) const setStartUnitId = useScanUnitIdZustand((z) => z.setStartUnitId) @@ -50,7 +50,7 @@ const StartUnitIdField = meme((): JSX.Element => { // // Count field const CountField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const count = useScanUnitIdZustand((z) => String(z.count)) const setCount = useScanUnitIdZustand((z) => z.setCount) @@ -77,7 +77,7 @@ const CountField = meme((): JSX.Element => { // // Address field with base toggle const AddressField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const address = useScanUnitIdZustand((z) => z.address) const setAddress = useScanUnitIdZustand((z) => z.setAddress) @@ -96,7 +96,7 @@ const AddressField = meme((): JSX.Element => { // // Length field const LengthField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const length = useScanUnitIdZustand((z) => String(z.length)) const setLength = useScanUnitIdZustand((z) => z.setLength) @@ -123,7 +123,7 @@ const LengthField = meme((): JSX.Element => { // // Timeout field const TimeoutField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const timeout = useScanUnitIdZustand((z) => z.timeout) const setTimeout = useScanUnitIdZustand((z) => z.setTimeout) @@ -141,7 +141,7 @@ const TimeoutField = meme((): JSX.Element => { // // Select register types const SelectRegisterTypes = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const setRegisterTypes = useScanUnitIdZustand((z) => z.setRegisterTypes) @@ -188,7 +188,7 @@ const SelectRegisterTypes = meme((): JSX.Element => { // // Scan button const ScanButton = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const polling = useRootZustand((z) => z.clientState.polling) const disabled = useScanUnitIdZustand((z) => z.registerTypes.length === 0) @@ -327,11 +327,11 @@ const ScanUnitIds = meme(() => { const setOpen = useScanUnitIdZustand((z) => z.setOpen) // Don't close while scanning - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) const handleClose = useCallback(() => { const currentRootState = useRootZustand.getState() - if (currentRootState.clientState.scanningUniId) return + if (currentRootState.clientState.scanningUnitIds) return // The results belong to the dialog. Leaving them behind means the next // scan opens on the last one and fills in around it. currentRootState.clearScanUnitIdResults() diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx index 6ae78ed..2bb87a1 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx @@ -34,7 +34,7 @@ beforeEach(() => { clientState: { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } } as never) @@ -120,7 +120,7 @@ describe('MenuConnectionOptions', () => { clientState: { connectState: 'connected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } } as never) diff --git a/src/renderer/src/context/root.zustand.ts b/src/renderer/src/context/root.zustand.ts index d604a35..5473206 100644 --- a/src/renderer/src/context/root.zustand.ts +++ b/src/renderer/src/context/root.zustand.ts @@ -123,7 +123,7 @@ export const useRootZustand = create< clientState: { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false }, setClientState: (clientState) => diff --git a/src/shared/__tests__/windows.test.ts b/src/shared/__tests__/windows.test.ts index d45eb0e..0004f6f 100644 --- a/src/shared/__tests__/windows.test.ts +++ b/src/shared/__tests__/windows.test.ts @@ -32,7 +32,7 @@ describe('Windows', () => { windows.send('client_state', { connectState: 'connected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } as never) @@ -51,7 +51,7 @@ describe('Windows', () => { windows.send('client_state', { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } as never) }) @@ -66,7 +66,7 @@ describe('Windows', () => { windows.send('client_state', { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } as never) @@ -83,7 +83,7 @@ describe('Windows', () => { windows.send('client_state', { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } as never) @@ -103,7 +103,7 @@ describe('Windows', () => { windows.send('client_state', { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } as never) ).not.toThrow() diff --git a/src/shared/default.ts b/src/shared/default.ts index 4796aee..eefb30f 100644 --- a/src/shared/default.ts +++ b/src/shared/default.ts @@ -33,7 +33,7 @@ export const defaultRegisterConfig: RegisterConfig = { export const defaultClientState: ClientState = { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index b79759c..e9f2dfa 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -175,7 +175,7 @@ export type ConnectState = z.infer export const ClientStateSchema = z.object({ connectState: ConnectStateSchema, polling: z.boolean(), - scanningUniId: z.boolean(), + scanningUnitIds: z.boolean(), scanningRegisters: z.boolean() }) export type ClientState = z.infer From 51f65733a81cadcff4008da99f340dc92425e2a6 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 00:35:39 +0200 Subject: [PATCH 20/72] test: the conventions, as assertions Seven rules the run settled, each one a test: every component wrapped in meme, nothing in shared importing from main, no selector returning an object and no useShallow, every store file named .zustand.ts, MUI imported deep, a data-testid on every interactive element, and every configured alias imported through. Each rule asserts twice. The second assertion is that the population holds no violation; the first is that the population is not empty, because a meter that reads no files passes every rule it has. It found two things the manual sweeps missed, before it was committed. ScanRegisters.tsx held a seventh local store inline, which is exactly what WriteModal.tsx was taken apart for one commit earlier. And vitest.config.mts still declared @main, @preload and @backend after they left the vite config. Both are fixed here, which is why this commit is green. Every rule was mutated once and watched go red: the meme wrapper removed from HomeButton, an @main import added to shared/default.ts, a selector rewritten to return an object, useShallow imported, a deep MUI import put back on the barrel, a data-testid deleted, a store file renamed off .zustand.ts, and a @ghost alias added to vitest.config.mts. Each turned exactly one test red and left the other fourteen green. The data-testid rule reads the whole attribute text rather than the top-level JSX attributes, because a picker carries the attribute inside slotProps and a meter that looked only at the top level reported the DateTimePicker as missing one it has had all along. --- src/__tests__/conformance.test.ts | 435 ++++++++++++++++++ .../MenuButton/MenuButton.tsx | 2 +- .../ScanRegisters/ScanRegisters.tsx | 47 +- .../ScanRegisters/scanRegisters.zustand.ts | 46 ++ .../ScanRegistersButton.tsx | 2 +- vitest.config.mts | 5 +- 6 files changed, 485 insertions(+), 52 deletions(-) create mode 100644 src/__tests__/conformance.test.ts create mode 100644 src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts new file mode 100644 index 0000000..dc14983 --- /dev/null +++ b/src/__tests__/conformance.test.ts @@ -0,0 +1,435 @@ +/** + * The conventions, as assertions. + * + * CONTRIBUTING.md says what the codebase agrees on. This says it again in a form + * that fails, because a rule that lives only in prose is the rule that produced + * a 104-of-190 memo split while the prose sat there being correct. + * + * Every rule asserts twice: that its population is not empty, and that the + * population holds no violation. Without the first, a meter that reads no files + * passes every rule it has. + * + * A violation prints the file and the symbol, never a line number, because the + * line moves on the next edit above it and the symbol does not. + */ +import { describe, expect, it } from 'vitest' +import { readdirSync, readFileSync } from 'fs' +import { join, relative } from 'path' +import ts from 'typescript' + +const repoRoot = join(__dirname, '..', '..') +const rendererRoot = join(repoRoot, 'src/renderer/src') + +const sourceFiles = (root: string, extensions = /\.tsx?$/): string[] => { + const found: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') walk(full) + } else if (extensions.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + found.push(full) + } + } + } + walk(root) + return found +} + +const parse = (file: string): ts.SourceFile => + ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + +const at = (file: string): string => relative(repoRoot, file) + +const eachNode = (source: ts.SourceFile, visit: (node: ts.Node) => void): void => { + const walk = (node: ts.Node): void => { + visit(node) + ts.forEachChild(node, walk) + } + walk(source) +} + +/** The module specifier of an import, or null for anything that is not one. */ +const importedFrom = (node: ts.Node): string | null => + ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) + ? node.moduleSpecifier.text + : null + +// +// ─── Every component is wrapped in meme ────────────────────────────────────── +// +// The rule the checkpoint settled: every component, props or not. A declaration +// counts as a component when it is rendered as JSX somewhere in the renderer or +// exported as its file's default, which is what makes the count reproducible. + +describe('every component is wrapped in meme', () => { + const files = sourceFiles(rendererRoot) + const parsed = files.map((file) => ({ file, source: parse(file) })) + + const renderedAsJsx = new Set() + const defaultExported = new Set() + + for (const { source } of parsed) { + eachNode(source, (node) => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + let name: ts.Node = node.tagName + while (ts.isPropertyAccessExpression(name)) name = name.expression + if (ts.isIdentifier(name)) renderedAsJsx.add(name.text) + } + if (ts.isExportAssignment(node) && !node.isExportEquals && ts.isIdentifier(node.expression)) { + defaultExported.add(node.expression.text) + } + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) { + defaultExported.add(node.name.text) + } + }) + } + + /** Peel the wrappers a component declaration can sit under. */ + const classify = (expression: ts.Expression): { isComponent: boolean; wrapped: boolean } => { + let node: ts.Node = expression + let wrapped = false + for (;;) { + if (ts.isCallExpression(node)) { + const callee = node.expression + const calleeName = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) + ? callee.text + : null + if (calleeName === 'meme' || calleeName === 'memo') { + wrapped = true + if (!node.arguments[0]) return { isComponent: true, wrapped } + node = node.arguments[0] + continue + } + if (calleeName === 'forwardRef') { + if (!node.arguments[0]) return { isComponent: true, wrapped } + node = node.arguments[0] + continue + } + // styled('svg')({}) is a component, but not one memo has anything to do + // with: it renders exactly its props and holds no state. + if ( + calleeName === 'styled' || + (ts.isCallExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'styled') + ) { + return { isComponent: false, wrapped } + } + return { isComponent: wrapped, wrapped } + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return { isComponent: true, wrapped } + } + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) { + node = node.expression + continue + } + return { isComponent: wrapped, wrapped } + } + } + + const components: { name: string; file: string; wrapped: boolean }[] = [] + for (const { file, source } of parsed) { + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue + const name = declaration.name.text + if (!/^[A-Z]/.test(name)) continue + if (!renderedAsJsx.has(name) && !defaultExported.has(name)) continue + const { isComponent, wrapped } = classify(declaration.initializer) + if (isComponent) components.push({ name, file: at(file), wrapped }) + } + } + } + + it('finds components to check', () => { + expect(components.length).toBeGreaterThan(150) + }) + + it('leaves none of them bare', () => { + const bare = components.filter((c) => !c.wrapped).map((c) => `${c.file}\t${c.name}`) + expect(bare).toEqual([]) + }) +}) + +// +// ─── shared may not reach back into main ───────────────────────────────────── +// +// All three processes import shared. It is the one layer that may not reach +// back, and an import of main from shared pulls Electron into the renderer. + +describe('shared does not import from main', () => { + const files = sourceFiles(join(repoRoot, 'src/shared')) + + it('finds shared files to check', () => { + expect(files.length).toBeGreaterThan(5) + }) + + it('has none of them importing main', () => { + const reaching: string[] = [] + for (const file of files) { + eachNode(parse(file), (node) => { + const specifier = importedFrom(node) + if (specifier === null) return + if (specifier.startsWith('@main') || /(^|\/)\.\.\/main\//.test(specifier)) { + reaching.push(`${at(file)}\t${specifier}`) + } + }) + } + expect(reaching).toEqual([]) + }) +}) + +// +// ─── One store selector per field ──────────────────────────────────────────── +// +// A selector returning an object literal is a whole-store subscription wearing a +// selector's clothes: a fresh object every render, so every flush re-renders. +// This is why the grid draws two thousand rows without useShallow. + +describe('one store selector per field', () => { + const files = sourceFiles(rendererRoot) + const selectorCalls: { file: string; text: string }[] = [] + const objectSelectors: string[] = [] + const shallowUses: string[] = [] + + for (const file of files) { + const source = parse(file) + eachNode(source, (node) => { + if (ts.isIdentifier(node) && node.text === 'useShallow') shallowUses.push(at(file)) + if (!ts.isCallExpression(node)) return + const callee = node.expression + if (!ts.isIdentifier(callee) || !/^use[A-Z].*Zustand$/.test(callee.text)) return + const argument = node.arguments[0] + if (!argument || !ts.isArrowFunction(argument)) return + selectorCalls.push({ file: at(file), text: callee.text }) + const body = argument.body + // ({ a, b }) is a parenthesized object literal; { return { a, b } } is a + // block that ends in one. Both hand back a new reference every render. + const returnsObject = + (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) || + ts.isObjectLiteralExpression(body) || + (ts.isBlock(body) && + body.statements.some( + (statement) => + ts.isReturnStatement(statement) && + statement.expression !== undefined && + ts.isObjectLiteralExpression(statement.expression) + )) + if (returnsObject) objectSelectors.push(`${at(file)}\t${callee.text}`) + }) + } + + it('finds selectors to check', () => { + expect(selectorCalls.length).toBeGreaterThan(100) + }) + + it('has none of them returning an object', () => { + expect(objectSelectors).toEqual([]) + }) + + it('has no useShallow anywhere', () => { + expect(shallowUses).toEqual([]) + }) +}) + +// +// ─── Stores are named after their component ────────────────────────────────── + +describe('every store file is named .zustand.ts', () => { + const files = sourceFiles(join(repoRoot, 'src')) + const storeFiles: string[] = [] + + for (const file of files) { + const source = parse(file) + let createsStore = false + let importsZustand = false + eachNode(source, (node) => { + if (importedFrom(node) === 'zustand') importsZustand = true + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'create' + ) { + createsStore = true + } + }) + if (createsStore && importsZustand) storeFiles.push(at(file)) + } + + it('finds stores to check', () => { + expect(storeFiles.length).toBeGreaterThan(5) + }) + + it('has none of them off the name', () => { + const misnamed = storeFiles.filter((file) => !file.endsWith('.zustand.ts')) + expect(misnamed).toEqual([]) + }) +}) + +// +// ─── MUI comes in deep ─────────────────────────────────────────────────────── +// +// A barrel import pulls the package's whole index through the dev server on +// every cold start. Two hooks have no deep home: the exports map in +// @mui/x-data-grid/package.json declares thirteen subpaths besides the root and +// neither hook is exported by any of them, so the root is the only way to write +// them. + +const rootOnlyGridHooks = ['useGridApiContext', 'useGridApiRef'] + +describe('MUI is imported deep', () => { + const files = sourceFiles(join(repoRoot, 'src')) + const muiImports: string[] = [] + const barrelImports: string[] = [] + + for (const file of files) { + const source = parse(file) + for (const statement of source.statements) { + const specifier = importedFrom(statement) + if (specifier === null || !specifier.startsWith('@mui/')) continue + muiImports.push(specifier) + if (specifier.split('/').length !== 2) continue + + const bindings = statement.importClause?.namedBindings + const names = + bindings && ts.isNamedImports(bindings) + ? bindings.elements.map((element) => element.name.text) + : [] + const allowed = names.length > 0 && names.every((name) => rootOnlyGridHooks.includes(name)) + if (!allowed) barrelImports.push(`${at(file)}\t${specifier}\t${names.join(', ')}`) + } + } + + it('finds MUI imports to check', () => { + expect(muiImports.length).toBeGreaterThan(100) + }) + + it('has no barrel import that could have been deep', () => { + expect(barrelImports).toEqual([]) + }) +}) + +// +// ─── Every interactive element carries a data-testid ───────────────────────── +// +// The e2e suite addresses the UI through them. Containers are excluded on +// purpose: ToggleButtonGroup and ButtonGroup are addressed through the buttons +// inside them, and a Select's options through getByRole('option'). +// +// A picker hands attributes to its input through slotProps, so the attribute can +// sit nested rather than on the element. Reading only JSX attributes misses +// those, which is how the DateTimePicker showed up as missing one it had. + +const interactiveLeaves = new Set([ + 'Button', + 'IconButton', + 'TextField', + 'Slider', + 'Switch', + 'Checkbox', + 'Autocomplete', + 'Link', + 'DateTimePicker', + 'GridActionsCellItem', + 'ToggleButton', + 'Select' +]) + +describe('every interactive element carries a data-testid', () => { + const files = sourceFiles(rendererRoot, /\.tsx$/) + const elements: string[] = [] + const bare: string[] = [] + + for (const file of files) { + const source = parse(file) + eachNode(source, (node) => { + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return + const tag = node.tagName.getText(source) + if (!interactiveLeaves.has(tag)) return + elements.push(`${at(file)}\t${tag}`) + + const attributes = node.attributes.properties + // A spread can carry anything, including the attribute, so it counts. + if (attributes.some((attribute) => ts.isJsxSpreadAttribute(attribute))) return + // slotProps nests the attribute one or more levels down, so the whole + // attribute list is searched rather than only its top level. + const carries = attributes.some((attribute) => + attribute.getText(source).includes('data-testid') + ) + if (!carries) bare.push(`${at(file)}\t${tag}`) + }) + } + + it('finds interactive elements to check', () => { + expect(elements.length).toBeGreaterThan(50) + }) + + it('leaves none of them without one', () => { + expect(bare).toEqual([]) + }) +}) + +// +// ─── Every alias resolves ──────────────────────────────────────────────────── +// +// @main, @preload and @backend outlived their use, and @backend outlived its +// directory. An alias nobody imports through is a name a contributor will reach +// for and a reviewer will have to rule on. + +describe('every configured path alias is used', () => { + const configs = ['electron.vite.config.ts', 'vitest.config.mts'] + const declared = new Map() + + for (const config of configs) { + const source = parse(join(repoRoot, config)) + const names: string[] = [] + eachNode(source, (node) => { + if (!ts.isPropertyAssignment(node)) return + const key = ts.isStringLiteral(node.name) + ? node.name.text + : ts.isIdentifier(node.name) + ? node.name.text + : null + if (key !== null && key.startsWith('@')) names.push(key) + }) + declared.set(config, [...new Set(names)]) + } + + const imported = new Set() + for (const file of [ + ...sourceFiles(join(repoRoot, 'src')), + ...sourceFiles(join(repoRoot, 'e2e')) + ]) { + eachNode(parse(file), (node) => { + const specifier = importedFrom(node) + if (specifier?.startsWith('@') === true) imported.add(specifier.split('/')[0]) + }) + } + + it('finds aliases to check', () => { + expect([...declared.values()].flat().length).toBeGreaterThan(2) + }) + + it('has none that nothing imports through', () => { + const unused: string[] = [] + for (const [config, names] of declared) { + for (const name of names) if (!imported.has(name)) unused.push(`${config}\t${name}`) + } + expect(unused).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx index 3faf14c..245653e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx @@ -1,4 +1,4 @@ -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' +import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useRef, useState } from 'react' import LoadDummyDataButton from './LoadDummyDataButton/LoadDummyDataButton' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index eaf76eb..2eac795 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import Box from '@mui/material/Box' import Button from '@mui/material/Button' import { InputBaseComponentProps } from '@mui/material/InputBase' @@ -8,10 +7,7 @@ import TextField from '@mui/material/TextField' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { ElementType, useCallback, useMemo } from 'react' -import { create } from 'zustand' -import { mutative } from 'zustand-mutative' import { maskInputProps } from '@renderer/components/shared/inputs/types' -import { MaskSetFn } from '@renderer/context/root.zustand.types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import UnitIdInput from '@renderer/components/shared/inputs/UnitIdInput' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' @@ -24,48 +20,7 @@ import { ScanTimeoutField } from '../../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' - -interface ScanRegistersZustand { - open: boolean - setOpen: (open: boolean) => void - address: number - setAddress: MaskSetFn - scanLength: number - setScanLength: MaskSetFn - chunkSize: number - setChunkSize: MaskSetFn - timeout: number - setTimeout: MaskSetFn -} -export const useScanRegistersZustand = create( - mutative((set) => ({ - open: false, - setOpen: (open) => - set((state) => { - state.open = open - }), - address: 0, - setAddress: (address) => - set((state) => { - state.address = Number(address) - }), - scanLength: 10000, - setScanLength: (scanLength) => - set((state) => { - state.scanLength = Number(scanLength) - }), - chunkSize: 100, - setChunkSize: (chunkSize) => - set((state) => { - state.chunkSize = Number(chunkSize) - }), - timeout: 500, - setTimeout: (timeout) => - set((state) => { - state.timeout = Number(timeout) - }) - })) -) +import { useScanRegistersZustand } from './scanRegisters.zustand' // // diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts new file mode 100644 index 0000000..feece44 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { create } from 'zustand' +import { mutative } from 'zustand-mutative' + +interface ScanRegistersZustand { + open: boolean + setOpen: (open: boolean) => void + address: number + setAddress: MaskSetFn + scanLength: number + setScanLength: MaskSetFn + chunkSize: number + setChunkSize: MaskSetFn + timeout: number + setTimeout: MaskSetFn +} +export const useScanRegistersZustand = create( + mutative((set) => ({ + open: false, + setOpen: (open) => + set((state) => { + state.open = open + }), + address: 0, + setAddress: (address) => + set((state) => { + state.address = Number(address) + }), + scanLength: 10000, + setScanLength: (scanLength) => + set((state) => { + state.scanLength = Number(scanLength) + }), + chunkSize: 100, + setChunkSize: (chunkSize) => + set((state) => { + state.chunkSize = Number(chunkSize) + }), + timeout: 500, + setTimeout: (timeout) => + set((state) => { + state.timeout = Number(timeout) + }) + })) +) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx index 2ecefa0..afd1105 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx @@ -1,5 +1,5 @@ import Button from '@mui/material/Button' -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' +import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useRootZustand } from '@renderer/context/root.zustand' import { useCallback } from 'react' diff --git a/vitest.config.mts b/vitest.config.mts index d02e9be..22e5706 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -12,11 +12,8 @@ export default defineConfig({ }, resolve: { alias: { - '@main': resolve('src/main'), '@shared': resolve('src/shared'), - '@backend': resolve('src/backend'), - '@renderer': resolve('src/renderer/src'), - '@preload': resolve('src/preload') + '@renderer': resolve('src/renderer/src') } } }) From 8f3cebf559d77417ff5471547f75df0c4adeb94a Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 00:35:49 +0200 Subject: [PATCH 21/72] docs: the conventions, in the file a contributor opens Seven rules were enforceable only by pointing at existing code. They are under Code style now, each one saying what it is and why, next to the test that fails when it is broken. An eighth is there under its own heading, because no test can see it: the store owns IPC that changes state and a component owns IPC the user asked for. The same channel can be called from both and be right both times, which is what makes it a reviewer's judgement rather than an assertion. Two corrections. The path alias line listed @main, @preload and @backend, none of which exist; it is @renderer/* and @shared, and there are no others. And the data-testid bullet said every interactive element without saying which elements are not one, so a ToggleButtonGroup and a Select's options kept turning up as findings when they are addressed through their children. --- CLAUDE.md | 4 ++++ CONTRIBUTING.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0427f16..b41aeb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,10 @@ src/renderer/ the React UI - **Every interactive element carries a `data-testid`.** The e2e suite addresses the UI through them. +`src/__tests__/conformance.test.ts` asserts seven conventions, three of them +these, so breaking one fails `yarn test`. What each of the seven means, and the +one no test can see, is in CONTRIBUTING.md under *Code style*. + # The rules @CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f09c6be..7bd4a6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ e2e/ fixtures/ Test data and helpers ``` -**Path aliases:** `@main`, `@renderer/*`, `@preload`, `@shared`, `@backend`. Use them instead of deep relative imports. +**Path aliases:** `@renderer/*` and `@shared`. There are no others. Use them instead of deep relative imports. ## Code style @@ -50,10 +50,64 @@ Beyond what the linter catches: - **Zod for validation.** External data (configs, IPC payloads) is validated with Zod schemas. Don't trust unvalidated input. - **Zustand + Mutative for state.** Follow the existing store patterns. Don't introduce new state management approaches. - **MUI only.** Don't add other UI libraries. -- **Every interactive element needs a `data-testid`** for e2e tests. - **Spell out variable names.** `resetButton`, not `rstBtn`. `registerAddress`, not `regAddr`. Abbreviations make code harder to read. The only exceptions are well-known conventions like `i` in loops, `el` in DOM callbacks, `z` for Zod schemas and Zustand state accessors, and established project abbreviations like `e2e`. - **Match existing patterns.** Look at how the codebase does it, do it the same way. +### The conventions this codebase has already settled + +`src/__tests__/conformance.test.ts` asserts the seven rules below, so a PR that +breaks one fails `yarn test` rather than waiting for a reviewer to notice. Each +rule asserts twice: that the population it reads is not empty, and that the +population holds no violation. + +**One store selector per field.** `useRootZustand((z) => z.a)` and then +`((z) => z.b)`, never one selector returning an object. An object literal is a +new reference on every render, so a selector that returns one re-renders its +component on every flush of any field. The renderer has zero whole-store +subscriptions and zero `useShallow`, and that is why it draws a two-thousand-row +grid without either. + +**Every component is wrapped in `meme`.** Props or not, one rule with no +exception to remember. A declaration counts as a component when it is rendered +as JSX somewhere or exported as its file's default. The comparator inside `meme` +is `deepEqual` and it stays. + +**A component gets a folder, a store gets its component's name.** One component +per folder, named after the component. A local store beside it is +`.zustand.ts`, matching the global stores in `context/`, and named after +the component rather than the folder. `columns/` and `shared/inputs/` stay flat: +they are leaf collections, not components that lost their folder. + +**MUI is imported deep.** `@mui/material/Button`, not `@mui/material`. The same +for `@mui/icons-material`, `@mui/x-data-grid` and `@mui/x-date-pickers`, because +the rule is about barrels and those are barrels. Two exceptions are the package's +doing rather than a choice: `useGridApiContext` and `useGridApiRef` are exported +by none of the thirteen subpaths `@mui/x-data-grid` declares, so they come from +the root. + +**Nothing in `src/shared` imports from `src/main`.** All three processes import +shared; it is the one layer that may not reach back. + +**Every interactive element carries a `data-testid`.** Buttons, fields, sliders +and grid action cells. Containers do not: a `ToggleButtonGroup` is reached +through its buttons, a `Select`'s options through `getByRole('option')`. A +picker takes the attribute through `slotProps`, which is still carrying it. + +**Every configured path alias is imported through.** `@main`, `@preload` and +`@backend` sat in the configs long after anything used them, and `@backend` +pointed at a directory that had been deleted. + +### One rule no test can see + +**The store owns IPC that changes state; a component owns IPC the user asked +for.** Writing through another store is a mutation, and the store owns those. A +button press is the component's. + +The same channel can be called from both and be right both times, which is why +this is a reviewer's judgement and not an assertion: `read` is a consequence of +flipping endianness in the store, and a button in the toolbar. Same channel, two +concerns. + ## Commits Follow [Conventional Commits](https://www.conventionalcommits.org/). Lowercase, no period at the end. From 24285f8c10ad6b3795c205e6c69bb949bb70c094 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 01:09:04 +0200 Subject: [PATCH 22/72] feat: every channel that carries an object is guarded The other fourteen. Three write paths were already guarded; the rest of the seventeen channels taking an object or a union now declare a schema beside their handler, so a payload that does not parse never reaches the socket. The count is a test: a channel added without one fails yarn test. Ten types had no schema: nine hand-written interfaces and a string union. The leaf schemas were already sitting beside them, so the new ones are assembly rather than invention. Two ranges the protocol fixes are named once in shared/types/ranges.ts and read from there: a register address is 16 bit, a unit id is one byte. A TCP port is 16 bit too and means something else, so it has its own name. The constraint on where a guard may go changed, because the old one was drawn in the wrong place. It read "only a channel returning void", on the grounds that a rejected payload leaves nothing to return. What actually matters is whether undefined is an honest answer, so it reads that now, and the three value-returning channels say so in their own types. create_server and set_server_port answer Promise. That is not cosmetic: the renderer wrote String(actualPort) straight into the port field, and String(undefined) is valid TypeScript, so the type alone would not have caught it. All three call sites check before they write. Two tests per channel, through initIpc rather than around it. The valid payload must reach the listener, which a swapped schema breaks. The invalid one must come back as a message naming the channel, which a missing schema breaks: a channel with no guard accepts everything, so passing it a valid payload proves nothing. --- src/__tests__/conformance.test.ts | 47 +++ src/main/__tests__/ipc.test.ts | 289 +++++++++++++++++- src/main/ipc.ts | 101 ++++-- .../PrivilegedPortModal.tsx | 4 + src/renderer/src/context/server.zustand.ts | 12 +- src/shared/types/index.ts | 1 + src/shared/types/ipc.ts | 6 +- src/shared/types/privilegedPort.ts | 4 +- src/shared/types/ranges.ts | 14 + src/shared/types/scan.ts | 29 +- src/shared/types/server.ts | 84 ++--- 11 files changed, 502 insertions(+), 89 deletions(-) create mode 100644 src/shared/types/ranges.ts diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index dc14983..e092233 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -433,3 +433,50 @@ describe('every configured path alias is used', () => { expect(unused).toEqual([]) }) }) + +// +// ─── Every channel carrying an object declares a schema ────────────────────── +// +// TypeScript covers the shape of a bare primitive, and sixteen channels take no +// argument at all. What is left is an object or a union, and that is where a +// hand-edited config file or anything reaching the boundary from outside the UI +// arrives. A channel added without a schema is the one that gets missed. + +describe('every channel carrying an object declares a schema', () => { + const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) + + /** Channel to the argument it takes, for the ones taking more than a primitive. */ + const carriers = new Map() + eachNode(spec, (node) => { + if (!ts.isInterfaceDeclaration(node) || node.name.text !== 'IpcHandlerSpec') return + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type || !ts.isTypeLiteralNode(member.type)) + continue + const args = member.type.members.find((m) => m.name?.getText(spec) === 'args') + const argument = (args?.type?.getText(spec) ?? '[]').slice(1, -1).trim() + if (argument === '' || ['string', 'number', 'boolean'].includes(argument)) continue + carriers.set(member.name.getText(spec).replace(/[[\]']/g, ''), argument) + } + }) + + /** Channel to whether its ipcHandle call was given a third argument. */ + const guarded = new Set() + eachNode(parse(join(repoRoot, 'src/main/ipc.ts')), (node) => { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return + if (node.expression.text !== 'ipcHandle') return + const channel = node.arguments[0] + if (channel && ts.isStringLiteral(channel) && node.arguments.length >= 3) + guarded.add(channel.text) + }) + + it('finds channels to check', () => { + expect(carriers.size).toBeGreaterThan(10) + }) + + it('leaves none of them unguarded', () => { + const unguarded = [...carriers] + .filter(([channel]) => !guarded.has(channel)) + .map(([channel, argument]) => `${channel}\t${argument}`) + expect(unguarded).toEqual([]) + }) +}) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index da92e76..f98d82e 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -11,12 +11,24 @@ vi.mock('electron', () => ({ import { AddRegisterParamsSchema, + ConnectionConfigSchema, + CreateServerParamsSchema, + PortSchema, + PrivilegedPortFixModeSchema, + RemoveRegisterParamsSchema, + ResetBoolsParamsSchema, + ResetRegistersParamsSchema, + ScanRegistersParametersSchema, + ScanUnitIDParametersSchema, SetBooleanParametersSchema, + StartRtuServerParamsSchema, + SyncBoolsParametersSchema, + SyncRegisterValueParamsSchema, WriteParametersSchema, type BackendMessage, type Windows } from '@shared' -import { createIpcHandle } from '../ipc' +import { createIpcHandle, initIpc } from '../ipc' const createWindows = (): { windows: Windows; sent: BackendMessage[] } => { const sent: BackendMessage[] = [] @@ -122,14 +134,24 @@ describe('createIpcHandle', () => { }) }) - it('refuses a schema on a channel that has to return a value', () => { + it('refuses a schema on a channel with no room for undefined in its answer', () => { const { windows } = createWindows() const ipcHandle = createIpcHandle(windows) - // create_server answers with the port it actually bound, so there is no - // honest value to return when the payload is rejected. - // @ts-expect-error a schema is only accepted on a channel returning void - ipcHandle('create_server', vi.fn(), SetBooleanParametersSchema) + // get_privileged_port_status answers with a status object and says nothing + // about undefined, so there is nothing to hand back for a rejected payload. + // @ts-expect-error a schema needs undefined to be an honest answer + ipcHandle('get_privileged_port_status', vi.fn(), PortSchema) + }) + + it('accepts one where the answer admits undefined', () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + // create_server answers Promise for exactly this, so + // the guard is allowed and a refused payload does not invent a port. + ipcHandle('create_server', vi.fn(), CreateServerParamsSchema) + expect(handle).toHaveBeenCalledWith('create_server', expect.any(Function)) }) }) @@ -185,3 +207,258 @@ describe('write-path schemas', () => { expect(result.success).toBe(false) }) }) + +// +// The channels that carry a loaded config outward. A saved config file can be +// hand-edited, so what comes back through these is the least trustworthy input +// the app takes. + +describe('scan schemas', () => { + it('accepts a scan over the whole unit id byte', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [0, 255], + address: 65535, + length: 1, + registerTypes: ['coils'], + timeout: 1 + }) + expect(result.success).toBe(true) + }) + + it('rejects a unit id scan with no register type, which scans nothing', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [1, 10], + address: 0, + length: 1, + registerTypes: [], + timeout: 500 + }) + expect(result.success).toBe(false) + }) + + it('rejects a unit id above the byte a unit id is', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [1, 256], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 500 + }) + expect(result.success).toBe(false) + }) + + it('rejects a register scan with a timeout of zero, which never waits', () => { + const result = ScanRegistersParametersSchema.safeParse({ + addressRange: [0, 100], + length: 10, + timeout: 0 + }) + expect(result.success).toBe(false) + }) +}) + +describe('server register schemas', () => { + it('rejects a remove with an empty uuid, which names no server', () => { + const result = RemoveRegisterParamsSchema.safeParse({ + uuid: '', + unitId: '1', + registerType: 'holding_registers', + address: 0, + dataType: 'uint16' + }) + expect(result.success).toBe(false) + }) + + it('accepts a sync that clears every register, which is a list of none', () => { + const result = SyncRegisterValueParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + registerValues: [], + littleEndian: false + }) + expect(result.success).toBe(true) + }) + + it('rejects a sync whose register carries no address', () => { + const result = SyncRegisterValueParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + registerValues: [{ registerType: 'holding_registers', dataType: 'uint16', value: 1 }], + littleEndian: false + }) + expect(result.success).toBe(false) + }) + + // The two reset channels take the same three fields and differ only in which + // register types they accept. Swapping their schemas would pass a test that + // only checked the happy path of each. + it('resets registers on a number type and refuses a boolean one', () => { + const params = { uuid: 'server-1', unitId: '1' } + expect( + ResetRegistersParamsSchema.safeParse({ ...params, registerType: 'holding_registers' }).success + ).toBe(true) + expect(ResetRegistersParamsSchema.safeParse({ ...params, registerType: 'coils' }).success).toBe( + false + ) + }) + + it('resets bools on a boolean type and refuses a number one', () => { + const params = { uuid: 'server-1', unitId: '1' } + expect(ResetBoolsParamsSchema.safeParse({ ...params, registerType: 'coils' }).success).toBe( + true + ) + expect( + ResetBoolsParamsSchema.safeParse({ ...params, registerType: 'holding_registers' }).success + ).toBe(false) + }) + + it('rejects a bool sync whose coils are not booleans', () => { + const result = SyncBoolsParametersSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + coils: [1, 0], + discrete_inputs: [] + }) + expect(result.success).toBe(false) + }) +}) + +describe('server lifecycle schemas', () => { + it('accepts the Modbus port and rejects one past 16 bits', () => { + expect(CreateServerParamsSchema.safeParse({ uuid: 'server-1', port: 502 }).success).toBe(true) + expect(CreateServerParamsSchema.safeParse({ uuid: 'server-1', port: 70000 }).success).toBe( + false + ) + }) + + it('rejects an RTU start with no serial config', () => { + const result = StartRtuServerParamsSchema.safeParse({ uuid: 'server-1' }) + expect(result.success).toBe(false) + }) + + it('accepts both privileged port fix modes and refuses a third', () => { + expect(PrivilegedPortFixModeSchema.safeParse('session').success).toBe(true) + expect(PrivilegedPortFixModeSchema.safeParse('persist').success).toBe(true) + expect(PrivilegedPortFixModeSchema.safeParse('reboot').success).toBe(false) + }) +}) + +describe('the config updates, which arrive one field at a time', () => { + it('accepts a nested field on its own', () => { + const result = ConnectionConfigSchema.deepPartial().safeParse({ tcp: { host: '10.0.0.4' } }) + expect(result.success).toBe(true) + }) + + it('rejects a unit id that is not a number, even nested in a partial', () => { + const result = ConnectionConfigSchema.deepPartial().safeParse({ unitId: 'one' }) + expect(result.success).toBe(false) + }) +}) + +// +// Which schema a channel got. +// +// The schema tests above check a schema, and the createIpcHandle tests check the +// guard. Neither says that `sync_bools` got SyncBoolsParametersSchema rather +// than the one beside it, and the reset and sync channels take payloads similar +// enough that a swap parses. +// +// So each channel is driven twice through initIpc. The valid payload must reach +// the listener, which a swapped schema breaks. The invalid one must come back as +// a message naming the channel, which a missing schema breaks: a channel with no +// guard accepts everything, and passing the valid payload proves nothing about +// it. + +describe('each guarded channel got its own schema', () => { + /** Enough of a collaborator to record the call and nothing more. */ + const stub = (): Record> => + new Proxy({} as Record>, { + get: (target, key: string) => (target[key] ??= vi.fn()) + }) + + const validPayloads: Record = { + update_connection_config: { unitId: 3 }, + update_register_config: { address: 40, length: 10 }, + set_register_mapping: { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: {} + }, + write: { address: 4, single: true, type: 'coils', value: [true] }, + scan_registers: { addressRange: [0, 100], length: 10, timeout: 500 }, + scan_unit_ids: { + range: [1, 10], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 500 + }, + add_replace_server_register: { + uuid: 'server-1', + unitId: '1', + littleEndian: false, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } + }, + remove_server_register: { + uuid: 'server-1', + unitId: '1', + registerType: 'holding_registers', + address: 0, + dataType: 'uint16' + }, + sync_server_register: { + uuid: 'server-1', + unitId: '1', + registerValues: [], + littleEndian: false + }, + reset_registers: { uuid: 'server-1', unitId: '1', registerType: 'holding_registers' }, + set_bool: { uuid: 'server-1', unitId: '1', registerType: 'coils', address: 0, state: true }, + reset_bools: { uuid: 'server-1', unitId: '1', registerType: 'coils' }, + sync_bools: { uuid: 'server-1', unitId: '1', coils: [], discrete_inputs: [] }, + start_rtu_server: { + uuid: 'server-1', + serialConfig: { + com: '/dev/ttyUSB0', + options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } + } + } + } + + const start = (): { sent: BackendMessage[] } => { + handle.mockClear() + const { windows, sent } = createWindows() + initIpc( + stub() as unknown as Electron.App, + stub() as never, + stub() as never, + stub() as never, + windows + ) + return { sent } + } + + it.each(Object.keys(validPayloads))('lets a valid %s payload through', async (channel) => { + const { sent } = start() + await invoke(channel, validPayloads[channel]) + expect(sent.map((message) => message.error)).toEqual([]) + }) + + // A string reaches every one of these as an object was expected, so it is the + // one payload that is wrong for all of them and right for none. + it.each(Object.keys(validPayloads))( + 'guards %s against a payload that is not one', + async (channel) => { + const { sent } = start() + await invoke(channel, 'not a payload') + expect(sent.map((message) => String(message.error).split(':')[0])).toEqual([channel]) + } + ) +}) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b742bf8..8db7d22 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -12,7 +12,19 @@ import { formatZodError, WriteParametersSchema, AddRegisterParamsSchema, - SetBooleanParametersSchema + SetBooleanParametersSchema, + CreateServerParamsSchema, + PrivilegedPortFixModeSchema, + RegisterConfigSchema, + RegisterMappingSchema, + RemoveRegisterParamsSchema, + ResetBoolsParamsSchema, + ResetRegistersParamsSchema, + ScanRegistersParametersSchema, + ScanUnitIDParametersSchema, + StartRtuServerParamsSchema, + SyncBoolsParametersSchema, + SyncRegisterValueParamsSchema } from '@shared' import { ModbusClient } from './modules/modbusClient' import { ModbusServer } from './modules/modbusServer' @@ -27,16 +39,18 @@ type IpcListener = ( ) => Promise | IpcHandlerMap[C]['return'] /** - * A schema may only guard a channel that returns nothing. + * A schema may only guard a channel where `undefined` is an honest answer. * - * A channel that returns a value has no honest answer to give when the payload - * is rejected. `create_server` returns the port it actually bound and the - * renderer writes that straight into the port field, so a stand-in number would - * appear in the UI as a real one. Those validate inside their own handler. + * A rejected payload leaves nothing to return. A channel answering `void` has + * nothing to return anyway; a channel answering a value has to say so in its + * type, because `create_server` hands back the port it actually bound and the + * renderer writes that straight into the port field. A stand-in number would + * show up there as a real one, and so would `String(undefined)`. */ -type PayloadSchema = IpcHandlerMap[C]['return'] extends void - ? ZodType - : never +type PayloadSchema = + undefined extends Awaited + ? ZodType + : never /** * Builds the `ipcHandle` used below, bound to the windows it reports through. @@ -97,10 +111,18 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { state.updateConnectionConfig(defaultConnectionConfig) return defaultConnectionConfig }) - ipcHandle('update_connection_config', (_, config) => state.updateConnectionConfig(config)) + ipcHandle( + 'update_connection_config', + (_, config) => state.updateConnectionConfig(config), + ConnectionConfigSchema.deepPartial() + ) // Register config - ipcHandle('update_register_config', (_, config) => state.updateRegisterConfig(config)) + ipcHandle( + 'update_register_config', + (_, config) => state.updateRegisterConfig(config), + RegisterConfigSchema.deepPartial() + ) // Client state ipcHandle('get_client_state', () => { @@ -109,7 +131,11 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { if (result.success) return result.data return defaultClientState }) - ipcHandle('set_register_mapping', (_, mapping) => state.setRegisterMapping(mapping)) + ipcHandle( + 'set_register_mapping', + (_, mapping) => state.setRegisterMapping(mapping), + RegisterMappingSchema + ) // Connection Actions ipcHandle('connect', () => client.connect()) @@ -124,12 +150,19 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { ipcHandle('write', (_, writeParameters) => client.write(writeParameters), WriteParametersSchema) // Scan Unit ID Actions - ipcHandle('scan_unit_ids', (_, scanUnitIdParameters) => client.scanUnitIds(scanUnitIdParameters)) + ipcHandle( + 'scan_unit_ids', + (_, scanUnitIdParameters) => client.scanUnitIds(scanUnitIdParameters), + ScanUnitIDParametersSchema + ) ipcHandle('stop_scanning_unit_ids', () => client.stopScanningUnitIds()) // Scan Registers Actions - ipcHandle('scan_registers', (_, scanRegistersParameters: ScanRegistersParameters) => - client.scanRegisters(scanRegistersParameters) + ipcHandle( + 'scan_registers', + (_, scanRegistersParameters: ScanRegistersParameters) => + client.scanRegisters(scanRegistersParameters), + ScanRegistersParametersSchema ) ipcHandle('stop_scanning_registers', () => client.stopScanningRegisters()) @@ -139,19 +172,35 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { (_, params) => server.addRegister(params), AddRegisterParamsSchema ) - ipcHandle('remove_server_register', (_, params) => server.removeRegister(params)) - ipcHandle('sync_server_register', (_, params) => server.syncServerRegisters(params)) - ipcHandle('reset_registers', (_, params) => server.resetRegisters(params)) + ipcHandle( + 'remove_server_register', + (_, params) => server.removeRegister(params), + RemoveRegisterParamsSchema + ) + ipcHandle( + 'sync_server_register', + (_, params) => server.syncServerRegisters(params), + SyncRegisterValueParamsSchema + ) + ipcHandle( + 'reset_registers', + (_, params) => server.resetRegisters(params), + ResetRegistersParamsSchema + ) ipcHandle('set_bool', (_, params) => server.setBool(params), SetBooleanParametersSchema) - ipcHandle('reset_bools', (_, params) => server.resetBools(params)) - ipcHandle('sync_bools', (_, params) => server.syncBools(params)) + ipcHandle('reset_bools', (_, params) => server.resetBools(params), ResetBoolsParamsSchema) + ipcHandle('sync_bools', (_, params) => server.syncBools(params), SyncBoolsParametersSchema) ipcHandle('reset_server', (_, uuid) => server.resetServer(uuid)) - ipcHandle('set_server_port', (_, params) => server.setPort(params)) - ipcHandle('create_server', (_, params) => server.createServer(params)) + ipcHandle('set_server_port', (_, params) => server.setPort(params), CreateServerParamsSchema) + ipcHandle('create_server', (_, params) => server.createServer(params), CreateServerParamsSchema) ipcHandle('delete_server', (_, uuid) => server.deleteServer(uuid)) // RTU Server - ipcHandle('start_rtu_server', (_, params) => server.startRtuServer(params)) + ipcHandle( + 'start_rtu_server', + (_, params) => server.startRtuServer(params), + StartRtuServerParamsSchema + ) ipcHandle('stop_rtu_server', () => server.stopRtuServer()) ipcHandle('stop_all_tcp_servers', () => server.stopAllTcpServers()) @@ -163,7 +212,11 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { // Linux privileged ports (port 502 needs the unprivileged-port floor lowered) ipcHandle('get_privileged_port_status', (_, port) => getPrivilegedPortStatus(port)) - ipcHandle('apply_privileged_port_fix', (_, mode) => applyPrivilegedPortFix(mode)) + ipcHandle( + 'apply_privileged_port_fix', + (_, mode) => applyPrivilegedPortFix(mode), + PrivilegedPortFixModeSchema + ) ipcHandle('get_serial_group_status', () => getSerialGroupStatus()) ipcHandle('apply_serial_group_fix', () => applySerialGroupFix()) ipcHandle('request_logout', () => requestLogout()) diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index e739ba8..2b72e6f 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -209,6 +209,10 @@ const RunCommandButton = meme((): JSX.Element | null => { setBusy(true) try { const result = await window.api.applyPrivilegedPortFix(mode) + // undefined means the payload was refused at the boundary, which already + // sent its own message. Saying so twice helps nobody. + if (!result) return + enqueueSnackbar({ message: result.message, variant: result.ok ? 'success' : 'warning' }) if (!result.ok) return diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 4c6db38..425fb71 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -113,11 +113,12 @@ export const useServerZustand = create< }) }, createServer: async (params) => { - // Only update port from backend response, never from input + // Only update port from backend response, never from input. A refused + // payload answers undefined, and writing that would put the string + // "undefined" in the port field. const actualPort = await window.api.createServer(params) - const { uuid, port } = params - - console.log({ port, uuid, actualPort }) + if (actualPort === undefined) return + const { uuid } = params set((state) => { state.port[uuid] = String(actualPort) @@ -213,6 +214,7 @@ export const useServerZustand = create< for (const syncUuid of uuidsToSync) { const port = Number(state.port[syncUuid]) const actualPort = await window.api.createServer({ uuid: syncUuid, port }) + if (actualPort === undefined) continue set((state) => { state.port[syncUuid] = String(actualPort) @@ -457,6 +459,8 @@ export const useServerZustand = create< // Only update port from backend response const actualPort = await window.api.setServerPort({ uuid, port: Number(port) }) + if (actualPort === undefined) return + set((state) => { state.port[uuid] = String(actualPort) }) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index a92b85c..13a4dce 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,6 +1,7 @@ export * from './datatype' export * from './server' export * from './utils' +export * from './ranges' export * from './scan' export * from './client' export * from './ipc' diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index dd102c4..38b60bb 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -233,13 +233,13 @@ export interface IpcHandlerSpec { /** Set the server port */ ['set_server_port']: { args: [CreateServerParams] - return: Promise + return: Promise } /** Create a new server */ ['create_server']: { args: [CreateServerParams] - return: Promise + return: Promise } /** Delete an existing server (UUID) */ @@ -305,7 +305,7 @@ export interface IpcHandlerSpec { /** Lower the Linux unprivileged-port floor via pkexec */ ['apply_privileged_port_fix']: { args: [PrivilegedPortFixMode] - return: PrivilegedPortFixResult + return: Promise } /** Report whether this user may open a serial port on Linux */ diff --git a/src/shared/types/privilegedPort.ts b/src/shared/types/privilegedPort.ts index 0a11426..b9d78eb 100644 --- a/src/shared/types/privilegedPort.ts +++ b/src/shared/types/privilegedPort.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' /** * Linux privileged port types * @@ -23,7 +24,8 @@ export const UNPRIVILEGED_PORT_CONF_PATH = '/etc/sysctl.d/50-unprivileged-ports. /** * `session` lasts until reboot, `persist` also writes a sysctl.d drop-in. */ -export type PrivilegedPortFixMode = 'session' | 'persist' +export const PrivilegedPortFixModeSchema = z.enum(['session', 'persist']) +export type PrivilegedPortFixMode = z.infer /** Sandboxes that cut pkexec off from the host system. */ export type PrivilegedPortSandbox = 'flatpak' | 'snap' diff --git a/src/shared/types/ranges.ts b/src/shared/types/ranges.ts new file mode 100644 index 0000000..8186c48 --- /dev/null +++ b/src/shared/types/ranges.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +/** + * Ranges the protocol and the socket fix, so a schema states them once. + * + * A register address is 16 bit, so 0 to 65535. A unit id is one byte: 0 is the + * broadcast address and 248 through 255 are reserved, but a field device answers + * on whatever its vendor put there, so the byte is the range and the reserved + * part is not refused here. A TCP port is also 16 bit, and shares no meaning + * with a register address beyond the width. + */ +export const RegisterAddressSchema = z.number().int().min(0).max(65535) +export const UnitIdSchema = z.number().int().min(0).max(255) +export const PortSchema = z.number().int().min(0).max(65535) diff --git a/src/shared/types/scan.ts b/src/shared/types/scan.ts index 0fe3c5b..d01f749 100644 --- a/src/shared/types/scan.ts +++ b/src/shared/types/scan.ts @@ -1,23 +1,26 @@ -import { RegisterType, RegisterTypeSchema } from './client' +import { RegisterTypeSchema } from './client' +import { RegisterAddressSchema, UnitIdSchema } from './ranges' import { z } from 'zod' // Scan Registers -export interface ScanRegistersParameters { - addressRange: [number, number] - length: number - timeout: number -} +export const ScanRegistersParametersSchema = z.object({ + addressRange: z.tuple([RegisterAddressSchema, RegisterAddressSchema]), + length: z.number().int().positive(), + timeout: z.number().int().positive() +}) +export type ScanRegistersParameters = z.infer // // // Scan Unit ID parameters -export interface ScanUnitIDParameters { - range: [number, number] - address: number - length: number - registerTypes: RegisterType[] - timeout: number -} +export const ScanUnitIDParametersSchema = z.object({ + range: z.tuple([UnitIdSchema, UnitIdSchema]), + address: RegisterAddressSchema, + length: z.number().int().positive(), + registerTypes: z.array(RegisterTypeSchema).min(1), + timeout: z.number().int().positive() +}) +export type ScanUnitIDParameters = z.infer const ScanUnitIdErrorMessageSchema = z.object({ coils: z.string(), diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index 84b947f..463c603 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -1,7 +1,8 @@ import { z } from 'zod' -import { BaseDataType, BaseDataTypeSchema } from './datatype' +import { BaseDataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' import { RegisterType, SerialPortOptionsSchema } from './client' +import { PortSchema, RegisterAddressSchema } from './ranges' import { unitIds } from './unitid' // Server mode (global: TCP or RTU) @@ -15,10 +16,11 @@ export const ServerSerialConfigSchema = z.object({ }) export type ServerSerialConfig = z.infer -export interface StartRtuServerParams { - uuid: string - serialConfig: ServerSerialConfig -} +export const StartRtuServerParamsSchema = z.object({ + uuid: z.string().min(1), + serialConfig: ServerSerialConfigSchema +}) +export type StartRtuServerParams = z.infer // Zod schema for boolean register types export const BooleanRegistersSchema = z.enum(['coils', 'discrete_inputs']) @@ -130,26 +132,29 @@ export const AddRegisterParamsSchema = z.object({ littleEndian: z.boolean() }) export type AddRegisterParams = z.infer -export interface RemoveRegisterParams { - uuid: string - unitId: UnitIdString - registerType: NumberRegisters - address: number - dataType: BaseDataType -} +export const RemoveRegisterParamsSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + registerType: NumberRegistersSchema, + address: RegisterAddressSchema, + dataType: BaseDataTypeSchema +}) +export type RemoveRegisterParams = z.infer -export interface SyncRegisterValueParams { - uuid: string - unitId: UnitIdString - registerValues: RegisterParams[] - littleEndian: boolean -} +export const SyncRegisterValueParamsSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + registerValues: z.array(RegisterParamsSchema), + littleEndian: z.boolean() +}) +export type SyncRegisterValueParams = z.infer -export interface ResetRegistersParams { - uuid: string - unitId: UnitIdString - registerType: NumberRegisters -} +export const ResetRegistersParamsSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + registerType: NumberRegistersSchema +}) +export type ResetRegistersParams = z.infer export const SetBooleanParametersSchema = z.object({ uuid: z.string().min(1), @@ -160,23 +165,26 @@ export const SetBooleanParametersSchema = z.object({ }) export type SetBooleanParameters = z.infer -export interface ResetBoolsParams { - uuid: string - unitId: UnitIdString - registerType: BooleanRegisters -} +export const ResetBoolsParamsSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + registerType: BooleanRegistersSchema +}) +export type ResetBoolsParams = z.infer -export interface SyncBoolsParameters { - uuid: string - unitId: UnitIdString - coils: boolean[] - discrete_inputs: boolean[] -} +export const SyncBoolsParametersSchema = z.object({ + uuid: z.string().min(1), + unitId: UnitIdStringSchema, + coils: z.array(z.boolean()), + discrete_inputs: z.array(z.boolean()) +}) +export type SyncBoolsParameters = z.infer -export interface CreateServerParams { - uuid: string - port: number -} +export const CreateServerParamsSchema = z.object({ + uuid: z.string().min(1), + port: PortSchema +}) +export type CreateServerParams = z.infer export interface SetUnitIdParams { uuid: string From 3e42b3f9671644f3c5c953403456edd07df8b073 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 01:20:20 +0200 Subject: [PATCH 23/72] fix: the conventions doc said things the suite did not A review of the two step 9 commits found seven claims that did not hold. Three were the suite falling short of what the doc promised, and those are fixed in the suite rather than walked back in the prose. The suite accepted React's bare memo as satisfying the meme rule, so a component could take the shallow comparator the rule exists to rule out and stay green. Only meme counts now. It also asserted nothing about whole-store subscriptions while the doc said the renderer has none: a call with no selector and one returning (z) => z are neither an object literal, so both walked past the check that was there. And CONTRIBUTING.md said every configured path points at something while nothing read the tsconfigs, where tsconfig.node.json still included src/backend, deleted in 3dea261 along with the alias step 8 removed. Reading the directory part off a glob got electron.vite.config.* wrong twice, once in each direction, so the include check expands the glob instead. Four were the doc overreaching. The folder-per-component rule was under the heading that says these are asserted, and it is neither asserted nor true: seventeen components sit flat, and columns/ holds a WriteModal folder. It moves down beside the IPC rule, as the judgement it is, saying what it actually distinguishes. The data-testid paragraph read as though a Select needed no attribute when the suite requires one on it and exempts only its options. Both files said seven rules when the eighth landed in 24285f8 and a ninth here. And "each rule asserts twice" was wrong for the selector rule, which had three and now has four. The sentence says what is actually true of all nine: the population is checked before the violations are. --- CLAUDE.md | 6 +-- CONTRIBUTING.md | 60 ++++++++++++++++++--------- src/__tests__/conformance.test.ts | 68 +++++++++++++++++++++++++++++-- tsconfig.node.json | 1 - 4 files changed, 109 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b41aeb1..d3cd98b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,9 @@ src/renderer/ the React UI - **Every interactive element carries a `data-testid`.** The e2e suite addresses the UI through them. -`src/__tests__/conformance.test.ts` asserts seven conventions, three of them -these, so breaking one fails `yarn test`. What each of the seven means, and the -one no test can see, is in CONTRIBUTING.md under *Code style*. +`src/__tests__/conformance.test.ts` asserts nine conventions, three of them +these, so breaking one fails `yarn test`. What each of the nine means, and the +two no test can see, is in CONTRIBUTING.md under *Code style*. # The rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bd4a6f..b8db563 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,28 +55,29 @@ Beyond what the linter catches: ### The conventions this codebase has already settled -`src/__tests__/conformance.test.ts` asserts the seven rules below, so a PR that -breaks one fails `yarn test` rather than waiting for a reviewer to notice. Each -rule asserts twice: that the population it reads is not empty, and that the -population holds no violation. +`src/__tests__/conformance.test.ts` asserts the nine rules below, so a PR that +breaks one fails `yarn test` rather than waiting for a reviewer to notice. Every +rule asserts that the population it reads is not empty before it asserts the +population holds no violation, because a meter that reads no files passes every +rule it has. **One store selector per field.** `useRootZustand((z) => z.a)` and then `((z) => z.b)`, never one selector returning an object. An object literal is a new reference on every render, so a selector that returns one re-renders its -component on every flush of any field. The renderer has zero whole-store -subscriptions and zero `useShallow`, and that is why it draws a two-thousand-row -grid without either. +component on every flush of any field. The same goes for a call with no selector +and for `(z) => z`, which take the whole store the long way round. The renderer +has zero of all three and zero `useShallow`, and that is why it draws a +two-thousand-row grid without either. **Every component is wrapped in `meme`.** Props or not, one rule with no exception to remember. A declaration counts as a component when it is rendered -as JSX somewhere or exported as its file's default. The comparator inside `meme` -is `deepEqual` and it stays. +as JSX somewhere or exported as its file's default. React's bare `memo` does not +satisfy it: `meme` is `memo` with `deepEqual`, and the shallow comparator is what +a mutated row defeats. -**A component gets a folder, a store gets its component's name.** One component -per folder, named after the component. A local store beside it is -`.zustand.ts`, matching the global stores in `context/`, and named after -the component rather than the folder. `columns/` and `shared/inputs/` stay flat: -they are leaf collections, not components that lost their folder. +**A local store is named after its component.** `.zustand.ts`, matching +the global stores in `context/`, and named after the component rather than the +folder it sits in. **MUI is imported deep.** `@mui/material/Button`, not `@mui/material`. The same for `@mui/icons-material`, `@mui/x-data-grid` and `@mui/x-date-pickers`, because @@ -88,16 +89,30 @@ the root. **Nothing in `src/shared` imports from `src/main`.** All three processes import shared; it is the one layer that may not reach back. -**Every interactive element carries a `data-testid`.** Buttons, fields, sliders -and grid action cells. Containers do not: a `ToggleButtonGroup` is reached -through its buttons, a `Select`'s options through `getByRole('option')`. A -picker takes the attribute through `slotProps`, which is still carrying it. +**Every interactive element carries a `data-testid`.** Buttons, fields, sliders, +selects and grid action cells. Containers do not, because the e2e suite reaches +what is inside them instead: a `ToggleButtonGroup` through its `ToggleButton`s, +and a `Select`'s options through `getByRole('option')`. The `Select` itself +carries one. A picker takes the attribute through `slotProps`, which still +counts as carrying it. + +**Every channel that carries an object declares a schema.** TypeScript covers a +bare primitive and sixteen channels take no argument at all. The rest take an +object or a union, and that is where a hand-edited config file arrives. The +schema goes beside the handler in `main/ipc.ts`, and it is only accepted where +`undefined` is an honest answer: a rejected payload has nothing else to give +back, so a channel returning a value has to say so in its type. **Every configured path alias is imported through.** `@main`, `@preload` and `@backend` sat in the configs long after anything used them, and `@backend` pointed at a directory that had been deleted. -### One rule no test can see +**Every configured include points at something.** `tsconfig.node.json` went on +including `src/backend/**/*` after the directory and the alias were both gone. A +glob that matches nothing costs nothing to keep and says nothing when it stops +being true, so the test expands it rather than reading its shape. + +### Two rules no test can see **The store owns IPC that changes state; a component owns IPC the user asked for.** Writing through another store is a mutation, and the store owns those. A @@ -108,6 +123,13 @@ this is a reviewer's judgement and not an assertion: `read` is a consequence of flipping endianness in the store, and a button in the toolbar. Same channel, two concerns. +**A component that owns something gets a folder.** Its store, its helpers, its +subcomponents and their tests go in with it, and the folder takes its name. A +component that owns nothing stays a file: `SliderComponent.tsx` and +`HomeButton.tsx` are leaves, `columns/` and `shared/inputs/` are collections of +them, and neither wants a folder each. Where the line falls is a judgement, so +no test draws it. + ## Commits Follow [Conventional Commits](https://www.conventionalcommits.org/). Lowercase, no period at the end. diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index e092233..4a5f3d5 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -13,7 +13,7 @@ * line moves on the next edit above it and the symbol does not. */ import { describe, expect, it } from 'vitest' -import { readdirSync, readFileSync } from 'fs' +import { globSync, readdirSync, readFileSync } from 'fs' import { join, relative } from 'path' import ts from 'typescript' @@ -107,12 +107,19 @@ describe('every component is wrapped in meme', () => { : ts.isIdentifier(callee) ? callee.text : null - if (calleeName === 'meme' || calleeName === 'memo') { + // Only meme, not React's memo. meme is memo with deepEqual, and a bare + // memo gets the shallow comparator that a mutated row defeats. + if (calleeName === 'meme') { wrapped = true if (!node.arguments[0]) return { isComponent: true, wrapped } node = node.arguments[0] continue } + if (calleeName === 'memo') { + if (!node.arguments[0]) return { isComponent: true, wrapped: false } + node = node.arguments[0] + continue + } if (calleeName === 'forwardRef') { if (!node.arguments[0]) return { isComponent: true, wrapped } node = node.arguments[0] @@ -206,6 +213,7 @@ describe('one store selector per field', () => { const selectorCalls: { file: string; text: string }[] = [] const objectSelectors: string[] = [] const shallowUses: string[] = [] + const wholeStore: string[] = [] for (const file of files) { const source = parse(file) @@ -215,7 +223,22 @@ describe('one store selector per field', () => { const callee = node.expression if (!ts.isIdentifier(callee) || !/^use[A-Z].*Zustand$/.test(callee.text)) return const argument = node.arguments[0] - if (!argument || !ts.isArrowFunction(argument)) return + // No selector at all subscribes to the whole store, and so does one that + // hands the state straight back. Neither is an object literal, so the + // check below would let both through. + if (!argument) { + if (!/\.(getState|setState|persist|subscribe)\b/.test(node.parent?.getText(source) ?? '')) { + wholeStore.push(`${at(file)}\t${callee.text}()`) + } + return + } + if (!ts.isArrowFunction(argument)) return + if (ts.isIdentifier(argument.body) && argument.parameters.length === 1) { + const parameter = argument.parameters[0].name + if (ts.isIdentifier(parameter) && parameter.text === argument.body.text) { + wholeStore.push(`${at(file)}\t${callee.text}((z) => z)`) + } + } selectorCalls.push({ file: at(file), text: callee.text }) const body = argument.body // ({ a, b }) is a parenthesized object literal; { return { a, b } } is a @@ -245,6 +268,10 @@ describe('one store selector per field', () => { it('has no useShallow anywhere', () => { expect(shallowUses).toEqual([]) }) + + it('has nothing subscribing to a whole store', () => { + expect(wholeStore).toEqual([]) + }) }) // @@ -480,3 +507,38 @@ describe('every channel carrying an object declares a schema', () => { expect(unguarded).toEqual([]) }) }) + +// +// ─── Every configured path is somewhere ────────────────────────────────────── +// +// @backend pointed at a directory that had been deleted, and the alias outlived +// it in three configs. A tsconfig include does the same thing more quietly: it +// names a glob, finds nothing, and says nothing. + +describe('every configured include points at something', () => { + const configs = ['tsconfig.node.json', 'tsconfig.web.json', 'tsconfig.e2e.json'] + const globs: { config: string; glob: string }[] = [] + + for (const config of configs) { + // A tsconfig is jsonc: comments and trailing commas, which JSON.parse + // refuses and the compiler's own reader does not. + const { config: parsed } = ts.parseConfigFileTextToJson( + config, + readFileSync(join(repoRoot, config), 'utf8') + ) + for (const glob of (parsed as { include?: string[] })?.include ?? []) + globs.push({ config, glob }) + } + + it('finds includes to check', () => { + expect(globs.length).toBeGreaterThan(5) + }) + + it('has none of them pointing at nothing', () => { + // Expanded rather than approximated. Reading the directory part off the + // glob was tried first and got electron.vite.config.* wrong twice, once in + // each direction. + const empty = globs.filter(({ glob }) => globSync(glob, { cwd: repoRoot }).length === 0) + expect(empty.map(({ config, glob }) => `${config}\t${glob}`)).toEqual([]) + }) +}) diff --git a/tsconfig.node.json b/tsconfig.node.json index eb4568a..cf0f87d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,7 +5,6 @@ "src/main/**/*", "src/preload/**/*", "src/shared/**/*", - "src/backend/**/*", ], "compilerOptions": { "strict": true, From 38a6b80218fc26ffcafc9cd996f61d6266c3ab3e Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 08:16:56 +0200 Subject: [PATCH 24/72] refactor: the app version leaves the client store version and setVersion are the running app's own version, read once at startup from get_app_version and written into a save file's metadata. Nothing about them is a client. They sit on the layout store now, which is the other app-wide one and which persists nothing, so the move touches no storage. Its three readers say so: the two save paths and the version in the corner of the home screen. --- .../RegisterGridToolbar/SaveButton/SaveButton.tsx | 3 ++- .../src/components/server/OpenSaveClear/OpenSaveClear.tsx | 4 ++-- src/renderer/src/containers/Home.tsx | 2 +- src/renderer/src/context/layout.zustand.ts | 5 +++++ src/renderer/src/context/layout.zustand.types.ts | 3 +++ src/renderer/src/context/root.zustand.ts | 7 ++----- src/renderer/src/context/root.zustand.types.ts | 2 -- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx index 0a0345c..02c03bc 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx @@ -1,6 +1,7 @@ import Save from '@mui/icons-material/Save' import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' +import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useRootZustand } from '@renderer/context/root.zustand' import { RegisterMapConfig, RegisterType } from '@shared' import { snakeCase } from 'lodash' @@ -22,7 +23,7 @@ const SaveButton = meme(() => { }) // The store reads the version once at startup; it cannot change after that - const modbuxVersion = z.version + const modbuxVersion = useLayoutZustand.getState().version const registerMapConfig: RegisterMapConfig = { version: 2, diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx index 05b646b..98c5ea7 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx @@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, migrateServerConfig } from '@shared' import { ServerConfig, ServerRegistersPerUnit, UnitIdStringSchema } from '@shared' @@ -141,7 +141,7 @@ const useSave: UseSaveHook = () => { }) // The store reads the version once at startup; it cannot change after that - const modbuxVersion = useRootZustand.getState().version + const modbuxVersion = useLayoutZustand.getState().version const config: ServerConfig = { version: 2, diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 678044d..f8bba03 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -145,7 +145,7 @@ const PloxcLogo = meme((): JSX.Element => { }) const Version = meme((): JSX.Element => { - const version = useRootZustand((z) => z.version) + const version = useLayoutZustand((z) => z.version) return ( ( mutative((set, get) => ({ showLog: false, + version: '', + setVersion: (version) => + set((state) => { + state.version = version + }), homeShiftKeyDown: false, hideHomeButton: isServerWindow, showClientRawValues: false, diff --git a/src/renderer/src/context/layout.zustand.types.ts b/src/renderer/src/context/layout.zustand.types.ts index d14f182..c8e4a94 100644 --- a/src/renderer/src/context/layout.zustand.types.ts +++ b/src/renderer/src/context/layout.zustand.types.ts @@ -10,6 +10,9 @@ export const PersistedLayoutZustandSchema = z.object({ export type PersistedLayoutZustand = z.infer export type LayoutZustand = { + /** The running app's own version, read once at startup. Not the client's. */ + version: string + setVersion: (version: string) => void hideHomeButton: boolean homeShiftKeyDown: boolean showClientRawValues: boolean diff --git a/src/renderer/src/context/root.zustand.ts b/src/renderer/src/context/root.zustand.ts index 5473206..0fbfd9b 100644 --- a/src/renderer/src/context/root.zustand.ts +++ b/src/renderer/src/context/root.zustand.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { create } from 'zustand' +import { useLayoutZustand } from './layout.zustand' import { mutative } from 'zustand-mutative' import { persist } from 'zustand/middleware' import { PersistedRootZustand, PersistedRootZustandSchema, RootZustand } from './root.zustand.types' @@ -371,10 +372,6 @@ export const useRootZustand = create< state.scanProgress = scanProgress }), version: '-', - setVersion: (version) => - set((state) => { - state.version = version - }), // Serial port discovery serialPorts: [], @@ -473,5 +470,5 @@ onEvent('scan_progress', (scanProgress) => { window.api.stopScanningUnitIds() window.api.getAppVersion().then((version) => { - state.setVersion(version) + useLayoutZustand.getState().setVersion(version) }) diff --git a/src/renderer/src/context/root.zustand.types.ts b/src/renderer/src/context/root.zustand.types.ts index 0f71ccf..d6c9ebf 100644 --- a/src/renderer/src/context/root.zustand.types.ts +++ b/src/renderer/src/context/root.zustand.types.ts @@ -32,7 +32,6 @@ export type PersistedRootZustand = z.infer export type RootZustand = { transactions: Transaction[] - version: string clientState: ClientState ready: boolean readConfiguration: boolean @@ -91,7 +90,6 @@ export type RootZustand = { // Read configuration setReadConfiguration: (readConfiguration: boolean) => void // Version - setVersion: (version: string) => void // Serial port discovery serialPorts: SerialPortInfo[] From 57ffee9f45df5722f8f6199086e2541f684aef89 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 08:33:23 +0200 Subject: [PATCH 25/72] refactor: the root store is the client store 44 of its 46 members were client state, and the other two left in the commit before this one. It is client.zustand.ts now, useClientZustand, ClientZustand, and migrateClientState in migrations/client/ where the folder already said so. The storage key moved too, and that part is not a rename. persist reads one key and builds an empty store when it finds nothing, so an upgrade would have come up with no connection config, no register config and no register mapping. There is no version bump that fixes that: migrate runs on what was read, and nothing was read. carryFormerStorageKey copies root.zustand to client.zustand before the store is built, and leaves the old key where it is so a build that goes back still finds its config. Five unit tests over the copy, each watched failing: the move itself, the old key surviving it, the guard that stops a second launch overwriting what the user changed since, the empty case, and storage that throws while the module graph is still loading. Whether that runs before persist reads is a question about module load order, so only the running app can answer it. 02-standalone/01-persistence writes a config, puts it back under the old key, restarts and finds it. Removing the one call from client.zustand.ts turns exactly that test red. --- .../02-standalone/01-persistence.spec.ts | 55 +++++++++++++++++ .../client/ClientGrids/ClientGrids.tsx | 4 +- .../BitMapDetailPanel/BitMapDetailPanel.tsx | 14 +++-- .../RegisterGrid/BitMapRow/BitMapRow.tsx | 4 +- .../ClientGrids/RegisterGrid/RegisterGrid.tsx | 18 +++--- .../ClearButton/ClearButton.tsx | 4 +- .../ClearConfigButton/ClearConfigButton.tsx | 8 +-- .../LoadButton/LoadButton.tsx | 6 +- .../LoadDummyDataButton.tsx | 6 +- .../MenuConnectionOptions.tsx | 8 +-- .../MenuRegisterOptions.tsx | 12 ++-- .../MenuButton/ScanProgress/ScanProgress.tsx | 8 +-- .../__tests__/TimeoutInput.test.tsx | 4 +- .../ScanRegisters/ScanRegisters.tsx | 38 ++++++------ .../ScanRegisters/scanRegisters.zustand.ts | 2 +- .../ScanRegistersButton.tsx | 6 +- .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 32 +++++----- .../ScanUnitIds/scanUnitIds.zustand.ts | 2 +- .../MenuButton/__tests__/MenuOptions.test.tsx | 26 ++++---- .../PollButton/PollButton.tsx | 6 +- .../RawButton/RawButton.tsx | 4 +- .../ReadButton/ReadButton.tsx | 4 +- .../RegisterGridToolbar.tsx | 8 +-- .../SaveButton/SaveButton.tsx | 6 +- .../TimeSettings/TimeSettings.tsx | 12 ++-- .../ToggleEndianButton/ToggleEndianButton.tsx | 8 +-- .../columns/WriteModal/WriteModal.tsx | 10 +-- .../columns/WriteModal/writeModal.zustand.ts | 2 +- .../RegisterGrid/columns/index.tsx | 14 ++--- .../RegisterGrid/columns/interpolation.tsx | 14 ++--- .../RegisterGrid/columns/scalingFactor.tsx | 4 +- .../RegisterGrid/columns/write.tsx | 4 +- .../TransactionGrid/TransactionGrid.tsx | 6 +- .../ConnectionConfig/ConnectionConfig.tsx | 20 +++--- .../ConnectionConfig/RtuConfig/RtuConfig.tsx | 58 +++++++++--------- .../ConnectionConfig/TcpConfig/TcpConfig.tsx | 16 ++--- .../client/RegisterConfig/RegisterConfig.tsx | 32 +++++----- .../SerialGroupModal/SerialGroupModal.tsx | 4 +- .../__tests__/SerialGroupModal.test.tsx | 11 ++-- .../AddRegister/addRegister.zustand.ts | 2 +- .../shared/inputs/AddressBaseInput.tsx | 8 +-- .../src/components/shared/inputs/types.ts | 2 +- src/renderer/src/containers/Client.tsx | 4 +- src/renderer/src/containers/Home.tsx | 4 +- .../__tests__/client.zustand.storage.test.ts | 61 +++++++++++++++++++ .../src/context/client.zustand.storage.ts | 28 +++++++++ .../{root.zustand.ts => client.zustand.ts} | 45 ++++++++------ ...stand.types.ts => client.zustand.types.ts} | 8 +-- src/renderer/src/context/data.zustand.ts | 12 ++-- .../src/context/server.zustand.types.ts | 2 +- src/shared/migrations/client/zustand.ts | 4 +- src/shared/migrations/index.ts | 2 +- 52 files changed, 419 insertions(+), 263 deletions(-) create mode 100644 src/renderer/src/context/__tests__/client.zustand.storage.test.ts create mode 100644 src/renderer/src/context/client.zustand.storage.ts rename src/renderer/src/context/{root.zustand.ts => client.zustand.ts} (92%) rename src/renderer/src/context/{root.zustand.types.ts => client.zustand.types.ts} (94%) diff --git a/e2e/specs/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index 50e7302..151fa06 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -125,3 +125,58 @@ test.describe.serial('Persistence — State survives app restart', () => { await expect(portInput).toHaveValue('502') }) }) + +// +// The client store was called root.zustand until it was named after what it +// holds. persist reads one key and builds an empty store when it finds nothing, +// so an upgrade would have come up with no connection config at all. +// +// Only this suite can see it. The unit test covers carryFormerStorageKey, but +// whether it runs before persist reads is a question about module load order, +// and that is only true in a running app. +test.describe.serial('Persistence — a config saved under the former key', () => { + test.afterAll(async () => { + if (app) await app.close() + }) + + test('write a config, then put it back under the old key', async () => { + await launchApp(true) + await page.getByTestId('home-client-btn').click() + await expect(page.getByTestId('protocol-tcp-btn')).toBeVisible({ timeout: 5000 }) + + await page.getByTestId('tcp-host-input').locator('input').fill('10.9.8.7') + await page.getByTestId('client-unitid-input').locator('input').fill('9') + await page.waitForTimeout(500) // let zustand persist + + // Moving what the app itself wrote keeps the payload valid, which a + // hand-built one would not be: the store validates on load and clears + // anything it cannot parse. + const moved = await page.evaluate(() => { + const saved = localStorage.getItem('client.zustand') + if (saved === null) return false + localStorage.setItem('root.zustand', saved) + localStorage.removeItem('client.zustand') + return true + }) + expect(moved).toBe(true) + }) + + test('close app', async () => { + await app.close() + await new Promise((r) => setTimeout(r, 1000)) + }) + + test('reopen and find the config carried over', async () => { + await launchApp(false) + await page.getByTestId('home-client-btn').click() + await expect(page.getByTestId('protocol-tcp-btn')).toBeVisible({ timeout: 5000 }) + + await expect(page.getByTestId('tcp-host-input').locator('input')).toHaveValue('10.9.8.7') + await expect(page.getByTestId('client-unitid-input').locator('input')).toHaveValue('9') + }) + + test('the old key is still there for a build that goes back', async () => { + const former = await page.evaluate(() => localStorage.getItem('root.zustand')) + expect(former).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx index 5ae3544..875d671 100644 --- a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx +++ b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx @@ -2,7 +2,7 @@ import Box from '@mui/material/Box' import TransactionGrid from '@renderer/components/client/ClientGrids/TransactionGrid/TransactionGrid' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import RegisterGrid from './RegisterGrid/RegisterGrid' /** @@ -16,7 +16,7 @@ import RegisterGrid from './RegisterGrid/RegisterGrid' const ClientGrids = meme((): JSX.Element | null => { const showLog = useLayoutZustand((z) => z.showLog) const showWhileScanning = useLayoutZustand((z) => z.showGridWhileScanning) - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) if (scanning && !showWhileScanning) return null diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx index 8725763..9b06ef5 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx @@ -1,6 +1,6 @@ import Box from '@mui/material/Box' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' import { BitColor, BitMapConfig } from '@shared' @@ -21,13 +21,15 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen (z) => z.registerData.find((r) => r.id === address)?.words?.uint16 ?? 0 ) - const bitConfig = useRootZustand((z) => z.registerMapping[z.registerConfig.type][address]?.bitMap) - const setRegisterMapping = useRootZustand((z) => z.setRegisterMapping) + const bitConfig = useClientZustand( + (z) => z.registerMapping[z.registerConfig.type][address]?.bitMap + ) + const setRegisterMapping = useClientZustand((z) => z.setRegisterMapping) - const registerType = useRootZustand((z) => z.registerConfig.type) + const registerType = useClientZustand((z) => z.registerConfig.type) const writable = registerType === 'holding_registers' - const connectState = useRootZustand((z) => z.clientState.connectState) - const polling = useRootZustand((z) => z.clientState.polling) + const connectState = useClientZustand((z) => z.clientState.connectState) + const polling = useClientZustand((z) => z.clientState.polling) const canWrite = writable && connectState === 'connected' && !polling const handleToggle = useCallback( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx index feb68c1..efc468f 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx @@ -1,7 +1,7 @@ import { GridRow, GridRowProps } from '@mui/x-data-grid/components' import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { BITMAP_DATATYPE } from '@shared' import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' @@ -19,7 +19,7 @@ const BitMapRow = meme((props: GridRowProps): JSX.Element => { const isExpanded = expandedAddress === address const isBitmap = - useRootZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === + useClientZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === BITMAP_DATATYPE if (!isBitmap) { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index 175fa2c..b1568c9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -1,6 +1,6 @@ import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { DateTime } from 'luxon' import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' @@ -21,7 +21,7 @@ import BitMapRow from './BitMapRow/BitMapRow' // // Footer const Footer = meme(() => { - const time = useRootZustand((z) => z.lastSuccessfulTransactionMillis) + const time = useClientZustand((z) => z.lastSuccessfulTransactionMillis) return ( @@ -42,19 +42,19 @@ const Footer = meme(() => { // DataGrid const RegisterGridContent = meme((): JSX.Element => { const registerData = useDataZustand((z) => z.registerData) - const registerMapping = useRootZustand((z) => z.registerMapping[z.registerConfig.type]) + const registerMapping = useClientZustand((z) => z.registerMapping[z.registerConfig.type]) const columns = useRegisterGridColumns() const apiRef = useGridApiRef() // When we read all configured registers, we hide the rows with undefined data type // So no empty rows are shown so all rows have a value to display. - const readConfiguration = useRootZustand((z) => z.readConfiguration) + const readConfiguration = useClientZustand((z) => z.readConfiguration) // While a scan fills the grid, the rows are there to watch, not to work on: // a cell put into edit mode or a column menu opened over data that is still // arriving is a fight nobody wins. Scrolling and paging stay. - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const prevReadConfigRef = useRef(readConfiguration) useEffect(() => { const filterModel: GridFilterModel = { @@ -176,7 +176,7 @@ const RegisterGridContent = meme((): JSX.Element => { // // Row update processRowUpdate={(newRow, oldRow) => { - const z = useRootZustand.getState() + const z = useClientZustand.getState() // Update datatype if (newRow['dataType'] && newRow['dataType'] !== oldRow['dataType']) { @@ -186,19 +186,19 @@ const RegisterGridContent = meme((): JSX.Element => { // Update scaling factor // This will ignore zero too, if you don't want to ignore zero compare with undefined if (newRow['scalingFactor'] && newRow['scalingFactor'] !== oldRow['scalingFactor']) { - const z = useRootZustand.getState() + const z = useClientZustand.getState() z.setRegisterMapping(newRow.id, 'scalingFactor', newRow['scalingFactor']) } // Update comment if (typeof newRow['comment'] === 'string' && newRow['comment'] !== oldRow['comment']) { - const z = useRootZustand.getState() + const z = useClientZustand.getState() z.setRegisterMapping(newRow.id, 'comment', newRow['comment']) } // Update group end if (typeof newRow['groupEnd'] === 'boolean' && newRow['groupEnd'] !== oldRow['groupEnd']) { - const z = useRootZustand.getState() + const z = useClientZustand.getState() z.setRegisterMapping(newRow.id, 'groupEnd', newRow['groupEnd']) } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx index ea09080..41b352c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx @@ -1,12 +1,12 @@ import Button from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' const ClearButton = meme((): JSX.Element => { const noData = useDataZustand((z) => z.registerData.length === 0) - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const disabled = noData || polling const setRegisterData = useDataZustand((z) => z.setRegisterData) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx index 5ef9977..4543891 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx @@ -2,16 +2,16 @@ import Delete from '@mui/icons-material/Delete' import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useState } from 'react' const ClearConfigButton = meme((): JSX.Element => { const [warn, setWarn] = useState(false) const handleClick = useCallback(() => { - useRootZustand.getState().setName('') - useRootZustand.getState().clearRegisterMapping() - useRootZustand.getState().setReadConfiguration(false) + useClientZustand.getState().setName('') + useClientZustand.getState().clearRegisterMapping() + useClientZustand.getState().setReadConfiguration(false) }, []) return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx index c5511b9..caae62b 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx @@ -1,7 +1,7 @@ import FileOpen from '@mui/icons-material/FileOpen' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { migrateClientConfig } from '@shared' import { useSnackbar } from 'notistack' import { useRef, useState, useCallback } from 'react' @@ -21,7 +21,7 @@ const LoadButton = meme((): JSX.Element => { openingRef.current = true setOpening(true) - const state = useRootZustand.getState() + const state = useClientZustand.getState() const content = await file.text() @@ -67,7 +67,7 @@ const LoadButton = meme((): JSX.Element => { openingRef.current = false setOpening(false) showMapping() - useRootZustand.getState().setReadConfiguration(false) + useClientZustand.getState().setReadConfiguration(false) }, [enqueueSnackbar] ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx index 5294903..dd367ef 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx @@ -1,18 +1,18 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { RegisterData, getDummyRegisterData } from '@shared' import { useCallback } from 'react' import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' import Button from '@mui/material/Button' const LoadDummyDataButton = meme(({ setAnchor }: SetAnchorProps) => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') // Load dummy data for the configured register range so columns can be edited // without having to connect to the device or read registers const loadDummy = useCallback(() => { - const state = useRootZustand.getState() + const state = useClientZustand.getState() const { address, length } = state.registerConfig const dataState = useDataZustand.getState() const dummyData: RegisterData[] = [] diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx index 4499266..65378b1 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx @@ -2,14 +2,14 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' // RTU over TCP (encapsulated RTU) is a niche, TCP-family transport, so it lives // here in the options menu rather than as a third connection toggle. Only shown // when TCP is selected; serial RTU has no use for it. const MenuConnectionOptions = meme((): JSX.Element | null => { - const protocol = useRootZustand((z) => z.connectionConfig.protocol) - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') + const protocol = useClientZustand((z) => z.connectionConfig.protocol) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') if (protocol === 'ModbusRtu') return null @@ -28,7 +28,7 @@ const MenuConnectionOptions = meme((): JSX.Element | null => { color="warning" checked={rtuOverTcp} onChange={(e) => - useRootZustand + useClientZustand .getState() .setProtocol(e.target.checked ? 'ModbusRtuOverTcp' : 'ModbusTcp') } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx index da32dad..c900116 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx @@ -2,13 +2,13 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' const MenuRegisterOptions = meme((): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) + const type = useClientZustand((z) => z.registerConfig.type) - const advanceMode = useRootZustand((z) => z.registerConfig.advancedMode) - const show64BitValues = useRootZustand((z) => z.registerConfig.show64BitValues) + const advanceMode = useClientZustand((z) => z.registerConfig.advancedMode) + const show64BitValues = useClientZustand((z) => z.registerConfig.show64BitValues) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) if (!registers16Bit) return null @@ -20,7 +20,7 @@ const MenuRegisterOptions = meme((): JSX.Element | null => { useRootZustand.getState().setAdvancedMode(e.target.checked)} + onChange={(e) => useClientZustand.getState().setAdvancedMode(e.target.checked)} data-testid="advanced-mode-checkbox" /> } @@ -32,7 +32,7 @@ const MenuRegisterOptions = meme((): JSX.Element | null => { useRootZustand.getState().setShow64BitValues(e.target.checked)} + onChange={(e) => useClientZustand.getState().setShow64BitValues(e.target.checked)} data-testid="show-64bit-checkbox" /> } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx index 74c9c47..d90c2cd 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx @@ -9,17 +9,17 @@ import Visibility from '@mui/icons-material/Visibility' import VisibilityOff from '@mui/icons-material/VisibilityOff' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { useClientZustand } from '@renderer/context/client.zustand' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { ElementType, forwardRef } from 'react' import { IMaskInput, IMask } from 'react-imask' // Scan progress export const ScanProgress = meme(() => { - const scanning = useRootZustand( + const scanning = useClientZustand( (z) => z.clientState.scanningUnitIds || z.clientState.scanningRegisters ) - const scanProgress = useRootZustand((z) => z.scanProgress) + const scanProgress = useClientZustand((z) => z.scanProgress) return scanning ? ( ({ - useRootZustand: (selector: (state: Record) => unknown): unknown => +vi.mock('@renderer/context/client.zustand', () => ({ + useClientZustand: (selector: (state: Record) => unknown): unknown => selector({ clientState: {}, scanProgress: 0 }) })) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index 2eac795..188ee4c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -5,7 +5,7 @@ import Modal from '@mui/material/Modal' import Paper from '@mui/material/Paper' import TextField from '@mui/material/TextField' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { ElementType, useCallback, useMemo } from 'react' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' @@ -26,9 +26,9 @@ import { useScanRegistersZustand } from './scanRegisters.zustand' // // Unit ID field (syncs with main connection config) const UnitIdField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) - const unitId = useRootZustand((z) => String(z.connectionConfig.unitId)) - const setUnitId = useRootZustand((z) => z.setUnitId) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) + const setUnitId = useClientZustand((z) => z.setUnitId) return ( { // // Address field with base toggle const AddressField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const address = useScanRegistersZustand((z) => z.address) const setAddress = useScanRegistersZustand((z) => z.setAddress) @@ -72,7 +72,7 @@ const AddressField = meme((): JSX.Element => { // // Scan Length field const ScanLengthField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const scanLength = useScanRegistersZustand((z) => String(z.scanLength)) const setScanLength = useScanRegistersZustand((z) => z.setScanLength) @@ -99,10 +99,10 @@ const ScanLengthField = meme((): JSX.Element => { // // Chunk Size field const ChunkSizeField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const chunkSize = useScanRegistersZustand((z) => String(z.chunkSize)) const setChunkSize = useScanRegistersZustand((z) => z.setChunkSize) - const type = useRootZustand((z) => z.registerConfig.type) + const type = useClientZustand((z) => z.registerConfig.type) const isCoilType = ['coils', 'discrete_inputs'].includes(type) const max = isCoilType ? 2000 : 125 @@ -129,7 +129,7 @@ const ChunkSizeField = meme((): JSX.Element => { // // Timeout field const TimeoutField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const timeout = useScanRegistersZustand((z) => z.timeout) const setTimeout = useScanRegistersZustand((z) => z.setTimeout) @@ -153,7 +153,7 @@ const TimeoutField = meme((): JSX.Element => { // up, and it means that while a scan is running, since the same list holds // polled data the rest of the time. const FoundCount = meme((): JSX.Element | null => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const count = useDataZustand((z) => z.registerData.length) if (!scanning) return null @@ -175,7 +175,7 @@ const GridToggle = meme((): JSX.Element => { // // Scan button const ScanButton = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const scan = useCallback(async () => { if (scanning) { @@ -186,14 +186,14 @@ const ScanButton = meme((): JSX.Element => { window.api.stopPolling() const state = useScanRegistersZustand.getState() - const rootState = useRootZustand.getState() + const clientZustand = useClientZustand.getState() const dataState = useDataZustand.getState() - rootState.setReadConfiguration(false) + clientZustand.setReadConfiguration(false) // A scan walks raw addresses, which is what the extra columns are for, and // the rows land in a grid you are now watching fill. - if (!rootState.registerConfig.advancedMode) rootState.setAdvancedMode(true) - rootState.clearScanUnitIdResults() - rootState.setScanProgress(0) + if (!clientZustand.registerConfig.advancedMode) clientZustand.setAdvancedMode(true) + clientZustand.clearScanUnitIdResults() + clientZustand.setScanProgress(0) dropPendingScanRows() dataState.setRegisterData([]) @@ -222,11 +222,11 @@ const ScanButton = meme((): JSX.Element => { const ScanRegisters = meme(() => { const open = useScanRegistersZustand((z) => z.open) - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const handleClose = useCallback(() => { - const rootState = useRootZustand.getState() - if (rootState.clientState.scanningRegisters) return + const clientZustand = useClientZustand.getState() + if (clientZustand.clientState.scanningRegisters) return useScanRegistersZustand.getState().setOpen(false) }, []) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts index feece44..ecdb4ee 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { create } from 'zustand' import { mutative } from 'zustand-mutative' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx index afd1105..035ad77 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx @@ -1,7 +1,7 @@ import Button from '@mui/material/Button' import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' export interface SetAnchorProps { @@ -9,8 +9,8 @@ export interface SetAnchorProps { } const ScanRegistersButton = meme(({ setAnchor }: SetAnchorProps) => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') - const type = useRootZustand((z) => z.registerConfig.type) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') + const type = useClientZustand((z) => z.registerConfig.type) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) const handleOpen = useCallback(() => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index ff0773b..6f81b3a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -11,7 +11,7 @@ import { DataGrid } from '@mui/x-data-grid/DataGrid' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { ElementType, useCallback, useMemo } from 'react' import useScanUnitIdColumns from './_columns' import { useScanUnitIdZustand } from './scanUnitIds.zustand' @@ -23,7 +23,7 @@ import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' // // Start Unit ID field const StartUnitIdField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) const setStartUnitId = useScanUnitIdZustand((z) => z.setStartUnitId) @@ -50,7 +50,7 @@ const StartUnitIdField = meme((): JSX.Element => { // // Count field const CountField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const count = useScanUnitIdZustand((z) => String(z.count)) const setCount = useScanUnitIdZustand((z) => z.setCount) @@ -77,7 +77,7 @@ const CountField = meme((): JSX.Element => { // // Address field with base toggle const AddressField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const address = useScanUnitIdZustand((z) => z.address) const setAddress = useScanUnitIdZustand((z) => z.setAddress) @@ -96,7 +96,7 @@ const AddressField = meme((): JSX.Element => { // // Length field const LengthField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const length = useScanUnitIdZustand((z) => String(z.length)) const setLength = useScanUnitIdZustand((z) => z.setLength) @@ -123,7 +123,7 @@ const LengthField = meme((): JSX.Element => { // // Timeout field const TimeoutField = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const timeout = useScanUnitIdZustand((z) => z.timeout) const setTimeout = useScanUnitIdZustand((z) => z.setTimeout) @@ -141,7 +141,7 @@ const TimeoutField = meme((): JSX.Element => { // // Select register types const SelectRegisterTypes = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const setRegisterTypes = useScanUnitIdZustand((z) => z.setRegisterTypes) @@ -188,8 +188,8 @@ const SelectRegisterTypes = meme((): JSX.Element => { // // Scan button const ScanButton = meme((): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) - const polling = useRootZustand((z) => z.clientState.polling) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const polling = useClientZustand((z) => z.clientState.polling) const disabled = useScanUnitIdZustand((z) => z.registerTypes.length === 0) const scan = useCallback(() => { @@ -201,9 +201,9 @@ const ScanButton = meme((): JSX.Element => { window.api.stopPolling() const state = useScanUnitIdZustand.getState() - const rootState = useRootZustand.getState() - rootState.clearScanUnitIdResults() - rootState.setScanProgress(0) + const clientZustand = useClientZustand.getState() + clientZustand.clearScanUnitIdResults() + clientZustand.setScanProgress(0) const { address, length, startUnitId, count, registerTypes, timeout } = state @@ -236,7 +236,7 @@ const ScanButton = meme((): JSX.Element => { // // Scan result grid const ScanResultGrid = meme(() => { - const scanResults = useRootZustand((z) => z.scanUnitIdResults) + const scanResults = useClientZustand((z) => z.scanUnitIdResults) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const columns = useScanUnitIdColumns() @@ -291,7 +291,7 @@ const ScanResultGrid = meme(() => { // // Scan unit ids button export const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') + const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') // Close the menu behind it, the way scanning registers does. Otherwise it is // still hanging there when you close the dialog again. @@ -327,10 +327,10 @@ const ScanUnitIds = meme(() => { const setOpen = useScanUnitIdZustand((z) => z.setOpen) // Don't close while scanning - const scanning = useRootZustand((z) => z.clientState.scanningUnitIds) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const handleClose = useCallback(() => { - const currentRootState = useRootZustand.getState() + const currentRootState = useClientZustand.getState() if (currentRootState.clientState.scanningUnitIds) return // The results belong to the dialog. Leaving them behind means the next // scan opens on the last one and fills in around it. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts index fcf1fff..786c033 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { RegisterType } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx index 2bb87a1..558dde0 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx @@ -14,7 +14,7 @@ vi.hoisted(() => { }) import { render, screen, fireEvent } from '@testing-library/react' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import MenuRegisterOptions from '../MenuRegisterOptions/MenuRegisterOptions' import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOptions' @@ -23,13 +23,13 @@ import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOption // leave a stray separator. These tests guard that null-behaviour and the // RTU-over-TCP toggle without needing a real Modbus server. -const seed = (partial: Parameters[0]): void => { - useRootZustand.setState(partial as never) +const seed = (partial: Parameters[0]): void => { + useClientZustand.setState(partial as never) } beforeEach(() => { window.api = { updateConnectionConfig: vi.fn() } as never - useRootZustand.setState({ + useClientZustand.setState({ ready: true, clientState: { connectState: 'disconnected', @@ -43,7 +43,7 @@ beforeEach(() => { describe('MenuRegisterOptions', () => { it('renders advanced/64-bit options with a trailing divider for 16-bit register types', () => { seed({ - registerConfig: { ...useRootZustand.getState().registerConfig, type: 'holding_registers' } + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'holding_registers' } }) const { container } = render() @@ -54,7 +54,7 @@ describe('MenuRegisterOptions', () => { }) it('renders nothing (no options, no divider) for non-16-bit register types', () => { - seed({ registerConfig: { ...useRootZustand.getState().registerConfig, type: 'coils' } }) + seed({ registerConfig: { ...useClientZustand.getState().registerConfig, type: 'coils' } }) const { container } = render() @@ -66,7 +66,7 @@ describe('MenuRegisterOptions', () => { describe('MenuConnectionOptions', () => { it('renders the RTU-over-TCP checkbox with a trailing divider when TCP is selected', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } }) const { container } = render() @@ -78,7 +78,7 @@ describe('MenuConnectionOptions', () => { it('checks the box when the protocol is RTU over TCP', () => { seed({ connectionConfig: { - ...useRootZustand.getState().connectionConfig, + ...useClientZustand.getState().connectionConfig, protocol: 'ModbusRtuOverTcp' } }) @@ -90,7 +90,7 @@ describe('MenuConnectionOptions', () => { it('renders nothing (no checkbox, no divider) for serial RTU', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusRtu' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusRtu' } }) const { container } = render() @@ -101,22 +101,22 @@ describe('MenuConnectionOptions', () => { it('toggles the protocol between TCP and RTU-over-TCP via the checkbox', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } }) render() fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useRootZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') expect(window.api.updateConnectionConfig).toHaveBeenCalledWith({ protocol: 'ModbusRtuOverTcp' }) fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useRootZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') }) it('disables the checkbox while not disconnected', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' }, + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' }, clientState: { connectState: 'connected', polling: false, diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx index d1b00ac..8f612b2 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx @@ -1,12 +1,12 @@ import Button, { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' const PollButton = meme((): JSX.Element => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') + const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const togglePolling = useCallback(() => { polling ? window.api.stopPolling() : window.api.startPolling() }, [polling]) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx index eeb0d70..b3bfab4 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx @@ -2,10 +2,10 @@ import Button from '@mui/material/Button' import { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' const RawButton = meme((): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) + const type = useClientZustand((z) => z.registerConfig.type) const showRawValues = useLayoutZustand((z) => z.showClientRawValues) if (!['input_registers', 'holding_registers'].includes(type)) return null diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx index 5eeb6e8..3de969d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx @@ -1,10 +1,10 @@ import Button, { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useRef, useState } from 'react' const ReadButton = meme((): JSX.Element => { - const disabled = useRootZustand( + const disabled = useClientZustand( (z) => z.clientState.connectState !== 'connected' || z.clientState.polling ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx index 8a1d08f..a65d094 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -12,11 +12,11 @@ import ShowLogButton from './ShowLogButton/ShowLogButton' import MenuButton from './MenuButton/MenuButton' import RawButton from './RawButton/RawButton' import ClearFiltersButton from './ClearFiltersButton/ClearFiltersButton' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import TextField from '@mui/material/TextField' const ClientConfigName = meme(() => { - const name = useRootZustand((z) => z.name ?? '') + const name = useClientZustand((z) => z.name ?? '') return ( { color="primary" placeholder="Client Configuration Name" value={name} - onChange={(e) => useRootZustand.getState().setName(e.target.value)} + onChange={(e) => useClientZustand.getState().setName(e.target.value)} /> ) }) @@ -36,7 +36,7 @@ const ClientConfigName = meme(() => { const RegisterGridToolbar = meme(() => { // Read, Poll, Clear and the config buttons would each undo a scan that is // still running, so the strip goes quiet with the rows underneath it. - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) return ( { const saveRegisterConfig = useCallback(() => { - const z = useRootZustand.getState() + const z = useClientZustand.getState() const { name } = z const registerMapping = structuredClone(z.registerMapping) @@ -43,7 +43,7 @@ const SaveButton = meme(() => { const { connectionConfig: { unitId } - } = useRootZustand.getState() + } = useClientZustand.getState() const idText = `_id${unitId}` diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx index 431fe5d..5e3e14a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx @@ -5,13 +5,13 @@ import Paper from '@mui/material/Paper' import Popover from '@mui/material/Popover' import { meme } from '@renderer/components/shared/inputs/meme' import SliderComponent from '@renderer/components/shared/SliderComponent' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useState } from 'react' // Polling interval slider const PollRate = meme((): JSX.Element => { - const value = useRootZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) - const setValue = useRootZustand((z) => z.setPollRate) + const value = useClientZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) + const setValue = useClientZustand((z) => z.setPollRate) return ( { // Read Timeout slider const Timeout = meme((): JSX.Element => { - const value = useRootZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) - const setValue = useRootZustand((z) => z.setTimeout) + const value = useClientZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) + const setValue = useClientZustand((z) => z.setTimeout) return ( { }) const TimeSettings = meme(() => { - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const [anchorEl, setAnchorEl] = useState(null) const handleOpenMenu = useCallback( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx index 5398fbd..ee1af01 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx @@ -3,12 +3,12 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import Tooltip from '@mui/material/Tooltip' import EndianTable from '@renderer/components/shared/inputs/EndianTable' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' const ToggleEndianButton = meme((): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) - const littleEndian = useRootZustand((z) => z.registerConfig.littleEndian) - const setLittleEndian = useRootZustand((z) => z.setLittleEndian) + const type = useClientZustand((z) => z.registerConfig.type) + const littleEndian = useClientZustand((z) => z.registerConfig.littleEndian) + const setLittleEndian = useClientZustand((z) => z.setLittleEndian) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) if (!registers16Bit) return null diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index 1f0b1ac..357f538 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -11,7 +11,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useMinMaxInteger } from '@renderer/hooks' import { BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' @@ -80,7 +80,7 @@ const DataTypeSelect = meme(({ address }: { address: number }) => { const { registerMapping, registerConfig: { type } - } = useRootZustand.getState() + } = useClientZustand.getState() const dataType = registerMapping[type][address]?.dataType if (!dataType) return @@ -141,7 +141,7 @@ const WriteRegistersButton = meme(() => { const CoilFunctionSelect = meme(() => { const address = useValueInputZustand((z) => z.address) - const registerConfigAddress = useRootZustand((z) => z.registerConfig.address) + const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const coils = useValueInputZustand((z) => z.coils) const coilFunction = useValueInputZustand((z) => z.coilFunction) const setCoilFunction = useValueInputZustand((z) => z.setCoilFunction) @@ -219,8 +219,8 @@ const CoilButton = meme(({ address, index }: CoilButtonProps) => { }) const Coils = meme(() => { - const length = useRootZustand((z) => z.registerConfig.length) - const registerConfigAddress = useRootZustand((z) => z.registerConfig.address) + const length = useClientZustand((z) => z.registerConfig.length) + const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const address = useValueInputZustand((z) => z.address) const coils = useValueInputZustand((z) => z.coils) const coilFunction = useValueInputZustand((z) => z.coilFunction) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts index 9e8ff5f..d84e52a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { BaseDataType } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx index db74fd8..a5ffd70 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx @@ -1,5 +1,5 @@ import { GridColDef } from '@mui/x-data-grid/models' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { RegisterData } from '@shared' import { useMemo } from 'react' import { addressColumn } from './address' @@ -21,14 +21,14 @@ import { bitmapValueColumn } from './bitmapValueColumn' // // COLUMNS const useRegisterGridColumns = (): GridColDef[] => { - const type = useRootZustand((z) => z.registerConfig.type) - const registerMap = useRootZustand((z) => z.registerMapping[type]) + const type = useClientZustand((z) => z.registerConfig.type) + const registerMap = useClientZustand((z) => z.registerMapping[type]) - const addressBase = useRootZustand((z) => z.registerConfig.addressBase) - const advanced = useRootZustand((z) => z.registerConfig.advancedMode) - const show64Bit = useRootZustand((z) => z.registerConfig.show64BitValues) + const addressBase = useClientZustand((z) => z.registerConfig.addressBase) + const advanced = useClientZustand((z) => z.registerConfig.advancedMode) + const show64Bit = useClientZustand((z) => z.registerConfig.show64BitValues) - const readConfiguration = useRootZustand((z) => z.readConfiguration) + const readConfiguration = useClientZustand((z) => z.readConfiguration) const showRaw = useLayoutZustand((z) => z.showClientRawValues) return useMemo(() => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index edd7425..6d2bd41 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -13,8 +13,8 @@ import { GridActionsCellItem } from '@mui/x-data-grid/components' import { GridColDef } from '@mui/x-data-grid/models' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { useClientZustand } from '@renderer/context/client.zustand' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { DataType, RegisterData, RegisterLinearInterpolation, RegisterType } from '@shared' import { deepEqual } from 'fast-equals' import { @@ -94,7 +94,7 @@ const useInterpolateValue = ( type: RegisterType, address: number ): string => - useRootZustand((z) => { + useClientZustand((z) => { const interpolate = z.registerMapping[type][address]?.interpolate return interpolate !== undefined ? interpolate[key] : defaultInterpolation[key] }) @@ -110,7 +110,7 @@ const InterpolationModal = meme( const handleChange = useCallback( (key: keyof RegisterLinearInterpolation, value: string) => { - const state = useRootZustand.getState() + const state = useClientZustand.getState() const interpolate: RegisterLinearInterpolation = state.registerMapping[type][address] ?.interpolate || { ...defaultInterpolation } state.setRegisterMapping(address, 'interpolate', { ...interpolate, [key]: value }) @@ -142,7 +142,7 @@ const InterpolationModal = meme( color="primary" size="small" onClick={() => { - useRootZustand + useClientZustand .getState() .setRegisterMapping(address, 'interpolate', { ...defaultInterpolation }) }} @@ -190,10 +190,10 @@ const Action = meme(({ type, address }: ActionProps): JSX.Element => { 'uint64' ] - const dataType = useRootZustand((z) => z.registerMapping[type][address]?.dataType) + const dataType = useClientZustand((z) => z.registerMapping[type][address]?.dataType) const enabled = dataType && enabledDatatypes.includes(dataType) const isDefault = isDefaultInterpolation( - useRootZustand.getState().registerMapping[type][address]?.interpolate + useClientZustand.getState().registerMapping[type][address]?.interpolate ) return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/scalingFactor.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/scalingFactor.tsx index a2021e3..192cf65 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/scalingFactor.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/scalingFactor.tsx @@ -1,5 +1,5 @@ import { GridColDef } from '@mui/x-data-grid/models' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { DataType, RegisterData, RegisterMapObject, RegisterType } from '@shared' import { ReactNode } from 'react' @@ -30,7 +30,7 @@ export const scalingFactorColumn = ( 'uint64' ] - const dataType = useRootZustand.getState().registerMapping[type][row.id]?.dataType + const dataType = useClientZustand.getState().registerMapping[type][row.id]?.dataType const enabled = dataType && enabledDatatypes.includes(dataType) return registerMap[row.id]?.dataType && registerMap[row.id]?.dataType !== 'none' && enabled diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx index f833e0b..5f11c5a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx @@ -5,7 +5,7 @@ import { GridActionsCellItem } from '@mui/x-data-grid/components' import WriteModal from '@renderer/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { RegisterType, RegisterData } from '@shared' import { ReactElement, useEffect, useRef, useState } from 'react' @@ -21,7 +21,7 @@ const Action = meme(({ address, type }: ActionProps): JSX.Element => { const actionCellRef = useRef(null) const apiRef = useGridApiContext() - const disabled = useRootZustand((z) => { + const disabled = useClientZustand((z) => { return z.clientState.polling || z.clientState.connectState !== 'connected' }) diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index c1493ec..586ba6a 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -4,7 +4,7 @@ import Paper from '@mui/material/Paper' import { useGridApiContext, useGridApiRef } from '@mui/x-data-grid' import { DataGrid } from '@mui/x-data-grid/DataGrid' import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import useTransactionGridColumns from './_columns' import { DateTime } from 'luxon' import { meme } from '@renderer/components/shared/inputs/meme' @@ -39,7 +39,7 @@ const ExportButton = meme((): JSX.Element => { // // Clears the transaction log const ClearButton = meme((): JSX.Element => { - const clear = useRootZustand((z) => z.clearTransactions) + const clear = useClientZustand((z) => z.clearTransactions) return ( ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx index 5e3e14a..137100d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx @@ -11,14 +11,18 @@ import { useCallback, useState } from 'react' // Polling interval slider const PollRate = meme((): JSX.Element => { const value = useClientZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) - const setValue = useClientZustand((z) => z.setPollRate) + + const handleChange = useCallback((seconds: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setPollRate(seconds * 1000) + }, []) return ( setValue(v * 1000)} + setValue={handleChange} /> ) }) @@ -26,14 +30,18 @@ const PollRate = meme((): JSX.Element => { // Read Timeout slider const Timeout = meme((): JSX.Element => { const value = useClientZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) - const setValue = useClientZustand((z) => z.setTimeout) + + const handleChange = useCallback((seconds: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setTimeout(seconds * 1000) + }, []) return ( setValue(v * 1000)} + setValue={handleChange} /> ) }) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx index ee1af01..38969a0 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx @@ -4,11 +4,17 @@ import Tooltip from '@mui/material/Tooltip' import EndianTable from '@renderer/components/shared/inputs/EndianTable' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' +import { useCallback } from 'react' const ToggleEndianButton = meme((): JSX.Element | null => { const type = useClientZustand((z) => z.registerConfig.type) const littleEndian = useClientZustand((z) => z.registerConfig.littleEndian) - const setLittleEndian = useClientZustand((z) => z.setLittleEndian) + + const handleChange = useCallback((_event: unknown, value: boolean | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setLittleEndian(value) + }, []) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) if (!registers16Bit) return null @@ -25,7 +31,7 @@ const ToggleEndianButton = meme((): JSX.Element | null => { exclusive color="primary" value={littleEndian} - onChange={(_, v) => v !== null && setLittleEndian(v)} + onChange={handleChange} > { const value = useValueInputZustand((z) => z.value) const valid = useValueInputZustand((z) => z.valid) - const setValue = useValueInputZustand((z) => z.setValue) + + const handleChange = useCallback((value: string, isValid?: boolean): void => { + const valueInputZustand = useValueInputZustand.getState() + valueInputZustand.setValue(value, isValid) + }, []) return ( { slotProps={{ input: { inputComponent: ValueInput as unknown as ElementType, - inputProps: maskInputProps({ set: setValue }) + inputProps: maskInputProps({ set: handleChange }) } }} /> @@ -73,10 +77,15 @@ const ValueInputComponent = meme(({ address }: { address: number }) => { const DataTypeSelect = meme(({ address }: { address: number }) => { const dataType = useValueInputZustand((z) => z.dataType) - const setDataType = useValueInputZustand((z) => z.setDataType) + + const handleChange = useCallback((value: BaseDataType): void => { + const valueInputZustand = useValueInputZustand.getState() + valueInputZustand.setDataType(value) + }, []) // Set the data type based on the address if it's defined in the register mapping useEffect(() => { + const valueInputZustand = useValueInputZustand.getState() const { registerMapping, registerConfig: { type } @@ -86,10 +95,10 @@ const DataTypeSelect = meme(({ address }: { address: number }) => { if (!dataType) return const result = BaseDataTypeSchema.safeParse(dataType) - if (result.success) setDataType(result.data) - }, [address, setDataType]) + if (result.success) valueInputZustand.setDataType(result.data) + }, [address]) - return + return }) const WriteRegistersButton = meme(() => { @@ -144,7 +153,12 @@ const CoilFunctionSelect = meme(() => { const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const coils = useValueInputZustand((z) => z.coils) const coilFunction = useValueInputZustand((z) => z.coilFunction) - const setCoilFunction = useValueInputZustand((z) => z.setCoilFunction) + + const handleFunctionChange = useCallback((_event: unknown, value: 5 | 15 | null): void => { + if (value === null) return + const valueInputZustand = useValueInputZustand.getState() + valueInputZustand.setCoilFunction(value) + }, []) const handleWrite = useCallback(() => { window.api.write({ @@ -163,7 +177,7 @@ const CoilFunctionSelect = meme(() => { exclusive color="primary" value={coilFunction} - onChange={(_, v) => v !== null && setCoilFunction(v)} + onChange={handleFunctionChange} > { const state = useValueInputZustand((z) => z.coils[index]) - const setCoils = useValueInputZustand((z) => z.setCoils) + + const handleClick = useCallback((): void => { + const valueInputZustand = useValueInputZustand.getState() + valueInputZustand.setCoils(!state, index) + }, [state, index]) return ( ) diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 066f8ea..21dbf05 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -22,7 +22,12 @@ import { meme } from '@renderer/components/shared/inputs/meme' // Protocol const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') - const setProtocol = useClientZustand((z) => z.setProtocol) + + const handleChange = useCallback((_event: unknown, value: Protocol | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setProtocol(value) + }, []) // RTU over TCP is a TCP-family transport (toggled from the options menu), // so the TCP button stays highlighted for it -- but in warning colour, since @@ -53,7 +58,7 @@ const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { exclusive color="primary" value={toggleValue} - onChange={(_, v) => v !== null && setProtocol(v)} + onChange={handleChange} > {rtuOverTcp ? ( @@ -71,14 +76,13 @@ const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { const ConnectButton = meme(() => { const connectState = useClientZustand((z) => z.clientState.connectState) - const setRegisterData = useDataZustand((z) => z.setRegisterData) const action = useCallback(async (): Promise => { const currentConnectedState = useClientZustand.getState().clientState.connectState if (['connecting', 'connected'].includes(currentConnectedState)) { window.api.disconnect() if (!useClientZustand.getState().readConfiguration) { - setRegisterData([]) + useDataZustand.getState().setRegisterData([]) } return } @@ -92,7 +96,7 @@ const ConnectButton = meme(() => { } window.api.connect() } - }, [setRegisterData]) + }, []) const disabled = ['disconnecting'].includes(connectState) diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index 6a0d9ec..1d1ace5 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -16,9 +16,10 @@ import { useComInputWidth } from '@renderer/components/shared/inputs/SerialPortInputs' import { useClientZustand } from '@renderer/context/client.zustand' +import type { ModbusBaudRate } from '@shared' import type { SerialPortOptions } from 'modbus-serial/ModbusRTU' import { useSnackbar } from 'notistack' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' // // @@ -142,39 +143,49 @@ const Com = meme((): JSX.Element => { const ClientBaudRateSelect = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const baudRate = useClientZustand((z) => z.connectionConfig.rtu.options.baudRate) - const setBaudRate = useClientZustand((z) => z.setBaudRate) - return + const handleChange = useCallback((value: ModbusBaudRate): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setBaudRate(value) + }, []) + + return }) const ClientParitySelect = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const parity = useClientZustand((z) => z.connectionConfig.rtu.options.parity ?? 'none') - const setParity = useClientZustand((z) => z.setParity) - return ( - setParity(v as SerialPortOptions['parity'])} - disabled={disabled} - /> - ) + const handleChange = useCallback((value: string): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setParity(value as SerialPortOptions['parity']) + }, []) + + return }) const ClientDataBitsSelect = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const dataBits = useClientZustand((z) => z.connectionConfig.rtu.options.dataBits) - const setDataBits = useClientZustand((z) => z.setDataBits) - return + const handleChange = useCallback((value: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setDataBits(value as SerialPortOptions['dataBits']) + }, []) + + return }) const ClientStopBitsSelect = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const stopBits = useClientZustand((z) => z.connectionConfig.rtu.options.stopBits) - const setStopBits = useClientZustand((z) => z.setStopBits) - return + const handleChange = useCallback((value: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setStopBits(value as SerialPortOptions['stopBits']) + }, []) + + return }) const RtuConfig = meme((): JSX.Element => { diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index 789a571..42e747c 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -6,14 +6,18 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useClientZustand } from '@renderer/context/client.zustand' -import { ElementType } from 'react' +import { ElementType, useCallback } from 'react' // Host const Host = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const host = useClientZustand((z) => z.connectionConfig.tcp.host) const hostValid = useClientZustand((z) => z.valid.host) - const setHost = useClientZustand((z) => z.setHost) + + const handleChange = useCallback((value: string, valid?: boolean): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setHost(value, valid) + }, []) return ( { slotProps={{ input: { inputComponent: HostInput as unknown as ElementType, - inputProps: maskInputProps({ set: setHost }) + inputProps: maskInputProps({ set: handleChange }) } }} /> diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index e0eee14..0bf9349 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -55,14 +55,18 @@ const TypeSelect = meme(() => { // Address const Address = meme(() => { const address = useClientZustand((z) => z.registerConfig.address) - const setAddress = useClientZustand((z) => z.setAddress) const readConfiguration = useClientZustand((z) => z.readConfiguration) + const handleChange = useCallback((value: string, valid?: boolean): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setAddress(value, valid) + }, []) + return ( @@ -75,10 +79,14 @@ const Address = meme(() => { const Length = meme(() => { const length = useClientZustand((z) => String(z.registerConfig.length)) const lengthValid = useClientZustand((z) => z.valid.lenght) - const setLength = useClientZustand((z) => z.setLength) const address = useClientZustand((z) => z.registerConfig.address) const readConfiguration = useClientZustand((z) => z.readConfiguration) + const handleChange = useCallback((value: string, valid?: boolean): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setLength(value, valid) + }, []) + return ( { slotProps={{ input: { inputComponent: LengthInput as unknown as ElementType, - inputProps: maskInputProps({ set: setLength, max: 65536 - address }) + inputProps: maskInputProps({ set: handleChange, max: 65536 - address }) } }} /> diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index eaf7b89..9cd5f8b 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -173,19 +173,23 @@ const RunCommandButton = meme((): JSX.Element | null => { }) const LaterButton = meme((): JSX.Element => { - const setOpen = useSerialGroupZustand((z) => z.setOpen) + const handleClick = useCallback((): void => { + const serialGroupZustand = useSerialGroupZustand.getState() + serialGroupZustand.setOpen(false) + }, []) + return ( - ) }) const LogoutButton = meme((): JSX.Element => { - const setOpen = useSerialGroupZustand((z) => z.setOpen) const { enqueueSnackbar } = useSnackbar() const logout = useCallback(async (): Promise => { + const serialGroupZustand = useSerialGroupZustand.getState() const asked = await window.api.requestLogout() if (!asked) { enqueueSnackbar({ @@ -193,8 +197,8 @@ const LogoutButton = meme((): JSX.Element => { variant: 'info' }) } - setOpen(false) - }, [enqueueSnackbar, setOpen]) + serialGroupZustand.setOpen(false) + }, [enqueueSnackbar]) return ( diff --git a/src/renderer/src/components/shared/MessageReceiver.tsx b/src/renderer/src/components/shared/MessageReceiver.tsx index c0a781b..9343c7e 100644 --- a/src/renderer/src/components/shared/MessageReceiver.tsx +++ b/src/renderer/src/components/shared/MessageReceiver.tsx @@ -11,8 +11,6 @@ const MessageReceiver = meme((): null => { const { enqueueSnackbar } = useSnackbar() const clientConfigWasReset = useClientZustand((z) => z.configWasReset) const serverConfigWasReset = useServerZustand((z) => z.configWasReset) - const acknowledgeClientReset = useClientZustand((z) => z.acknowledgeConfigReset) - const acknowledgeServerReset = useServerZustand((z) => z.acknowledgeConfigReset) const handleMessage = useCallback( (message: BackendMessage) => { @@ -38,27 +36,23 @@ const MessageReceiver = meme((): null => { // Server rather than at the root: without that, walking Home and back reports // the same reset again. useEffect(() => { + const clientZustand = useClientZustand.getState() + const serverZustand = useServerZustand.getState() if (clientConfigWasReset) { enqueueSnackbar({ variant: 'error', message: 'Client configuration was corrupted and has been reset to defaults.' }) - acknowledgeClientReset() + clientZustand.acknowledgeConfigReset() } if (serverConfigWasReset) { enqueueSnackbar({ variant: 'error', message: 'Server configuration was corrupted and has been reset to defaults.' }) - acknowledgeServerReset() + serverZustand.acknowledgeConfigReset() } - }, [ - clientConfigWasReset, - serverConfigWasReset, - acknowledgeClientReset, - acknowledgeServerReset, - enqueueSnackbar - ]) + }, [clientConfigWasReset, serverConfigWasReset, enqueueSnackbar]) return null }) diff --git a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx index 921e728..0eefb14 100644 --- a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx +++ b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx @@ -20,7 +20,12 @@ interface AddressBaseInputProps { const AddressBaseInput = meme( ({ disabled, address, setAddress, testId, baseTestId }: AddressBaseInputProps): JSX.Element => { const addressBase = useClientZustand((z) => z.registerConfig.addressBase) - const setAddressBase = useClientZustand((z) => z.setAddressBase) + + const handleBaseChange = useCallback((_event: unknown, value: '0' | '1' | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setAddressBase(value) + }, []) const base = Number(addressBase) const displayValue = String(address + base) @@ -50,7 +55,7 @@ const AddressBaseInput = meme( exclusive color="primary" value={addressBase} - onChange={(_, v) => v !== null && setAddressBase(v)} + onChange={handleBaseChange} > { - const setAppType = useLayoutZustand((z) => z.setAppType) const connected = useClientZustand((z) => z.clientState.connectState === 'connected') + + const handleClick = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.setAppType('client') + }, []) + return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx index a65d094..96fb211 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -14,10 +14,16 @@ import RawButton from './RawButton/RawButton' import ClearFiltersButton from './ClearFiltersButton/ClearFiltersButton' import { useClientZustand } from '@renderer/context/client.zustand' import TextField from '@mui/material/TextField' +import { ChangeEvent, useCallback } from 'react' const ClientConfigName = meme(() => { const name = useClientZustand((z) => z.name ?? '') + const handleChange = useCallback((event: ChangeEvent): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setName(event.target.value) + }, []) + return ( { color="primary" placeholder="Client Configuration Name" value={name} - onChange={(e) => useClientZustand.getState().setName(e.target.value)} + onChange={handleChange} /> ) }) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index 7d080e1..cddfcd3 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -119,6 +119,11 @@ const InterpolationModal = meme( [type, address] ) + const handleReset = useCallback((): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setRegisterMapping(address, 'interpolate', { ...defaultInterpolation }) + }, [address]) + return ( open && ( @@ -142,11 +147,7 @@ const InterpolationModal = meme( data-testid="interpolation-reset-btn" color="primary" size="small" - onClick={() => { - useClientZustand - .getState() - .setRegisterMapping(address, 'interpolate', { ...defaultInterpolation }) - }} + onClick={handleReset} > diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 21dbf05..189ff7d 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -138,6 +138,11 @@ const ConnectButton = meme(() => { const UnitId = meme(() => { const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) + const handleChange = useCallback((value: string, valid?: boolean): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setUnitId(value, valid) + }, []) + return ( { slotProps={{ input: { inputComponent: UnitIdInput as unknown as ElementType, - inputProps: maskInputProps({ set: useClientZustand.getState().setUnitId }) + inputProps: maskInputProps({ set: handleChange }) } }} /> diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index 1d1ace5..bf012bd 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -32,6 +32,18 @@ const ComInput = meme(() => { const ports = useClientZustand((z) => z.serialPorts) const inputWidth = useComInputWidth(ports) + // Typing is valid only once it is not blank; picking from the list always is. + const handleInputChange = useCallback((_event: unknown, value: string): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setCom(value, value.trim().length > 0) + }, []) + + const handleChange = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const clientZustand = useClientZustand.getState() + clientZustand.setCom(value, true) + }, []) + return ( { options={ports.map((p) => p.path)} value={com} data-testid="rtu-com-input" - onInputChange={(_event, newValue) => - useClientZustand.getState().setCom(newValue, newValue.trim().length > 0) - } - onChange={(_event, newValue) => { - if (newValue) useClientZustand.getState().setCom(newValue, true) - }} + onInputChange={handleInputChange} + onChange={handleChange} sx={{ width: inputWidth, maxWidth: 220 }} renderInput={(params) => ( diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index 42e747c..7ce2b8a 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -46,6 +46,11 @@ const Port = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const port = useClientZustand((z) => String(z.connectionConfig.tcp.options.port)) + const handleChange = useCallback((value: string, valid?: boolean): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setPort(value, valid) + }, []) + return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: useClientZustand.getState().setPort }) + inputProps: maskInputProps({ set: handleChange }) } }} /> diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 9cd5f8b..a2fc617 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -268,14 +268,19 @@ const SerialGroupModal = meme(({ active }: SerialGroupModalProps): JSX.Element | } }, [active, ports]) + // Read busy rather than subscribe to it: the shell has no other reason to + // re-render while the command runs. + const handleClose = useCallback((): void => { + const serialGroupZustand = useSerialGroupZustand.getState() + if (!serialGroupZustand.busy) decline() + }, []) + if (!hasStatus) return null return ( !useSerialGroupZustand.getState().busy && decline()} + onClose={handleClose} maxWidth="sm" fullWidth data-testid="serial-group-modal" diff --git a/src/renderer/src/components/server/SelectServer/SelectServer.tsx b/src/renderer/src/components/server/SelectServer/SelectServer.tsx index 8f8a27d..7abf759 100644 --- a/src/renderer/src/components/server/SelectServer/SelectServer.tsx +++ b/src/renderer/src/components/server/SelectServer/SelectServer.tsx @@ -38,6 +38,12 @@ const SelectServer = meme(() => { serverZustand.deleteServer(serverZustand.selectedUuid) }, []) + const handleSelect = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const serverZustand = useServerZustand.getState() + serverZustand.setSelectedUuid(value) + }, []) + if (serverMode === 'rtu') return null return ( @@ -68,10 +74,7 @@ const SelectServer = meme(() => { color="primary" value={selectedUuid} exclusive - onChange={(_, v) => { - if (!v) return - useServerZustand.getState().setSelectedUuid(v) - }} + onChange={handleSelect} > {serverUuids.map((uuid) => ( diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index e0550aa..2cc8674 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -11,7 +11,7 @@ import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, ServerMode } from '@shared' import { ElementType, forwardRef } from 'react' import { IMaskInput, IMask } from 'react-imask' -import Select from '@mui/material/Select' +import Select, { SelectChangeEvent } from '@mui/material/Select' import { UnitIdString, UnitIdStringSchema } from '@shared' import MenuItem from '@mui/material/MenuItem' import React, { useCallback, useState } from 'react' @@ -21,11 +21,12 @@ const ModeToggle = meme(() => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') const handleModeChange = async (_: React.MouseEvent, value: ServerMode | null): Promise => { + const serverZustand = useServerZustand.getState() if (!value || value === serverMode) return if (value === 'rtu') { - await useServerZustand.getState().switchToRtu() + await serverZustand.switchToRtu() } else { - await useServerZustand.getState().switchToTcp() + await serverZustand.switchToTcp() } } @@ -124,6 +125,12 @@ const UnitId = meme(() => { }) const labelId = 'unit-id-select' + const handleChange = useCallback((event: SelectChangeEvent): void => { + const serverZustand = useServerZustand.getState() + const result = UnitIdStringSchema.safeParse(event.target.value) + if (result.success) serverZustand.setUnitId(result.data) + }, []) + return ( Unit ID @@ -133,10 +140,7 @@ const UnitId = meme(() => { labelId={labelId} value={unitId} label="Unit ID" - onChange={(e) => { - const result = UnitIdStringSchema.safeParse(e.target.value) - if (result.success) useServerZustand.getState().setUnitId(result.data) - }} + onChange={handleChange} slotProps={{ input: { sx: { pr: 0, pl: 1 } } }} > {UnitIdStringSchema.options.map((unitId) => ( @@ -190,6 +194,11 @@ PortInput.displayName = 'PortInput' const Port = meme(() => { const port = useServerZustand((z) => z.port[z.selectedUuid]) + const handleChange = useCallback((value: string, valid?: boolean): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setPort(value, valid) + }, []) + return ( { slotProps={{ input: { inputComponent: PortInput as unknown as ElementType, - inputProps: maskInputProps({ - set: useServerZustand.getState().setPort - }) + inputProps: maskInputProps({ set: handleChange }) } }} /> diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index 1026d51..bf73e8f 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -19,7 +19,7 @@ import { } from '@renderer/components/shared/inputs/SerialPortInputs' import { useServerZustand } from '@renderer/context/server.zustand' import { ModbusBaudRate } from '@shared' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' // // @@ -37,12 +37,22 @@ const ComInput = meme(() => { }, [comFromStore]) const applyOnBlur = (): void => { + const serverZustand = useServerZustand.getState() if (localCom !== comFromStore) { - useServerZustand.getState().setServerCom(localCom) - useServerZustand.getState().applyServerCom() + serverZustand.setServerCom(localCom) + serverZustand.applyServerCom() } } + // Picking from the dropdown applies at once; typing waits for the blur. + const handleChange = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const serverZustand = useServerZustand.getState() + setLocalCom(value) + serverZustand.setServerCom(value) + serverZustand.applyServerCom() + }, []) + const comLabel = comFromStore ? `COM ${comFromStore}` : 'COM Port' const comError = !comFromStore || comFromStore.trim().length === 0 @@ -53,14 +63,7 @@ const ComInput = meme(() => { value={localCom} data-testid="server-rtu-com-input" onInputChange={(_event, newValue) => setLocalCom(newValue)} - onChange={(_event, newValue) => { - if (newValue) { - setLocalCom(newValue) - // Dropdown selection: apply immediately - useServerZustand.getState().setServerCom(newValue) - useServerZustand.getState().applyServerCom() - } - }} + onChange={handleChange} onBlur={applyOnBlur} sx={{ width: inputWidth, maxWidth: 220 }} renderInput={(params) => ( @@ -179,10 +182,15 @@ const Com = meme((): JSX.Element => { const ServerBaudRateSelect = meme(() => { const baudRate = useServerZustand((z) => z.serialConfig?.options.baudRate ?? '9600') + const handleChange = useCallback((value: ModbusBaudRate): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setServerBaudRate(value) + }, []) + return ( useServerZustand.getState().setServerBaudRate(v)} + onChange={handleChange} testId="server-rtu-baudrate-select" /> ) @@ -191,36 +199,37 @@ const ServerBaudRateSelect = meme(() => { const ServerParitySelect = meme(() => { const parity = useServerZustand((z) => z.serialConfig?.options.parity ?? 'none') - return ( - useServerZustand.getState().setServerParity(v)} - testId="server-rtu-parity-select" - /> - ) + const handleChange = useCallback((value: string): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setServerParity(value) + }, []) + + return }) const ServerDataBitsSelect = meme(() => { const dataBits = useServerZustand((z) => z.serialConfig?.options.dataBits ?? 8) + const handleChange = useCallback((value: number): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setServerDataBits(value) + }, []) + return ( - useServerZustand.getState().setServerDataBits(v)} - testId="server-rtu-databits-select" - /> + ) }) const ServerStopBitsSelect = meme(() => { const stopBits = useServerZustand((z) => z.serialConfig?.options.stopBits ?? 1) + const handleChange = useCallback((value: number): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setServerStopBits(value) + }, []) + return ( - useServerZustand.getState().setServerStopBits(v)} - testId="server-rtu-stopbits-select" - /> + ) }) diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx index b464e8c..08b30e8 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx @@ -11,9 +11,9 @@ import useServerGridZustand from '../serverGrid.zustand' const AddButton = meme(({ type }: { type: RegisterType }) => { const handleClick = useCallback(() => { + const addRegisterZustand = useAddRegisterZustand.getState() if (type === 'input_registers' || type === 'holding_registers') { - const setRegisterType = useAddRegisterZustand.getState().setRegisterType - setRegisterType(type) + addRegisterZustand.setRegisterType(type) } // For bools, the inline add bar in ServerBooleans handles adding }, [type]) @@ -74,6 +74,12 @@ const ServerPartTitleName = meme( const amount = Object.keys(z.serverRegisters[uuid]?.[unitId]?.[registerType] ?? {}).length return amount }) + + const handleClick = useCallback((): void => { + const serverGridZustand = useServerGridZustand.getState() + serverGridZustand.toggleCollapse(registerType) + }, [registerType]) + return ( useServerGridZustand.getState().toggleCollapse(registerType)} + onClick={handleClick} > {name} ({amount}) diff --git a/src/renderer/src/containers/Server.tsx b/src/renderer/src/containers/Server.tsx index c8f70ad..00ffd84 100644 --- a/src/renderer/src/containers/Server.tsx +++ b/src/renderer/src/containers/Server.tsx @@ -10,9 +10,16 @@ import Fade from '@mui/material/Fade' import Box from '@mui/material/Box' import ServerGrid from '@renderer/components/server/ServerGrid/ServerGrid' import PrivilegedPortModal from '@renderer/components/server/PrivilegedPortModal/PrivilegedPortModal' +import { ChangeEvent, useCallback } from 'react' const ServerName = meme(() => { const name = useServerZustand((z) => z.name[z.selectedUuid] ?? '') + + const handleChange = useCallback((event: ChangeEvent): void => { + const serverZustand = useServerZustand.getState() + serverZustand.setName(event.target.value) + }, []) + return ( { color="primary" placeholder="Server Name" value={name} - onChange={(e) => useServerZustand.getState().setName(e.target.value)} + onChange={handleChange} /> ) }) From d4d34765200e355a2eb320a74f355018f147372c Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 19:58:06 +0200 Subject: [PATCH 38/72] fix: answer only for the unit ids the server hosts ServerSerial was built without unitID, so modbus-serial fell back to UNIT_ID = 255, "listen to all addresses", and its serverUnitID !== unitID gate never fired. Every frame on the bus reached the vector and the vector answered all of them: unhosted ids got an exception frame, and a broadcast FC6 write got the normal echo and then created unit 0's four 65536-element arrays through _setHoldingRegister. A unit id is now one of ours when it has data under the uuid, and the answer for one that is not depends on the transport. On RTU the vector returns without calling cb, which is the only way modbus-serial transmits nothing; an exception frame there would collide with the real device at that address. On TCP silence is a client timeout, so it gets GATEWAY_TARGET_FAILED. Unit 0 is broadcast on RTU: a write reaches every hosted unit and nothing goes back on the line, a read gets nothing at all. On TCP there is no broadcast and 0 is an address like any other. The setters no longer create the unit they were asked about, so a stray write can no longer turn an unconfigured id into one the server answers for. When the RTU port is open and unit 0 holds data, the server says once that those registers cannot be read. The renderer opens the port before it syncs registers, so both orders reach the message. 23-server-rtu and 24-client-rtu-over-tcp polled the server on unit 0 over RTU, which is now silence. They poll unit 1 against the -unit1.json fixtures, and 23-server-rtu also asserts the silence and the message. --- .../config-files/server-basic-unit1.json | 46 + .../server-huawei-smartlogger-unit1.json | 936 ++++++++++++++++++ e2e/specs/01-main/23-server-rtu.spec.ts | 77 +- .../01-main/24-client-rtu-over-tcp.spec.ts | 10 +- .../modules/__tests__/modbusServer.test.ts | 288 +++++- src/main/modules/modbusServer.ts | 222 ++++- 6 files changed, 1503 insertions(+), 76 deletions(-) create mode 100644 e2e/fixtures/config-files/server-basic-unit1.json create mode 100644 e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json diff --git a/e2e/fixtures/config-files/server-basic-unit1.json b/e2e/fixtures/config-files/server-basic-unit1.json new file mode 100644 index 0000000..bb1dbd3 --- /dev/null +++ b/e2e/fixtures/config-files/server-basic-unit1.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "modbuxVersion": "2.0.0", + "name": "Basic Server (unit 1)", + "littleEndian": false, + "serverRegistersPerUnit": { + "1": { + "coils": {}, + "discrete_inputs": {}, + "input_registers": { + "0": { + "value": 200, + "params": { + "address": 0, + "registerType": "input_registers", + "dataType": "int16", + "comment": "temperature", + "value": 200 + } + } + }, + "holding_registers": { + "0": { + "value": 100, + "params": { + "address": 0, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "setpoint", + "value": 100 + } + }, + "1": { + "value": 500, + "params": { + "address": 1, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "counter", + "value": 500 + } + } + } + } + } +} diff --git a/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json b/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json new file mode 100644 index 0000000..4d7269b --- /dev/null +++ b/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json @@ -0,0 +1,936 @@ +{ + "version": 2, + "modbuxVersion": "2.0.0", + "name": "Huawei Smart Logger (unit 1)", + "littleEndian": false, + "serverRegistersPerUnit": { + "1": { + "coils": {}, + "discrete_inputs": {}, + "holding_registers": { + "40000": { + "value": 0, + "params": { + "address": 40000, + "registerType": "holding_registers", + "dataType": "unix", + "comment": "Date & Time (UTC)", + "min": 0, + "max": 0, + "interval": 1000 + } + }, + "40002": { + "value": 12345, + "params": { + "address": 40002, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "City", + "value": 12345 + } + }, + "40004": { + "value": 1, + "params": { + "address": 40004, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Daylight Saving Time Enabled", + "value": 1 + } + }, + "40005": { + "value": 3600, + "params": { + "address": 40005, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Time zone offset (s)", + "value": 3600 + } + }, + "40007": { + "value": 1, + "params": { + "address": 40007, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "DST state (0: not entered, 1: entered)", + "value": 1 + } + }, + "40008": { + "value": 60, + "params": { + "address": 40008, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "DST offset (min)", + "value": 60 + } + }, + "40009": { + "value": 1748961632, + "params": { + "address": 40009, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Local Time", + "value": 1748961632 + } + }, + "40011": { + "value": 2025, + "params": { + "address": 40011, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Year", + "value": 2025 + } + }, + "40012": { + "value": 6, + "params": { + "address": 40012, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Month", + "value": 6 + } + }, + "40013": { + "value": 3, + "params": { + "address": 40013, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Day", + "value": 3 + } + }, + "40014": { + "value": 16, + "params": { + "address": 40014, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Hour", + "value": 16 + } + }, + "40015": { + "value": 46, + "params": { + "address": 40015, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Minute", + "value": 46 + } + }, + "40016": { + "value": 12, + "params": { + "address": 40016, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Second", + "value": 12 + } + }, + "40204": { + "value": 0, + "params": { + "address": 40204, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Transfer trip (0: Run, 1: Fault outage)", + "value": 0 + } + }, + "40420": { + "value": 4294967295, + "params": { + "address": 40420, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Adjustment of all inverters (kW)", + "value": 4294967295 + } + }, + "40422": { + "value": 2147483647, + "params": { + "address": 40422, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Adjustment of all inverters (kVar)", + "value": 2147483647 + } + }, + "40424": { + "value": 4294967295, + "params": { + "address": 40424, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Adjustment of all inverters (kW) VOLATILE", + "value": 4294967295 + } + }, + "40426": { + "value": 0, + "params": { + "address": 40426, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Adjustment of all inverters (kVar) VOLATILE", + "value": 0 + } + }, + "40428": { + "value": 990, + "params": { + "address": 40428, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Active adjustment % (all inverters)", + "value": 990 + } + }, + "40429": { + "value": 30000, + "params": { + "address": 40429, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Power Factor (-1,-0.8]U[0.8,1]", + "value": 30000 + } + }, + "40500": { + "value": 0, + "params": { + "address": 40500, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "DC Current (A)", + "min": 500, + "max": 520, + "interval": 1000 + } + }, + "40521": { + "value": 0, + "params": { + "address": 40521, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Input Power (kW)", + "min": 2000000, + "max": 2004452, + "interval": 1000 + } + }, + "40523": { + "value": 123456789, + "params": { + "address": 40523, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "CO2 Reduction (kg)", + "value": 123456789 + } + }, + "40525": { + "value": 0, + "params": { + "address": 40525, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Active Power (kW)", + "min": 1800000, + "max": 1802356, + "interval": 1000 + } + }, + "40532": { + "value": 0, + "params": { + "address": 40532, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Power Factor", + "min": 999, + "max": 1000, + "interval": 1000 + } + }, + "40543": { + "value": 1, + "params": { + "address": 40543, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Qinghai (1: Unlimited, 2: Limited, 3: Idle, 4: Outage, 5: Comm interrupt)", + "value": 1 + } + }, + "40544": { + "value": 0, + "params": { + "address": 40544, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Power (kVar)", + "min": 230, + "max": 245, + "interval": 1000 + } + }, + "40550": { + "value": 12345678900, + "params": { + "address": 40550, + "registerType": "holding_registers", + "dataType": "uint64", + "comment": "CO2 Reduction (kg) - larger value range", + "value": 12345678900 + } + }, + "40554": { + "value": 0, + "params": { + "address": 40554, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "DC current 2 (A) - larger value range", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40560": { + "value": 65432485, + "params": { + "address": 40560, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total energy generated by all inverters (kWh)", + "value": 65432485 + } + }, + "40562": { + "value": 35445, + "params": { + "address": 40562, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total energy daily (kWh)", + "value": 35445 + } + }, + "40564": { + "value": 98, + "params": { + "address": 40564, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Duration of daily power generation (h)", + "value": 98 + } + }, + "40566": { + "value": 1, + "params": { + "address": 40566, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Xinjiang", + "value": 1 + } + }, + "40567": { + "value": 1, + "params": { + "address": 40567, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Ningxia", + "value": 1 + } + }, + "40568": { + "value": 0, + "params": { + "address": 40568, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active alarm sequence number", + "value": 0 + } + }, + "40570": { + "value": 0, + "params": { + "address": 40570, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Historical alarm sequence number", + "value": 0 + } + }, + "40572": { + "value": 0, + "params": { + "address": 40572, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase A current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40573": { + "value": 0, + "params": { + "address": 40573, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase B current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40574": { + "value": 0, + "params": { + "address": 40574, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase C current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40575": { + "value": 0, + "params": { + "address": 40575, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage AB (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40576": { + "value": 0, + "params": { + "address": 40576, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage BC (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40577": { + "value": 0, + "params": { + "address": 40577, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage CA (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40685": { + "value": 0, + "params": { + "address": 40685, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Inverter Efficiency (%)", + "min": 9845, + "max": 9854, + "interval": 1000 + } + }, + "40693": { + "value": 12554, + "params": { + "address": 40693, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Max reactive adjustment (kVar)", + "value": 12554 + } + }, + "40695": { + "value": -12554, + "params": { + "address": 40695, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Min reactive adjustment (kVar)", + "value": -12554 + } + }, + "40697": { + "value": 12345, + "params": { + "address": 40697, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Max active adjustment (kW)", + "value": 12345 + } + }, + "40699": { + "value": 1, + "params": { + "address": 40699, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "0: Locked - 1: Unlocked", + "value": 1 + } + }, + "40700": { + "value": 37, + "params": { + "address": 40700, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "DI status", + "value": 37, + "bitMap": { + "0": { + "comment": "DI1" + }, + "1": { + "comment": "DI2" + }, + "2": { + "comment": "DI3" + }, + "3": { + "comment": "DI4" + }, + "4": { + "comment": "DI5" + }, + "5": { + "comment": "DI6" + }, + "6": { + "comment": "DI7" + }, + "7": { + "comment": "DI8" + } + } + } + }, + "40713": { + "value": 0, + "params": { + "address": 40713, + "registerType": "holding_registers", + "dataType": "utf8", + "comment": "ESN", + "stringValue": "HW-SL3000A", + "length": 10, + "value": 0 + } + }, + "40736": { + "value": 0, + "params": { + "address": 40736, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device access status (0: done, 1: in progress, 2: failed)", + "value": 0 + } + }, + "40737": { + "value": 4, + "params": { + "address": 40737, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Active Mode (0: No limit, 4: Remote scheduling)", + "value": 4 + } + }, + "40738": { + "value": 20000, + "params": { + "address": 40738, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Target total active power (kW)", + "value": 20000 + } + }, + "40740": { + "value": 2, + "params": { + "address": 40740, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Reactive Mode (2: Reactive power fix control)", + "value": 2 + } + }, + "40741": { + "value": 1, + "params": { + "address": 40741, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Reactive power curve mode (0: PF, 1: reactive fixed)", + "value": 1 + } + }, + "40742": { + "value": 123, + "params": { + "address": 40742, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive power scheduling target value", + "value": 123 + } + }, + "40802": { + "value": 100, + "params": { + "address": 40802, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Scheduling percentage (%)", + "value": 100 + } + }, + "41124": { + "value": 1234, + "params": { + "address": 41124, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "CO2 emission reduction coefficient (kg/kWh)", + "value": 1234 + } + }, + "41934": { + "value": 1800000, + "params": { + "address": 41934, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "PV Module capacity (kW)", + "value": 1800000 + } + }, + "41936": { + "value": 2000000, + "params": { + "address": 41936, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Rated plant capacity (kW)", + "value": 2000000 + } + }, + "41938": { + "value": 60000, + "params": { + "address": 41938, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total rated capacity of grid-connected inverters (kW)", + "value": 60000 + } + }, + "41940": { + "value": 750, + "params": { + "address": 41940, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Conversion coefficient", + "value": 750 + } + }, + "41942": { + "value": 0, + "params": { + "address": 41942, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Status of communication", + "value": 0 + } + }, + "41947": { + "value": 0, + "params": { + "address": 41947, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Communication abnormal shutdown (0: Disable, 1: Enable)", + "value": 0 + } + }, + "41948": { + "value": 300, + "params": { + "address": 41948, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Communication abnormal detection time (s) [60-1800]", + "value": 300 + } + }, + "41949": { + "value": 1, + "params": { + "address": 41949, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Auto start upon communication recovery (0: Disable, 1: Enable)", + "value": 1 + } + }, + "42017": { + "value": 2025, + "params": { + "address": 42017, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Year", + "value": 2025 + } + }, + "42018": { + "value": 6, + "params": { + "address": 42018, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Month", + "value": 6 + } + }, + "42019": { + "value": 3, + "params": { + "address": 42019, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Day", + "value": 3 + } + }, + "42020": { + "value": 16, + "params": { + "address": 42020, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Hour", + "value": 16 + } + }, + "42021": { + "value": 46, + "params": { + "address": 42021, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Minute", + "value": 46 + } + }, + "42022": { + "value": 12, + "params": { + "address": 42022, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Second", + "value": 12 + } + }, + "42150": { + "value": 0, + "params": { + "address": 42150, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Current error during scanning", + "value": 0 + } + }, + "50000": { + "value": 2048, + "params": { + "address": 50000, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 1", + "value": 2048, + "bitMap": { + "3": { + "comment": "Active Schedule" + }, + "11": { + "comment": "Reactive Schedule" + } + } + } + }, + "50001": { + "value": 8, + "params": { + "address": 50001, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 2", + "value": 8, + "bitMap": { + "1": { + "comment": "MCB" + }, + "2": { + "comment": "Cubicle" + }, + "3": { + "comment": "Addr Conflict" + }, + "4": { + "comment": "SPD" + }, + "5": { + "comment": "DI1" + }, + "6": { + "comment": "DI2" + }, + "7": { + "comment": "DI3" + }, + "8": { + "comment": "DI4" + }, + "9": { + "comment": "DI5" + }, + "10": { + "comment": "DI6" + }, + "11": { + "comment": "DI7" + }, + "12": { + "comment": "DI8" + }, + "13": { + "comment": "24V" + }, + "14": { + "comment": "License" + } + } + } + }, + "50002": { + "value": 0, + "params": { + "address": 50002, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 3", + "value": 0, + "bitMap": { + "0": { + "comment": "Cert alarm 1" + }, + "1": { + "comment": "Cert alarm 2" + }, + "2": { + "comment": "Cert alarm 3" + } + } + } + }, + "65521": { + "value": 5, + "params": { + "address": 65521, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device list change number", + "value": 5 + } + }, + "65522": { + "value": 1, + "params": { + "address": 65522, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Port number", + "value": 1 + } + }, + "65523": { + "value": 0, + "params": { + "address": 65523, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device Address", + "value": 0 + } + }, + "65524": { + "value": 0, + "params": { + "address": 65524, + "registerType": "holding_registers", + "dataType": "utf8", + "comment": "Device name", + "stringValue": "SmartLogger", + "length": 10, + "value": 0 + } + }, + "65534": { + "value": 45057, + "params": { + "address": 65534, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device connection status (0xB000=Disconnected, 0xB001=Online)", + "value": 45057 + } + } + }, + "input_registers": {} + } + } +} diff --git a/e2e/specs/01-main/23-server-rtu.spec.ts b/e2e/specs/01-main/23-server-rtu.spec.ts index d030c1f..7f409e8 100644 --- a/e2e/specs/01-main/23-server-rtu.spec.ts +++ b/e2e/specs/01-main/23-server-rtu.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { test, expect } from '../../fixtures/electron-app' +import type { Page } from '@playwright/test' import { navigateToServer, navigateToClient, @@ -15,6 +16,7 @@ import { cell, expandAllServerPanels, selectRegisterType, + selectUnitId, expectCell } from '../../fixtures/helpers' import { resolve } from 'path' @@ -23,7 +25,11 @@ import { existsSync, unlinkSync } from 'fs' import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') -const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-huawei-smartlogger.json') +// Unit 0 is the broadcast address on RTU, so an RTU server hosts nothing there. +// This is the same SmartLogger config with its registers on unit 1. +const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-huawei-smartlogger-unit1.json') +const SERVER_CONFIG_UNIT_0 = resolve(CONFIG_DIR, 'server-huawei-smartlogger.json') +const SNACKBARS = '.notistack-SnackbarContainer' const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-huawei-smartlogger.json') const PTY_0 = '/tmp/ttyV0' @@ -165,6 +171,7 @@ test.describe.serial('Server RTU — UI elements', () => { // First switch to TCP and load config await mainPage.getByTestId('server-mode-tcp-btn').click() await loadServerConfig(mainPage, SERVER_CONFIG) + await selectUnitId(mainPage, '1') // Check register count const section = mainPage.getByTestId('section-holding_registers') @@ -241,10 +248,11 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await cleanServerState(mainPage) }) - test('load Huawei server config (TCP mode)', async ({ mainPage }) => { + test('load Huawei server config on unit 1 (TCP mode)', async ({ mainPage }) => { test.setTimeout(15_000) await loadServerConfig(mainPage, SERVER_CONFIG) await mainPage.waitForTimeout(500) + await selectUnitId(mainPage, '1') // Verify registers loaded const section = mainPage.getByTestId('section-holding_registers') @@ -280,8 +288,8 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await navigateToClient(mainPage) }) - test('configure client RTU', async ({ mainPage }) => { - await connectClientRTU(mainPage, '0', '9600', 'none', '8', '1') + test('configure client RTU on unit 1', async ({ mainPage }) => { + await connectClientRTU(mainPage, '1', '9600', 'none', '8', '1') }) test('enter client COM /tmp/ttyV1 + connect', async ({ mainPage }) => { @@ -372,6 +380,67 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await expectCell(mainPage, 65534, 'word_uint16', '45057') }) + // ─── Which unit ids answer on the bus ────────────────────────────── + + /** + * Reads 40011 on one unit id. The failure message names the id, which is what + * separates this read's silence from the one before it. + */ + async function readYearOn(mainPage: Page, unitId: string): Promise { + await mainPage.getByTestId('reg-address-input').locator('input').fill('40011') + await mainPage.getByTestId('reg-length-input').locator('input').fill('1') + await mainPage.getByTestId('client-unitid-input').locator('input').fill(unitId) + await mainPage.getByTestId('read-btn').click() + } + + test('a unit the server does not host says nothing', async ({ mainPage }) => { + test.setTimeout(20_000) + + // Silence is the only safe answer on RS-485: an exception frame would + // collide with whatever real device carries that address. + await readYearOn(mainPage, '7') + + await expect(mainPage.locator(SNACKBARS)).toContainText('Timed out [addr:40011, len:1, id:7]', { + timeout: 10_000 + }) + }) + + test('unit 0 says nothing, because it is the broadcast address', async ({ mainPage }) => { + test.setTimeout(20_000) + + await readYearOn(mainPage, '0') + + await expect(mainPage.locator(SNACKBARS)).toContainText('Timed out [addr:40011, len:1, id:0]', { + timeout: 10_000 + }) + }) + + test('back to unit 1, which still answers', async ({ mainPage }) => { + test.setTimeout(20_000) + + await readYearOn(mainPage, '1') + await expectCell(mainPage, 40011, 'word_uint16', '2025') + }) + + test('a config on unit 0 is called out as unreadable over RTU', async ({ mainPage }) => { + test.setTimeout(30_000) + + await navigateToServer(mainPage) + await loadServerConfig(mainPage, SERVER_CONFIG_UNIT_0) + + await expect(mainPage.locator(SNACKBARS)).toContainText( + 'Unit 0 is the broadcast address on RTU', + { timeout: 10_000 } + ) + }) + + test('restore the unit 1 config', async ({ mainPage }) => { + test.setTimeout(15_000) + await loadServerConfig(mainPage, SERVER_CONFIG) + await selectUnitId(mainPage, '1') + await navigateToClient(mainPage) + }) + // ─── ReadConfiguration via client config ─────────────────────────── test('load client config + readConfiguration', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts b/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts index 73dbea2..4203b21 100644 --- a/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts +++ b/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts @@ -8,6 +8,7 @@ import { disconnectClient, readRegisters, selectRegisterType, + selectUnitId, expectCell } from '../../fixtures/helpers' import { resolve } from 'path' @@ -16,7 +17,9 @@ import { existsSync, unlinkSync } from 'fs' import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') -const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-basic.json') +// The gateway carries real RTU frames, so unit 0 is the broadcast address here +// and the server hosts nothing on it. Same config, moved to unit 1. +const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-basic-unit1.json') // A serial-to-Ethernet gateway in transparent mode passes raw RTU frames (with // CRC) between a TCP socket and a serial line. A single socat instance emulates @@ -71,9 +74,10 @@ test.describe.serial('Client RTU over TCP — round-trip via socat gateway', () await cleanServerState(mainPage) }) - test('load basic server config', async ({ mainPage }) => { + test('load basic server config on unit 1', async ({ mainPage }) => { await loadServerConfig(mainPage, SERVER_CONFIG) await mainPage.waitForTimeout(500) + await selectUnitId(mainPage, '1') await expect(mainPage.getByTestId('section-holding_registers')).toContainText('(2)') await expect(mainPage.getByTestId('section-input_registers')).toContainText('(1)') @@ -118,7 +122,7 @@ test.describe.serial('Client RTU over TCP — round-trip via socat gateway', () test('connect to the gateway over TCP', async ({ mainPage }) => { await mainPage.getByTestId('tcp-host-input').locator('input').fill('127.0.0.1') await mainPage.getByTestId('tcp-port-input').locator('input').fill(TCP_PORT) - await mainPage.getByTestId('client-unitid-input').locator('input').fill('0') + await mainPage.getByTestId('client-unitid-input').locator('input').fill('1') await mainPage.getByTestId('connect-btn').click() await expect(mainPage.getByTestId('connect-btn')).toContainText('Disconnect', { diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 7c3d982..1d85701 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -50,7 +50,12 @@ vi.mock('net', () => ({ } })) -import { ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS } from '../modbusServer' +import { + ModbusServer, + SERVER_DEVICE_FAILURE, + ILLEGAL_DATA_ADDRESS, + GATEWAY_TARGET_FAILED +} from '../modbusServer' import { ServerTCP, ServerSerial } from 'modbus-serial' const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows @@ -1338,11 +1343,11 @@ describe('ModbusServer', () => { ) }) - it('returns error when no data exists for unitId', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() await vector.getCoil!(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false ) }) @@ -1365,11 +1370,11 @@ describe('ModbusServer', () => { ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() await vector.getDiscreteInput!(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false ) }) @@ -1407,11 +1412,11 @@ describe('ModbusServer', () => { ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() await vector.getInputRegister!(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 ) }) @@ -1449,11 +1454,11 @@ describe('ModbusServer', () => { ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() await vector.getHoldingRegister!(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 ) }) @@ -1474,15 +1479,21 @@ describe('ModbusServer', () => { ) }) - it('creates default data when unitId has no existing data', async () => { + it('refuses a unit id it does not host and leaves it unhosted', async () => { const cb = vi.fn() await vector.setCoil!(5, true, 1, cb) - expect(cb).toHaveBeenCalledWith(null) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) - // Verify the coil was set by reading it back + // The write must not have created the unit it was refused for. const getCb = vi.fn() await vector.getCoil!(5, 1, getCb) - expect(getCb).toHaveBeenCalledWith(null, true) + expect(getCb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + false + ) }) it('returns error for invalid unitId', async () => { @@ -1531,15 +1542,21 @@ describe('ModbusServer', () => { ) }) - it('creates default data when unitId has no existing data', async () => { + it('refuses a unit id it does not host and leaves it unhosted', async () => { const cb = vi.fn() await vector.setRegister!(0, 500, 1, cb) - expect(cb).toHaveBeenCalledWith(null) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) - // Verify it was set + // The write must not have created the unit it was refused for. const getCb = vi.fn() await vector.getHoldingRegister!(0, 1, getCb) - expect(getCb).toHaveBeenCalledWith(null, 500) + expect(getCb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) }) it('returns error for invalid unitId', async () => { @@ -1552,4 +1569,241 @@ describe('ModbusServer', () => { }) }) }) + // ─── C5: which unit ids the server answers for ──────────────────────────── + + describe('the unit ids a server answers for', () => { + const serialConfig = { + com: '/dev/ttyUSB0', + options: { baudRate: '9600' as const, dataBits: 8, stopBits: 1, parity: 'none' as const } + } + + const hostUnit = (id: UnitIdString, address: number, value: number): void => + server.addRegister({ + uuid, + unitId: id, + littleEndian: false, + params: { + address, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value, + min: undefined, + max: undefined, + interval: undefined + } + }) + + const tcpVector = async (): Promise => { + await server.createServer({ uuid, port: 5020 }) + return vi.mocked(ServerTCP).mock.calls.at(-1)![0] + } + + const rtuVector = async (): Promise => { + await server.startRtuServer({ uuid, serialConfig }) + vi.mocked(ServerSerial).mock.results.at(-1)!.value._handlers['initialized']() + return vi.mocked(ServerSerial).mock.calls.at(-1)![0] as IServiceVector + } + + describe('over TCP', () => { + it('answers for a unit it hosts', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 42) + }) + + it('refuses a unit it does not host, because silence would be a timeout', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 5, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) + }) + + it('treats unit 0 as an address like any other', async () => { + hostUnit('0', 0, 7) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 0, cb) + expect(cb).toHaveBeenCalledWith(null, 7) + }) + + it('sends a write to unit 0 to unit 0 alone', async () => { + hostUnit('0', 0, 7) + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const setCb = vi.fn() + await vector.setRegister!(9, 111, 0, setCb) + expect(setCb).toHaveBeenCalledWith(null) + + const zeroCb = vi.fn() + await vector.getHoldingRegister!(9, 0, zeroCb) + expect(zeroCb).toHaveBeenCalledWith(null, 111) + + const oneCb = vi.fn() + await vector.getHoldingRegister!(9, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, 0) + }) + + it('answers an address past the top of the range with an address error', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(70000, 1, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + 0 + ) + }) + }) + + describe('over RTU', () => { + it('answers for a unit it hosts', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 42) + }) + + it('says nothing for a unit it does not host, because the bus is shared', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 5, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('says nothing for a coil on a unit it does not host', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getCoil!(0, 5, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('never reads unit 0, even when it holds data', async () => { + hostUnit('0', 0, 7) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister!(0, 0, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('sends a write to unit 0 to every unit it hosts', async () => { + hostUnit('1', 0, 42) + hostUnit('2', 0, 43) + const vector = await rtuVector() + + await vector.setRegister!(9, 4242, 0, vi.fn()) + + const oneCb = vi.fn() + await vector.getHoldingRegister!(9, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, 4242) + + const twoCb = vi.fn() + await vector.getHoldingRegister!(9, 2, twoCb) + expect(twoCb).toHaveBeenCalledWith(null, 4242) + }) + + it('does not acknowledge a write to unit 0', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.setRegister!(9, 4242, 0, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('sends a coil write to unit 0 to every unit it hosts', async () => { + hostUnit('1', 0, 42) + hostUnit('2', 0, 43) + const vector = await rtuVector() + + await vector.setCoil!(3, true, 0, vi.fn()) + + const oneCb = vi.fn() + await vector.getCoil!(3, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, true) + + const twoCb = vi.fn() + await vector.getCoil!(3, 2, twoCb) + expect(twoCb).toHaveBeenCalledWith(null, true) + }) + + it('creates nothing from a write to a unit it does not host', async () => { + hostUnit('1', 0, 42) + const rtu = await rtuVector() + const tcp = await tcpVector() + + await rtu.setRegister!(0, 500, 5, vi.fn()) + + // The same uuid over TCP is the only way to ask whether unit 5 now exists. + const cb = vi.fn() + await tcp.getHoldingRegister!(0, 5, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) + }) + }) + + describe('the unit 0 warning', () => { + const warning = 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.' + + const warnings = (): unknown[] => + getWindowCalls('backend_message').filter((c) => c[1].message === warning) + + it('warns when the port comes up on a config that already uses unit 0', async () => { + hostUnit('0', 0, 7) + await rtuVector() + + expect(warnings().length).toBe(1) + }) + + it('warns when unit 0 arrives after the port came up', async () => { + await rtuVector() + hostUnit('0', 0, 7) + + expect(warnings().length).toBe(1) + }) + + it('says nothing when unit 0 holds no data', async () => { + hostUnit('1', 0, 42) + await rtuVector() + + expect(warnings().length).toBe(0) + }) + + it('says it once', async () => { + hostUnit('0', 0, 7) + await rtuVector() + hostUnit('0', 1, 8) + hostUnit('0', 2, 9) + + expect(warnings().length).toBe(1) + }) + + it('says nothing on TCP', async () => { + hostUnit('0', 0, 7) + await tcpVector() + + expect(warnings().length).toBe(0) + }) + }) + }) }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 4567236..4653372 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -53,6 +53,16 @@ export const GATEWAY_PATH_UNAVAILABLE = 10 export const GATEWAY_TARGET_FAILED = 11 export const DEFAULT_MOBUS_PORT = 502 +/** + * The transport a vector answers on. RS-485 is shared and a socket is not, so a + * request for a unit id this server does not host cannot get the same answer on + * both. + */ +export type ServerTransport = 'tcp' | 'rtu' + +/** Unit 0 addresses every device on an RTU bus at once. */ +export const BROADCAST_UNIT_ID: UnitIdString = '0' + /** 0 is a port number the way "any" is a name: the kernel picks, and it listens. */ export const isPort = (port: number): boolean => Number.isInteger(port) && port >= 1 && port <= 65535 @@ -77,6 +87,7 @@ export class ModbusServer { private _rtuServer: ServerSerial | null = null private _rtuUuid: string | null = null private _rtuActive: boolean = false + private _broadcastWarningSent: boolean = false private _windows: Windows // Map to store server data for each unit ID of a server UUID @@ -107,24 +118,76 @@ export class ModbusServer { } /** - * Returns a Modbus service vector for a given server UUID. + * Returns a Modbus service vector for a given server UUID and transport. * This vector provides all the Modbus register accessors and mutators. */ - private _getVector = (uuid: string): IServiceVector => ({ - getCoil: this._getCoil(uuid), - getDiscreteInput: this._getDiscreteInput(uuid), - getInputRegister: this._getInputRegister(uuid), - getHoldingRegister: this._getHoldingRegister(uuid), - setCoil: this._setCoil(uuid), - setRegister: this._setHoldingRegister(uuid) + private _getVector = (uuid: string, transport: ServerTransport): IServiceVector => ({ + getCoil: this._getCoil(uuid, transport), + getDiscreteInput: this._getDiscreteInput(uuid, transport), + getInputRegister: this._getInputRegister(uuid, transport), + getHoldingRegister: this._getHoldingRegister(uuid, transport), + setCoil: this._setCoil(uuid, transport), + setRegister: this._setHoldingRegister(uuid, transport) }) + /** + * A unit id is one of ours when it has data under this uuid. The Select + * offers all 256, and nothing but a register makes one of them exist. + */ + private _hostsUnit(uuid: string, unitId: UnitIdString): boolean { + return this._serverData.get(uuid)?.has(unitId) ?? false + } + + /** + * Unit 0 is broadcast on RTU. On TCP there is no broadcast at all and the + * unit identifier routes through a gateway, so 0 is an address like any other. + */ + private _isBroadcast(transport: ServerTransport, unitId: UnitIdString): boolean { + return transport === 'rtu' && unitId === BROADCAST_UNIT_ID + } + + /** + * Answers a request for a unit id this server does not host. + * + * modbus-serial writes a frame when the vector calls `cb` and writes nothing + * when it does not, so returning without calling it is silence on the wire. + * On RS-485 silence is the only safe answer: the id belongs to a real device + * answering at that moment, and a second frame collides with it. A socket + * carries one device, so silence there is a client timeout instead, and the + * gateway code says what happened. + */ + private _refuseUnit(transport: ServerTransport, cb: FCallbackVal, value: T): void { + if (transport === 'rtu') return + this._mbError(GATEWAY_TARGET_FAILED, cb, value) + } + + /** + * Says once per RTU session that registers on unit 0 are unreachable. + * + * The renderer opens the port before it syncs registers, so on a fresh start + * the data arrives after `initialized` and on a config load it is already + * there. Hence the two call sites, and the flag that keeps them to one + * message. + */ + private _warnBroadcastUnit(uuid: string): void { + if (!this._rtuActive || this._rtuUuid !== uuid) return + if (this._broadcastWarningSent) return + if (!this._hostsUnit(uuid, BROADCAST_UNIT_ID)) return + + this._broadcastWarningSent = true + this._emitMessage({ + message: 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.', + variant: 'warning' + }) + } + /** * Helper to set server data for a unitId in the server data map. */ private _setServerData(uuid: string, unitId: UnitIdString, serverData: ServerData): void { const perUnitMap = this._ensureInnerMap(this._serverData, uuid) perUnitMap.set(unitId, serverData) + this._warnBroadcastUnit(uuid) } /** @@ -201,7 +264,7 @@ export class ModbusServer { for (let i = 0; i < maxAttempts; i++) { const result = await this._isPortAvailable(actualPort) if (result.available) { - server = new ServerTCP(this._getVector(uuid), { + server = new ServerTCP(this._getVector(uuid, 'tcp'), { host: '0.0.0.0', port: actualPort }) @@ -311,7 +374,7 @@ export class ModbusServer { // Ensure server data map for this server and unitId const perUnitMap = this._ensureInnerMap(this._serverData, uuid) const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + this._setServerData(uuid, unitId, serverData) // If a fixed value is provided, set the register directly const fixedValue = !interval && value !== undefined @@ -486,9 +549,13 @@ export class ModbusServer { public startRtuServer = async ({ uuid, serialConfig }: StartRtuServerParams): Promise => { if (!serialConfig.com.trim()) return await this.stopRtuServer() + this._broadcastWarningSent = false try { - this._rtuServer = new ServerSerial(this._getVector(uuid), { + // No unitID on purpose: passing one makes the library answer for that id + // alone. Its default of 255 means "listen to all addresses", and the + // vector filters, because only the vector knows which ids have data. + this._rtuServer = new ServerSerial(this._getVector(uuid, 'rtu'), { path: serialConfig.com, baudRate: Number(serialConfig.options.baudRate), dataBits: serialConfig.options.dataBits as 8 | 7 | 6 | 5, @@ -518,6 +585,7 @@ export class ModbusServer { variant: 'success' }) this._windows.send('rtu_server_status', { active: true }) + this._warnBroadcastUnit(uuid) }) this._rtuServer.on('error', (err) => { @@ -546,6 +614,7 @@ export class ModbusServer { this._rtuServer = null this._rtuUuid = null this._rtuActive = false + this._broadcastWarningSent = false this._windows.send('rtu_server_status', { active: false }) if (wasActive) { this._emitMessage({ message: 'RTU server stopped', variant: 'warning' }) @@ -628,7 +697,7 @@ export class ModbusServer { this._port.delete(uuid) } - const server = new ServerTCP(this._getVector(uuid), { + const server = new ServerTCP(this._getVector(uuid, 'tcp'), { host: '0.0.0.0', port: requestedPort }) @@ -645,10 +714,13 @@ export class ModbusServer { * Returns the value of a coil for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getCoil: (uuid: string) => IServiceVector['getCoil'] = - (uuid) => async (address, unitIdNumber, cb) => { + private _getCoil: (uuid: string, transport: ServerTransport) => IServiceVector['getCoil'] = + (uuid, transport) => async (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) + // A broadcast is never acknowledged, so there is nothing to read from one. + if (this._isBroadcast(transport, unitId.data)) return + if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, false) const value = this._serverData.get(uuid)?.get(unitId.data)?.coils[address] if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) @@ -660,10 +732,15 @@ export class ModbusServer { * Returns the value of a discrete input for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getDiscreteInput: (uuid: string) => IServiceVector['getDiscreteInput'] = - (uuid) => async (address, unitIdNumber, cb) => { + private _getDiscreteInput: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getDiscreteInput'] = + (uuid, transport) => async (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) + if (this._isBroadcast(transport, unitId.data)) return + if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, false) const value = this._serverData.get(uuid)?.get(unitId.data)?.discrete_inputs[address] if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) @@ -675,51 +752,89 @@ export class ModbusServer { * Returns the value of an input register for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getInputRegister: (uuid: string) => IServiceVector['getInputRegister'] = - (uuid) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) - - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.input_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) - - cb(null, value) - } + private _getInputRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getInputRegister'] = (uuid, transport) => async (address, unitId, cb) => { + const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) + if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + if (this._isBroadcast(transport, unitIdSafe.data)) return + if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) + + const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.input_registers[address] + if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + + cb(null, value) + } /** * Returns the value of a holding register for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getHoldingRegister: (uuid: string) => IServiceVector['getHoldingRegister'] = - (uuid) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + private _getHoldingRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getHoldingRegister'] = (uuid, transport) => async (address, unitId, cb) => { + const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) + if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + if (this._isBroadcast(transport, unitIdSafe.data)) return + if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) + + const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.holding_registers[address] + if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + + cb(null, value) + } - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.holding_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + /** + * Writes a coil into a unit this server hosts and tells the view. + */ + private _writeCoil(uuid: string, unitId: UnitIdString, address: number, value: boolean): void { + const serverData = this._serverData.get(uuid)?.get(unitId) + if (!serverData) return + serverData.coils[address] = value - cb(null, value) - } + const registerType: BooleanRegisters = 'coils' + this._windows.send('boolean_value', { uuid, unitId, registerType, address, value }) + } + + /** + * Writes a holding register into a unit this server hosts and tells the view. + */ + private _writeHoldingRegister( + uuid: string, + unitId: UnitIdString, + address: number, + raw: number + ): void { + const serverData = this._serverData.get(uuid)?.get(unitId) + if (!serverData) return + serverData.holding_registers[address] = raw + + const registerType: NumberRegisters = 'holding_registers' + this._windows.send('register_value', { uuid, unitId, registerType, address, raw }) + } /** * Sets the value of a coil for a given address and unitId. * Updates the server data and emits a value change event. */ - private _setCoil: (uuid: string) => IServiceVector['setCoil'] = - (uuid) => async (address, value, unitIdNumber, cb) => { + private _setCoil: (uuid: string, transport: ServerTransport) => IServiceVector['setCoil'] = + (uuid, transport) => async (address, value, unitIdNumber, cb) => { const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) const unitId = unitIdSafe.data - const currentServerData = this._serverData.get(uuid)?.get(unitId) ?? getDefaultServerData() - currentServerData.coils[address] = value - - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - perUnitMap.set(unitId, currentServerData) + // A broadcast write reaches every unit on the bus and is never answered. + if (this._isBroadcast(transport, unitId)) { + for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) + this._writeCoil(uuid, hostedUnitId, address, value) + return + } - const registerType: BooleanRegisters = 'coils' - this._windows.send('boolean_value', { uuid, unitId, registerType, address, value }) + if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) + this._writeCoil(uuid, unitId, address, value) cb(null) } @@ -727,21 +842,24 @@ export class ModbusServer { * Sets the value of a holding register for a given address and unitId. * Updates the server data and emits a value change event. */ - private _setHoldingRegister: (uuid: string) => IServiceVector['setRegister'] = - (uuid) => async (address, raw, unitIdNumber, cb) => { + private _setHoldingRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['setRegister'] = + (uuid, transport) => async (address, raw, unitIdNumber, cb) => { const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) const unitId = unitIdSafe.data - const currentServerData = this._serverData.get(uuid)?.get(unitId) ?? getDefaultServerData() - currentServerData.holding_registers[address] = raw - - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - perUnitMap.set(unitId, currentServerData) + if (this._isBroadcast(transport, unitId)) { + for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) + this._writeHoldingRegister(uuid, hostedUnitId, address, raw) + return + } - const registerType: NumberRegisters = 'holding_registers' - this._windows.send('register_value', { uuid, unitId, registerType, address, raw }) + if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) + this._writeHoldingRegister(uuid, unitId, address, raw) cb(null) } From c90ba8052488014faad0b9553f26cdc27ddbf4f0 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 21:13:19 +0200 Subject: [PATCH 39/72] refactor: a pass-through prop takes the action, not a wrapper around it Thirty useCallbacks read the store and called one action with the same parameters they took. The wrapper's only effect was to give the action a second name, and the dependency list was empty because there was nothing the component owned to put in it. The action is now read where the component can name it, and the prop takes that name. It is neither of the two things the rule forbids: no selector hands back a store function, and no getState() sits in a JSX attribute. Reading at render is safe because no store reassigns an action inside a set(), and persist stores JSON, which carries no functions, over a default merge that spreads current state first. Four wrappers stay. RawButton, ShowLogButton, TransactionGrid and the ScanRegisters toggle hang a parameterless action on onClick, and there the wrapper is what keeps the MouseEvent out of a function that declares no parameters. MaskInputProps.set now says MaskSetFn | AsyncMaskSetFn. Server setPort is the only masked setter that waits on the backend, and its wrapper's void annotation was what hid that from the prop. The mask inputs discard what set returns either way. --- .../ScanRegisters/ScanRegisters.tsx | 35 +++++------------ .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 35 +++++------------ .../columns/WriteModal/WriteModal.tsx | 16 +++----- .../ConnectionConfig/ConnectionConfig.tsx | 7 +--- .../ConnectionConfig/RtuConfig/RtuConfig.tsx | 8 +--- .../ConnectionConfig/TcpConfig/TcpConfig.tsx | 16 +++----- .../client/RegisterConfig/RegisterConfig.tsx | 14 ++----- .../server/ServerConfig/ServerConfig.tsx | 7 +--- .../ServerRtuConfig/ServerRtuConfig.tsx | 38 +++++++++---------- .../AddRegister/registerFields.tsx | 15 ++------ .../AddRegister/valueParameters.tsx | 35 +++++------------ .../src/components/shared/inputs/types.ts | 8 +++- 12 files changed, 78 insertions(+), 156 deletions(-) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index f0b6b6b..5e6a25e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -29,10 +29,7 @@ const UnitIdField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setUnitId(value, valid) - }, []) + const setUnitId = useClientZustand.getState().setUnitId return ( { slotProps={{ input: { inputComponent: UnitIdInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setUnitId }) } }} /> @@ -60,16 +57,13 @@ const AddressField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const address = useScanRegistersZustand((z) => z.address) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanRegistersZustand = useScanRegistersZustand.getState() - scanRegistersZustand.setAddress(value, valid) - }, []) + const setAddress = useScanRegistersZustand.getState().setAddress return ( @@ -83,10 +77,7 @@ const ScanLengthField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const scanLength = useScanRegistersZustand((z) => String(z.scanLength)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanRegistersZustand = useScanRegistersZustand.getState() - scanRegistersZustand.setScanLength(value, valid) - }, []) + const setScanLength = useScanRegistersZustand.getState().setScanLength return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setScanLength }) } }} /> @@ -117,10 +108,7 @@ const ChunkSizeField = meme((): JSX.Element => { const isCoilType = ['coils', 'discrete_inputs'].includes(type) const max = isCoilType ? 2000 : 125 - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanRegistersZustand = useScanRegistersZustand.getState() - scanRegistersZustand.setChunkSize(value, valid) - }, []) + const setChunkSize = useScanRegistersZustand.getState().setChunkSize return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange, max }) + inputProps: maskInputProps({ set: setChunkSize, max }) } }} /> @@ -148,16 +136,13 @@ const TimeoutField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const timeout = useScanRegistersZustand((z) => z.timeout) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanRegistersZustand = useScanRegistersZustand.getState() - scanRegistersZustand.setTimeout(value, valid) - }, []) + const setTimeout = useScanRegistersZustand.getState().setTimeout return ( ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index d5f65db..61d0a0a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -27,10 +27,7 @@ const StartUnitIdField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanUnitIdZustand = useScanUnitIdZustand.getState() - scanUnitIdZustand.setStartUnitId(value, valid) - }, []) + const setStartUnitId = useScanUnitIdZustand.getState().setStartUnitId return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange, max: 255 }) + inputProps: maskInputProps({ set: setStartUnitId, max: 255 }) } }} /> @@ -58,10 +55,7 @@ const CountField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const count = useScanUnitIdZustand((z) => String(z.count)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanUnitIdZustand = useScanUnitIdZustand.getState() - scanUnitIdZustand.setCount(value, valid) - }, []) + const setCount = useScanUnitIdZustand.getState().setCount return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange, max: 256 }) + inputProps: maskInputProps({ set: setCount, max: 256 }) } }} /> @@ -89,16 +83,13 @@ const AddressField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const address = useScanUnitIdZustand((z) => z.address) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanUnitIdZustand = useScanUnitIdZustand.getState() - scanUnitIdZustand.setAddress(value, valid) - }, []) + const setAddress = useScanUnitIdZustand.getState().setAddress return ( @@ -112,10 +103,7 @@ const LengthField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const length = useScanUnitIdZustand((z) => String(z.length)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanUnitIdZustand = useScanUnitIdZustand.getState() - scanUnitIdZustand.setLength(value, valid) - }, []) + const setLength = useScanUnitIdZustand.getState().setLength return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setLength }) } }} /> @@ -143,16 +131,13 @@ const TimeoutField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const timeout = useScanUnitIdZustand((z) => z.timeout) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const scanUnitIdZustand = useScanUnitIdZustand.getState() - scanUnitIdZustand.setTimeout(value, valid) - }, []) + const setTimeout = useScanUnitIdZustand.getState().setTimeout return ( ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index e09a974..2fd7a75 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -13,7 +13,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' import { useClientZustand } from '@renderer/context/client.zustand' import { useMinMaxInteger } from '@renderer/hooks' -import { BaseDataType, BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' +import { BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { IMaskInput, IMask } from 'react-imask' import { useValueInputZustand } from './writeModal.zustand' @@ -51,10 +51,7 @@ const ValueInputComponent = meme(({ address }: { address: number }) => { const value = useValueInputZustand((z) => z.value) const valid = useValueInputZustand((z) => z.valid) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const valueInputZustand = useValueInputZustand.getState() - valueInputZustand.setValue(value, isValid) - }, []) + const setValue = useValueInputZustand.getState().setValue return ( { slotProps={{ input: { inputComponent: ValueInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setValue }) } }} /> @@ -78,10 +75,7 @@ const ValueInputComponent = meme(({ address }: { address: number }) => { const DataTypeSelect = meme(({ address }: { address: number }) => { const dataType = useValueInputZustand((z) => z.dataType) - const handleChange = useCallback((value: BaseDataType): void => { - const valueInputZustand = useValueInputZustand.getState() - valueInputZustand.setDataType(value) - }, []) + const setDataType = useValueInputZustand.getState().setDataType // Set the data type based on the address if it's defined in the register mapping useEffect(() => { @@ -98,7 +92,7 @@ const DataTypeSelect = meme(({ address }: { address: number }) => { if (result.success) valueInputZustand.setDataType(result.data) }, [address]) - return + return }) const WriteRegistersButton = meme(() => { diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 189ff7d..586297b 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -138,10 +138,7 @@ const ConnectButton = meme(() => { const UnitId = meme(() => { const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setUnitId(value, valid) - }, []) + const setUnitId = useClientZustand.getState().setUnitId return ( { slotProps={{ input: { inputComponent: UnitIdInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setUnitId }) } }} /> diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index bf012bd..bc87aee 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -16,7 +16,6 @@ import { useComInputWidth } from '@renderer/components/shared/inputs/SerialPortInputs' import { useClientZustand } from '@renderer/context/client.zustand' -import type { ModbusBaudRate } from '@shared' import type { SerialPortOptions } from 'modbus-serial/ModbusRTU' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' @@ -152,12 +151,9 @@ const ClientBaudRateSelect = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const baudRate = useClientZustand((z) => z.connectionConfig.rtu.options.baudRate) - const handleChange = useCallback((value: ModbusBaudRate): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setBaudRate(value) - }, []) + const setBaudRate = useClientZustand.getState().setBaudRate - return + return }) const ClientParitySelect = meme(() => { diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index 7ce2b8a..b78352b 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -6,7 +6,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useClientZustand } from '@renderer/context/client.zustand' -import { ElementType, useCallback } from 'react' +import { ElementType } from 'react' // Host const Host = meme(() => { @@ -14,10 +14,7 @@ const Host = meme(() => { const host = useClientZustand((z) => z.connectionConfig.tcp.host) const hostValid = useClientZustand((z) => z.valid.host) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setHost(value, valid) - }, []) + const setHost = useClientZustand.getState().setHost return ( { slotProps={{ input: { inputComponent: HostInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setHost }) } }} /> @@ -46,10 +43,7 @@ const Port = meme(() => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') const port = useClientZustand((z) => String(z.connectionConfig.tcp.options.port)) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setPort(value, valid) - }, []) + const setPort = useClientZustand.getState().setPort return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setPort }) } }} /> diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 0bf9349..38ab7b5 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -57,16 +57,13 @@ const Address = meme(() => { const address = useClientZustand((z) => z.registerConfig.address) const readConfiguration = useClientZustand((z) => z.readConfiguration) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setAddress(value, valid) - }, []) + const setAddress = useClientZustand.getState().setAddress return ( @@ -82,10 +79,7 @@ const Length = meme(() => { const address = useClientZustand((z) => z.registerConfig.address) const readConfiguration = useClientZustand((z) => z.readConfiguration) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const clientZustand = useClientZustand.getState() - clientZustand.setLength(value, valid) - }, []) + const setLength = useClientZustand.getState().setLength return ( { slotProps={{ input: { inputComponent: LengthInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange, max: 65536 - address }) + inputProps: maskInputProps({ set: setLength, max: 65536 - address }) } }} /> diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index 2cc8674..cfb1218 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -194,10 +194,7 @@ PortInput.displayName = 'PortInput' const Port = meme(() => { const port = useServerZustand((z) => z.port[z.selectedUuid]) - const handleChange = useCallback((value: string, valid?: boolean): void => { - const serverZustand = useServerZustand.getState() - serverZustand.setPort(value, valid) - }, []) + const setPort = useServerZustand.getState().setPort return ( { slotProps={{ input: { inputComponent: PortInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setPort }) } }} /> diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index bf73e8f..61ee1de 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -182,15 +182,12 @@ const Com = meme((): JSX.Element => { const ServerBaudRateSelect = meme(() => { const baudRate = useServerZustand((z) => z.serialConfig?.options.baudRate ?? '9600') - const handleChange = useCallback((value: ModbusBaudRate): void => { - const serverZustand = useServerZustand.getState() - serverZustand.setServerBaudRate(value) - }, []) + const setServerBaudRate = useServerZustand.getState().setServerBaudRate return ( ) @@ -199,37 +196,38 @@ const ServerBaudRateSelect = meme(() => { const ServerParitySelect = meme(() => { const parity = useServerZustand((z) => z.serialConfig?.options.parity ?? 'none') - const handleChange = useCallback((value: string): void => { - const serverZustand = useServerZustand.getState() - serverZustand.setServerParity(value) - }, []) + const setServerParity = useServerZustand.getState().setServerParity - return + return ( + + ) }) const ServerDataBitsSelect = meme(() => { const dataBits = useServerZustand((z) => z.serialConfig?.options.dataBits ?? 8) - const handleChange = useCallback((value: number): void => { - const serverZustand = useServerZustand.getState() - serverZustand.setServerDataBits(value) - }, []) + const setServerDataBits = useServerZustand.getState().setServerDataBits return ( - + ) }) const ServerStopBitsSelect = meme(() => { const stopBits = useServerZustand((z) => z.serialConfig?.options.stopBits ?? 1) - const handleChange = useCallback((value: number): void => { - const serverZustand = useServerZustand.getState() - serverZustand.setServerStopBits(value) - }, []) + const setServerStopBits = useServerZustand.getState().setServerStopBits return ( - + ) }) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx index ee19e49..bc70c0c 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx @@ -10,7 +10,6 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import { ChangeEvent, ElementType, useCallback } from 'react' import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' -import { BaseDataType } from '@shared' import { AddressInput } from './maskedInputs' export const AddressField = meme(() => { @@ -19,10 +18,7 @@ export const AddressField = meme(() => { const addressFitError = useAddRegisterZustand((z) => z.addressFitError) const valid = useAddRegisterZustand((z) => z.valid.address) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setAddress(value, isValid) - }, []) + const setAddress = useAddRegisterZustand.getState().setAddress return ( @@ -40,7 +36,7 @@ export const AddressField = meme(() => { InputBaseComponentProps, 'input' >, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setAddress }) } }} /> @@ -59,12 +55,9 @@ export const AddressField = meme(() => { export const DataTypeSelect = meme(() => { const dataType = useAddRegisterZustand((z) => z.dataType) - const handleChange = useCallback((value: BaseDataType): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setDataType(value) - }, []) + const setDataType = useAddRegisterZustand.getState().setDataType - return + return }) // diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx index a8ba128..7ac86b9 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -63,10 +63,7 @@ const ValueInputComponent = meme(() => { const value = useAddRegisterZustand((z) => z.value) const valid = useAddRegisterZustand((z) => z.valid.value) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setValue(value, isValid) - }, []) + const setValue = useAddRegisterZustand.getState().setValue return ( { slotProps={{ input: { inputComponent: ValueInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setValue }) } }} /> @@ -97,10 +94,7 @@ const MinTextField = meme(() => { const min = useAddRegisterZustand((z) => String(z.min)) const valid = useAddRegisterZustand((z) => z.valid.min) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setMin(value, isValid) - }, []) + const setMin = useAddRegisterZustand.getState().setMin return ( { slotProps={{ input: { inputComponent: MinInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setMin }) } }} /> @@ -125,10 +119,7 @@ const MaxTextField = meme(() => { const max = useAddRegisterZustand((z) => String(z.max)) const valid = useAddRegisterZustand((z) => z.valid.max) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setMax(value, isValid) - }, []) + const setMax = useAddRegisterZustand.getState().setMax return ( { slotProps={{ input: { inputComponent: MaxInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setMax }) } }} /> @@ -159,10 +150,7 @@ const IntervalTextField = meme(() => { const interval = useAddRegisterZustand((z) => String(z.interval)) const valid = useAddRegisterZustand((z) => z.valid.interval) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setInterval(value, isValid) - }, []) + const setInterval = useAddRegisterZustand.getState().setInterval return ( { slotProps={{ input: { inputComponent: IntervalInput as unknown as ElementType, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setInterval }) } }} /> @@ -287,10 +275,7 @@ const RegisterLengthField = meme(() => { const registerLength = useAddRegisterZustand((z) => z.registerLength) const valid = useAddRegisterZustand((z) => z.valid.registerLength) - const handleChange = useCallback((value: string, isValid?: boolean): void => { - const addRegisterZustand = useAddRegisterZustand.getState() - addRegisterZustand.setRegisterLength(value, isValid) - }, []) + const setRegisterLength = useAddRegisterZustand.getState().setRegisterLength return ( { InputBaseComponentProps, 'input' >, - inputProps: maskInputProps({ set: handleChange }) + inputProps: maskInputProps({ set: setRegisterLength }) } }} /> diff --git a/src/renderer/src/components/shared/inputs/types.ts b/src/renderer/src/components/shared/inputs/types.ts index 91eac83..73efee6 100644 --- a/src/renderer/src/components/shared/inputs/types.ts +++ b/src/renderer/src/components/shared/inputs/types.ts @@ -1,7 +1,11 @@ -import { MaskSetFn } from '@renderer/context/client.zustand.types' +import { AsyncMaskSetFn, MaskSetFn } from '@renderer/context/client.zustand.types' export interface MaskInputProps { - set: MaskSetFn + /** + * The mask inputs call this and discard what comes back, so a setter that + * waits on the backend fits here too. Server `setPort` is the one that does. + */ + set: MaskSetFn | AsyncMaskSetFn max?: number } export const maskInputProps = (props: MaskInputProps): MaskInputProps => props From 1014a105386f5932a723a388cce98f94b6eafc47 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 21:45:08 +0200 Subject: [PATCH 40/72] docs: an unreleased section for what the server answers C5 changes what goes on the wire, and someone running the server against real devices needs to know before they upgrade: a unit id the server does not host is silence on RTU, and unit 0 there is broadcast, so registers put on it stop being readable. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1244c2..e08bc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to Modbux will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **The server no longer answers for units it does not have.** On a shared + RS-485 line it replied to every address on the bus, including the ones + belonging to the real devices on it, so its frame went out at the same moment + theirs did. It now says nothing at all for an address it does not host, which + is the only answer that leaves the line alone. Over TCP, where saying nothing + is just a timeout, it replies that the unit is not there. A unit is one you + gave registers to, and the ID picker still lists all 256. +- **A write to a unit you never configured no longer creates one.** Any client + on the network could turn an unused unit ID into one the server answers for, + and nothing in the view said it had happened. + +### Changed + +- **Unit 0 is the broadcast address on RTU.** A request to 0 goes to every + device on the bus at once and none of them replies, so registers you put on + unit 0 cannot be read back over serial. A write to 0 still lands, on every + unit the server hosts, and nothing goes back on the line. Modbux says so once + while the RTU server is running and unit 0 holds registers. Over TCP there is + no broadcast and unit 0 stays an ordinary address. + ## [2.3.0] - 2026-08-30 ### Added From 4f55ae63b692d999bdc3df833c80fe02230bc68d Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 23:53:10 +0200 Subject: [PATCH 41/72] fix: one answer for how wide a register is Deleting a UTF-8 register zeroed 24 addresses from its own, whatever length it was given, so a register standing anywhere in those 24 was written to zero and the view went on showing it. Reported from the app: a double at 20, a two-register string at 18, delete the string, and a client reading 20 gets nothing while the row still says 1234.5. The width was stated seven times. Five copies asked for the length the user typed and answered length ?? 10. getRegisterLength had no length parameter at all, so it measured the gap to the next address and capped that at 24, and it is the one removeRegister asks. createRegisters has no utf8 case and sizes a string as one register. The seven also disagreed on none, 0 against 1. registerWidth in shared is now the only table. Its switch is exhaustive with no default, so a new DataType is a type error rather than a wrong number somewhere else. The gap arithmetic was a different question and became getReadSpan, because a client's register mapping carries no length and the next mapped address is the only thing that says where a string ends. The length was never missing. It is a field on RegisterParamsBasePart, persisted, and in the config files on disk. RemoveRegisterParams had nowhere to put it while both callers were holding the register, so it carries length now and both pass params.length. createRegisters keeps its hardcoded two bytes for utf8 rather than asking registerWidth, because its switch writes nothing for a string and ten registers of zero would be worse than one. The server branches to createStringRegisters before reaching it; the client's write path does not, which is a defect this change does not touch. --- .../modules/__tests__/modbusServer.test.ts | 96 ++++++++++++++++++- src/main/modules/modbusServer.ts | 9 +- .../modules/modbusServer/valueGenerator.ts | 15 +-- .../addRegister.zustand.helpers.test.ts | 33 ------- .../addRegister.zustand.helpers.ts | 19 +--- .../AddRegister/addRegister.zustand.ts | 13 ++- .../AddRegister/addRegisterActions.tsx | 7 +- .../AddRegister/maskedInputs.tsx | 11 +-- src/renderer/src/context/server.zustand.ts | 5 +- src/shared/__tests__/addressGrouping.test.ts | 40 ++++---- src/shared/__tests__/utils.test.ts | 41 ++++++++ src/shared/addressGrouping.ts | 53 ++++------ src/shared/types/server.ts | 5 +- src/shared/utils.ts | 54 ++++++++--- 14 files changed, 249 insertions(+), 152 deletions(-) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 1d85701..5811965 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { UnitIdString, Windows } from '@shared' +import type { BaseDataType, RegisterParams, UnitIdString, Windows } from '@shared' import type { IServiceVector } from 'modbus-serial/ServerTCP' // Configurable port availability for net mock @@ -1806,4 +1806,98 @@ describe('ModbusServer', () => { }) }) }) + // ─── C1: removing a register erases what it occupied, and no more ───────── + + describe('removeRegister erases what the register occupied', () => { + const addRegister = ( + address: number, + dataType: BaseDataType, + extra: { value?: number; stringValue?: string; length?: number } = {} + ): void => { + const params: RegisterParams = { + address, + registerType: 'holding_registers', + dataType, + comment: '', + value: extra.value ?? 0, + stringValue: extra.stringValue, + length: extra.length, + min: undefined, + max: undefined, + interval: undefined + } + server.addRegister({ uuid, unitId, littleEndian: false, params }) + } + + const readHolding = async (vector: IServiceVector, address: number): Promise => + new Promise((resolve) => + vector.getHoldingRegister!(address, 1, (error, value) => resolve(error ? 'ERR' : value)) + ) + + it('leaves the register next to a deleted string alone', async () => { + // The width comes from the register, not from the type. + addRegister(20, 'double', { value: 1234.5 }) + addRegister(18, 'utf8', { stringValue: 'HAHA', length: 2 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + + const before = await readHolding(vector, 20) + expect(before).not.toBe(0) + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 18, + dataType: 'utf8', + length: 2 + }) + + expect(await readHolding(vector, 18)).toBe(0) + expect(await readHolding(vector, 19)).toBe(0) + expect(await readHolding(vector, 20)).toBe(before) + }) + + it('erases every register a wide type occupied', async () => { + addRegister(30, 'double', { value: 1234.5 }) + addRegister(40, 'uint16', { value: 7 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 30, + dataType: 'double' + }) + + for (const address of [30, 31, 32, 33]) { + expect(await readHolding(vector, address)).toBe(0) + } + expect(await readHolding(vector, 40)).toBe(7) + }) + + it('falls back to ten registers for a string that carries no length', async () => { + addRegister(0, 'utf8', { stringValue: 'HAHA' }) + addRegister(10, 'uint16', { value: 7 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 0, + dataType: 'utf8' + }) + + expect(await readHolding(vector, 0)).toBe(0) + expect(await readHolding(vector, 9)).toBe(0) + expect(await readHolding(vector, 10)).toBe(7) + }) + }) }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 4653372..f39a6ed 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -21,7 +21,7 @@ import { ServerTCP, ServerSerial } from 'modbus-serial' import { Windows } from '@shared' import { ValueGenerator } from './modbusServer/valueGenerator' import type { IServiceVector, FCallbackVal } from 'modbus-serial' -import { getRegisterLength } from '@shared' +import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' import net from 'net' const getDefaultGenerators = (): ValueGenerators => ({ @@ -381,7 +381,7 @@ export class ModbusServer { if (fixedValue) { const registers = dataType === 'utf8' - ? createStringRegisters(stringValue ?? '', length ?? 10) + ? createStringRegisters(stringValue ?? '', length ?? DEFAULT_UTF8_LENGTH) : createRegisters(dataType, value, littleEndian) registers.forEach((register, index) => { const registerAddress = address + index @@ -429,14 +429,15 @@ export class ModbusServer { unitId, registerType, address, - dataType + dataType, + length }: RemoveRegisterParams): void => { const perUnitMap = this._ensureInnerMap(this._serverData, uuid) const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) // Reset all registers occupied by this data type - const registerCount = getRegisterLength(dataType, address) + const registerCount = registerWidth(dataType, length) for (let i = 0; i < registerCount; i++) { serverData[registerType][address + i] = 0 } diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index e481d86..a234ecb 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -8,6 +8,7 @@ import { BaseDataType, RegisterParams, RegisterValueGenerator, + registerWidth, UnitIdString } from '@shared' import { round } from 'lodash' @@ -89,19 +90,7 @@ export class ValueGenerator implements RegisterValueGenerator { public dispose = (): void => { clearInterval(this._intervalTimer) - // Determine how many addresses to reset based on data type size - let size: number - if (['int16', 'uint16'].includes(this._dataType)) { - size = 1 - } else if (['uint32', 'int32', 'float', 'unix'].includes(this._dataType)) { - size = 2 - } else if (['int64', 'uint64', 'double', 'datetime'].includes(this._dataType)) { - size = 4 - } else if (this._dataType === 'utf8') { - size = this._length - } else { - size = 1 - } + const size = registerWidth(this._dataType, this._length) for (let i = 0; i < size; i++) { this._serverData[this._registerType][this._address + i] = 0 diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts index ae620e8..9216114 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts @@ -1,43 +1,10 @@ import { describe, it, expect } from 'vitest' import { - getRegisterSize, isAddressInUse, toRegisterParams, type RegisterFormValues } from '../addRegister.zustand.helpers' -// ─── getRegisterSize ──────────────────────────────────────────────── - -describe('getRegisterSize', () => { - it('returns 1 for 16-bit types', () => { - expect(getRegisterSize('int16')).toBe(1) - expect(getRegisterSize('uint16')).toBe(1) - }) - - it('returns 2 for 32-bit types', () => { - expect(getRegisterSize('int32')).toBe(2) - expect(getRegisterSize('uint32')).toBe(2) - expect(getRegisterSize('float')).toBe(2) - expect(getRegisterSize('unix')).toBe(2) - }) - - it('returns 4 for 64-bit types', () => { - expect(getRegisterSize('int64')).toBe(4) - expect(getRegisterSize('uint64')).toBe(4) - expect(getRegisterSize('double')).toBe(4) - expect(getRegisterSize('datetime')).toBe(4) - }) - - it('returns provided length for utf8', () => { - expect(getRegisterSize('utf8', 5)).toBe(5) - expect(getRegisterSize('utf8', 124)).toBe(124) - }) - - it('defaults to 10 for utf8 without length', () => { - expect(getRegisterSize('utf8')).toBe(10) - }) -}) - // ─── isAddressInUse ───────────────────────────────────────────────── describe('isAddressInUse', () => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts index 8b0c967..cbcd080 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts @@ -1,21 +1,12 @@ -import type { +import { BaseDataType, DataType, NumberRegisters, RegisterParams, - RegisterParamsBasePart + RegisterParamsBasePart, + registerWidth } from '@shared' -/** - * Returns the number of Modbus registers a data type occupies. - */ -export const getRegisterSize = (dataType: DataType, length?: number): number => { - if (['double', 'uint64', 'int64', 'datetime'].includes(dataType)) return 4 - if (['uint32', 'int32', 'float', 'unix'].includes(dataType)) return 2 - if (dataType === 'utf8') return length ?? 10 - return 1 -} - /** * Pure function that checks whether an address (+ its data-type span) overlaps * with already-used addresses, optionally excluding the addresses of the @@ -28,11 +19,11 @@ export const isAddressInUse = ( length?: number, editRegister?: { dataType: DataType; address: number; length?: number } ): boolean => { - const size = getRegisterSize(dataType, length) + const size = registerWidth(dataType, length) const addressesNeeded = Array.from({ length: size }, (_, i) => address + i) if (editRegister) { - const editSize = getRegisterSize(editRegister.dataType, editRegister.length) + const editSize = registerWidth(editRegister.dataType, editRegister.length) const editAddresses = Array.from({ length: editSize }, (_, i) => editRegister.address + i) const filteredUsed = usedAddresses.filter((a) => !editAddresses.includes(a)) return addressesNeeded.some((a) => filteredUsed.includes(Number(a))) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index 4669135..54e7a23 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -4,14 +4,16 @@ import { useServerZustand } from '@renderer/context/server.zustand' import { BaseDataType, DataType, + DEFAULT_UTF8_LENGTH, getAddressFitError, NumberRegisters, + registerWidth, ServerRegister, UnitIdString } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' -import { getRegisterSize, isAddressInUse, toRegisterParams } from './addRegister.zustand.helpers' +import { isAddressInUse, toRegisterParams } from './addRegister.zustand.helpers' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -89,7 +91,7 @@ const validateAddress = ( const uuid = serverZustand.selectedUuid const unitId = serverZustand.getUnitId(uuid) const addressNum = Number(address) - const length = dataType === 'utf8' ? Number(registerLength) || 10 : undefined + const length = dataType === 'utf8' ? Number(registerLength) || DEFAULT_UTF8_LENGTH : undefined const addressInUse = getAddressInUse(uuid, unitId, registerType, dataType, addressNum, length) const addressFitError = getAddressFitError(dataType, addressNum, length) @@ -256,7 +258,7 @@ export const useAddRegisterZustand = create { const { registerLength } = getState() - const maxBytes = (Number(registerLength) || 10) * 2 + const maxBytes = (Number(registerLength) || DEFAULT_UTF8_LENGTH) * 2 const valid = new TextEncoder().encode(value).length <= maxBytes set((state) => { state.stringValue = value @@ -292,7 +294,7 @@ export const useAddRegisterZustand = create { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) @@ -35,7 +35,7 @@ export const AddButtons = meme(() => { if (!result) return const { address, dataType } = result const addRegisterZustand = useAddRegisterZustand.getState() - const size = getRegisterSize(dataType, Number(addRegisterZustand.registerLength) || 10) + const size = registerWidth(dataType, Number(addRegisterZustand.registerLength) || undefined) // Reset value and comment, keep dataType/LE/fixed/min/max/interval addRegisterZustand.setValue('0', true) addRegisterZustand.setComment('') @@ -112,7 +112,8 @@ export const DeleteButton = meme(() => { unitId, address: numericAddress, registerType, - dataType + dataType, + length: entry?.params?.length }) setRegisterType(undefined) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx index 263c128..127ce2a 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx @@ -10,19 +10,16 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { MaskInputProps } from '@renderer/components/shared/inputs/types' import { forwardRef } from 'react' import { IMask, IMaskInput } from 'react-imask' -import { notEmpty } from '@shared' +import { notEmpty, registerWidth } from '@shared' import { useMinMaxInteger } from '@renderer/hooks' const AddressInputForward = forwardRef((props, ref) => { const { set, ...other } = props // Set maximum address based on data type - const maxAddress = useAddRegisterZustand((z) => { - if (['int32', 'uint32', 'float', 'unix'].includes(z.dataType)) return 65534 - if (['int64', 'uint64', 'double', 'datetime'].includes(z.dataType)) return 65532 - if (z.dataType === 'utf8') return Math.max(0, 65535 - (Number(z.registerLength) || 10) + 1) - return 65535 - }) + const maxAddress = useAddRegisterZustand((z) => + Math.max(0, 65535 - registerWidth(z.dataType, Number(z.registerLength) || undefined) + 1) + ) return ( 4) return // Defensive: only support 1-4 registers // 3) Determine which register‐offset was written diff --git a/src/shared/__tests__/addressGrouping.test.ts b/src/shared/__tests__/addressGrouping.test.ts index ab7b9bb..de65ce4 100644 --- a/src/shared/__tests__/addressGrouping.test.ts +++ b/src/shared/__tests__/addressGrouping.test.ts @@ -1,47 +1,47 @@ import { describe, it, expect } from 'vitest' -import { getRegisterLength, buildAddrInfos, groupAddressInfos } from '../addressGrouping' +import { getReadSpan, buildAddrInfos, groupAddressInfos } from '../addressGrouping' import type { RegisterMapObject } from '../types' -describe('getRegisterLength', () => { +describe('getReadSpan', () => { it('returns 1 for 16-bit types', () => { - expect(getRegisterLength('int16', 0)).toBe(1) - expect(getRegisterLength('uint16', 0)).toBe(1) + expect(getReadSpan('int16', 0)).toBe(1) + expect(getReadSpan('uint16', 0)).toBe(1) }) it('returns 2 for 32-bit types', () => { - expect(getRegisterLength('int32', 0)).toBe(2) - expect(getRegisterLength('uint32', 0)).toBe(2) - expect(getRegisterLength('float', 0)).toBe(2) - expect(getRegisterLength('unix', 0)).toBe(2) + expect(getReadSpan('int32', 0)).toBe(2) + expect(getReadSpan('uint32', 0)).toBe(2) + expect(getReadSpan('float', 0)).toBe(2) + expect(getReadSpan('unix', 0)).toBe(2) }) it('returns 4 for 64-bit types', () => { - expect(getRegisterLength('int64', 0)).toBe(4) - expect(getRegisterLength('uint64', 0)).toBe(4) - expect(getRegisterLength('double', 0)).toBe(4) - expect(getRegisterLength('datetime', 0)).toBe(4) + expect(getReadSpan('int64', 0)).toBe(4) + expect(getReadSpan('uint64', 0)).toBe(4) + expect(getReadSpan('double', 0)).toBe(4) + expect(getReadSpan('datetime', 0)).toBe(4) }) - it('returns 0 for unknown type', () => { - expect(getRegisterLength('none', 0)).toBe(0) + it('returns 0 for none, an address with no data type', () => { + expect(getReadSpan('none', 0)).toBe(0) }) describe('utf8', () => { it('returns gap when next address is known and smaller than default', () => { - expect(getRegisterLength('utf8', 10, 20)).toBe(10) + expect(getReadSpan('utf8', 10, 20)).toBe(10) }) - it('caps at DEFAULT_UTF8_REGISTERS when gap is larger', () => { - expect(getRegisterLength('utf8', 0, 100)).toBe(24) + it('caps at 24 when the gap is larger', () => { + expect(getReadSpan('utf8', 0, 100)).toBe(24) }) it('returns default when next address is not provided', () => { - expect(getRegisterLength('utf8', 0)).toBe(24) + expect(getReadSpan('utf8', 0)).toBe(24) }) it('returns default when next address is not greater than current', () => { - expect(getRegisterLength('utf8', 10, 10)).toBe(24) - expect(getRegisterLength('utf8', 10, 5)).toBe(24) + expect(getReadSpan('utf8', 10, 10)).toBe(24) + expect(getReadSpan('utf8', 10, 5)).toBe(24) }) }) }) diff --git a/src/shared/__tests__/utils.test.ts b/src/shared/__tests__/utils.test.ts index 7dc35d4..a2e2a86 100644 --- a/src/shared/__tests__/utils.test.ts +++ b/src/shared/__tests__/utils.test.ts @@ -6,6 +6,7 @@ import { bigEndian64, littleEndian64, createRegisters, + registerWidth, getMinMaxValues, notEmpty, humanizeSerialError @@ -91,6 +92,46 @@ describe('littleEndian64', () => { // --------------------------------------------------------------------------- // createRegisters // --------------------------------------------------------------------------- +describe('registerWidth', () => { + it('is 1 for the 16-bit types', () => { + expect(registerWidth('int16')).toBe(1) + expect(registerWidth('uint16')).toBe(1) + expect(registerWidth('bitmap')).toBe(1) + }) + + it('is 2 for the 32-bit types', () => { + expect(registerWidth('int32')).toBe(2) + expect(registerWidth('uint32')).toBe(2) + expect(registerWidth('float')).toBe(2) + expect(registerWidth('unix')).toBe(2) + }) + + it('is 4 for the 64-bit types', () => { + expect(registerWidth('int64')).toBe(4) + expect(registerWidth('uint64')).toBe(4) + expect(registerWidth('double')).toBe(4) + expect(registerWidth('datetime')).toBe(4) + }) + + it('is the length the user gave for a string', () => { + expect(registerWidth('utf8', 2)).toBe(2) + expect(registerWidth('utf8', 124)).toBe(124) + }) + + it('is 10 for a string with no length, which is what the dialog offers', () => { + expect(registerWidth('utf8')).toBe(10) + }) + + it('ignores a length on a type that does not have one', () => { + expect(registerWidth('int16', 7)).toBe(1) + expect(registerWidth('double', 7)).toBe(4) + }) + + it('is 1 for none, which occupies its address without holding a value', () => { + expect(registerWidth('none')).toBe(1) + }) +}) + describe('createRegisters', () => { describe('int16', () => { it('converts positive value', () => { diff --git a/src/shared/addressGrouping.ts b/src/shared/addressGrouping.ts index 4a25a0d..c0f3f45 100644 --- a/src/shared/addressGrouping.ts +++ b/src/shared/addressGrouping.ts @@ -1,47 +1,28 @@ import type { AddressGroup, DataType, RegisterMapObject, RegisterMapValue } from './types' +import { registerWidth } from './utils' + +/** How far a string is read when nothing in the mapping says where it ends. */ +const MAX_UTF8_READ_REGISTERS = 24 /** - * Determine how many Modbus registers to read for a given DataType. - * For Utf8, if `nextAddress` is provided we read up to that gap; - * otherwise we fall back to a safe default of 24 registers. + * How many registers to read for a mapped address. + * + * This is not `registerWidth`. A client's register mapping carries no length, + * so the only thing saying where a string ends is the next mapped address. + * `none` is an address with no data type, which is nothing to read at all. */ -export const getRegisterLength = ( +export const getReadSpan = ( dataType: DataType, currentAddress: number, nextAddress?: number ): number => { - const DEFAULT_UTF8_REGISTERS = 24 - - switch (dataType) { - case 'none': - return 0 - - case 'int16': - case 'uint16': - case 'bitmap': - return 1 - - case 'float': - case 'int32': - case 'uint32': - case 'unix': - return 2 - - case 'int64': - case 'uint64': - case 'double': - case 'datetime': - return 4 - - case 'utf8': - if (typeof nextAddress === 'number' && nextAddress > currentAddress) { - // only use the real gap if it's no larger than DEFAULT_UTF8_REGISTERS - const gap = nextAddress - currentAddress - return Math.min(gap, DEFAULT_UTF8_REGISTERS) - } - // fallback for when we don't know the next address or it's not helpful - return DEFAULT_UTF8_REGISTERS + if (dataType === 'none') return 0 + if (dataType !== 'utf8') return registerWidth(dataType) + + if (typeof nextAddress === 'number' && nextAddress > currentAddress) { + return Math.min(nextAddress - currentAddress, MAX_UTF8_READ_REGISTERS) } + return MAX_UTF8_READ_REGISTERS } /** @@ -59,7 +40,7 @@ export const buildAddrInfos = ( const next = arr[index + 1] const nextAddress = next?.[0] ? Number(next[0]) : undefined - const registerCount = getRegisterLength(dataType, address, nextAddress) + const registerCount = getReadSpan(dataType, address, nextAddress) return { address, diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index 463c603..aa5a23c 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -137,7 +137,10 @@ export const RemoveRegisterParamsSchema = z.object({ unitId: UnitIdStringSchema, registerType: NumberRegistersSchema, address: RegisterAddressSchema, - dataType: BaseDataTypeSchema + dataType: BaseDataTypeSchema, + // Only a string has a width the user chose, and without it the server has to + // guess how much of the map the register occupied and erases the guess. + length: z.number().optional() }) export type RemoveRegisterParams = z.infer diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 9daf72f..5dfa574 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -2,6 +2,41 @@ import { BaseDataType, DataType, RegisterParams, ServerRegisters } from './types export const getBit = (word: number, bit: number): boolean => (word & (2 ** bit)) === 2 ** bit +/** The width the add dialog offers for a string when the field is left alone. */ +export const DEFAULT_UTF8_LENGTH = 10 + +/** + * How many registers a value of this type occupies. + * + * One answer, because this was stated seven times and the copies disagreed on + * `utf8`, so deleting a string erased registers belonging to its neighbours. + * The switch is exhaustive: a new DataType is a type error here. + */ +export const registerWidth = (dataType: DataType, length?: number): number => { + switch (dataType) { + case 'utf8': + return length ?? DEFAULT_UTF8_LENGTH + + case 'int32': + case 'uint32': + case 'float': + case 'unix': + return 2 + + case 'int64': + case 'uint64': + case 'double': + case 'datetime': + return 4 + + case 'none': + case 'int16': + case 'uint16': + case 'bitmap': + return 1 + } +} + // Regular most significant word first (big endian) export const bigEndian32 = (buffer: Buffer, offset: number): Buffer => { return buffer.subarray(offset, offset + 4) @@ -35,10 +70,10 @@ export const createRegisters = ( value: number, littleEndian: boolean ): number[] => { - let bufferSize = 2 - - if (['int32', 'uint32', 'float', 'unix'].includes(dataType)) bufferSize = 4 - if (['int64', 'uint64', 'double', 'datetime'].includes(dataType)) bufferSize = 8 + // The switch below has no utf8 case, so asking registerWidth for one would + // buy ten registers of zero. The server branches to createStringRegisters + // before reaching here; the client's write path does not. + const bufferSize = dataType === 'utf8' ? 2 : registerWidth(dataType) * 2 let buffer = Buffer.alloc(bufferSize) @@ -167,10 +202,7 @@ export const humanizeSerialError = (error: Error, port?: string): string => { export const getUsedAddresses = (registers: RegisterParams[]): number[] => { const addressSet = new Set() registers.forEach((p) => { - let size = 1 - if (['int32', 'uint32', 'float', 'unix'].includes(p.dataType)) size = 2 - else if (['int64', 'uint64', 'double', 'datetime'].includes(p.dataType)) size = 4 - else if (p.dataType === 'utf8') size = p.length ?? 10 + const size = registerWidth(p.dataType, p.length) for (let i = 0; i < size; i++) { addressSet.add(p.address + i) @@ -194,11 +226,7 @@ export function getAddressFitError( address: number, length?: number ): boolean { - let size = 1 - if (['int32', 'uint32', 'float', 'unix'].includes(dataType)) size = 2 - if (['int64', 'uint64', 'double', 'datetime'].includes(dataType)) size = 4 - if (dataType === 'utf8') size = length ?? 10 - return address + size - 1 > 65535 + return address + registerWidth(dataType, length) - 1 > 65535 } export const findAvailablePort = (usedPorts: number[]): number | undefined => { From c6340dc2af2bf8adb8b8b891b5b5bba3ca7ab527 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 23:53:23 +0200 Subject: [PATCH 42/72] chore: the hooks fire on the write, not on the tool it went through prose-trigger classified before it fired: a markdown path, a comment opener in the text, or a git commit. An edit that goes out as a heredoc puts the comment in tool_input.command, where content and new_string are empty, so the whole C1 refactor was written without the trigger saying anything. It fires on every Write and Edit now, and on a Bash command that writes a file. test-trigger, precommit-trigger, bulk-edit-guard and git-restore-guard are ported from the ploxc repo, which has run them for a while. bash-target comes with them so both triggers share one definition of what writes a file, including its anchor before the redirect: without it an awk NF>4 and a grep for --> read as writes. precommit-trigger's watched list is this project's, and each entry passes the test the ploxc one sets: a numbered step of the checklist names it. test:watch and test:e2e:scan-perf do not, so they stay silent. session-marker is imported again. It had lost its last caller when the prose rule stopped shortening after the first firing, and the three hooks that do shorten use it. Eleven mutations, one per rule, each red on its own test. Two survived the first round with nothing covering them: the session split in precommit-trigger, and the checkout -b exclusion, which the path check already caught for a branch name that is not also a file. --- .claude/hooks/__tests__/guards.test.mjs | 145 ++++++++++++++++++ .../hooks/__tests__/prose-trigger.test.mjs | 59 +++++-- .claude/hooks/__tests__/test-trigger.test.mjs | 74 +++++++++ .claude/hooks/bash-target.mjs | 32 ++++ .claude/hooks/bulk-edit-guard.mjs | 42 +++++ .claude/hooks/git-restore-guard.mjs | 93 +++++++++++ .claude/hooks/precommit-trigger.mjs | 79 ++++++++++ .claude/hooks/prose-trigger.mjs | 36 ++--- .claude/hooks/test-trigger.mjs | 59 +++++++ .claude/settings.json | 29 +++- 10 files changed, 607 insertions(+), 41 deletions(-) create mode 100644 .claude/hooks/__tests__/guards.test.mjs create mode 100644 .claude/hooks/__tests__/test-trigger.test.mjs create mode 100644 .claude/hooks/bash-target.mjs create mode 100644 .claude/hooks/bulk-edit-guard.mjs create mode 100644 .claude/hooks/git-restore-guard.mjs create mode 100644 .claude/hooks/precommit-trigger.mjs create mode 100644 .claude/hooks/test-trigger.mjs diff --git a/.claude/hooks/__tests__/guards.test.mjs b/.claude/hooks/__tests__/guards.test.mjs new file mode 100644 index 0000000..23de63e --- /dev/null +++ b/.claude/hooks/__tests__/guards.test.mjs @@ -0,0 +1,145 @@ +/** + * The three Bash guards, both directions. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case names what must fire and what must not. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOKS = dirname(fileURLToPath(import.meta.url)) +const hook = (name) => join(HOOKS, '..', `${name}.mjs`) + +const fire = (name, toolInput, extra = {}) => { + const payload = JSON.stringify({ + session_id: `test-${Math.random()}`, + ...extra, + ...(toolInput ? { tool_input: toolInput } : {}) + }) + const out = execFileSync('node', [hook(name)], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('precommit-trigger', () => { + it('fires on the whole-project commands the checklist names', () => { + for (const command of [ + 'yarn lint', + 'yarn typecheck', + 'yarn test', + 'yarn verify', + 'yarn test:e2e', + 'yarn test:all:mac' + ]) { + expect(fire('precommit-trigger', { command }), command).not.toBe('') + } + }) + + it('fires when one follows another command', () => + expect(fire('precommit-trigger', { command: 'yarn lint && yarn typecheck' })).not.toBe('')) + it('fires with no whitespace before the separator', () => + expect(fire('precommit-trigger', { command: 'yarn lint; echo done' })).not.toBe('')) + + it('stays quiet on watch mode, which is how you check one change', () => + expect(fire('precommit-trigger', { command: 'yarn test:watch' })).toBe('')) + it('stays quiet on a measurement rather than a check', () => + expect(fire('precommit-trigger', { command: 'yarn test:e2e:scan-perf' })).toBe('')) + it('stays quiet on a single spec or file', () => { + expect(fire('precommit-trigger', { command: 'npx vitest run src/shared/a.test.ts' })).toBe('') + expect(fire('precommit-trigger', { command: 'npx playwright test e2e/a.spec.ts' })).toBe('') + }) + it('stays quiet on prose quoting the command', () => + expect(fire('precommit-trigger', { command: "grep -rn 'yarn lint' CONTRIBUTING.md" })).toBe('')) + + it('states the rule once, then asks the question', () => { + const session = `same-${Math.random()}` + const again = (command) => { + const payload = JSON.stringify({ session_id: session, tool_input: { command } }) + const out = execFileSync('node', [hook('precommit-trigger')], { + input: payload, + encoding: 'utf8' + }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' + } + expect(again('yarn lint')).toContain('start at step 1') + expect(again('yarn test')).toBe('precommit: finishing work, or checking one change?') + }) +}) + +describe('bulk-edit-guard', () => { + it('fires on an in-place sed', () => + expect(fire('bulk-edit-guard', { command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires on an in-place perl', () => + expect(fire('bulk-edit-guard', { command: "perl -i -pe 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires after a separator', () => + expect(fire('bulk-edit-guard', { command: "yarn lint && sed -i.bak 's/a/b/' a.ts" })).not.toBe( + '' + )) + it('stays quiet on a sed that only reads', () => + expect(fire('bulk-edit-guard', { command: "sed -n '1,20p' src/a.ts" })).toBe('')) + it('stays quiet on a heredoc, which the prose trigger owns', () => + expect(fire('bulk-edit-guard', { command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe('')) +}) + +describe('git-restore-guard', () => { + /** A repo with one unstaged change, so the guard has something to warn about. */ + const dirtyRepo = () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-')) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-q') + git('config', 'user.email', 'a@b.c') + git('config', 'user.name', 'test') + writeFileSync(join(dir, 'a.ts'), 'const x = 1\n') + git('add', 'a.ts') + git('commit', '-qm', 'first') + writeFileSync(join(dir, 'a.ts'), 'const x = 2\n') + return dir + } + + it('fires on a checkout naming a path that exists', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain('a.ts') + }) + it('fires on git restore', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git restore a.ts' }, { cwd })).toContain( + 'git restore' + ) + }) + it('fires on any git stash', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git stash' }, { cwd })).toContain('stash pop') + }) + it('stays quiet on a branch switch, which carries the work along', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout main' }, { cwd })).toBe('') + }) + it('stays quiet on checkout -b', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -b feature/x' }, { cwd })).toBe('') + }) + it('stays quiet on checkout -b whose branch name is also a file', () => { + // Only the -b exclusion separates this from a restore: the path check sees + // a name that exists and would say yes. + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -b a.ts' }, { cwd })).toBe('') + }) + it('stays quiet on --staged alone, the undo of git add', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git restore --staged a.ts' }, { cwd })).toBe('') + }) + it('stays quiet when nothing is unstaged', () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-clean-')) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-q') + git('config', 'user.email', 'a@b.c') + git('config', 'user.name', 'test') + writeFileSync(join(dir, 'a.ts'), 'const x = 1\n') + git('add', 'a.ts') + git('commit', '-qm', 'first') + expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd: dir })).toBe('') + }) +}) diff --git a/.claude/hooks/__tests__/prose-trigger.test.mjs b/.claude/hooks/__tests__/prose-trigger.test.mjs index 06d42c7..ae65dcb 100644 --- a/.claude/hooks/__tests__/prose-trigger.test.mjs +++ b/.claude/hooks/__tests__/prose-trigger.test.mjs @@ -21,22 +21,59 @@ const fire = (toolInput, sessionId = `test-${Math.random()}`) => { return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' } -describe('prose-trigger fires on', () => { +describe('prose-trigger fires on every write and edit', () => { it('a markdown write', () => expect(fire({ file_path: 'a.md', content: 'x' })).not.toBe('')) it('an edit adding a line comment', () => expect(fire({ file_path: 'a.ts', new_string: ' // why\nconst x = 1' })).not.toBe('')) it('an edit adding a block comment', () => expect(fire({ file_path: 'a.tsx', new_string: '/* why */\nconst y = 2' })).not.toBe('')) + + // The classification these three used to fail is what let a refactor's worth + // of comments through: an edit that reads as code today carries a comment in + // the next call, and the hook has no way to know which is which. + it('code with no comment in it', () => + expect(fire({ file_path: 'a.ts', new_string: 'const x = 1' })).not.toBe('')) + it('a file that is neither markdown nor source', () => + expect(fire({ file_path: 'a.json', content: '{"a":1}' })).not.toBe('')) + it('a write that names a file and no content at all', () => + expect(fire({ file_path: 'a.ts' })).not.toBe('')) }) describe('prose-trigger stays quiet on', () => { - it('code with no comment', () => - expect(fire({ file_path: 'a.ts', new_string: 'const x = 1' })).toBe('')) - it('a // that is inside a string', () => - expect(fire({ file_path: 'a.ts', new_string: "const u = 'http://x'" })).toBe('')) - it('a file that is neither markdown nor commented code', () => - expect(fire({ file_path: 'a.json', content: '{"a":1}' })).toBe('')) it('a payload with no tool_input', () => expect(fire(null)).toBe('')) + it('a tool that names no file and runs no command', () => + expect(fire({ pattern: 'foo', path: 'src' })).toBe('')) + it('an empty file path', () => expect(fire({ file_path: '' })).toBe('')) +}) + +describe('prose-trigger reaches an edit made through Bash', () => { + // Every one of these wrote a TypeScript comment during the C1 refactor and + // the hook said nothing, because the text sat in the command rather than in + // content or new_string. + it('fires on a heredoc', () => + expect(fire({ command: "python3 - <<'PYEOF'\nprint(1)\nPYEOF" })).not.toBe('')) + it('fires on an unquoted heredoc', () => + expect(fire({ command: 'cat > a.ts < + expect(fire({ command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires on tee', () => expect(fire({ command: 'echo x | tee src/a.ts' })).not.toBe('')) + it('fires on a redirect into a file', () => + expect(fire({ command: 'echo x > src/a.ts' })).not.toBe('')) + + it('stays quiet on a command that only reads', () => { + expect(fire({ command: 'yarn test' })).toBe('') + expect(fire({ command: "grep -rn 'utf8' src/" })).toBe('') + expect(fire({ command: 'git status --porcelain' })).toBe('') + }) + it('stays quiet on output thrown away', () => + expect(fire({ command: 'yarn lint > /dev/null 2>&1' })).toBe('')) + it('stays quiet on a `>` that is not a redirect', () => { + // The anchor before the `>` is what separates these from a write. + expect(fire({ command: "awk 'NF>4 { print }' src/a.ts" })).toBe('') + expect(fire({ command: "grep -n '\\-\\->' src/a.ts" })).toBe('') + }) + it('stays quiet on a pipe, which writes no file', () => + expect(fire({ command: 'yarn test 2>&1 | tail -5' })).toBe('')) }) describe('prose-trigger says the whole rule', () => { @@ -61,15 +98,13 @@ describe('prose-trigger reaches a commit message', () => { }) it('fires when a commit follows another command', () => expect(fire({ command: 'yarn test && git commit -F -' })).not.toBe('')) + // A command that mentions a commit and writes nothing is what keeps this + // matcher honest. A heredoc mentioning one used to be here too, and now + // fires as the write it is. it('stays quiet on a command that merely mentions the word', () => { expect(fire({ command: "grep -rn 'commit' docs/" })).toBe('') expect(fire({ command: "rg 'git commit' .claude/" })).toBe('') }) - it('stays quiet on a heredoc that writes about a commit', () => { - // This is the false positive that fired while the Bash matcher was added. - const heredoc = "python3 - <<'PY'\ns = \"expect(fire({ command: 'git commit -F -' }))\"\nPY" - expect(fire({ command: heredoc })).toBe('') - }) }) describe('prose-trigger never interrupts', () => { diff --git a/.claude/hooks/__tests__/test-trigger.test.mjs b/.claude/hooks/__tests__/test-trigger.test.mjs new file mode 100644 index 0000000..dea379a --- /dev/null +++ b/.claude/hooks/__tests__/test-trigger.test.mjs @@ -0,0 +1,74 @@ +/** + * Both directions, in one run. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case here names what must fire and what must not. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'test-trigger.mjs') + +/** What the hook says, or '' when it declined. Throws when it exits non-zero. */ +const fire = (toolInput, sessionId = `test-${Math.random()}`) => { + const payload = JSON.stringify({ + session_id: sessionId, + ...(toolInput ? { tool_input: toolInput } : {}) + }) + const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('test-trigger fires on a test written through Write or Edit', () => { + it('a unit test by its path', () => + expect(fire({ file_path: 'src/shared/__tests__/utils.test.ts', content: 'x' })).not.toBe('')) + it('an e2e spec by its path', () => + expect(fire({ file_path: 'e2e/specs/01-main/01-home.spec.ts', content: 'x' })).not.toBe('')) + it('a source file that grows a describe', () => + expect(fire({ file_path: 'src/a.ts', new_string: "describe('x', () => {})" })).not.toBe('')) + it('a source file that grows an it', () => + expect(fire({ file_path: 'src/a.tsx', new_string: " it('does', async () => {})" })).not.toBe( + '' + )) +}) + +describe('test-trigger stays quiet on', () => { + it('source with no test call in it', () => + expect(fire({ file_path: 'src/a.ts', new_string: 'const x = 1' })).toBe('')) + it('a markdown file that talks about tests', () => + expect(fire({ file_path: 'CONTRIBUTING.md', content: "describe('x', () => {})" })).toBe('')) + it('a payload with no tool_input', () => expect(fire(null)).toBe('')) + it('a command that only runs the suite', () => + expect(fire({ command: 'npx vitest run src/shared/__tests__/utils.test.ts' })).toBe('')) + it('a command that only reads a spec', () => + expect(fire({ command: 'cat e2e/specs/01-main/01-home.spec.ts' })).toBe('')) +}) + +describe('test-trigger reaches a test written through Bash', () => { + it('fires on a heredoc naming a test path', () => + expect( + fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" }) + ).not.toBe('')) + it('fires on a heredoc carrying a test call', () => + expect(fire({ command: "python3 - <<'PY'\ns = \"it('works', () => {})\"\nPY" })).not.toBe('')) + it('stays quiet on a heredoc that writes neither', () => + expect(fire({ command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe('')) +}) + +describe('test-trigger states the rule once, then asks', () => { + it('gives the whole rule first and the questions after', () => { + const session = `same-${Math.random()}` + const first = fire({ file_path: 'a.test.ts', content: 'x' }, session) + const second = fire({ file_path: 'b.test.ts', content: 'y' }, session) + expect(first).toContain('A test you have not seen fail proves nothing') + expect(second).toContain('seen it fail') + expect(second).not.toBe(first) + }) +}) + +describe('test-trigger never interrupts', () => { + it('exits 0 on unparseable stdin', () => + expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe('')) +}) diff --git a/.claude/hooks/bash-target.mjs b/.claude/hooks/bash-target.mjs new file mode 100644 index 0000000..50ac97d --- /dev/null +++ b/.claude/hooks/bash-target.mjs @@ -0,0 +1,32 @@ +/** + * A heredoc puts the bytes in `command`, where `file_path` and `new_string` + * never look. + * + * Ported from `scripts/hooks/bash-target.ts` in the ploxc repo so the two stay + * one rule. Every hook here that has to ask what a shell command is about to do + * asks these three. + */ + +/** Anchored, so a grep for `git commit` is not one. */ +export const IS_COMMIT = /(?:^|[;&|]\s*|&&\s*|\|\|\s*)git\s+(?:commit|merge)\b/ + +/** Whitespace before the `>`, or `NF>4` and `-->` read as writes. */ +export const WRITES_A_FILE = new RegExp( + [ + '<<-?\\s*[\'"]?\\w', // heredoc + '(?:^|[;&|]\\s*)sed\\s+(?:-[^\\s]+\\s+)*-i', // in-place sed + '(?:^|[;&|]\\s*)tee\\b', // tee + '(?:^|[\\s;&|])>>?\\s*(?!/dev/null)[.~$\\w/-]+' // redirect to a path + ].join('|') +) + +const PATH_TOKEN = /[\w./~-]*\.[A-Za-z]\w*/g + +/** A glob names nothing rather than the wrong file. Reads and writes both. */ +export function pathsIn(command) { + const seen = new Set() + for (const token of command.replace(/['"]/g, ' ').match(PATH_TOKEN) ?? []) { + if (token.length > 0) seen.add(token) + } + return [...seen] +} diff --git a/.claude/hooks/bulk-edit-guard.mjs b/.claude/hooks/bulk-edit-guard.mjs new file mode 100644 index 0000000..c38c4e4 --- /dev/null +++ b/.claude/hooks/bulk-edit-guard.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Fires before an in-place scripted edit, and names the reads that catch what + * it did wrong. + * + * A scripted substitution fails in three directions and every one is quiet: it + * removes more than you named, it eats half a sentence in prose and leaves no + * symbol behind, or it raises before writing and changes nothing at all. A green + * suite looks the same after each. + * + * It reminds and never blocks, and it fires once per session. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** In-place editors, in any position a shell would run one. */ +const IN_PLACE = /(^|&&|\|\||\||;|\(|\n)\s*(perl\s+-[a-zA-Z]*i|sed\s+-[a-zA-Z]*i)/ + +const REMINDER = + 'A scripted edit fails quietly in three directions: it removes what you did not name, it ' + + 'eats half a sentence in prose and leaves no symbol behind, or it raises before writing and ' + + 'changes nothing. After it runs: `git diff --stat -- ` (empty means ' + + 'nothing happened), `git diff | grep \'^-\' | grep -E \'const |function |export \'` (what ' + + 'left, by name), and for prose `git diff | grep -E \'^[-+][[:space:]]*(//|\\*)\'`. Not ' + + '`--word-diff`, which prefixes every line with a space so those filters return nothing.' + +/** Every firing after it asks the question instead of repeating the rule. */ +const SHORT = 'bulk edit: did more leave than you named? did anything happen at all?' + +const payload = await readPayload() + +if (!IN_PLACE.test(payload.tool_input?.command ?? '')) process.exit(0) +const first = firstThisSession('bulk-edit-guard', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? REMINDER : SHORT } + }) +) diff --git a/.claude/hooks/git-restore-guard.mjs b/.claude/hooks/git-restore-guard.mjs new file mode 100644 index 0000000..2839c0e --- /dev/null +++ b/.claude/hooks/git-restore-guard.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Fires before a git command that can silently destroy uncommitted work. + * + * `git checkout` and `git restore` naming a path restore from the *index*, and + * on an unstaged file the index is HEAD. `git stash` with nothing to stash is a + * no-op that still succeeds, so the `git stash pop` after it takes whatever was + * already on the stack. + * + * **It matches the verb, not a spelling.** A prose rule naming one spelling, + * `git checkout -- `, is a rule the next differently spelled command walks + * past. It reminds, never blocks, and is silent when nothing is unstaged. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { readPayload } from './payload.mjs' + +/** `git checkout` or `git restore` where a shell would run one, and not `checkout -b`. */ +const RESTORING = /(^|&&|\|\||\||;|\(|\n)\s*git\s+(checkout\s+(?!-b\b|--orphan\b)|restore\s+)/ + +/** Any `git stash`, because the empty-stash trap does not depend on the subcommand. */ +const STASH = /(^|&&|\|\||\||;|\(|\n)\s*git\s+stash\b/ + +/** + * A checkout naming a path restores files; one naming only a ref switches + * branch and carries the work along. Asked relative to `cwd`, the directory the + * command will run in, because a bare name is a path there and not here. + */ +function restoresPaths(text, cwd) { + const match = text.match(/git\s+(?:checkout|restore)\s+([^;&|\n]*)/) + if (match?.[1] === undefined) return false + const args = match[1].trim().split(/\s+/) + // `--staged` alone writes the index from HEAD and leaves the worktree, so it + // is the undo of `git add`. With `--worktree` beside it, it destroys again. + if (args.includes('--staged') && !args.includes('--worktree')) return false + if (args.includes('--')) return true + return args.some((a) => a.length > 0 && !a.startsWith('-') && existsSync(join(cwd, a))) +} + +/** The verb the user actually typed, so the reminder names their command. */ +function verb(text) { + return /git\s+restore\b/.test(text) ? 'git restore' : 'git checkout' +} + +/** + * The files git would not restore from, which is what this command can take + * away. An empty list on failure: `execFileSync` throws on a non-zero exit and + * when it cannot start the process at all, so the catch makes that one answer. + */ +function unstaged(cwd) { + try { + return execFileSync('git', ['diff', '--name-only'], { cwd, encoding: 'utf8' }) + .split('\n') + .filter((line) => line.length > 0) + } catch { + return [] + } +} + +const payload = await readPayload() + +const command = payload.tool_input?.command ?? '' +const isStash = STASH.test(command) +const cwd = payload.cwd ?? process.cwd() +const isCheckout = RESTORING.test(command) && restoresPaths(command, cwd) +if (!isCheckout && !isStash) process.exit(0) + +const atRisk = unstaged(cwd) +if (atRisk.length === 0) process.exit(0) + +const listed = atRisk.slice(0, 10).join(', ') +const rest = atRisk.length > 10 ? ', and more' : '' + +const REMINDER = isStash + ? `These files hold unstaged changes: ${listed}${rest}. \`git stash\` with nothing to stash ` + + 'succeeds anyway, so a later `git stash pop` takes whatever was already on the stack, ' + + 'possibly another branch\'s work. To carry work to another branch, `git checkout ` ' + + 'brings it along when nothing conflicts. To measure another commit, use a worktree.' + : `These files hold unstaged changes: ${listed}${rest}. \`${verb(command)}\` naming a path ` + + 'restores from the index, and for an unstaged file the index is HEAD, so it deletes ' + + 'everything else written in that file with no warning. `git add` first if you mean to ' + + 'keep it. To measure another commit, use a worktree, never the working tree.' + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: REMINDER } + }) +) diff --git a/.claude/hooks/precommit-trigger.mjs b/.claude/hooks/precommit-trigger.mjs new file mode 100644 index 0000000..d76b329 --- /dev/null +++ b/.claude/hooks/precommit-trigger.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * The trigger for `/precommit` that does not depend on anyone remembering it. + * + * The checklist hangs on `git commit`, and the shape that keeps recurring is + * earlier than a commit: a whole-project command is run on its own, as "is my + * work finished". By the time the checklist is opened it reads as a repetition + * of work already done, and step 1 is skipped again. So the trigger is the + * *command*. Running one of these is being in the checklist, whether or not it + * was opened. + * + * It is a reminder, never a block, and it fires once per session. These + * commands are legitimate mid-work too, and a hook that argues with you is a + * hook you learn to ignore. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** + * The whole-project commands, longest first so the lookahead below cannot cut + * `test:e2e` down to `test`. + * + * **The test each entry passes: a numbered step of the checklist names it.** + * `test:watch` fails it, because watch mode is how you check one change while + * writing it. So do `npx vitest run ` and `npx playwright test `, + * which name what they run. `test:e2e:scan-perf` fails it too: CONTRIBUTING + * calls it a measurement rather than a check. + */ +const WATCHED = [ + 'test:all:windows', + 'test:all:linux', + 'test:all:mac', + 'test:e2e:packaged', + 'test:e2e', + 'typecheck', + 'verify', + 'lint', + 'test' +] + +/** + * Where a shell would actually run one of them: at the start or after a + * separator, and ending where the script name ends. + * + * **Anchored, not a substring**, or it fires on prose quoting the command. + * A `grep -rn 'yarn lint' CONTRIBUTING.md` would spend the session's one + * reminder. + * + * **The right-hand side is a lookahead, not a space.** A separator can follow + * with no whitespace, as in `yarn lint; echo` or `(yarn lint)`, and what must + * still not match is a longer script name. + */ +const RUNS_IT = new RegExp( + String.raw`(^|&&|\|\||\||;|\(|\n)\s*yarn (${WATCHED.join('|')})(?![A-Za-z0-9:_-])` +) + +const REMINDER = + 'This command is a step of the `/precommit` checklist. Running it means you are in the ' + + 'checklist, so if this is you finishing work rather than checking one change, invoke ' + + '`/precommit` and start at step 1, reading the diff, rather than in the middle. Doing the ' + + 'substance of a step is not doing the step.' + +/** Every firing after it asks the question instead of repeating the rule. */ +const SHORT = 'precommit: finishing work, or checking one change?' + +const payload = await readPayload() + +const command = payload.tool_input?.command ?? '' +if (!RUNS_IT.test(command)) process.exit(0) +const first = firstThisSession('precommit-trigger', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? REMINDER : SHORT } + }) +) diff --git a/.claude/hooks/prose-trigger.mjs b/.claude/hooks/prose-trigger.mjs index 59babc2..6205175 100755 --- a/.claude/hooks/prose-trigger.mjs +++ b/.claude/hooks/prose-trigger.mjs @@ -1,23 +1,18 @@ #!/usr/bin/env node /** - * The trigger for `/prose` that does not depend on anyone remembering it. + * The trigger for `/prose`, on every write rather than the prose-looking ones. * - * The moment a sentence needs checking is the moment before it is written, and - * no user words announce it. So the trigger is the *write*: a markdown file, or - * an edit that adds a comment. - * - * It is a reminder, never a block, and it states the whole rule every time. The - * short form it used to degrade to after the first firing is the form that gets - * read past, and the sentence being written is the thing at stake. + * A heredoc puts the sentence in `command`, so a matcher reading `content` and + * `new_string` never sees it. Both halves classify as little as possible, it + * reminds and never blocks, and it says the whole rule every time: a short form + * gets read past. * * Reads the hook payload on stdin, writes hook JSON on stdout. */ +import { IS_COMMIT, WRITES_A_FILE } from './bash-target.mjs' import { readPayload } from './payload.mjs' -/** A comment opener at the start of a line, in TypeScript and JavaScript. */ -const ADDS_COMMENT = /(^|\n)\s*(\/\/|\/\*)/ - const RULE = 'This write is prose, not code. Every sentence is a claim, an order, or a measurement — ' + 'anything else is narration, so cut it. Then read back what you wrote: for every sentence ' + @@ -28,23 +23,12 @@ const RULE = 'to end only ever grows. No em dash in anything a person reads.' const payload = await readPayload() +const input = payload.tool_input ?? {} +const command = input.command ?? '' -const path = payload.tool_input?.file_path ?? '' -const written = payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - -/** - * A commit message is prose, and it is written through Bash rather than a Write. - * - * Anchored to the start of a command rather than matched anywhere in the string: - * a heredoc writing a test about `git commit`, or a grep for it, is not a commit. - * That false positive fired on the run that added this line. - */ -const command = payload.tool_input?.command ?? '' -const IS_COMMIT = /(?:^|[;&|]\s*|&&\s*|\|\|\s*)git\s+(?:commit|merge)\b/ +const namesAFile = typeof input.file_path === 'string' && input.file_path.length > 0 -const isProse = - path.endsWith('.md') || ADDS_COMMENT.test(written) || IS_COMMIT.test(command) -if (!isProse) process.exit(0) +if (!namesAFile && !IS_COMMIT.test(command) && !WRITES_A_FILE.test(command)) process.exit(0) console.log( JSON.stringify({ diff --git a/.claude/hooks/test-trigger.mjs b/.claude/hooks/test-trigger.mjs new file mode 100644 index 0000000..b9b5154 --- /dev/null +++ b/.claude/hooks/test-trigger.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * The trigger for `/test`, at the moment a test is written rather than at the + * commit that carries it. + * + * A test written from the same model as the fix inherits that model's blind + * spot, and `precommit` opens hours later. + * + * The first firing of a session states the rule; every one after it asks the + * questions. It reminds and never blocks. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { pathsIn, WRITES_A_FILE } from './bash-target.mjs' +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** The three spellings this suite uses. Vitest and Playwright share them. */ +const IS_TEST_CODE = /\b(describe|it|test)\s*\(/ + +/** A path that is a test whatever it holds. */ +const IS_TEST_PATH = /(^|\/)__tests__\/|(^|\/)e2e\/|\.test\.[tj]sx?$|\.spec\.[tj]sx?$/ + +const FULL = + 'This write is a test. Which tests the change needs, and whether each one can fail, is ' + + '`/test`: cover the blast radius rather than the bug, ship the pair (the state that must ' + + 'not recur and the state that must keep working) and prove the first goes red when the fix ' + + 'is reverted. A test you have not seen fail proves nothing.' + +const SHORT = 'test: seen it fail? does the pair cover both directions?' + +const payload = await readPayload() + +const input = payload.tool_input ?? {} +const path = input.file_path ?? '' +const written = input.content ?? input.new_string ?? '' +const command = input.command ?? '' + +// A heredoc writing a test names neither `file_path` nor `new_string`, so the +// command is asked the same two questions: a test path among the paths it +// names, or a test call in the bytes it is about to write. +const bashWritesATest = + WRITES_A_FILE.test(command) && + (pathsIn(command).some((p) => IS_TEST_PATH.test(p)) || IS_TEST_CODE.test(command)) + +// The content rule reads code only. `describe(` inside a markdown table is +// prose about tests, and matching it fires the hook on documentation. +const isSource = /\.[tj]sx?$/.test(path) +const writeIsATest = IS_TEST_PATH.test(path) || (isSource && IS_TEST_CODE.test(written)) +if (!writeIsATest && !bashWritesATest) process.exit(0) + +const first = firstThisSession('test-trigger', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? FULL : SHORT } + }) +) diff --git a/.claude/settings.json b/.claude/settings.json index a1dc391..eea37cf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,15 +1,38 @@ { "hooks": { "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/precommit-trigger.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-restore-guard.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/bulk-edit-guard.mjs"] + } + ] + }, { "matcher": "Write|Edit|Bash", "hooks": [ { "type": "command", "command": "node", - "args": [ - "${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs" - ] + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/test-trigger.mjs"] } ] } From dba1285001b3a80e8be759a9894dad76e6ddd53c Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 2 Sep 2026 23:53:35 +0200 Subject: [PATCH 43/72] docs: cut the narration out of CONTRIBUTING The opening thanked the reader and said the guidelines exist to keep the review smooth. Neither is a claim, an order or a measurement, which is what this repo asks of a sentence. One line replaces both, and it points at the conformance test. Two of the ten rules told what went wrong once instead of what the rule is. The path-alias rule named @main, @preload and @backend, none of which appear in any tsconfig or in electron.vite.config.ts any more, and src/backend is gone, so its whole body described a state nobody can check. It now names the two aliases that exist and says when one retires. The include rule kept its second sentence, which says how the test works, and lost its first. An action is fetched where it runs now names both shapes, after the refactor that made a pass-through prop take the action itself. Three em dashes and one -- left over from stripping one, each rewritten as its own sentence. The Arduino paragraph said twice that the board is found by USB vendor ID and skips without one. Still standing: What will get your PR rejected repeats ground rule 3 and two lines of Code style. --- CONTRIBUTING.md | 67 ++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c998b60..f97c1b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,13 @@ # Contributing to Modbux -Thanks for your interest in contributing. Modbux was born from real-world frustration with Modbus tooling, and your help makes it better for the entire industry. - -Before you start, please read this document carefully. These guidelines exist to keep the codebase consistent and the review process smooth. They are not suggestions. +Everything below is a rule rather than a suggestion. Ten of them are asserted by +`src/__tests__/conformance.test.ts`, so breaking one fails `yarn test` instead of +waiting for a reviewer. ## Ground rules -1. **Open an issue first.** Before writing code, open an issue describing the bug or feature. This avoids wasted effort if the change doesn't align with the project direction. -2. **One PR, one concern.** Don't mix a bug fix with a refactor. Don't sneak in "while I was here" changes. Keep your diff focused. +1. **Open an issue first.** Describe the bug or the feature before writing code, so a change that does not fit the project's direction is found before you build it. +2. **One PR, one concern.** Don't mix a bug fix with a refactor, and don't sneak in "while I was here" changes. 3. **Don't break the build.** Run `yarn verify` before pushing. If it doesn't pass, your PR won't be reviewed. 4. **Match the existing style.** Don't introduce new patterns, conventions, or abstractions without discussing them first. @@ -69,18 +69,22 @@ and for `(z) => z`, which take the whole store the long way round. The renderer has zero of all three and zero `useShallow`, and that is why it draws a two-thousand-row grid without either. -**An action is fetched where it runs, not subscribed to.** The handler is a -`useCallback` whose first line reads the store: -`const clientZustand = useClientZustand.getState()`. -A selector that hands back a store function puts -that function in the dependency list, and a dependency list naming something the -component does not own is a list no reader can check. What the component holds -goes in the list; what the store holds is read at the moment it is used. That -second half also covers a *value* the component wants at a moment rather than on -every change: read through `getState()` and it causes no render. - -The handler has a name and the prop takes the name, so a `getState()` written -into a JSX attribute breaks the same rule from the other side: the call sits +**An action is fetched where it runs, not subscribed to.** A selector that hands +back a store function puts that function in the dependency list, and a +dependency list naming something the component does not own is a list no reader +can check. What the component holds goes in the list; what the store holds is +read through `getState()`. That also covers a *value* the component wants at a +moment rather than on every change: read that way, it causes no render. + +Two shapes, and which one you write depends on whether the component adds +anything. A handler that does its own work is a `useCallback` whose first line +reads the store, `const clientZustand = useClientZustand.getState()`. A prop +that only forwards takes the action itself, +`const setHost = useClientZustand.getState().setHost`, because wrapping it in a +`useCallback` that calls it with the same arguments only gives it a second name. + +Either way the thing has a name and the prop takes the name, so a `getState()` +written into a JSX attribute breaks the rule from the other side: the call sits where the reader is looking at layout, and a handler with no name is a handler with nothing to read. @@ -118,14 +122,14 @@ schema goes beside the handler in `main/ipc.ts`, and it is only accepted where `undefined` is an honest answer: a rejected payload has nothing else to give back, so a channel returning a value has to say so in its type. -**Every configured path alias is imported through.** `@main`, `@preload` and -`@backend` sat in the configs long after anything used them, and `@backend` -pointed at a directory that had been deleted. +**Every configured path alias is imported through.** `@renderer/*` and `@shared` +are the two, in the tsconfigs and in `electron.vite.config.ts` alike. An alias +nobody imports through resolves whatever it points at, including a directory +that is gone, so the last import leaving is what retires it. -**Every configured include points at something.** `tsconfig.node.json` went on -including `src/backend/**/*` after the directory and the alias were both gone. A -glob that matches nothing costs nothing to keep and says nothing when it stops -being true, so the test expands it rather than reading its shape. +**Every configured include points at something.** A glob that matches nothing +costs nothing to keep and says nothing when it stops being true, so the test +expands it rather than reading its shape. ### Two rules no test can see @@ -191,15 +195,15 @@ Don't use `feat` for a bug fix. Don't use `fix` for a refactor. Mean what you sa are invoked on purpose: - `99-hardware` needs an Arduino on a serial port. It finds the board by USB - vendor ID and skips the suite when none is attached, so it runs unattended -- - but CI has no board, which is why it stays out of `test:e2e`. + vendor ID and skips the suite when none is attached, so it runs unattended. + CI has no board, which is why it stays out of `test:e2e`. - `03-presentation` is a documentation utility, not a check. It clicks through the app and captures what it sees without asserting much, so it costs two minutes to tell you little that `01-main` does not already cover. Run it when the UI changed and the manual needs new screenshots. -`verify` deliberately leaves out `test:e2e:packaged` — it adds a full packaging -step and runs far longer, which is too much for every push. The `test:all:*` +`verify` deliberately leaves out `test:e2e:packaged`, which adds a full packaging +step and runs far longer than is worth doing on every push. The `test:all:*` rounds do include it, and those are for cutting a release rather than for a PR. It is the only check that exercises what actually ships: `electron-vite` externalizes whatever sits in `dependencies` and `electron-builder` packs only @@ -211,10 +215,9 @@ installed Modbux's config. `playwright.config.ts` ignores `99-hardware`, so neither `test:e2e` nor `test:e2e:packaged` picks those specs up. They need an Arduino running `tools/arduino/iem3000.ino` on a serial port. The board is found by USB vendor -ID — `manufacturer` is useless for this, it reads "Microsoft" on Windows where -the generic driver claims the device — and the suite skips itself when no board -is attached. So the round is unattended, and every `test:all:*` ends with it. -Use `yarn test:e2e:hardware` to run it alone. +ID rather than by `manufacturer`, which reads "Microsoft" on Windows where the +generic driver claims the device. Every `test:all:*` ends with this round, and +`yarn test:e2e:hardware` runs it alone. ### Test expectations From 23b1b639761b6c88a3b7a548863563a5111c835c Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 3 Sep 2026 00:22:40 +0200 Subject: [PATCH 44/72] test: a hook exits 0 or it is broken, for every one the settings wire The harness reads a non-zero code as something to show the user, and 2 as a refusal, which on PreToolUse blocks the tool call. Nothing pinned that here, so a hook that dereferenced a bare payload would have blocked whatever it fired on and no test would have said so. The list of hooks under test is checked against .claude/settings.json rather than kept beside it, or a hook wired and not listed sits outside the claim. Both directions proven: wiring a sixth hook turns the list test red, and dropping one optional chain in precommit-trigger turns its exit test red. Ported from scripts/hooks/hooks.test.ts in the ploxc repo, which pins the same rule. --- .claude/hooks/__tests__/wired.test.mjs | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .claude/hooks/__tests__/wired.test.mjs diff --git a/.claude/hooks/__tests__/wired.test.mjs b/.claude/hooks/__tests__/wired.test.mjs new file mode 100644 index 0000000..6d4edc2 --- /dev/null +++ b/.claude/hooks/__tests__/wired.test.mjs @@ -0,0 +1,66 @@ +/** + * The rule this pins is one sentence: **a hook exits 0 or it is broken.** + * + * The harness reads a non-zero code as something to show the user, and 2 as a + * refusal: on `PreToolUse` that blocks the tool call. None of these may reach + * it, on input none of them was written against. + * + * Ported from `scripts/hooks/hooks.test.ts` in the ploxc repo, which pins the + * same rule for every hook its settings wire. + */ +import { describe, it, expect } from 'vitest' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOKS = join(dirname(fileURLToPath(import.meta.url)), '..') + +/** + * Every wired hook, by the filename `.claude/settings.json` names. + * + * This list has to be the wiring's, or a hook added there and not here sits + * outside every claim below. + */ +const WIRED = [ + 'bulk-edit-guard.mjs', + 'git-restore-guard.mjs', + 'precommit-trigger.mjs', + 'prose-trigger.mjs', + 'test-trigger.mjs' +] + +describe('every wired hook', () => { + it('the list here is the list in .claude/settings.json', () => { + const settings = JSON.parse(readFileSync(join(HOOKS, '..', 'settings.json'), 'utf8')) + const wired = new Set() + for (const groups of Object.values(settings.hooks)) { + for (const group of groups) { + for (const hook of group.hooks ?? []) { + // Both documented shapes: `command` plus `args`, and the whole line + // in `command`. Reading one lets a hook wired in the other stay + // outside every claim below. + for (const field of [hook.command, ...(hook.args ?? [])]) { + const name = field?.match(/hooks\/([\w-]+\.mjs)\b/)?.[1] + if (name !== undefined) wired.add(name) + } + } + } + } + expect([...wired].sort()).toEqual([...WIRED].sort()) + }) + + it.each(WIRED)('%s exits 0 on a payload it was not written for', (name) => { + for (const payload of ['', '{not json', '{}', '[]', 'null', '{"tool_input":42}']) { + const run = spawnSync('node', [join(HOOKS, name)], { input: payload, encoding: 'utf8' }) + expect(run.status, `${name} on ${JSON.stringify(payload)}: ${run.stderr}`).toBe(0) + } + }) + + it.each(WIRED)('%s says nothing on a payload it was not written for', (name) => { + for (const payload of ['{}', 'null', '{"tool_input":42}']) { + const run = spawnSync('node', [join(HOOKS, name)], { input: payload, encoding: 'utf8' }) + expect(run.stdout.trim(), `${name} on ${payload}`).toBe('') + } + }) +}) From 8ca2b107704a44154a4d94f1c034a9124ed9cfcf Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 3 Sep 2026 07:46:35 +0200 Subject: [PATCH 45/72] fix: a config that does not parse loses only the field that failed persist merges shallowly, so a persisted connectionConfig of {} replaced the whole default sub-object rather than being filled in, and the check over the whole state then failed on the merged result. That failure was answered by clearStorage, which took every sibling with it: a register mapping built by hand went along with the field that broke, and nothing was kept to recover it from. repairPersisted checks each field the schema names on its own, keeps the ones that parse, defaults the ones that do not, and returns their names. Both stores now report a ConfigReset instead of a boolean, and MessageReceiver says which fields went rather than that something did. The blob is copied to .corrupt- before the reset. The reset is what makes the app usable again and also what destroyed the evidence, and a mapping worth hundreds of rows is worth having in a bug report even once it is unreadable. A state saved by a newer version went through migrate untouched and was cast to the schema's type without ever meeting it. persist calls migrate for any version that is not the current one, so the number is recorded there and read once: the fields that still fit are kept, the rest reset, and the message says where the config came from. A newer config that loses nothing is still reported, because a field this version does not know is a field it silently dropped. Nine mutations, one per rule, each red on its own test. The non-object guard survived the first attempt: its rival read a string as a record and answered the same for every input the tests give it. --- .../src/components/shared/MessageReceiver.tsx | 30 ++- .../context/__tests__/corruptedConfig.test.ts | 99 +++++++++- src/renderer/src/context/client.zustand.ts | 49 +++-- .../src/context/client.zustand.types.ts | 7 +- src/renderer/src/context/server.zustand.ts | 47 +++-- .../src/context/server.zustand.types.ts | 7 +- src/shared/__tests__/repairPersisted.test.ts | 185 ++++++++++++++++++ src/shared/index.ts | 1 + src/shared/migrations/index.ts | 3 +- src/shared/migrations/server/zustand.ts | 3 + src/shared/repairPersisted.ts | 132 +++++++++++++ 11 files changed, 496 insertions(+), 67 deletions(-) create mode 100644 src/shared/__tests__/repairPersisted.test.ts create mode 100644 src/shared/repairPersisted.ts diff --git a/src/renderer/src/components/shared/MessageReceiver.tsx b/src/renderer/src/components/shared/MessageReceiver.tsx index 9343c7e..8de11bc 100644 --- a/src/renderer/src/components/shared/MessageReceiver.tsx +++ b/src/renderer/src/components/shared/MessageReceiver.tsx @@ -2,15 +2,15 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useServerZustand } from '@renderer/context/server.zustand' import { onEvent } from '@renderer/events' -import { BackendMessage } from '@shared' +import { BackendMessage, resetMessage } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' // Receives message and shows them in a snackbar const MessageReceiver = meme((): null => { const { enqueueSnackbar } = useSnackbar() - const clientConfigWasReset = useClientZustand((z) => z.configWasReset) - const serverConfigWasReset = useServerZustand((z) => z.configWasReset) + const clientConfigReset = useClientZustand((z) => z.configReset) + const serverConfigReset = useServerZustand((z) => z.configReset) const handleMessage = useCallback( (message: BackendMessage) => { @@ -27,10 +27,10 @@ const MessageReceiver = meme((): null => { return (): void => unlisten() }, [handleMessage]) - // A store whose persisted config failed its schema resets itself while the - // module graph is still evaluating, which is before any provider exists to - // tell. It records the reset instead, and this says so. Both windows report - // their own: the server window runs the server store and no message listener. + // A store repairs its persisted config while the module graph is still + // evaluating, which is before any provider exists to tell. It records what it + // had to reset instead, and this says so. Both windows report their own: the + // server window runs the server store and no message listener. // // Acknowledged after telling, because this component mounts inside Client and // Server rather than at the root: without that, walking Home and back reports @@ -38,21 +38,15 @@ const MessageReceiver = meme((): null => { useEffect(() => { const clientZustand = useClientZustand.getState() const serverZustand = useServerZustand.getState() - if (clientConfigWasReset) { - enqueueSnackbar({ - variant: 'error', - message: 'Client configuration was corrupted and has been reset to defaults.' - }) + if (clientConfigReset !== undefined) { + enqueueSnackbar({ variant: 'error', message: resetMessage('Client', clientConfigReset) }) clientZustand.acknowledgeConfigReset() } - if (serverConfigWasReset) { - enqueueSnackbar({ - variant: 'error', - message: 'Server configuration was corrupted and has been reset to defaults.' - }) + if (serverConfigReset !== undefined) { + enqueueSnackbar({ variant: 'error', message: resetMessage('Server', serverConfigReset) }) serverZustand.acknowledgeConfigReset() } - }, [clientConfigWasReset, serverConfigWasReset, enqueueSnackbar]) + }, [clientConfigReset, serverConfigReset, enqueueSnackbar]) return null }) diff --git a/src/renderer/src/context/__tests__/corruptedConfig.test.ts b/src/renderer/src/context/__tests__/corruptedConfig.test.ts index fa75b17..7268f22 100644 --- a/src/renderer/src/context/__tests__/corruptedConfig.test.ts +++ b/src/renderer/src/context/__tests__/corruptedConfig.test.ts @@ -25,7 +25,15 @@ beforeEach(() => { stubRenderer() }) -describe('a persisted client config that fails its schema', () => { +/** A mapping a user would have built by hand, and would not want to lose. */ +const mapping = { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { '5': { dataType: 'uint16', comment: 'Feeder A' } } +} + +describe('a persisted client config with one field that fails its schema', () => { it('lets the module finish evaluating', async () => { localStorage.setItem( 'client.zustand', @@ -34,10 +42,21 @@ describe('a persisted client config that fails its schema', () => { const { useClientZustand } = await import('../client.zustand') - expect(useClientZustand.getState().configWasReset).toBe(true) + expect(useClientZustand.getState().configReset).toBeDefined() + }) + + it('names the field it reset', async () => { + localStorage.setItem( + 'client.zustand', + JSON.stringify({ state: { connectionConfig: {} }, version: 2 }) + ) + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().configReset?.fields).toEqual(['connectionConfig']) }) - it('leaves the store on its defaults rather than the broken config', async () => { + it('defaults that field rather than leaving the broken one', async () => { localStorage.setItem( 'client.zustand', JSON.stringify({ state: { connectionConfig: {} }, version: 2 }) @@ -48,25 +67,91 @@ describe('a persisted client config that fails its schema', () => { expect(useClientZustand.getState().connectionConfig.protocol).toBeDefined() }) + it('keeps the register mapping standing beside it', async () => { + localStorage.setItem( + 'client.zustand', + JSON.stringify({ state: { connectionConfig: {}, registerMapping: mapping }, version: 2 }) + ) + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().registerMapping.holding_registers[5]?.comment).toBe( + 'Feeder A' + ) + }) + + it('copies the unreadable blob rather than clearing it', async () => { + const stored = JSON.stringify({ state: { connectionConfig: {} }, version: 2 }) + localStorage.setItem('client.zustand', stored) + + await import('../client.zustand') + + const kept = Object.keys(localStorage).filter((k) => k.startsWith('client.zustand.corrupt-')) + expect(kept).toHaveLength(1) + expect(localStorage.getItem(kept[0])).toBe(stored) + }) + it('says nothing when the config parses', async () => { const { useClientZustand } = await import('../client.zustand') - expect(useClientZustand.getState().configWasReset).toBe(false) + expect(useClientZustand.getState().configReset).toBeUndefined() + }) +}) + +describe('a persisted client config from a newer version', () => { + it('keeps the fields that still fit and says where it came from', async () => { + localStorage.setItem( + 'client.zustand', + JSON.stringify({ state: { registerMapping: mapping, connectionConfig: {} }, version: 99 }) + ) + + const { useClientZustand } = await import('../client.zustand') + + const reset = useClientZustand.getState().configReset + expect(reset?.savedByNewerVersion).toBe(true) + expect(reset?.fields).toEqual(['connectionConfig']) + expect(useClientZustand.getState().registerMapping.holding_registers[5]?.comment).toBe( + 'Feeder A' + ) + }) + + it('reports it even when every field still fits', async () => { + const { useClientZustand: fresh } = await import('../client.zustand') + const whole = JSON.stringify({ + state: { + name: '', + registerMapping: fresh.getInitialState().registerMapping, + connectionConfig: fresh.getInitialState().connectionConfig, + registerConfig: fresh.getInitialState().registerConfig + }, + version: 99 + }) + vi.resetModules() + localStorage.clear() + stubRenderer() + localStorage.setItem('client.zustand', whole) + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().configReset).toEqual({ + fields: [], + savedByNewerVersion: true + }) }) }) -describe('a persisted server config that fails its schema', () => { +describe('a persisted server config with one field that fails its schema', () => { it('lets the module finish evaluating', async () => { localStorage.setItem('server.zustand', JSON.stringify({ state: { port: 'nope' }, version: 3 })) const { useServerZustand } = await import('../server.zustand') - expect(useServerZustand.getState().configWasReset).toBe(true) + expect(useServerZustand.getState().configReset?.fields).toContain('port') }) it('says nothing when the config parses', async () => { const { useServerZustand } = await import('../server.zustand') - expect(useServerZustand.getState().configWasReset).toBe(false) + expect(useServerZustand.getState().configReset).toBeUndefined() }) }) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index fca9386..47a4ad5 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -14,11 +14,21 @@ import { CURRENT_CLIENT_ZUSTAND_VERSION, migrateClientState, carryFormerClientState, - CLIENT_ZUSTAND_STORAGE_KEY + CLIENT_ZUSTAND_STORAGE_KEY, + keepCorrupt, + repairPersisted } from '@shared' import { useDataZustand } from './data.zustand' import { onEvent } from '@renderer/events' +/** + * The version the blob on disk carried, set by `migrate` and read once below. + * + * persist calls `migrate` for any version that is not the current one, the ones + * above it included, and that call is the only place the number is offered. + */ +let persistedVersion: number | undefined + // Debounced IPC sync — avoids flooding the main process on rapid cell edits let _ipcTimer: ReturnType | null = null function syncRegisterMappingToMain(): void { @@ -71,10 +81,10 @@ export const useClientZustand = create< set((state) => { state.name = name }), - configWasReset: false, + configReset: undefined, acknowledgeConfigReset: () => set((state) => { - state.configWasReset = false + state.configReset = undefined }), registerMapping: { coils: {}, @@ -412,7 +422,10 @@ export const useClientZustand = create< { name: CLIENT_ZUSTAND_STORAGE_KEY, version: CURRENT_CLIENT_ZUSTAND_VERSION, - migrate: (state, version) => migrateClientState(state, version) as PersistedClientZustand, + migrate: (state, version) => { + persistedVersion = version + return migrateClientState(state, version) as PersistedClientZustand + }, partialize: (state) => ({ name: state.name, connectionConfig: state.connectionConfig, @@ -426,27 +439,29 @@ export const useClientZustand = create< const clientZustand = useClientZustand.getState() /** - * Clear when state is corrupted, and record that it happened. + * Keep the fields that parsed and default the rest, then say which went. * * This runs while the module graph is still evaluating. notistack assigns its * standalone enqueueSnackbar inside the SnackbarProvider constructor, and that * provider is built by createRoot().render() in main.tsx, so calling it here * throws out of module scope and nothing below this line ever runs: no init, no - * event listeners, and no React render either. MessageReceiver reads the flag + * event listeners, and no React render either. MessageReceiver reads the report * once it is mounted, where a provider exists to tell. + * + * The blob is copied rather than cleared, because a register mapping worth + * hundreds of rows is worth having in a bug report even once it is unreadable. */ -const clear = (): void => { - useClientZustand.persist.clearStorage() - useClientZustand.setState({ - ...useClientZustand.getInitialState(), - configWasReset: true - }) -} +const repair = repairPersisted( + PersistedClientZustandSchema, + clientZustand, + useClientZustand.getInitialState(), + persistedVersion !== undefined && persistedVersion > CURRENT_CLIENT_ZUSTAND_VERSION +) -const stateResult = PersistedClientZustandSchema.safeParse(clientZustand) -if (!stateResult.success) { - console.warn(stateResult.error) - clear() +if (repair.reset !== undefined) { + console.warn('client config repaired', repair.reset) + keepCorrupt(localStorage, CLIENT_ZUSTAND_STORAGE_KEY) + useClientZustand.setState({ ...repair.state, configReset: repair.reset }) } // Sync the main process state with the front end diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 5b0c5e0..b338b1d 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -11,7 +11,8 @@ import { ConnectionConfigSchema, RegisterConfigSchema, SerialPortInfo, - SerialPortValidationResult + SerialPortValidationResult, + ConfigReset } from '@shared' import { SerialPortOptions } from 'modbus-serial/ModbusRTU' import z from 'zod' @@ -48,8 +49,8 @@ export type ClientZustand = { ) => void replaceRegisterMapping: (registerMapping: RegisterMapping) => void clearRegisterMapping: () => void - /** Set when the persisted config failed its schema and was reset. */ - configWasReset: boolean + /** What the persisted config lost on the way in, or undefined when it lost nothing. */ + configReset: ConfigReset | undefined /** Called once the reset has been reported, so it is reported once. */ acknowledgeConfigReset: () => void // Transaction log diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 498f79f..f2a6541 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -22,9 +22,12 @@ import { migrateServerModeState, migrateBoolShape, CURRENT_SERVER_ZUSTAND_VERSION, + SERVER_ZUSTAND_STORAGE_KEY, registerWidth, ServerSerialConfig, - ModbusBaudRate + ModbusBaudRate, + keepCorrupt, + repairPersisted } from '@shared' import { onEvent } from '@renderer/events' import { round } from 'lodash' @@ -62,16 +65,24 @@ const restartRtuIfActive = (get: () => ServerZustand): void => { }) } +/** + * The version the blob on disk carried, set by `migrate` and read once below. + * + * persist calls `migrate` for any version that is not the current one, the ones + * above it included, and that call is the only place the number is offered. + */ +let persistedVersion: number | undefined + export const useServerZustand = create< ServerZustand, [['zustand/persist', PersistedServerZustand], ['zustand/mutative', never]] >( persist( mutative((set, get) => ({ - configWasReset: false, + configReset: undefined, acknowledgeConfigReset: () => set((state) => { - state.configWasReset = false + state.configReset = undefined }), ready: { [MAIN_SERVER_UUID]: false }, selectedUuid: MAIN_SERVER_UUID, @@ -588,9 +599,10 @@ export const useServerZustand = create< } })), { - name: `server.zustand`, + name: SERVER_ZUSTAND_STORAGE_KEY, version: CURRENT_SERVER_ZUSTAND_VERSION, migrate: (persistedState, version) => { + persistedVersion = version let state = persistedState as Record // Version 0/1 (old format with littleEndian per register) @@ -628,25 +640,24 @@ export const useServerZustand = create< ) /** - * Clear when state is corrupted, and record that it happened. + * Keep the fields that parsed and default the rest, then say which went. * - * Module scope, so it cannot report through notistack: see the same function in + * Module scope, so it cannot report through notistack: see the same block in * client.zustand.ts for why. MessageReceiver tells the user once it is mounted. */ -const clear = (): void => { - useServerZustand.persist.clearStorage() - useServerZustand.setState({ - ...useServerZustand.getInitialState(), - configWasReset: true - }) -} - const serverZustand = useServerZustand.getState() -const stateResult = PersistedServerZustandSchema.safeParse(serverZustand) -if (!stateResult.success) { - console.warn(stateResult.error) - clear() +const repair = repairPersisted( + PersistedServerZustandSchema, + serverZustand, + useServerZustand.getInitialState(), + persistedVersion !== undefined && persistedVersion > CURRENT_SERVER_ZUSTAND_VERSION +) + +if (repair.reset !== undefined) { + console.warn('server config repaired', repair.reset) + keepCorrupt(localStorage, SERVER_ZUSTAND_STORAGE_KEY) + useServerZustand.setState({ ...repair.state, configReset: repair.reset }) } // Init server diff --git a/src/renderer/src/context/server.zustand.types.ts b/src/renderer/src/context/server.zustand.types.ts index f9726b4..69df6b8 100644 --- a/src/renderer/src/context/server.zustand.types.ts +++ b/src/renderer/src/context/server.zustand.types.ts @@ -12,7 +12,8 @@ import { ServerModeSchema, ServerSerialConfigSchema, SerialPortInfo, - ModbusBaudRate + ModbusBaudRate, + ConfigReset } from '@shared' import { AsyncMaskSetFn, MaskSetFn } from './client.zustand.types' import { z } from 'zod' @@ -55,8 +56,8 @@ export interface SetRegisterValueParameters { } export type ServerZustand = { - /** Set when the persisted config failed its schema and was reset. */ - configWasReset: boolean + /** What the persisted config lost on the way in, or undefined when it lost nothing. */ + configReset: ConfigReset | undefined /** Called once the reset has been reported, so it is reported once. */ acknowledgeConfigReset: () => void ready: { [uuid: string]: boolean } diff --git a/src/shared/__tests__/repairPersisted.test.ts b/src/shared/__tests__/repairPersisted.test.ts new file mode 100644 index 0000000..2055000 --- /dev/null +++ b/src/shared/__tests__/repairPersisted.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { keepCorrupt, repairPersisted, resetMessage } from '../repairPersisted' + +const Schema = z.object({ + name: z.string(), + port: z.number(), + mapping: z.record(z.string(), z.number()) +}) + +const defaults = { name: 'Modbux', port: 502, mapping: {} } + +describe('repairPersisted', () => { + it('keeps every field when they all parse', () => { + const stored = { name: 'Plant', port: 5020, mapping: { '5': 1 } } + + const repair = repairPersisted(Schema, stored, defaults) + + expect(repair.state).toEqual(stored) + expect(repair.reset).toBeUndefined() + }) + + it('keeps the siblings of a field that failed', () => { + // The mapping is what a user built by hand, and clearing the whole key to + // answer a broken connection config is what took it. + const stored = { name: 'Plant', port: 'nope', mapping: { '5': 1 } } + + const repair = repairPersisted(Schema, stored, defaults) + + expect(repair.state.mapping).toEqual({ '5': 1 }) + expect(repair.state.name).toBe('Plant') + expect(repair.state.port).toBe(502) + expect(repair.reset?.fields).toEqual(['port']) + }) + + it('names every field it had to reset', () => { + const stored = { name: 42, port: 'nope', mapping: { '5': 1 } } + + const repair = repairPersisted(Schema, stored, defaults) + + expect(repair.reset?.fields).toEqual(['name', 'port']) + }) + + it('resets a field that is missing rather than leaving it undefined', () => { + const repair = repairPersisted(Schema, { name: 'Plant' }, defaults) + + expect(repair.state.port).toBe(502) + expect(repair.reset?.fields).toEqual(['port', 'mapping']) + }) + + it('defaults everything when the blob is not an object', () => { + for (const stored of [null, 'nope', 42, undefined]) { + const repair = repairPersisted(Schema, stored, defaults) + expect(repair.state, String(stored)).toEqual(defaults) + expect(repair.reset?.fields, String(stored)).toEqual(['name', 'port', 'mapping']) + } + }) + + it('ignores a field the schema does not declare', () => { + const stored = { name: 'Plant', port: 5020, mapping: {}, fromTheFuture: true } + + const repair = repairPersisted(Schema, stored, defaults) + + expect(repair.state).not.toHaveProperty('fromTheFuture') + expect(repair.reset).toBeUndefined() + }) + + it('carries the newer-version flag through', () => { + const repair = repairPersisted(Schema, { name: 'Plant' }, defaults, true) + + expect(repair.reset?.savedByNewerVersion).toBe(true) + expect(repair.state.name).toBe('Plant') + }) + + it('leaves the defaults it was given alone', () => { + const given = { name: 'Modbux', port: 502, mapping: {} } + + repairPersisted(Schema, { name: 'Plant', port: 5020, mapping: { '1': 2 } }, given) + + expect(given).toEqual({ name: 'Modbux', port: 502, mapping: {} }) + }) +}) + +describe('resetMessage', () => { + it('names the one field it reset, and says the rest was kept', () => { + const message = resetMessage('Client', { + fields: ['connectionConfig'], + savedByNewerVersion: false + }) + + expect(message).toBe( + 'Client configuration: the connection settings could not be read and was reset. ' + + 'Everything else was kept.' + ) + }) + + it('lists several fields and agrees with itself about the verb', () => { + const message = resetMessage('Server', { + fields: ['port', 'uuids', 'serialConfig'], + savedByNewerVersion: false + }) + + expect(message).toContain('the ports, the server list and the serial settings') + expect(message).toContain('were reset') + }) + + it('names a field the labels do not know rather than dropping it', () => { + const message = resetMessage('Client', { fields: ['whatIsThis'], savedByNewerVersion: false }) + + expect(message).toContain('`whatIsThis`') + }) + + it('says where a newer config came from', () => { + const message = resetMessage('Client', { + fields: ['connectionConfig'], + savedByNewerVersion: true + }) + + expect(message).toContain('saved by a newer version of Modbux') + expect(message).toContain('did not come across') + }) + + it('still says so when a newer config lost nothing', () => { + const message = resetMessage('Server', { fields: [], savedByNewerVersion: true }) + + expect(message).toBe( + 'Server configuration was saved by a newer version of Modbux and was read in full.' + ) + }) +}) + +describe('keepCorrupt', () => { + /** Just enough Storage, and a record of what was written. */ + const storage = ( + initial: Record = {} + ): { + held: Record + getItem: (k: string) => string | null + setItem: (k: string, v: string) => void + } => { + const held = { ...initial } + return { + held, + getItem: (k: string): string | null => held[k] ?? null, + setItem: (k: string, v: string): void => { + held[k] = v + } + } + } + + it('copies the blob under a key nothing reads', () => { + const store = storage({ 'client.zustand': '{"broken":true}' }) + + keepCorrupt(store, 'client.zustand', () => 1756800000000) + + expect(store.held['client.zustand.corrupt-1756800000000']).toBe('{"broken":true}') + }) + + it('leaves the original where it is', () => { + const store = storage({ 'client.zustand': '{"broken":true}' }) + + keepCorrupt(store, 'client.zustand', () => 1) + + expect(store.held['client.zustand']).toBe('{"broken":true}') + }) + + it('writes nothing when there is no blob', () => { + const store = storage() + + keepCorrupt(store, 'client.zustand', () => 1) + + expect(Object.keys(store.held)).toEqual([]) + }) + + it('says nothing when storage throws', () => { + const throwing = { + getItem: (): string => { + throw new Error('unavailable') + }, + setItem: (): void => {} + } + + expect(() => keepCorrupt(throwing, 'client.zustand')).not.toThrow() + }) +}) diff --git a/src/shared/index.ts b/src/shared/index.ts index 7ed3326..0238172 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,4 +4,5 @@ export * from './utils' export * from './windows' export * from './conversion' export * from './addressGrouping' +export * from './repairPersisted' export * from './migrations' diff --git a/src/shared/migrations/index.ts b/src/shared/migrations/index.ts index aa7b1f6..c2ee99e 100644 --- a/src/shared/migrations/index.ts +++ b/src/shared/migrations/index.ts @@ -5,7 +5,8 @@ export { migrateServerRegistersState, migrateServerModeState, migrateBoolShape, - CURRENT_SERVER_ZUSTAND_VERSION + CURRENT_SERVER_ZUSTAND_VERSION, + SERVER_ZUSTAND_STORAGE_KEY } from './server/zustand' export { migrateClientConfig, CURRENT_CLIENT_CONFIG_VERSION } from './client/config' export { diff --git a/src/shared/migrations/server/zustand.ts b/src/shared/migrations/server/zustand.ts index 66ece1d..e9a84e7 100644 --- a/src/shared/migrations/server/zustand.ts +++ b/src/shared/migrations/server/zustand.ts @@ -2,6 +2,9 @@ import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } f export const CURRENT_SERVER_ZUSTAND_VERSION = 3 +/** Where the server store keeps its state. */ +export const SERVER_ZUSTAND_STORAGE_KEY = 'server.zustand' + interface V1ZustandServerState { serverRegisters?: Record littleEndian?: Record diff --git a/src/shared/repairPersisted.ts b/src/shared/repairPersisted.ts new file mode 100644 index 0000000..8715e6e --- /dev/null +++ b/src/shared/repairPersisted.ts @@ -0,0 +1,132 @@ +import { z } from 'zod' + +/** What a store lost on the way in, and why, so the user can be told both. */ +export interface ConfigReset { + /** The fields that failed, by name. */ + fields: string[] + /** The persisted blob was written by a version this code does not know. */ + savedByNewerVersion: boolean +} + +/** What a repair found, and what it had to give up to get there. */ +export interface Repair { + state: T + /** Undefined when every field parsed, which is the ordinary case. */ + reset: ConfigReset | undefined +} + +/** + * Keeps the fields of a persisted state that parse and defaults the rest. + * + * `persist` merges shallowly, so one corrupt top-level field replaces its whole + * default sub-object rather than being filled in, and a check over the whole + * object then fails on the merged result. Checking the whole object is also how + * that failure is answered: `clearStorage` takes the siblings that were fine, + * and a register mapping built by hand goes with them. + * + * A state saved by a newer version reaches this the same way, because the + * fields it still shares with this one are the fields worth keeping. + */ +export function repairPersisted( + schema: z.ZodObject, + state: unknown, + /** The store's initial state. Only the fields the schema names are read. */ + defaults: object, + savedByNewerVersion = false +): Repair>> { + const repaired: Record = {} + const resetFields: string[] = [] + + // A state that is not an object shares no field with the schema, so every + // one of them resets and the caller reports the lot. + const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null ? (value as Record) : {} + const fields = asRecord(state) + const fallback = asRecord(defaults) + + for (const [key, fieldSchema] of Object.entries(schema.shape)) { + const result = fieldSchema.safeParse(fields[key]) + if (result.success) { + repaired[key] = result.data + continue + } + repaired[key] = fallback[key] + resetFields.push(key) + } + + const nothingLost = resetFields.length === 0 && !savedByNewerVersion + return { + state: repaired as z.infer>, + reset: nothingLost ? undefined : { fields: resetFields, savedByNewerVersion } + } +} + +/** + * What each persisted field is called on screen. A field with no entry is named + * as it is stored, which is worth more in a bug report than a guess. + */ +const FIELD_LABELS: Record = { + connectionConfig: 'the connection settings', + registerConfig: 'the register settings', + registerMapping: 'the register mapping', + name: 'the name', + port: 'the ports', + selectedUuid: 'the selected server', + uuids: 'the server list', + serverRegisters: 'the registers', + usedAddresses: 'the used addresses', + unitId: 'the unit ids', + littleEndian: 'the endianness', + serverMode: 'the server mode', + serialConfig: 'the serial settings' +} + +const listFields = (fields: string[]): string => { + const labelled = fields.map((field) => FIELD_LABELS[field] ?? `\`${field}\``) + if (labelled.length === 1) return labelled[0] + return `${labelled.slice(0, -1).join(', ')} and ${labelled[labelled.length - 1]}` +} + +/** + * What to tell the user about a config that did not come in whole. + * + * Naming the fields is the point: "your configuration was reset" leaves the + * reader to work out whether the mapping they built by hand is still there. + */ +export function resetMessage(store: 'Client' | 'Server', reset: ConfigReset): string { + const kept = 'Everything else was kept.' + + if (!reset.savedByNewerVersion) { + return `${store} configuration: ${listFields(reset.fields)} could not be read and ${ + reset.fields.length === 1 ? 'was' : 'were' + } reset. ${kept}` + } + + if (reset.fields.length === 0) { + return `${store} configuration was saved by a newer version of Modbux and was read in full.` + } + + return `${store} configuration was saved by a newer version of Modbux. ${listFields( + reset.fields + )} did not come across and ${reset.fields.length === 1 ? 'was' : 'were'} reset. ${kept}` +} + +/** + * Puts the unreadable blob somewhere it can be sent in with a bug report. + * + * The reset is what makes the app usable again, and it is also what destroys + * the evidence. A copy under a key nothing reads costs the bytes it holds. + */ +export function keepCorrupt( + storage: Pick, + key: string, + now: () => number = Date.now +): void { + try { + const blob = storage.getItem(key) + if (blob === null) return + storage.setItem(`${key}.corrupt-${now()}`, blob) + } catch { + // Storage that cannot be read holds nothing worth keeping either. + } +} From f339d938056dc3586be22d5c3550adb9f3fc1163 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 3 Sep 2026 09:58:39 +0200 Subject: [PATCH 46/72] fix: keep the listener the server already has Opening the server in its own window dropped every connected master. The second window loads the same renderer entry, so `server.zustand.ts` runs its module-scope `init()` again, and in TCP mode that calls `createServer` per uuid. `createServer` closed the existing `ServerTCP` and bound a new one, and `ServerTCP.close` destroys every socket in `modbus.socks`. Measured in the running app with a raw socket on 502: `["connected","FIN","closed hadError=false"]`, and `["connected"]` after this change. `createServer` now answers with the port when `_servers` already holds a listener for that uuid on it. The rebind bought nothing: the vectors read `_serverData` when a request arrives, so a port change is the only reason to rebind and `setPort` owns that. `resetServer` therefore keeps its listener too, which is the same fix for clearing a server's registers. A refused bind was invisible in the same way. `new ServerTCP()` returns before `listen` has finished and reports a failure as a `serverError` event rather than a throw, so `createServer` returned a port while `_servers` held a listener that was never up. `_bindServer` waits for `initialized` or `serverError` before writing either map, with a timeout so a library that sends neither cannot hang the caller. `createServer` moves to the next port and `setPort` puts the server back where it was, saying so. `10-split-view` connects a socket before the window opens and asserts it survives. With the guard removed that test reports `Array [ "closed" ]`, and it takes two windows, so no unit test can see it. --- CHANGELOG.md | 9 + e2e/specs/01-main/10-split-view.spec.ts | 34 +++- .../modules/__tests__/modbusServer.test.ts | 101 +++++++++- src/main/modules/modbusServer.ts | 174 +++++++++++------- 4 files changed, 249 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08bc02..24ebfad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A write to a unit you never configured no longer creates one.** Any client on the network could turn an unused unit ID into one the server answers for, and nothing in the view said it had happened. +- **Opening the server in its own window no longer disconnects your clients.** + The second window restarted every running server, and anything connected was + dropped without a word. Clearing a server's registers did the same. Both now + leave the connection where it is. +- **A server that fails to start says so.** The port was reported back to the + view before the server had actually taken it, so a port claimed in the + meantime left you with a server that looked up and answered nothing. Modbux + now waits for the answer, moves to the next free port, and keeps a server on + its old port when a port change cannot be completed. ### Changed diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index ce12b80..2a6b353 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -1,11 +1,16 @@ import { test, expect } from '../../fixtures/electron-app' -import { navigateToHome } from '../../fixtures/helpers' +import { navigateToHome, navigateToServer } from '../../fixtures/helpers' import { type Page } from '@playwright/test' +import net from 'net' let serverPage: Page +let serverPort: number +let master: net.Socket +const masterEvents: string[] = [] test.describe.serial('Split View — Server in separate window', () => { test.afterAll(async ({ electronApp }) => { + master?.destroy() await electronApp.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows() .filter((w) => w.getTitle() === 'Server') @@ -13,10 +18,24 @@ test.describe.serial('Split View — Server in separate window', () => { }) }) - test('navigate to home', async ({ mainPage }) => { + test('read the server port, then navigate to home', async ({ mainPage }) => { + await navigateToServer(mainPage) + serverPort = Number( + await mainPage.getByTestId('server-port-input').locator('input').inputValue() + ) + expect(serverPort).toBeGreaterThan(0) await navigateToHome(mainPage) }) + test('a master connects to the server before the window opens', async () => { + master = net.connect(serverPort, '127.0.0.1') + await new Promise((resolve, reject) => { + master.once('connect', resolve) + master.once('error', reject) + }) + master.on('close', () => masterEvents.push('closed')) + }) + test('open split view from Home', async ({ electronApp, mainPage }) => { await mainPage.getByTestId('home-split-btn').click() serverPage = await electronApp.waitForEvent('window', { timeout: 10000 }) @@ -40,6 +59,17 @@ test.describe.serial('Split View — Server in separate window', () => { await expect(serverPage.getByTestId('section-holding_registers')).toBeVisible() }) + /** + * The second window runs the store module again, so `init` calls + * `createServer` for every uuid. When that rebound the listener, + * `ServerTCP.close` destroyed every open socket and a master outside Modbux + * got a FIN. Only the e2e suite can see this: it takes two windows. + */ + test('the master keeps its connection through the window opening', async () => { + expect(masterEvents).toEqual([]) + expect(master.readyState).toBe('open') + }) + test('close server window and verify main returns to normal', async ({ electronApp, mainPage diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 5811965..222c797 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -7,11 +7,31 @@ import type { IServiceVector } from 'modbus-serial/ServerTCP' // Each entry is either a boolean (true=available) or a string error code (e.g. 'EACCES', 'EADDRINUSE') let portAvailableResults: (boolean | string)[] = [] +// What each ServerTCP bind does, in order: true emits `initialized`, a string +// emits `serverError` with that code, false emits neither so the timeout runs. +let bindResults: (boolean | string)[] = [] + // Mock modbus-serial before importing ModbusServer vi.mock('modbus-serial', () => ({ // Must use `function` (not arrow) so it can be called with `new` ServerTCP: vi.fn().mockImplementation(function () { - return { close: vi.fn((cb: (err: Error | null) => void) => cb(null)) } + const handlers: Record void> = {} + const entry = bindResults.length > 0 ? bindResults.shift()! : true + + // The real constructor returns before `listen` finishes, so the event + // cannot fire until the caller has had the chance to register for it. + queueMicrotask(() => { + if (entry === true) handlers['initialized']?.() + else if (typeof entry === 'string') + handlers['serverError']?.(Object.assign(new Error(`listen ${entry}`), { code: entry })) + }) + + return { + on: vi.fn((event: string, handler: (err?: Error) => void) => { + handlers[event] = handler + }), + close: vi.fn((cb: (err: Error | null) => void) => cb(null)) + } }), ServerSerial: vi.fn().mockImplementation(function () { const handlers: Record void> = {} @@ -54,7 +74,8 @@ import { ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS, - GATEWAY_TARGET_FAILED + GATEWAY_TARGET_FAILED, + BIND_TIMEOUT_MS } from '../modbusServer' import { ServerTCP, ServerSerial } from 'modbus-serial' @@ -69,6 +90,7 @@ describe('ModbusServer', () => { beforeEach(() => { vi.useFakeTimers() portAvailableResults = [] + bindResults = [] vi.mocked(ServerTCP).mockClear() vi.mocked(ServerSerial).mockClear() windows = createMockWindows() @@ -892,6 +914,45 @@ describe('ModbusServer', () => { expect(messages.some((m) => m[1].message === 'Error closing server')).toBe(true) }) + it('leaves a listener that is already on the requested port alone', async () => { + await server.createServer({ uuid, port: 5020 }) + const firstInstance = vi.mocked(ServerTCP).mock.results[0].value + + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + expect(firstInstance.close).not.toHaveBeenCalled() + }) + + it('binds again on the same port after the TCP servers were stopped', async () => { + await server.createServer({ uuid, port: 5020 }) + await server.stopAllTcpServers() + vi.mocked(ServerTCP).mockClear() + + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + }) + + it('moves on when the bind fails after the probe passed', async () => { + bindResults = ['EADDRINUSE'] + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5021) + // The refused listener is closed rather than kept as if it were up. + expect(vi.mocked(ServerTCP).mock.results[0].value.close).toHaveBeenCalled() + }) + + it('moves on when the bind answers with neither event', async () => { + bindResults = [false] + const pending = server.createServer({ uuid, port: 5020 }) + await vi.advanceTimersByTimeAsync(BIND_TIMEOUT_MS) + + expect(await pending).toBe(5021) + }) + it('emits error and returns port when no port available after max attempts', async () => { portAvailableResults = new Array(10000).fill(false) const port = await server.createServer({ uuid, port: 5020 }) @@ -988,8 +1049,10 @@ describe('ModbusServer', () => { .filter((c) => c[0] === 'register_value') expect(newCalls.length).toBe(0) - // Server was recreated (ServerTCP called again) - expect(vi.mocked(ServerTCP).mock.calls.length).toBeGreaterThanOrEqual(2) + // The listener is left alone: a reset clears data the vectors read per + // request, and rebinding would drop whoever is connected. + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + expect(vi.mocked(ServerTCP).mock.results[0].value.close).not.toHaveBeenCalled() }) it('handles reset when no generators exist', async () => { @@ -1050,6 +1113,36 @@ describe('ModbusServer', () => { expect(messages.some((m) => m[1].message === 'Port 5020 is already in use')).toBe(true) }) + it('puts the server back on its old port when the new bind fails', async () => { + await server.createServer({ uuid, port: 5020 }) + ;(windows.send as ReturnType).mockClear() + vi.mocked(ServerTCP).mockClear() + + // The probe passes and the bind still fails, which is what happens when + // something takes the port between the two. + bindResults = ['EADDRINUSE', true] + const port = await server.setPort({ uuid, port: 5021 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls[1][1]).toEqual({ host: '0.0.0.0', port: 5020 }) + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Port 5021 is already in use')).toBe(true) + }) + + it('says so when the old port cannot be taken back either', async () => { + await server.createServer({ uuid, port: 5020 }) + ;(windows.send as ReturnType).mockClear() + + bindResults = ['EADDRINUSE', 'EADDRINUSE'] + const port = await server.setPort({ uuid, port: 5021 }) + + expect(port).toBe(5020) + const messages = getWindowCalls('backend_message') + expect( + messages.some((m) => m[1].message === 'The server could not be restarted on port 5020') + ).toBe(true) + }) + it('refuses port 0 and keeps the server where it is', async () => { await server.createServer({ uuid, port: 5020 }) ;(windows.send as ReturnType).mockClear() diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index f39a6ed..0d120af 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -67,6 +67,15 @@ export const BROADCAST_UNIT_ID: UnitIdString = '0' export const isPort = (port: number): boolean => Number.isInteger(port) && port >= 1 && port <= 65535 +/** + * How long a bind may take before the listener is treated as failed. + * + * `listen` answers with one of its two events, so nobody sits through this. It + * is here because a promise that neither event resolves would hang + * `createServer` and every caller behind it. + */ +export const BIND_TIMEOUT_MS = 5000 + type ServerDataUnitMap = Map type ValueGeneratorsUnitMap = Map @@ -236,50 +245,97 @@ export class ModbusServer { } /** - * Creates or recreates a Modbus TCP server for the given UUID and port. - * If a server already exists, it is closed and replaced. - * Also ensures value generator maps are initialized for all unitIds. + * Closes the listener registered for a UUID and forgets it, if there is one. + * + * `ServerTCP.close` destroys every socket in `modbus.socks`, so whoever was + * connected gets a FIN. + */ + private async _closeAndForget(uuid: string): Promise { + const existingServer = this._servers.get(uuid) + if (!existingServer) return + await new Promise((resolve) => { + existingServer.close((err) => { + if (err) + this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) + resolve() + }) + }) + this._servers.delete(uuid) + this._port.delete(uuid) + } + + /** + * Binds a TCP listener for a UUID and answers what the socket did. + * + * The constructor returns before `listen` has finished, and a refused bind + * arrives as a `serverError` carrying `EADDRINUSE` rather than as a throw. A + * constructor that returned is therefore no evidence of a listener, so the + * maps are written only once one of the two events has said so. + */ + private async _bindServer( + uuid: string, + port: number + ): Promise<{ ok: boolean; errorCode?: string }> { + const server = new ServerTCP(this._getVector(uuid, 'tcp'), { host: '0.0.0.0', port }) + + // // !Debug: Simulate connection loss by destroying incoming sockets after a delay. + // // - Short delay (e.g. 3000ms): triggers burst detection (reconnects fail within the 10s stability window) + // // - Long delay (e.g. 15000ms): allows stable connection, so the reconnect counter resets between drops + // const netServer = server['_server'] as net.Server + // netServer.on('connection', (sock) => { + // setTimeout(() => sock.destroy(), 15000) + // }) + + const result = await new Promise<{ ok: boolean; errorCode?: string }>((resolve) => { + const timer = setTimeout( + () => resolve({ ok: false, errorCode: 'ETIMEDOUT' }), + BIND_TIMEOUT_MS + ) + server.on('initialized', () => { + clearTimeout(timer) + resolve({ ok: true }) + }) + server.on('serverError', (err) => { + clearTimeout(timer) + resolve({ ok: false, errorCode: (err as NodeJS.ErrnoException | null)?.code }) + }) + }) + + if (!result.ok) { + server.close(() => {}) + return result + } + + this._servers.set(uuid, server) + this._port.set(uuid, port) + return result + } + + /** + * Creates a Modbus TCP server for the given UUID and port. * Returns the actual port used (may differ from requested if taken). + * + * A listener already on the requested port is the answer to this call. The + * vectors read `_serverData` when a request arrives rather than when they are + * built, so nothing about the register data needs a fresh listener, and a + * port change is `setPort`'s job. Rebinding drops every connected master, so + * it happens only where it buys something. */ public createServer = async ({ uuid, port }: CreateServerParams): Promise => { // A stored 0 from before this was refused would send the server to a port // nobody can name, so it starts where it would have started without one. let actualPort = port !== undefined && isPort(port) ? port : DEFAULT_MOBUS_PORT const maxAttempts = 10000 - let server: ServerTCP | undefined - const existingServer = this._servers.get(uuid) - if (existingServer) { - await new Promise((resolve) => { - existingServer.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - this._servers.delete(uuid) - this._port.delete(uuid) - } + if (this._servers.has(uuid) && this._port.get(uuid) === actualPort) return actualPort + + await this._closeAndForget(uuid) for (let i = 0; i < maxAttempts; i++) { const result = await this._isPortAvailable(actualPort) if (result.available) { - server = new ServerTCP(this._getVector(uuid, 'tcp'), { - host: '0.0.0.0', - port: actualPort - }) - - // // !Debug: Simulate connection loss by destroying incoming sockets after a delay. - // // - Short delay (e.g. 3000ms): triggers burst detection (reconnects fail within the 10s stability window) - // // - Long delay (e.g. 15000ms): allows stable connection, so the reconnect counter resets between drops - // const netServer = server['_server'] as net.Server - // netServer.on('connection', (sock) => { - // setTimeout(() => sock.destroy(), 15000) - // }) - - this._servers.set(uuid, server) - this._port.set(uuid, actualPort) - return actualPort + const bind = await this._bindServer(uuid, actualPort) + if (bind.ok) return actualPort } actualPort++ } @@ -300,20 +356,11 @@ export class ModbusServer { await this.stopRtuServer() } - const server = this._servers.get(uuid) - if (!server) { + if (!this._servers.has(uuid)) { this._emitMessage({ message: `No server found for UUID ${uuid}`, variant: 'error' }) return } - await new Promise((resolve) => { - server.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - this._servers.delete(uuid) - this._port.delete(uuid) + await this._closeAndForget(uuid) const unitIdGenerators = this._generatorMap.get(uuid) if (unitIdGenerators) { this._disposeAllGenerators(unitIdGenerators) @@ -322,8 +369,12 @@ export class ModbusServer { } /** - * Resets the server for a given UUID. - * Disposes all value generators, clears server data, and recreates the server. + * Resets the server for a given UUID: disposes its value generators and + * clears its register data. + * + * The vectors read `_serverData` per request, so the cleared data is what a + * master gets from the listener that is already up. `createServer` is called + * for the case where there is none, such as after a spell in RTU mode. */ public resetServer = async (uuid: string): Promise => { const unitIdGenerators = this._generatorMap.get(uuid) @@ -685,26 +736,23 @@ export class ModbusServer { } // Port is confirmed available — now close the existing server - const existingServer = this._servers.get(uuid) - if (existingServer) { - await new Promise((resolve) => { - existingServer.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) + await this._closeAndForget(uuid) + + const bind = await this._bindServer(uuid, requestedPort) + if (bind.ok) return requestedPort + + // The probe above passed and the bind still failed, so something took the + // port in between. The old listener is already gone, so put it back rather + // than leave the uuid with none. + this._emitMessage({ message: `Port ${requestedPort} is already in use`, variant: 'error' }) + const restored = await this._bindServer(uuid, currentPort) + if (!restored.ok) { + this._emitMessage({ + message: `The server could not be restarted on port ${currentPort}`, + variant: 'error' }) - this._servers.delete(uuid) - this._port.delete(uuid) } - - const server = new ServerTCP(this._getVector(uuid, 'tcp'), { - host: '0.0.0.0', - port: requestedPort - }) - this._servers.set(uuid, server) - this._port.set(uuid, requestedPort) - return requestedPort + return currentPort } // ------------------------------------------------------------------------- From 4281bf393bf14777bcd8cbd51e0453550c8803cc Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 3 Sep 2026 14:00:17 +0200 Subject: [PATCH 47/72] fix: the coil dialog opens with what the device answered FC15 sends every coil from the opened address to the end of the range, and the dialog seeded that list with Array(length).fill(false) without ever reading registerData. Writing one coil therefore switched off every coil above it that the user had not touched. seedCoils fills the list from the rows the grid holds, so what is on screen is what goes back out. FC5 is unaffected: main takes value[0], the coil at the opened address. The coil buttons now carry aria-pressed, which is what the e2e helper reads to decide whether a click is needed. Without it the helper clicked whenever the caller asked for TRUE, which now means it would send the opposite for a coil the device already had on. Verified with a mutation in each direction. seedCoils returning false for every row turns four of the five unit tests red, and indexing from zero instead of from the first address turns two red. Putting Array(length).fill(false) back in the component and rebuilding turns the new e2e test red on coil 6, with the 22 tests before it still green. --- CHANGELOG.md | 5 +++ e2e/fixtures/helpers.ts | 39 +++++++++++++++-- e2e/specs/01-main/09-write-operations.spec.ts | 33 +++++++++++++++ .../columns/WriteModal/WriteModal.tsx | 10 +++-- .../__tests__/writeModal.zustand.test.ts | 42 +++++++++++++++++++ .../columns/WriteModal/writeModal.zustand.ts | 26 +++++++++++- 6 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/writeModal.zustand.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ebfad..9c38ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The second window restarted every running server, and anything connected was dropped without a word. Clearing a server's registers did the same. Both now leave the connection where it is. +- **Writing one coil no longer switches off the ones beside it.** The write + dialog started every coil at off, and a write of multiple coils sends every + coil from the one you opened to the end of the range, so everything you had + not touched went out as off. The dialog now opens showing what the last read + returned, which is what goes back to the device. - **A server that fails to start says so.** The port was reported back to the view before the server had actually taken it, so a port claimed in the meantime left you with a server that looked up and answered nothing. Modbux diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts index d8b0e6d..2138714 100644 --- a/e2e/fixtures/helpers.ts +++ b/e2e/fixtures/helpers.ts @@ -575,9 +575,7 @@ export async function writeCoil(p: Page, address: number, state: boolean): Promi await p.getByTestId(`write-action-${address}`).click() await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() - if (state) { - await p.getByTestId(`write-coil-${address}-select-btn`).click() - } + await setCoilButton(p, address, state) await p.getByTestId('write-fc5-btn').click() await p.getByTestId('write-submit-btn').click() @@ -587,6 +585,41 @@ export async function writeCoil(p: Page, address: number, state: boolean): Promi await expect(p.getByTestId(`write-coil-${address}-select-btn`)).not.toBeVisible() } +/** + * Click a coil button only when it is not already showing the state you want. + * + * The dialog opens with what the grid holds, so a coil the device already + * answered TRUE for opens pressed, and clicking it would send the opposite of + * what the caller asked for. + */ +async function setCoilButton(p: Page, address: number, state: boolean): Promise { + const coil = p.getByTestId(`write-coil-${address}-select-btn`) + if ((await coil.getAttribute('aria-pressed')) !== String(state)) await coil.click() +} + +/** Open the write dialog on `address`, set the given coils, and send FC15 */ +export async function writeCoilsFc15( + p: Page, + address: number, + states: Record +): Promise { + await p.getByTestId(`write-action-${address}`).click() + await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() + + await p.getByTestId('write-fc15-btn').click() + + for (const [coilAddress, state] of Object.entries(states)) { + await expect(p.getByTestId(`write-coil-${coilAddress}-select-btn`)).toBeVisible() + await setCoilButton(p, Number(coilAddress), state) + } + + await p.getByTestId('write-submit-btn').click() + + // Close the dialog + await p.keyboard.press('Escape') + await expect(p.getByTestId(`write-coil-${address}-select-btn`)).not.toBeVisible() +} + /** Ensure a server panel is in the desired collapse state */ export async function setServerPanelCollapsed( p: Page, diff --git a/e2e/specs/01-main/09-write-operations.spec.ts b/e2e/specs/01-main/09-write-operations.spec.ts index 46d2568..3f1a7e8 100644 --- a/e2e/specs/01-main/09-write-operations.spec.ts +++ b/e2e/specs/01-main/09-write-operations.spec.ts @@ -12,6 +12,7 @@ import { cleanServerState, writeRegister, writeCoil, + writeCoilsFc15, expectCell, expectCellContains } from '../../fixtures/helpers' @@ -185,6 +186,38 @@ test.describe.serial('Write Operations', () => { await expectCell(mainPage, 0, 'bit', 'FALSE') await clearData(mainPage) }) + + test('set a coil above the one FC15 will be opened on', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await writeCoil(mainPage, 6, true) + }) + + test('verify the neighbour is TRUE before the FC15 write', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 6, 'bit', 'TRUE') + }) + + /** + * FC15 sends every coil from the opened address to the end of the range, so + * coil 6 is in the frame a write opened on coil 5 puts on the wire. + */ + test('write coil 5 via FC15', async ({ mainPage }) => { + await writeCoilsFc15(mainPage, 5, { 5: true }) + }) + + test('verify FC15 wrote coil 5 and left coil 6 alone', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 5, 'bit', 'TRUE') + await expectCell(mainPage, 6, 'bit', 'TRUE') + }) + + test('put both coils back to FALSE', async ({ mainPage }) => { + await writeCoilsFc15(mainPage, 5, { 5: false, 6: false }) + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 5, 'bit', 'FALSE') + await expectCell(mainPage, 6, 'bit', 'FALSE') + await clearData(mainPage) + }) }) // ─── Cleanup ─────────────────────────────────────────────────────── diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index 2fd7a75..d20d4c0 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -12,11 +12,12 @@ import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSele import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { useMinMaxInteger } from '@renderer/hooks' import { BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { IMaskInput, IMask } from 'react-imask' -import { useValueInputZustand } from './writeModal.zustand' +import { seedCoils, useValueInputZustand } from './writeModal.zustand' const ValueInputForward = forwardRef((props, ref) => { const { set, ...other } = props @@ -220,6 +221,7 @@ const CoilButton = meme(({ address, index }: CoilButtonProps) => {