From 2f4659a8fdf916eb85194bc2ea0a48ecafe961fe Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 11:36:25 +0200 Subject: [PATCH 01/91] fix: keep a client setting the boundary refused Every setter in `client.zustand.ts` wrote the store and called main afterwards, so a payload the schemas refuse left the two sides on different values with one snackbar to say so, and C7 removed the channel that could read main's config back. `1,5` typed into a mask field is `NaN`, `UnitIdSchema` refuses it, `JSON.stringify` persisted it as `null`, and the next launch reset the connection settings to their defaults. `update_connection_config` and `update_register_config` now answer whether the payload was taken, and each setter writes only after that answer. `server.zustand.ts setPort` already carried the pattern. An invalid host or length is still written locally and never sent, because those two fields read their text out of the store. The three copies of the reset-the-rows condition became `clearRegisterDataWhenIdle`. The four test stubs answered `undefined` to every channel, which under this change refuses every payload, and one of them put that `undefined` in `clientState`. They are one helper now, answering the two config channels through the schemas `main/ipc.ts` guards them with. Measured in the built app: a round trip has a median of 0.2 ms and a maximum of 0.5 ms idle, 1.4 ms while the client polls a connected server, against 2.5 ms between two keystrokes typed as fast as Playwright types them. No field waits on the answer. lint 0, typecheck 0, `yarn test` 45 files and 945 passed, `yarn test:e2e` 582 passed. --- CHANGELOG.md | 6 + src/main/ipc.ts | 10 +- .../MenuButton/__tests__/MenuOptions.test.tsx | 16 +- .../context/__tests__/corruptedConfig.test.ts | 13 +- .../context/__tests__/persistedParity.test.ts | 13 +- .../persistedRegisterAddress.test.ts | 13 +- .../__tests__/setRegisterValue.test.ts | 13 +- .../src/context/__tests__/stubRenderer.ts | 57 +++ .../__tests__/writeAfterAccept.test.ts | 104 +++++ src/renderer/src/context/client.zustand.ts | 363 +++++++++++------- .../src/context/client.zustand.types.ts | 38 +- src/shared/types/ipc.ts | 14 +- 12 files changed, 437 insertions(+), 223 deletions(-) create mode 100644 src/renderer/src/context/__tests__/stubRenderer.ts create mode 100644 src/renderer/src/context/__tests__/writeAfterAccept.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fab0e7..d7fe6bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 occurred in the main process" dialog and no window at all. Your client stays connected and polling the whole time, and the server keeps the connections it has, which was already true and is now covered by a test. +- **A comma typed into a client field no longer resets those settings at the + next start.** The unit ID, port, address and length fields take a comma as a + decimal separator, and a value carrying one was stored as no number at all. + The next start could not read it back and put the connection or the register + settings back to their defaults. Modbux now keeps the value the field had, and + what your client sends and what the field shows can no longer drift apart. ### Changed diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f409bd20..6f15e731 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -103,14 +103,20 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { // Connection config ipcHandle( 'update_connection_config', - (_, config) => state.updateConnectionConfig(config), + (_, config) => { + state.updateConnectionConfig(config) + return true + }, ConnectionConfigSchema.deepPartial() ) // Register config ipcHandle( 'update_register_config', - (_, config) => state.updateRegisterConfig(config), + (_, config) => { + state.updateRegisterConfig(config) + return true + }, RegisterConfigSchema.deepPartial() ) 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 558dde06..8613f8f0 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 @@ -13,7 +13,7 @@ vi.hoisted(() => { w.api = new Proxy({}, { get: () => () => Promise.resolve(undefined) }) }) -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { useClientZustand } from '@renderer/context/client.zustand' import MenuRegisterOptions from '../MenuRegisterOptions/MenuRegisterOptions' import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOptions' @@ -28,7 +28,9 @@ const seed = (partial: Parameters[0]): void => } beforeEach(() => { - window.api = { updateConnectionConfig: vi.fn() } as never + // The setter writes what main accepted, so a stub answering `undefined` + // refuses every payload and the store never moves. + window.api = { updateConnectionConfig: vi.fn(() => Promise.resolve(true)) } as never useClientZustand.setState({ ready: true, clientState: { @@ -99,7 +101,7 @@ describe('MenuConnectionOptions', () => { expect(container.querySelectorAll('hr')).toHaveLength(0) }) - it('toggles the protocol between TCP and RTU-over-TCP via the checkbox', () => { + it('toggles the protocol between TCP and RTU-over-TCP via the checkbox', async () => { seed({ connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } }) @@ -107,11 +109,15 @@ describe('MenuConnectionOptions', () => { render() fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') + await waitFor(() => + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') + ) expect(window.api.updateConnectionConfig).toHaveBeenCalledWith({ protocol: 'ModbusRtuOverTcp' }) fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') + await waitFor(() => + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') + ) }) it('disables the checkbox while not disconnected', () => { diff --git a/src/renderer/src/context/__tests__/corruptedConfig.test.ts b/src/renderer/src/context/__tests__/corruptedConfig.test.ts index 9ed76e83..6b438a78 100644 --- a/src/renderer/src/context/__tests__/corruptedConfig.test.ts +++ b/src/renderer/src/context/__tests__/corruptedConfig.test.ts @@ -7,18 +7,7 @@ // window came up blank with no UI left to clear the bad config from. import { beforeEach, describe, expect, it, vi } from 'vitest' import { CURRENT_CLIENT_ZUSTAND_VERSION } from '@shared' - -const stubRenderer = (): void => { - const w = window as unknown as { electron: unknown; api: unknown } - w.electron = { - ipcRenderer: { - on: (): (() => void) => (): void => {}, - send: (): void => {}, - invoke: async (): Promise => undefined - } - } - w.api = new Proxy({}, { get: () => (): Promise => Promise.resolve(undefined) }) -} +import { stubRenderer } from './stubRenderer' beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/context/__tests__/persistedParity.test.ts b/src/renderer/src/context/__tests__/persistedParity.test.ts index cb5c82c7..e4764057 100644 --- a/src/renderer/src/context/__tests__/persistedParity.test.ts +++ b/src/renderer/src/context/__tests__/persistedParity.test.ts @@ -5,18 +5,7 @@ // such a blob, because the repair is only reached if `migrate` is wired to call // it and the version constant has moved past the blob's own. import { beforeEach, describe, expect, it, vi } from 'vitest' - -const stubRenderer = (): void => { - const w = window as unknown as { electron: unknown; api: unknown } - w.electron = { - ipcRenderer: { - on: (): (() => void) => (): void => {}, - send: (): void => {}, - invoke: async (): Promise => undefined - } - } - w.api = new Proxy({}, { get: () => (): Promise => Promise.resolve(undefined) }) -} +import { stubRenderer } from './stubRenderer' beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts b/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts index 688fdba7..a4de6bd1 100644 --- a/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts +++ b/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts @@ -5,18 +5,7 @@ // because the drop is only reached if `migrate` is wired to call it and the // version constant has moved past the blob's own. import { beforeEach, describe, expect, it, vi } from 'vitest' - -const stubRenderer = (): void => { - const w = window as unknown as { electron: unknown; api: unknown } - w.electron = { - ipcRenderer: { - on: (): (() => void) => (): void => {}, - send: (): void => {}, - invoke: async (): Promise => undefined - } - } - w.api = new Proxy({}, { get: () => (): Promise => Promise.resolve(undefined) }) -} +import { stubRenderer } from './stubRenderer' const register = (address: number): Record => ({ value: 1, diff --git a/src/renderer/src/context/__tests__/setRegisterValue.test.ts b/src/renderer/src/context/__tests__/setRegisterValue.test.ts index e30c7506..610a012e 100644 --- a/src/renderer/src/context/__tests__/setRegisterValue.test.ts +++ b/src/renderer/src/context/__tests__/setRegisterValue.test.ts @@ -6,18 +6,7 @@ // that is gone. import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAIN_SERVER_UUID, ServerRegister, ServerRegisters } from '@shared' - -const stubRenderer = (): void => { - const w = window as unknown as { electron: unknown; api: unknown } - w.electron = { - ipcRenderer: { - on: (): (() => void) => (): void => {}, - send: (): void => {}, - invoke: async (): Promise => undefined - } - } - w.api = new Proxy({}, { get: () => (): Promise => Promise.resolve(undefined) }) -} +import { stubRenderer } from './stubRenderer' beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts new file mode 100644 index 00000000..14d2defb --- /dev/null +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -0,0 +1,57 @@ +import { ClientState, ConnectionConfigSchema, RegisterConfigSchema } from '@shared' + +/** + * The `window` a store finds when it is imported, answering like the boundary. + * + * The stores call IPC at module scope and from every setter, so a blanket + * `undefined` here is a mock that lies: the config setters write only when main + * accepts, and under that stub none of them would ever write. These two parse + * with the schemas `main/ipc.ts` guards the channels with, and answer what + * `createIpcHandle` answers. + */ +const answerConfig = (schema: { + safeParse: (value: unknown) => { success: boolean } +}): ((payload: unknown) => Promise) => { + return (payload: unknown): Promise => + Promise.resolve(schema.safeParse(payload).success ? true : undefined) +} + +const disconnected: ClientState = { + connectState: 'disconnected', + polling: false, + scanningUnitIds: false, + scanningRegisters: false +} + +/** + * A channel whose type cannot answer `undefined` answers something here. + * + * `init` writes what `get_client_state` hands back, so a stub answering + * `undefined` puts that in the store and every setter reading `connectState` + * throws a line later. + */ +const answers: Record Promise> = { + updateConnectionConfig: answerConfig(ConnectionConfigSchema.deepPartial()), + updateRegisterConfig: answerConfig(RegisterConfigSchema.deepPartial()), + getClientState: () => Promise.resolve(disconnected), + getAppVersion: () => Promise.resolve('0.0.0-test'), + listSerialPorts: () => Promise.resolve([]) +} + +export const stubRenderer = (): void => { + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { + on: (): (() => void) => (): void => {}, + send: (): void => {}, + invoke: async (): Promise => undefined + } + } + w.api = new Proxy( + {}, + { + get: (_target, method: string): ((payload: unknown) => Promise) => + answers[method] ?? ((): Promise => Promise.resolve(undefined)) + } + ) +} diff --git a/src/renderer/src/context/__tests__/writeAfterAccept.test.ts b/src/renderer/src/context/__tests__/writeAfterAccept.test.ts new file mode 100644 index 00000000..d8681d04 --- /dev/null +++ b/src/renderer/src/context/__tests__/writeAfterAccept.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +// +// The mask fields have a radix of ',' and map '.' onto it, so `1,5` typed into +// the unit id is `NaN` by the time a setter sees it. The store used to write it +// and call main afterwards, `JSON.stringify` turned it into `null`, and the next +// launch refused the whole persisted config and reset it. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RegisterData } from '@shared' +import { stubRenderer } from './stubRenderer' + +const load = async (): Promise<{ + clientZustand: typeof import('../client.zustand') + dataZustand: typeof import('../data.zustand') +}> => ({ + clientZustand: await import('../client.zustand'), + dataZustand: await import('../data.zustand') +}) + +const rows: RegisterData[] = [ + { + id: 0, + buffer: new Uint8Array([0, 1]), + hex: '0001', + words: undefined, + bit: false, + isScanned: false + } +] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +describe('a payload the boundary refuses', () => { + it('leaves the unit id the store had', async () => { + const { clientZustand } = await load() + const { useClientZustand } = clientZustand + + await useClientZustand.getState().setUnitId('7') + await useClientZustand.getState().setUnitId('1,5') + + expect(useClientZustand.getState().connectionConfig.unitId).toBe(7) + }) + + it('leaves the address and the rows read at it', async () => { + const { clientZustand, dataZustand } = await load() + const { useClientZustand } = clientZustand + dataZustand.useDataZustand.getState().setRegisterData(rows) + + await useClientZustand.getState().setAddress('40') + dataZustand.useDataZustand.getState().setRegisterData(rows) + await useClientZustand.getState().setAddress('4,0') + + expect(useClientZustand.getState().registerConfig.address).toBe(40) + expect(dataZustand.useDataZustand.getState().registerData).toHaveLength(1) + }) +}) + +describe('a payload the boundary takes', () => { + it('writes the unit id', async () => { + const { clientZustand } = await load() + + await clientZustand.useClientZustand.getState().setUnitId('7') + + expect(clientZustand.useClientZustand.getState().connectionConfig.unitId).toBe(7) + }) + + it('writes the address and drops the rows read at the old one', async () => { + const { clientZustand, dataZustand } = await load() + dataZustand.useDataZustand.getState().setRegisterData(rows) + + await clientZustand.useClientZustand.getState().setAddress('40') + + expect(clientZustand.useClientZustand.getState().registerConfig.address).toBe(40) + expect(dataZustand.useDataZustand.getState().registerData).toEqual([]) + }) +}) + +describe('a value the field marks invalid', () => { + /** + * The host and the length are the two the field reads back out of the store, + * so they are written without being sent and the input keeps what was typed. + */ + it('keeps the half-typed host in the store and marks it invalid', async () => { + const { clientZustand } = await load() + const { useClientZustand } = clientZustand + + await useClientZustand.getState().setHost('192.168.', false) + + expect(useClientZustand.getState().connectionConfig.tcp.host).toBe('192.168.') + expect(useClientZustand.getState().valid.host).toBe(false) + }) + + it('keeps an empty length in the store and marks it invalid', async () => { + const { clientZustand } = await load() + const { useClientZustand } = clientZustand + + await useClientZustand.getState().setLength('', false) + + expect(useClientZustand.getState().valid.lenght).toBe(false) + }) +}) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 0d8fffc1..0e3d309f 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -51,6 +51,19 @@ export const flushRegisterMappingToMain = (): void => { window.api.setRegisterMapping(useClientZustand.getState().registerMapping) } +/** + * Drop the rows on screen, unless something is about to replace them. + * + * Address, length and type each change what a read asks for, so the rows from + * the last read answer a different question. Polling puts new ones there on its + * own, and so does read configuration. + */ +const clearRegisterDataWhenIdle = (): void => { + const { clientState, readConfiguration } = useClientZustand.getState() + if (clientState.polling || readConfiguration) return + useDataZustand.getState().setRegisterData([]) +} + /** * Whether a `client_state` push has landed since the module was evaluated. * @@ -189,178 +202,236 @@ export const useClientZustand = create< // // // Protocol - setProtocol: (protocol) => - set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + // + // Every setter below sends first and writes only what main took, so a + // payload the schemas refuse leaves both sides on the value they had. + // `1,5` in a mask field is `NaN`, `UnitIdSchema` refuses it, and a store + // that wrote it persisted `null` and lost the whole config on the next + // launch. A round trip is shorter than the gap between two keystrokes, so + // no field waits on the answer. + setProtocol: async (protocol) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + if (!(await window.api.updateConnectionConfig({ protocol }))) return + set((state) => { state.connectionConfig.protocol = protocol - window.api.updateConnectionConfig({ protocol }) - }), + }) + }, // // // TCP - setPort: (port) => - set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + setPort: async (port) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + const newPort = Number(port) + if (!(await window.api.updateConnectionConfig({ tcp: { options: { port: newPort } } }))) + return - const newPort = Number(port) - state.connectionConfig.tcp.options.port = newPort - window.api.updateConnectionConfig({ tcp: { options: { port: newPort } } }) - }), - setHost: (host, valid) => set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + state.connectionConfig.tcp.options.port = newPort + }) + }, + setHost: async (host, valid) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + // The field reads its text from the store, so an invalid host is kept + // here and never sent. What the boundary never sees needs no answer. + if (!valid) { + set((state) => { + state.valid.host = false + state.connectionConfig.tcp.host = host + }) + return + } + + if (!(await window.api.updateConnectionConfig({ tcp: { host } }))) return - state.valid.host = !!valid + set((state) => { + state.valid.host = true state.connectionConfig.tcp.host = host - if (!valid) return - window.api.updateConnectionConfig({ tcp: { host } }) - }), + }) + }, // // // RTU - setCom: (com, valid) => - set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + setCom: async (com, valid) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + if (!(await window.api.updateConnectionConfig({ rtu: { com } }))) return + set((state) => { state.valid.com = !!valid state.connectionConfig.rtu.com = com - window.api.updateConnectionConfig({ rtu: { com } }) - }), - setBaudRate: (baudRate) => - set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + }) + }, + setBaudRate: async (baudRate) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + if (!(await window.api.updateConnectionConfig({ rtu: { options: { baudRate } } }))) return - state.connectionConfig.rtu.options.baudRate = baudRate - window.api.updateConnectionConfig({ rtu: { options: { baudRate } } }) - }), - setParity: (parity) => set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + state.connectionConfig.rtu.options.baudRate = baudRate + }) + }, + setParity: async (parity) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + if (!(await window.api.updateConnectionConfig({ rtu: { options: { parity } } }))) return - state.connectionConfig.rtu.options.parity = parity - window.api.updateConnectionConfig({ rtu: { options: { parity } } }) - }), - setDataBits: (dataBits) => set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + state.connectionConfig.rtu.options.parity = parity + }) + }, + setDataBits: async (dataBits) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + const newDataBits = Number(dataBits) + if ( + !(await window.api.updateConnectionConfig({ + rtu: { options: { dataBits: newDataBits } } + })) + ) + return - const newDataBits = Number(dataBits) - state.connectionConfig.rtu.options.dataBits = newDataBits - window.api.updateConnectionConfig({ rtu: { options: { dataBits: newDataBits } } }) - }), - setStopBits: (stopBits) => set((state) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + state.connectionConfig.rtu.options.dataBits = newDataBits + }) + }, + setStopBits: async (stopBits) => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + const newStopBits = Number(stopBits) + if ( + !(await window.api.updateConnectionConfig({ + rtu: { options: { stopBits: newStopBits } } + })) + ) + return - const newStopBits = Number(stopBits) + set((state) => { state.connectionConfig.rtu.options.stopBits = newStopBits - window.api.updateConnectionConfig({ rtu: { options: { stopBits: newStopBits } } }) - }), + }) + }, // // // Layout configuration settings - setAddressBase: (addressBase) => + setAddressBase: async (addressBase) => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ addressBase }))) return + set((state) => { - if (!get().ready) return state.registerConfig.addressBase = addressBase - window.api.updateRegisterConfig({ addressBase }) - }), - setShow64BitValues: (show64BitValues) => + }) + }, + setShow64BitValues: async (show64BitValues) => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ show64BitValues }))) return + set((state) => { - if (!get().ready) return state.registerConfig.show64BitValues = show64BitValues - window.api.updateRegisterConfig({ show64BitValues }) - }), - setAdvancedMode: (advancedMode) => + }) + }, + setAdvancedMode: async (advancedMode) => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ advancedMode }))) return + set((state) => { - if (!get().ready) return state.registerConfig.advancedMode = advancedMode - window.api.updateRegisterConfig({ advancedMode }) - }), + }) + }, // Addressing - setUnitId: (unitId) => + setUnitId: async (unitId) => { + if (!get().ready) return + + const newUnitId = Number(unitId) + if (!(await window.api.updateConnectionConfig({ unitId: newUnitId }))) return + set((state) => { - if (!get().ready) return - const newUnitId = Number(unitId) state.connectionConfig.unitId = newUnitId - window.api.updateConnectionConfig({ unitId: newUnitId }) - }), - setAddress: (address) => - set((state) => { - const currentState = get() - if (!currentState.ready) return + }) + }, + setAddress: async (address) => { + const currentState = get() + if (!currentState.ready) return - const newAddress = Number(address) - if (newAddress === currentState.registerConfig.address) return + const newAddress = Number(address) + if (newAddress === currentState.registerConfig.address) return - state.registerConfig.address = newAddress - window.api.updateRegisterConfig({ address: newAddress }) + if (!(await window.api.updateRegisterConfig({ address: newAddress }))) return - // Reset registerdata when not polling and not in readConfiguration mode - if (!currentState.clientState.polling && !currentState.readConfiguration) - useDataZustand.getState().setRegisterData([]) - }), - setLength: (length, valid) => set((state) => { - const currentState = get() - if (!currentState.ready) return + state.registerConfig.address = newAddress + }) + clearRegisterDataWhenIdle() + }, + setLength: async (length, valid) => { + const currentState = get() + if (!currentState.ready) return + + const newLength = Number(length) + + // The field reads its length from the store, so an empty or zero one is + // kept here and never sent. + if (!valid) { + set((state) => { + state.valid.lenght = false + state.registerConfig.length = newLength + }) + return + } - state.valid.lenght = !!valid - const newLength = Number(length) - state.registerConfig.length = newLength - if (!valid) return - window.api.updateRegisterConfig({ length: newLength }) + if (!(await window.api.updateRegisterConfig({ length: newLength }))) return - // Reset registerdata when not polling and not in readConfiguration mode - if (!currentState.clientState.polling && !currentState.readConfiguration) - useDataZustand.getState().setRegisterData([]) - }), - setType: (type) => set((state) => { - const currentState = get() - if (!currentState.ready) return + state.valid.lenght = true + state.registerConfig.length = newLength + }) + clearRegisterDataWhenIdle() + }, + setType: async (type) => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ type }))) return + set((state) => { state.registerConfig.type = type - window.api.updateRegisterConfig({ type }) + }) + clearRegisterDataWhenIdle() + }, + setLittleEndian: async (littleEndian) => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ littleEndian }))) return - // Reset registerdata when not polling and not in readConfiguration mode - if (!currentState.clientState.polling && !currentState.readConfiguration) - useDataZustand.getState().setRegisterData([]) - }), - setLittleEndian: (littleEndian) => set((state) => { - const currentState = get() - if (!currentState.ready) return state.registerConfig.littleEndian = littleEndian - window.api.updateRegisterConfig({ littleEndian }) - - // The rows on screen were read in the other word order, and the - // conversion happens where the reading does, so they stay that way - // until the next read. Ask for one, unless something else is about - // to: polling reads on its own, and a scan is filling the list. - const { connectState, polling, scanningRegisters } = currentState.clientState - const hasRows = useDataZustand.getState().registerData.length > 0 - if (connectState === 'connected' && !polling && !scanningRegisters && hasRows) { - window.api.read() - } - }), + }) + + // The rows on screen were read in the other word order, and the + // conversion happens where the reading does, so they stay that way + // until the next read. Ask for one, unless something else is about + // to: polling reads on its own, and a scan is filling the list. + const { connectState, polling, scanningRegisters } = get().clientState + const hasRows = useDataZustand.getState().registerData.length > 0 + if (connectState === 'connected' && !polling && !scanningRegisters && hasRows) { + window.api.read() + } + }, setReadConfiguration: (readConfiguration) => set((state) => { if (!get().ready) return @@ -368,32 +439,34 @@ export const useClientZustand = create< window.api.setReadConfiguration(readConfiguration) }), // Reading - setPollRate: (pollRate) => - set((state) => { - if (!get().ready) return + setPollRate: async (pollRate) => { + if (!get().ready) return - if (pollRate % 1000 !== 0 || pollRate < 1000 || pollRate > 10000) { - console.error( - 'Invalid poll rate. Must be a multiple of 1000 and between 1000 and 10000.' - ) - return - } + if (pollRate % 1000 !== 0 || pollRate < 1000 || pollRate > 10000) { + console.error('Invalid poll rate. Must be a multiple of 1000 and between 1000 and 10000.') + return + } + + if (!(await window.api.updateRegisterConfig({ pollRate }))) return - state.registerConfig.pollRate = pollRate - window.api.updateRegisterConfig({ pollRate }) - }), - setTimeout: (timeout) => set((state) => { - if (!get().ready) return + state.registerConfig.pollRate = pollRate + }) + }, + setTimeout: async (timeout) => { + if (!get().ready) return - if (timeout % 1000 !== 0 || timeout < 1000 || timeout > 10000) { - console.error('Invalid timeout. Must be a multiple of 1000 and between 1000 and 10000.') - return - } + if (timeout % 1000 !== 0 || timeout < 1000 || timeout > 10000) { + console.error('Invalid timeout. Must be a multiple of 1000 and between 1000 and 10000.') + return + } + if (!(await window.api.updateRegisterConfig({ timeout }))) return + + set((state) => { state.registerConfig.timeout = timeout - window.api.updateRegisterConfig({ timeout }) - }), + }) + }, // Transaction lastSuccessfulTransactionMillis: null, setLastSuccessfulTransactionMillis: (value) => diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 47632190..8ee28681 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -61,27 +61,27 @@ export type ClientZustand = { init: () => Promise // State setClientState: (clientState: ClientState) => void - // Configuration actions - setProtocol: (protocol: Protocol) => void - setPort: MaskSetFn - setHost: MaskSetFn - setUnitId: MaskSetFn - setAddress: MaskSetFn - setLength: MaskSetFn - setType: (type: RegisterType) => void - setCom: MaskSetFn - setBaudRate: (baudRate: ModbusBaudRate) => void - setParity: (parity: Parity) => void - setDataBits: (dataBits: SerialPortOptions['dataBits']) => void - setStopBits: (stopBits: SerialPortOptions['stopBits']) => void - setPollRate: (pollRate: number) => void - setTimeout: (timeout: number) => void - setLittleEndian: (littleEndian: boolean) => void + // Configuration actions, each one waiting on the boundary before it writes + setProtocol: (protocol: Protocol) => Promise + setPort: AsyncMaskSetFn + setHost: AsyncMaskSetFn + setUnitId: AsyncMaskSetFn + setAddress: AsyncMaskSetFn + setLength: AsyncMaskSetFn + setType: (type: RegisterType) => Promise + setCom: AsyncMaskSetFn + setBaudRate: (baudRate: ModbusBaudRate) => Promise + setParity: (parity: Parity) => Promise + setDataBits: (dataBits: SerialPortOptions['dataBits']) => Promise + setStopBits: (stopBits: SerialPortOptions['stopBits']) => Promise + setPollRate: (pollRate: number) => Promise + setTimeout: (timeout: number) => Promise + setLittleEndian: (littleEndian: boolean) => Promise // Layout configuration settings (i want them to be persistent) - setAddressBase: (addressBase: '0' | '1') => void - setAdvancedMode: (advancedMode: boolean) => void - setShow64BitValues: (show64BitValues: boolean) => void + setAddressBase: (addressBase: '0' | '1') => Promise + setAdvancedMode: (advancedMode: boolean) => Promise + setShow64BitValues: (show64BitValues: boolean) => Promise // Transaction setLastSuccessfulTransactionMillis: (value: number | null) => void diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index a4c7959e..5b2c5226 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -97,16 +97,22 @@ export type IpcChannel = (typeof IPC_CHANNELS)[number] * ! If you remove one, remove it here. No extras allowed. */ export interface IpcHandlerSpec { - /** Update the ConnectionConfig (DeepPartial) */ + /** + * Update the ConnectionConfig (DeepPartial), and say whether it was taken. + * + * `true` is the whole answer, because main merges exactly the payload it was + * given. A refusal answers `undefined`, which is what `createIpcHandle` sends + * back, and the store keeps what it had. + */ ['update_connection_config']: { args: [DeepPartial] - return: void + return: true | undefined } - /** Update the RegisterConfig (DeepPartial) */ + /** Update the RegisterConfig (DeepPartial), and say whether it was taken. */ ['update_register_config']: { args: [DeepPartial] - return: void + return: true | undefined } /** From 46412887dbac68bee445432a14cadd4999446f01 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 12:38:12 +0200 Subject: [PATCH 02/91] fix: keep the decimal separator out of the whole number fields `IMask.MaskedNumber` defaults to a scale of 2 and a radix of ',' with '.' mapped onto it, so a field that took the defaults accepted `1,5` and handed its setter a string `Number()` answers `NaN` to. That is where the config reset `0f53ced` guards against comes from, and the guard is the second line of defence rather than the first: nothing about the field said the value could not be a number. One `integerMask` now carries the rule, and the six fields that took the defaults spread it: the unit id, the length, the uint fields, the server port, the scan timeout and the add-register address. The interval and the register length stated the same thing by hand and now name it. Measured before the change, typing `1,5` into the unit id left `1,5` in the field. The unit tests for it go red on removing the scale, and the one that also refuses a dot is the only one a rival `radix: '.'` turns red, which is what tells the two rules apart. --- CHANGELOG.md | 9 +- e2e/specs/01-main/02-input-validation.spec.ts | 32 +++++++ .../MenuButton/ScanProgress/ScanProgress.tsx | 5 +- .../__tests__/TimeoutInput.test.tsx | 10 ++ .../server/ServerConfig/ServerConfig.tsx | 5 +- .../AddRegister/maskedInputs.tsx | 13 +-- .../components/shared/inputs/LengthInput.tsx | 5 +- .../components/shared/inputs/UintInput.tsx | 5 +- .../components/shared/inputs/UnitIdInput.tsx | 5 +- .../inputs/__tests__/integerMask.test.tsx | 95 +++++++++++++++++++ .../components/shared/inputs/integerMask.ts | 14 +++ 11 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/shared/inputs/__tests__/integerMask.test.tsx create mode 100644 src/renderer/src/components/shared/inputs/integerMask.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fe6bb0..3f7c1915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,12 +91,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 occurred in the main process" dialog and no window at all. Your client stays connected and polling the whole time, and the server keeps the connections it has, which was already true and is now covered by a test. -- **A comma typed into a client field no longer resets those settings at the - next start.** The unit ID, port, address and length fields take a comma as a +- **A comma typed into a number field no longer resets those settings at the + next start.** The unit ID, port, address and length fields read a comma as a decimal separator, and a value carrying one was stored as no number at all. The next start could not read it back and put the connection or the register - settings back to their defaults. Modbux now keeps the value the field had, and - what your client sends and what the field shows can no longer drift apart. + settings back to their defaults. Those fields hold whole numbers now, so the + comma never lands, and what your client sends and what the field shows can no + longer drift apart. ### Changed diff --git a/e2e/specs/01-main/02-input-validation.spec.ts b/e2e/specs/01-main/02-input-validation.spec.ts index da1162b9..f11a628a 100644 --- a/e2e/specs/01-main/02-input-validation.spec.ts +++ b/e2e/specs/01-main/02-input-validation.spec.ts @@ -36,6 +36,14 @@ test.describe.serial('Input validation — AddRegister modal and client inputs', expect(Number(val)).toBe(65535) }) + test('address input: a typed comma is not a decimal separator', async ({ mainPage }) => { + const addressInput = mainPage.getByTestId('add-reg-address-input').locator('input') + await addressInput.fill('') + await addressInput.pressSequentially('1,5') + await mainPage.waitForTimeout(200) + expect(await addressInput.inputValue()).toBe('15') + }) + test('address max changes per data type: INT32 max=65534, INT64 max=65532', async ({ mainPage }) => { @@ -267,6 +275,30 @@ test.describe.serial('Input validation — AddRegister modal and client inputs', await mainPage.waitForTimeout(300) }) + /** + * A comma used to reach the setter, and `Number()` answers `NaN` to it. The + * store wrote that, `JSON.stringify` persisted `null`, and the next start + * refused the connection config and reset it. + * + * They run before the two clamping tests, so those still leave the fields as + * they did for the specs after this one. + */ + test('unit ID input: a typed comma is not a decimal separator', async ({ mainPage }) => { + const unitIdInput = mainPage.getByTestId('client-unitid-input').locator('input') + await unitIdInput.fill('') + await unitIdInput.pressSequentially('1,5') + await mainPage.waitForTimeout(300) + expect(await unitIdInput.inputValue()).toBe('15') + }) + + test('port input: a typed comma is not a decimal separator', async ({ mainPage }) => { + const portInput = mainPage.getByTestId('tcp-port-input').locator('input') + await portInput.fill('') + await portInput.pressSequentially('50,2') + await mainPage.waitForTimeout(300) + expect(await portInput.inputValue()).toBe('502') + }) + test('port input: clamped to 65535', async ({ mainPage }) => { const portInput = mainPage.getByTestId('tcp-port-input').locator('input') await portInput.fill('99999') 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 d90c2cd3..2ea4216b 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 @@ -7,12 +7,13 @@ 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 { integerMask } from '@renderer/components/shared/inputs/integerMask' 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 { MaskSetFn } from '@renderer/context/client.zustand.types' import { ElementType, forwardRef } from 'react' -import { IMaskInput, IMask } from 'react-imask' +import { IMaskInput } from 'react-imask' // Scan progress export const ScanProgress = meme(() => { @@ -55,7 +56,7 @@ const TimeoutInputForward = forwardRef((props, return ( set(value, true)} diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx index eff4a42a..c2ff3846 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx @@ -56,6 +56,16 @@ describe('scan timeout field', () => { expect(input.value).toBe('100') }) + it('refuses a decimal separator, which would reach the setter as NaN', async () => { + const user = userEvent.setup() + const input = renderField() + + await user.clear(input) + await user.type(input, '1,5') + + expect(input.value).toBe('15') + }) + it('leaves a usable value alone', async () => { const user = userEvent.setup() const input = renderField() diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index 55613e14..86cecc71 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -5,12 +5,13 @@ 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 { integerMask } from '@renderer/components/shared/inputs/integerMask' import { meme } from '@renderer/components/shared/inputs/meme' import { MaskInputProps, maskInputProps } from '@renderer/components/shared/inputs/types' import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, ServerMode } from '@shared' import { ElementType, forwardRef } from 'react' -import { IMaskInput, IMask } from 'react-imask' +import { IMaskInput } from 'react-imask' import Select, { SelectChangeEvent } from '@mui/material/Select' import { UnitIdString, UnitIdStringSchema } from '@shared' import MenuItem from '@mui/material/MenuItem' @@ -166,8 +167,8 @@ const PortInput = forwardRef((props, ref) => { return ( ((props, return ( ((props return ( set(value, notEmpty(value))} /> @@ -160,12 +157,10 @@ const RegisterLengthForward = forwardRef((prop return ( set(value, notEmpty(value))} /> diff --git a/src/renderer/src/components/shared/inputs/LengthInput.tsx b/src/renderer/src/components/shared/inputs/LengthInput.tsx index 231b6e70..91cb6653 100644 --- a/src/renderer/src/components/shared/inputs/LengthInput.tsx +++ b/src/renderer/src/components/shared/inputs/LengthInput.tsx @@ -1,5 +1,6 @@ -import { IMaskInput, IMask } from 'react-imask' +import { IMaskInput } from 'react-imask' import { forwardRef } from 'react' +import { integerMask } from './integerMask' import { meme } from './meme' import { MaskInputProps } from './types' @@ -8,7 +9,7 @@ const LengthInputForward = forwardRef((props, return ( ((props, re return ( ((props, return ( +// +// `IMask.MaskedNumber` has a scale of 2 and a radix of ',', so a field that +// takes the defaults accepts a decimal separator. The value reaching the setter +// is then a string `Number()` answers `NaN` to, and the store used to persist +// that as `null`. +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import LengthInput from '../LengthInput' +import UIntInput from '../UintInput' +import UnitIdInput from '../UnitIdInput' + +const typeInto = async (input: HTMLInputElement, text: string): Promise => { + const user = userEvent.setup() + await user.clear(input) + await user.type(input, text) +} + +const renderField = (field: JSX.Element): HTMLInputElement => { + render(field) + return screen.getByRole('textbox') as HTMLInputElement +} + +describe('an integer field and a decimal separator', () => { + it('drops the comma from the unit id', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '1,5') + + expect(input.value).toBe('15') + expect(set).toHaveBeenLastCalledWith('15', true) + }) + + it('drops the comma from the length', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '1,5') + + expect(input.value).toBe('15') + }) + + it('drops the comma from a uint field', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '1,5') + + expect(input.value).toBe('15') + }) + + it('drops the dot as well, which the mask used to map onto the comma', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '1.5') + + expect(input.value).toBe('15') + }) +}) + +describe('what the integer fields keep doing', () => { + it('takes a plain number', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '25') + + expect(input.value).toBe('25') + expect(set).toHaveBeenLastCalledWith('25', true) + }) + + it('holds the unit id at its ceiling', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '300') + + expect(input.value).toBe('255') + }) + + it('marks an emptied length invalid', async () => { + const set = vi.fn() + const input = renderField() + + await typeInto(input, '10') + await userEvent.setup().clear(input) + + expect(set).toHaveBeenLastCalledWith('', false) + }) +}) diff --git a/src/renderer/src/components/shared/inputs/integerMask.ts b/src/renderer/src/components/shared/inputs/integerMask.ts new file mode 100644 index 00000000..4a4203b2 --- /dev/null +++ b/src/renderer/src/components/shared/inputs/integerMask.ts @@ -0,0 +1,14 @@ +import { IMask } from 'react-imask' + +/** + * The mask a field takes when the value it carries is a whole number. + * + * `IMask.MaskedNumber` defaults to a scale of 2 and a radix of ',' with '.' + * mapped onto it, so a field on the defaults accepts a decimal separator and + * hands its setter a string `Number()` answers `NaN` to. A scale of 0 refuses + * both separators, and the digits around one still arrive. + * + * The fields that do carry a fraction set their own scale and radix beside the + * mask, and this is not for them. + */ +export const integerMask = { mask: IMask.MaskedNumber, scale: 0 } From ce7dc306e0732e572b6febfa88858425e8408e11 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 13:16:51 +0200 Subject: [PATCH 03/91] refactor: build a server's unit map where the write lands `createServer` called `get().clean(uuid)` from inside its own recipe, and a `set` that runs inside a running recipe is discarded when that recipe finalises. Measured before the change: after `createServer`, `port` and `uuids` carried the new server and `serverRegisters`, `usedAddresses` and `unitId` were all `undefined`. It stood there since 352e4df. Nothing showed, because fifteen lines over eight actions rebuilt what `clean` had failed to build. That prologue is gone. One `unitRegisters` and one `unitUsedAddresses` make a unit the first time something is written into it, and the three actions that only read take the optional chain, so removing a bool from a unit that holds nothing no longer builds one. `clean` writes two empty maps rather than an entry for each of the 256 unit ids, which is what it filled before. Measured on the old shape: 21395 bytes of `serverRegisters` and 13203 bytes of `usedAddresses` per uuid in localStorage, for units the save filters out again through `checkHasConfig`. A config written by an older build still carries its 256 and still loads, which has a test. `getUnitId` is a read now. Two of its callers ask from inside a running recipe, where the `set` it made was discarded and attempted again on every call, and `init` and `clean` already write the '0' it was repairing. `addBool`, `removeBool`, `setBool` and `resetBools` send their IPC after the recipe rather than inside it, which is what `removeRegister` and `resetRegisters` already did. No test can see that one: the call goes out either way. --- .../__tests__/serverStoreWrites.test.ts | 180 +++++++++++++++++ .../__tests__/setRegisterValue.test.ts | 10 +- src/renderer/src/context/server.zustand.ts | 187 ++++++++---------- 3 files changed, 270 insertions(+), 107 deletions(-) create mode 100644 src/renderer/src/context/__tests__/serverStoreWrites.test.ts diff --git a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts new file mode 100644 index 00000000..5afb0c7c --- /dev/null +++ b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom +// +// `createServer` called `clean` from inside its own recipe, and a `set` that +// runs inside a running recipe is discarded, so a new server got a port and a +// uuid and no unit map at all. Fifteen lines spread over eight actions rebuilt +// what `clean` had failed to build, and that prologue read as care. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CURRENT_SERVER_ZUSTAND_VERSION, + MAIN_SERVER_UUID, + SERVER_ZUSTAND_STORAGE_KEY +} from '@shared' +import { stubRenderer } from './stubRenderer' + +const NEW_UUID = 'a-second-server' + +const stubCreateServer = (port: number): void => { + const w = window as unknown as { api: Record } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, method: string): unknown => + method === 'createServer' + ? (): Promise => Promise.resolve(port) + : Reflect.get(target, method) + }) +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +describe('a server that was just created', () => { + it('has the unit map and the unit id clean gives it', async () => { + stubCreateServer(5020) + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) + + const state = useServerZustand.getState() + expect(state.serverRegisters[NEW_UUID]).toEqual({}) + expect(state.usedAddresses[NEW_UUID]).toEqual({}) + expect(state.unitId[NEW_UUID]).toBe('0') + expect(state.port[NEW_UUID]).toBe('5020') + }) + + it('takes a register on the unit the dialog offers', async () => { + stubCreateServer(5020) + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) + + await useServerZustand.getState().addRegister({ + uuid: NEW_UUID, + unitId: '0', + littleEndian: false, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 7, + min: undefined, + max: undefined, + interval: undefined + } + }) + + const state = useServerZustand.getState() + expect(state.serverRegisters[NEW_UUID]?.['0']?.holding_registers[10]?.value).toBe(0) + expect(state.usedAddresses[NEW_UUID]?.['0']?.['holding_registers']).toEqual([10]) + }) +}) + +describe('what clean writes', () => { + it('leaves the unit map empty rather than filling all 256', async () => { + const { useServerZustand } = await import('../server.zustand') + + useServerZustand.getState().clean(MAIN_SERVER_UUID) + + expect( + Object.keys(useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID] ?? {}) + ).toEqual([]) + }) +}) + +describe('a config written before the unit map was left empty', () => { + it('loads, and takes a register on a unit it already carries', async () => { + const registers = { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: {} + } + localStorage.setItem( + SERVER_ZUSTAND_STORAGE_KEY, + JSON.stringify({ + state: { + selectedUuid: MAIN_SERVER_UUID, + uuids: [MAIN_SERVER_UUID], + serverRegisters: { [MAIN_SERVER_UUID]: { '0': registers, '1': registers } }, + usedAddresses: { [MAIN_SERVER_UUID]: { '0': {}, '1': {} } }, + port: { [MAIN_SERVER_UUID]: '502' }, + unitId: { [MAIN_SERVER_UUID]: '0' }, + name: {}, + littleEndian: { [MAIN_SERVER_UUID]: false } + }, + version: CURRENT_SERVER_ZUSTAND_VERSION + }) + ) + + const { useServerZustand } = await import('../server.zustand') + useServerZustand.getState().addBool('coils', 1) + + const state = useServerZustand.getState() + expect(state.configReset).toBeUndefined() + expect(Object.keys(state.serverRegisters[MAIN_SERVER_UUID] ?? {})).toEqual(['0', '1']) + expect(state.serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[1]?.value).toBe(false) + }) +}) + +describe('getUnitId', () => { + it('answers 0 for a uuid it has never seen and writes nothing', async () => { + const { useServerZustand } = await import('../server.zustand') + + const unitId = useServerZustand.getState().getUnitId(NEW_UUID) + + expect(unitId).toBe('0') + expect(Object.keys(useServerZustand.getState().unitId)).toEqual([MAIN_SERVER_UUID]) + }) +}) + +describe('a bool the store writes', () => { + it('reaches the backend with what was written', async () => { + const { useServerZustand } = await import('../server.zustand') + const setBool = vi.fn() + const w = window as unknown as { api: Record } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, method: string): unknown => + method === 'setBool' ? setBool : Reflect.get(target, method) + }) + useServerZustand.getState().clean(MAIN_SERVER_UUID) + + useServerZustand.getState().addBool('coils', 3) + + expect( + useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[3]?.value + ).toBe(false) + expect(setBool).toHaveBeenCalledWith({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'coils', + address: 3, + state: false + }) + }) + + it('keeps the value an address already carries', async () => { + const { useServerZustand } = await import('../server.zustand') + useServerZustand.getState().clean(MAIN_SERVER_UUID) + useServerZustand.getState().addBool('coils', 3) + useServerZustand.getState().setBool({ registerType: 'coils', address: 3, boolState: true }) + + useServerZustand.getState().addBool('coils', 3) + + expect( + useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[3]?.value + ).toBe(true) + }) + + it('builds no unit when a bool is removed from one that holds nothing', async () => { + const { useServerZustand } = await import('../server.zustand') + useServerZustand.getState().clean(MAIN_SERVER_UUID) + + useServerZustand.getState().removeBool('coils', 3) + + expect(useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]).toEqual({}) + }) +}) diff --git a/src/renderer/src/context/__tests__/setRegisterValue.test.ts b/src/renderer/src/context/__tests__/setRegisterValue.test.ts index 610a012e..41e2acbf 100644 --- a/src/renderer/src/context/__tests__/setRegisterValue.test.ts +++ b/src/renderer/src/context/__tests__/setRegisterValue.test.ts @@ -17,7 +17,8 @@ beforeEach(() => { /** * The main server's default unit, or a failure saying it is not there. * - * `clean` fills every unit id, so a miss here means the store was not seeded. + * A unit is made when something is written into it, so a miss here means the + * store was not seeded. */ const unit = ( registers: Record | undefined> @@ -46,8 +47,11 @@ const entry = (address: number, value: number): ServerRegister[string] => ({ const seeded = async (): Promise => { const store = await import('../server.zustand') store.useServerZustand.getState().clean(MAIN_SERVER_UUID) - store.useServerZustand.setState((state) => { - unit(state.serverRegisters).holding_registers[10] = entry(10, 1) + store.useServerZustand.getState().replaceServerRegisters('0', { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { 10: entry(10, 1) } }) return store } diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 7be74f1b..8835e72d 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -17,7 +17,7 @@ import { ServerRegisters, SyncBoolsParameters, UnitIdString, - UnitIdStringSchema, + SetBooleanParameters, migrateServerRegistersState, migrateServerModeState, migrateBoolShape, @@ -56,6 +56,28 @@ const getDefaultUsedAddresses = (): UsedAddresses => ({ holding_registers: [] }) +/** + * Where an empty unit is made, and the only place that makes one. + * + * `clean` gives a uuid two empty maps rather than an entry for each of the 256 + * unit ids, so a unit gets its entry the first time something is written into + * it. A read takes the optional chain instead: a unit nobody has written to + * holds nothing, and asking what is in it should not create it. + */ +const serverRegistersOf = (state: ServerZustand, uuid: string) => + (state.serverRegisters[uuid] ??= {}) + +const usedAddressesOf = (state: ServerZustand, uuid: string) => (state.usedAddresses[uuid] ??= {}) + +const unitRegisters = (state: ServerZustand, uuid: string, unitId: UnitIdString): ServerRegisters => + (serverRegistersOf(state, uuid)[unitId] ??= getDefaultServerRegisters()) + +const unitUsedAddresses = ( + state: ServerZustand, + uuid: string, + unitId: UnitIdString +): UsedAddresses => (usedAddressesOf(state, uuid)[unitId] ??= getDefaultUsedAddresses()) + /** Restart RTU server only if in RTU mode and COM port is set */ const restartRtuIfActive = (get: () => ServerZustand): void => { const state = get() @@ -105,10 +127,6 @@ export const useServerZustand = create< state.unitId[uuid] = '0' state.serverRegisters[uuid] = {} state.usedAddresses[uuid] = {} - for (const unitId of UnitIdStringSchema.options) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - state.usedAddresses[uuid][unitId] = getDefaultUsedAddresses() - } }), /** * Remove all state entries for uuids that are not present in the uuids array. @@ -140,10 +158,13 @@ export const useServerZustand = create< set((state) => { state.port[uuid] = String(actualPort) state.ready[uuid] = true - get().clean(uuid) state.uuids.push(uuid) state.selectedUuid = uuid }) + // `clean` writes through a `set` of its own, and a `set` that runs + // inside a recipe is discarded when that recipe finalises. It stood + // inside this one since 352e4df, so a new server got no unit map. + get().clean(uuid) get().cleanOrphanedServerState() }, deleteServer: async (uuid) => { @@ -216,10 +237,9 @@ export const useServerZustand = create< const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) set((state) => { - if (!state.usedAddresses[syncUuid]) state.usedAddresses[syncUuid] = {} - if (!state.usedAddresses[syncUuid][unitId]) state.usedAddresses[syncUuid][unitId] = {} - state.usedAddresses[syncUuid][unitId]['input_registers'] = inputUsedAddresses - state.usedAddresses[syncUuid][unitId]['holding_registers'] = holdingUsedAddresses + const addresses = unitUsedAddresses(state, syncUuid, unitId) + addresses['input_registers'] = inputUsedAddresses + addresses['holding_registers'] = holdingUsedAddresses }) } @@ -260,11 +280,9 @@ export const useServerZustand = create< const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) set((state) => { - if (!state.usedAddresses[syncUuid]) state.usedAddresses[syncUuid] = {} - if (!state.usedAddresses[syncUuid][unitId]) - state.usedAddresses[syncUuid][unitId] = {} - state.usedAddresses[syncUuid][unitId]['input_registers'] = inputUsedAddresses - state.usedAddresses[syncUuid][unitId]['holding_registers'] = holdingUsedAddresses + const addresses = unitUsedAddresses(state, syncUuid, unitId) + addresses['input_registers'] = inputUsedAddresses + addresses['holding_registers'] = holdingUsedAddresses }) } @@ -296,46 +314,45 @@ export const useServerZustand = create< addBool: (registerType, address) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + let added = false set((state) => { - if (state.serverRegisters[uuid]?.[unitId]?.[registerType][address]) return - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - state.serverRegisters[uuid][unitId][registerType][address] = { value: false } - window.api.setBool({ uuid, unitId, registerType, address, state: false }) + const registers = unitRegisters(state, uuid, unitId) + if (registers[registerType][address]) return + registers[registerType][address] = { value: false } + added = true }) + if (added) window.api.setBool({ uuid, unitId, registerType, address, state: false }) }, removeBool: (registerType, address) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + let removed = false set((state) => { - if (state.serverRegisters[uuid]?.[unitId]?.[registerType][address] === undefined) return - delete state.serverRegisters[uuid][unitId][registerType][address] - window.api.setBool({ uuid, unitId, registerType, address, state: false }) + const registers = state.serverRegisters[uuid]?.[unitId] + if (registers?.[registerType][address] === undefined) return + delete registers[registerType][address] + removed = true }) + if (removed) window.api.setBool({ uuid, unitId, registerType, address, state: false }) }, setBool: (params) => { + const written: SetBooleanParameters[] = [] set((state) => { - if (!Array.isArray(params)) params = [params] - - for (const p of params) { + for (const p of Array.isArray(params) ? params : [params]) { const { registerType, address, boolState, optionalUuid, optionalUnitId } = p const uuid = optionalUuid ?? get().selectedUuid const unitId = optionalUnitId ?? get().getUnitId(uuid) - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - const entry = state.serverRegisters[uuid][unitId][registerType][address] + const registers = unitRegisters(state, uuid, unitId) + const entry = registers[registerType][address] if (entry) { entry.value = boolState } else { - state.serverRegisters[uuid][unitId][registerType][address] = { value: boolState } + registers[registerType][address] = { value: boolState } } - window.api.setBool({ uuid, unitId, registerType, address, state: boolState }) + written.push({ uuid, unitId, registerType, address, state: boolState }) } }) + for (const bool of written) window.api.setBool(bool) }, setBoolComment: (registerType, address, comment) => { const uuid = get().selectedUuid @@ -363,20 +380,16 @@ export const useServerZustand = create< currentDiscreteInputs[Number(k)] = v.value }) set((state) => { - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - state.serverRegisters[uuid][unitId][registerType] = {} - const newBools: SyncBoolsParameters = { - uuid, - unitId, - coils: currentCoils, - discrete_inputs: currentDiscreteInputs, - [registerType]: new Array(65536).fill(false) - } - window.api.syncBools(newBools) + unitRegisters(state, uuid, unitId)[registerType] = {} }) + const newBools: SyncBoolsParameters = { + uuid, + unitId, + coils: currentCoils, + discrete_inputs: currentDiscreteInputs, + [registerType]: new Array(65536).fill(false) + } + window.api.syncBools(newBools) }, addRegister: async (addParams) => { const { uuid, unitId, params } = addParams @@ -384,23 +397,11 @@ export const useServerZustand = create< const littleEndian = get().littleEndian[uuid] ?? false set((state) => { - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - state.serverRegisters[uuid][unitId][params.registerType][params.address] = { - value: 0, - params - } - // Update used addresses - const usedAddresses = getUsedAddresses( - Object.values(state.serverRegisters[uuid][unitId][params.registerType]).map( - (r) => r.params - ) + const registers = unitRegisters(state, uuid, unitId) + registers[params.registerType][params.address] = { value: 0, params } + unitUsedAddresses(state, uuid, unitId)[params.registerType] = getUsedAddresses( + Object.values(registers[params.registerType]).map((r) => r.params) ) - if (!state.usedAddresses[uuid]) state.usedAddresses[uuid] = {} - if (!state.usedAddresses[uuid][unitId]) state.usedAddresses[uuid][unitId] = {} - state.usedAddresses[uuid][unitId][params.registerType] = usedAddresses }) // Send to backend with littleEndian from global state @@ -414,18 +415,12 @@ export const useServerZustand = create< removeRegister: (removeParams) => { const { uuid, unitId, registerType, address } = removeParams set((state) => { - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - delete state.serverRegisters[uuid][unitId][registerType][address] - // Update used addresses after deletion - const usedAddresses = getUsedAddresses( - Object.values(state.serverRegisters[uuid][unitId][registerType]).map((r) => r.params) + const registers = state.serverRegisters[uuid]?.[unitId] + if (registers === undefined) return + delete registers[registerType][address] + unitUsedAddresses(state, uuid, unitId)[registerType] = getUsedAddresses( + Object.values(registers[registerType]).map((r) => r.params) ) - if (!state.usedAddresses[uuid]) state.usedAddresses[uuid] = {} - if (!state.usedAddresses[uuid][unitId]) state.usedAddresses[uuid][unitId] = {} - state.usedAddresses[uuid][unitId][registerType] = usedAddresses }) window.api.removeServerRegister(removeParams) }, @@ -437,11 +432,6 @@ export const useServerZustand = create< const uuid = optionalUuid ?? get().selectedUuid const unitId = optionalUnitId ?? get().getUnitId(uuid) - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - // The address is the one level that can be gone by now. These // arrive batched on a 50 ms timer, and the event that proved the // entry existed fired before it, so a `removeRegister` or a @@ -451,7 +441,7 @@ export const useServerZustand = create< // its own: a bool entry is a value, and a register entry carries // the params that say what it is. There is nothing here to build // one from. - const entry = state.serverRegisters[uuid][unitId][registerType][address] + const entry = state.serverRegisters[uuid]?.[unitId]?.[registerType][address] if (!entry) continue entry.value = value } @@ -462,16 +452,8 @@ export const useServerZustand = create< const unitId = get().getUnitId(uuid) window.api.resetRegisters({ uuid, unitId, registerType }) set((state) => { - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - if (!state.serverRegisters[uuid][unitId]) { - state.serverRegisters[uuid][unitId] = getDefaultServerRegisters() - } - if (!state.usedAddresses[uuid]) state.usedAddresses[uuid] = {} - if (!state.usedAddresses[uuid][unitId]) { - state.usedAddresses[uuid][unitId] = getDefaultUsedAddresses() - } - state.serverRegisters[uuid][unitId][registerType] = {} - state.usedAddresses[uuid][unitId][registerType] = [] + unitRegisters(state, uuid, unitId)[registerType] = {} + unitUsedAddresses(state, uuid, unitId)[registerType] = [] }) }, setPort: async (port) => { @@ -526,8 +508,7 @@ export const useServerZustand = create< replaceServerRegisters: (unitId, registers) => { const uuid = get().selectedUuid set((state) => { - if (!state.serverRegisters[uuid]) state.serverRegisters[uuid] = {} - state.serverRegisters[uuid][unitId] = registers + serverRegistersOf(state, uuid)[unitId] = registers }) }, switchToRtu: async () => { @@ -603,17 +584,15 @@ export const useServerZustand = create< }) } }, - getUnitId: (uuid: string): UnitIdString => { - const state = get() - let unitId = state.unitId[uuid] - if (unitId === undefined) { - set((state) => { - state.unitId[uuid] = '0' - }) - unitId = '0' - } - return unitId as UnitIdString - } + /** + * A read, and nothing else. + * + * Two of its callers ask from inside a running recipe, where the `set` + * this used to make was discarded and attempted again on the next call. + * `init` gives every uuid a unit id and `clean` gives a new one '0', so + * there was nothing left for it to repair. + */ + getUnitId: (uuid: string): UnitIdString => get().unitId[uuid] ?? '0' })), { name: SERVER_ZUSTAND_STORAGE_KEY, From ec37dcd278f1ccee4971a4756e48477efd3cb7d3 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 14:24:51 +0200 Subject: [PATCH 04/91] refactor: let the server own the byte order it encodes in `littleEndian` travelled with every `add_replace_server_register` and every `sync_server_register`, and all three senders read the same field of the same server: `state.littleEndian[uuid]`. The v1 to v2 migration had already taken the setting off each register, so what the payload carried was the server's own stand, sent again on every write. Main keeps it now. `set_server_endianness` is the one channel that sets it, `addRegister` and `syncServerRegisters` read it where they encode, and a server main has not been told about is big-endian. `deleteServer` drops it, so a server built again on the same uuid starts big-endian too. The two places that encode say it first: `init` before it syncs a uuid, and `setLittleEndian` before it re-sends the registers. That order is what the payload used to guarantee for free, and it is what the store test asserts. `OpenSaveClear` does not await `setLittleEndian`, and the `init` at the end of its load is what settles the order for a config that carries the other one. Nothing persisted changed shape: the store still keeps `littleEndian` per uuid through `partialize`, and a saved config file still carries its own. No migration, and an existing config loads unchanged. Main had no test that passed a little-endian register: all 31 calls in `modbusServer.test.ts` passed false. It has two now, and they go red on main ignoring what it was told and on `deleteServer` keeping it. --- src/main/__tests__/ipc.test.ts | 11 +- src/main/ipc.ts | 8 +- .../modules/__tests__/modbusServer.test.ts | 92 ++++++++----- src/main/modules/modbusServer.ts | 24 +++- .../AddRegister/addRegister.zustand.ts | 7 +- .../ServerBitMapDetail/ServerBitMapDetail.tsx | 11 +- .../__tests__/serverEndianness.test.ts | 129 ++++++++++++++++++ .../__tests__/serverStoreWrites.test.ts | 1 - .../src/context/server.zustand.helpers.ts | 6 +- src/renderer/src/context/server.zustand.ts | 34 +++-- src/shared/types/ipc.ts | 8 ++ src/shared/types/server.ts | 21 ++- 12 files changed, 271 insertions(+), 81 deletions(-) create mode 100644 src/renderer/src/context/__tests__/serverEndianness.test.ts diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index 1fdf1435..7e7c2328 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -201,7 +201,6 @@ describe('write-path schemas', () => { const result = AddRegisterParamsSchema.safeParse({ uuid: 'server-1', unitId: '1', - littleEndian: false, params: { address: 0, registerType: 'holding_registers' } }) expect(result.success).toBe(false) @@ -273,8 +272,7 @@ describe('server register schemas', () => { const result = SyncRegisterValueParamsSchema.safeParse({ uuid: 'server-1', unitId: '1', - registerValues: [], - littleEndian: false + registerValues: [] }) expect(result.success).toBe(true) }) @@ -283,8 +281,7 @@ describe('server register schemas', () => { const result = SyncRegisterValueParamsSchema.safeParse({ uuid: 'server-1', unitId: '1', - registerValues: [{ registerType: 'holding_registers', dataType: 'uint16', value: 1 }], - littleEndian: false + registerValues: [{ registerType: 'holding_registers', dataType: 'uint16', value: 1 }] }) expect(result.success).toBe(false) }) @@ -397,7 +394,6 @@ describe('each guarded channel got its own schema', () => { add_replace_server_register: { uuid: 'server-1', unitId: '1', - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -416,8 +412,7 @@ describe('each guarded channel got its own schema', () => { sync_server_register: { uuid: 'server-1', unitId: '1', - registerValues: [], - littleEndian: false + registerValues: [] }, reset_registers: { uuid: 'server-1', unitId: '1', registerType: 'holding_registers' }, set_bool: { uuid: 'server-1', unitId: '1', registerType: 'coils', address: 0, state: true }, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6f15e731..a1f58511 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -21,7 +21,8 @@ import { ScanUnitIDParametersSchema, StartRtuServerParamsSchema, SyncBoolsParametersSchema, - SyncRegisterValueParamsSchema + SyncRegisterValueParamsSchema, + ServerEndiannessSchema } from '@shared' import { ModbusClient } from './modules/modbusClient' import { ModbusServer } from './modules/modbusServer' @@ -175,6 +176,11 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { (_, params) => server.syncServerRegisters(params), SyncRegisterValueParamsSchema ) + ipcHandle( + 'set_server_endianness', + (_, params) => server.setEndianness(params), + ServerEndiannessSchema + ) ipcHandle( 'reset_registers', (_, params) => server.resetRegisters(params), diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index cde623f1..b554024e 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -180,7 +180,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -210,7 +209,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 10, registerType: 'holding_registers', @@ -240,11 +238,70 @@ describe('ModbusServer', () => { ) }) + /** + * The byte order used to travel with every add and every sync, which was + * the same field of the same server read again each time. The server holds + * it, and until it is told, a register is big-endian. + */ + it('swaps the words of a 32-bit value once the server is told', () => { + server.setEndianness({ uuid, littleEndian: true }) + + server.addRegister({ + uuid, + unitId, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 70000, + min: undefined, + max: undefined, + interval: undefined + } + }) + + // 70000 = 0x00011170, low word first + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + expect.objectContaining({ address: 10, raw: 4464 }) + ) + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + expect.objectContaining({ address: 11, raw: 1 }) + ) + }) + + it('goes back to big-endian for a server built again on the same uuid', async () => { + server.setEndianness({ uuid, littleEndian: true }) + await server.createServer({ uuid, port: 5020 }) + await server.deleteServer(uuid) + + server.addRegister({ + uuid, + unitId, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 70000, + min: undefined, + max: undefined, + interval: undefined + } + }) + + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + expect.objectContaining({ address: 10, raw: 1 }) + ) + }) + it('writes to input_registers', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 5, registerType: 'input_registers', @@ -273,7 +330,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -301,7 +357,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -321,7 +376,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -361,7 +415,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 5, registerType: 'holding_registers', @@ -389,7 +442,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -439,7 +491,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -488,7 +539,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -522,7 +572,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -676,7 +725,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -714,7 +762,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -732,7 +779,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId, - littleEndian: false, registerValues: [ { address: 10, @@ -763,7 +809,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'input_registers', @@ -779,7 +824,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -796,7 +840,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId, - littleEndian: false, registerValues: [] }) @@ -814,7 +857,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid: 'fresh-uuid', unitId, - littleEndian: false, registerValues: [ { address: 0, @@ -842,7 +884,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -860,7 +901,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId: '2' as UnitIdString, - littleEndian: false, registerValues: [ { address: 0, @@ -890,7 +930,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -907,7 +946,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId: unitId2, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1080,7 +1118,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'input_registers', @@ -1096,7 +1133,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1316,7 +1352,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1492,7 +1527,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1620,7 +1654,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 10, registerType: 'input_registers', @@ -1662,7 +1695,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1747,7 +1779,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1816,7 +1847,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId: id, - littleEndian: false, params: { address, registerType: 'holding_registers', @@ -2061,7 +2091,7 @@ describe('ModbusServer', () => { max: undefined, interval: undefined } - server.addRegister({ uuid, unitId, littleEndian: false, params }) + server.addRegister({ uuid, unitId, params }) } const readHolding = async (vector: ServerVector, address: number): Promise => diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index fa5a2c73..1d437e1f 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -18,7 +18,7 @@ import { StartRtuServerParams } from '@shared' import { ServerTCP, ServerSerial } from 'modbus-serial' -import { Windows } from '@shared' +import { ServerEndianness, Windows } from '@shared' import { ValueGenerator } from './modbusServer/valueGenerator' import type { IServiceVector, FCallbackVal } from 'modbus-serial' import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' @@ -100,6 +100,14 @@ export class ModbusServer { private _windows: Windows // Map to store server data for each unit ID of a server UUID + /** + * The byte order each server encodes its registers in, big-endian until told. + * + * The renderer used to send it with every add and every sync, which is the + * same field of the same server read again each time. It is set once here + * and read where a register is encoded. + */ + private _littleEndian: Map = new Map() private _serverData: ServerDataMap = new Map() private _generatorMap: ValueGeneratorsMap = new Map() @@ -366,6 +374,7 @@ export class ModbusServer { this._disposeAllGenerators(unitIdGenerators) } this._generatorMap.delete(uuid) + this._littleEndian.delete(uuid) } /** @@ -387,12 +396,18 @@ export class ModbusServer { if (port) await this.createServer({ uuid, port }) } + /** Sets the byte order this server encodes its registers in. */ + public setEndianness = ({ uuid, littleEndian }: ServerEndianness): void => { + this._littleEndian.set(uuid, littleEndian) + } + /** * Adds a register or value generator for a given server and unitId. * If a generator already exists at the address, it is disposed and replaced. * If a fixed value is provided, sets the register directly. */ - public addRegister = ({ uuid, unitId, params, littleEndian }: AddRegisterParams): void => { + public addRegister = ({ uuid, unitId, params }: AddRegisterParams): void => { + const littleEndian = this._littleEndian.get(uuid) ?? false const { address, registerType, @@ -512,8 +527,7 @@ export class ModbusServer { public syncServerRegisters = ({ uuid, unitId, - registerValues, - littleEndian + registerValues }: SyncRegisterValueParams): void => { // Cleanup generators only for this unitId const unitIdGenerators = this._generatorMap.get(uuid) @@ -528,7 +542,7 @@ export class ModbusServer { } this.resetRegisters({ uuid, unitId, registerType: 'holding_registers' }) this.resetRegisters({ uuid, unitId, registerType: 'input_registers' }) - for (const params of registerValues) this.addRegister({ uuid, unitId, params, littleEndian }) + for (const params of registerValues) this.addRegister({ uuid, unitId, params }) } /** 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 2c5291dd..82e928ba 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 @@ -379,12 +379,7 @@ export const useAddRegisterZustand = create z.selectedUuid) const unitId = useServerZustand((z) => z.getUnitId(z.selectedUuid)) - const littleEndian = useServerZustand((z) => z.littleEndian[z.selectedUuid] ?? false) const handleToggle = useCallback( (bitIndex: number) => { @@ -30,11 +29,10 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele serverZustand.addRegister({ uuid, unitId, - params: { ...params, value: newValue, min: undefined, max: undefined, interval: undefined }, - littleEndian + params: { ...params, value: newValue, min: undefined, max: undefined, interval: undefined } }) }, - [register.value, params, uuid, unitId, littleEndian] + [register.value, params, uuid, unitId] ) const handleCommentChange = useCallback( @@ -60,11 +58,10 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele params: { ...params, bitMap: newBitMap - }, - littleEndian + } }) }, - [bitConfig, params, uuid, unitId, littleEndian] + [bitConfig, params, uuid, unitId] ) return ( diff --git a/src/renderer/src/context/__tests__/serverEndianness.test.ts b/src/renderer/src/context/__tests__/serverEndianness.test.ts new file mode 100644 index 00000000..76fbc62c --- /dev/null +++ b/src/renderer/src/context/__tests__/serverEndianness.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +// +// Main keeps the byte order per server now, so a write says which registers to +// encode rather than how. That only holds if main is told before it encodes +// anything, and the two places that encode are `init` and `setLittleEndian`. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CURRENT_SERVER_ZUSTAND_VERSION, + MAIN_SERVER_UUID, + SERVER_ZUSTAND_STORAGE_KEY +} from '@shared' +import { stubRenderer } from './stubRenderer' + +/** Every channel the store called, in the order it called them. */ +let calls: { method: string; payload: unknown }[] = [] + +const recordingBoundary = (): void => { + const w = window as unknown as { api: Record } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, method: string): unknown => { + const answer = Reflect.get(target, method) as (payload: unknown) => Promise + return (payload: unknown): Promise => { + calls.push({ method, payload }) + if (method === 'createServer') return Promise.resolve(502) + return answer(payload) + } + } + }) +} + +/** One holding register on the default unit, so there is something to encode. */ +const persisted = { + state: { + selectedUuid: MAIN_SERVER_UUID, + uuids: [MAIN_SERVER_UUID], + serverRegisters: { + [MAIN_SERVER_UUID]: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 10: { + value: 1, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 1 + } + } + } + } + } + }, + usedAddresses: { + [MAIN_SERVER_UUID]: { '0': { holding_registers: [10], input_registers: [] } } + }, + port: { [MAIN_SERVER_UUID]: '502' }, + unitId: { [MAIN_SERVER_UUID]: '0' }, + name: {}, + littleEndian: { [MAIN_SERVER_UUID]: false } + }, + version: CURRENT_SERVER_ZUSTAND_VERSION +} + +const methods = (): string[] => calls.map((call) => call.method) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + calls = [] + stubRenderer() + recordingBoundary() + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(persisted)) +}) + +describe('who tells main the byte order', () => { + it('init says it before it sends the registers', async () => { + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().init() + + expect(methods()).toContain('setServerEndianness') + expect(methods().indexOf('setServerEndianness')).toBeLessThan( + methods().indexOf('syncServerRegister') + ) + expect(calls.find((call) => call.method === 'setServerEndianness')?.payload).toEqual({ + uuid: MAIN_SERVER_UUID, + littleEndian: false + }) + }) + + it('setLittleEndian says it before it sends the registers', async () => { + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + await useServerZustand.getState().setLittleEndian(true) + + expect(methods()).toEqual(['setServerEndianness', 'syncServerRegister']) + expect(calls[0]?.payload).toEqual({ uuid: MAIN_SERVER_UUID, littleEndian: true }) + expect(useServerZustand.getState().littleEndian[MAIN_SERVER_UUID]).toBe(true) + }) + + it('sends the registers without saying how to encode them', async () => { + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + await useServerZustand.getState().setLittleEndian(true) + + expect(calls.find((call) => call.method === 'syncServerRegister')?.payload).toEqual({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerValues: [ + { + address: 10, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 1 + } + ] + }) + }) +}) diff --git a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts index 5afb0c7c..956df95c 100644 --- a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts @@ -53,7 +53,6 @@ describe('a server that was just created', () => { await useServerZustand.getState().addRegister({ uuid: NEW_UUID, unitId: '0', - littleEndian: false, params: { address: 10, registerType: 'holding_registers', diff --git a/src/renderer/src/context/server.zustand.helpers.ts b/src/renderer/src/context/server.zustand.helpers.ts index 8c308506..f0cb5cd0 100644 --- a/src/renderer/src/context/server.zustand.helpers.ts +++ b/src/renderer/src/context/server.zustand.helpers.ts @@ -41,8 +41,7 @@ export const syncBoolsWithBackend = async ( export const syncRegistersWithBackend = async ( serverRegisters: DefinedServerRegisters, unitId: UnitIdString, - uuid: string, - littleEndian: boolean + uuid: string ): Promise<{ inputRegisterRegisterValues: RegisterParams[] holdingRegisterRegisterValues: RegisterParams[] @@ -57,8 +56,7 @@ export const syncRegistersWithBackend = async ( await window.api.syncServerRegister({ uuid: uuid, unitId, - registerValues: [...inputRegisterRegisterValues, ...holdingRegisterRegisterValues], - littleEndian + registerValues: [...inputRegisterRegisterValues, ...holdingRegisterRegisterValues] }) return { inputRegisterRegisterValues, holdingRegisterRegisterValues } diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 8835e72d..1d6b86e2 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -226,12 +226,18 @@ export const useServerZustand = create< }) } + // Before anything is encoded: main keeps the byte order per server and + // reads it where it builds a register. + await window.api.setServerEndianness({ + uuid: syncUuid, + littleEndian: !!state.littleEndian[syncUuid] + }) + const unitIdsWithData = extractUnitIdsWithData(serverRegisters) for (const unitId of unitIdsWithData) { await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) - const littleEndian = !!state.littleEndian[syncUuid] const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = - await syncRegistersWithBackend(serverRegisters, unitId, syncUuid, littleEndian) + await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) const inputUsedAddresses = getUsedAddresses(inputRegisterRegisterValues) const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) @@ -268,13 +274,17 @@ export const useServerZustand = create< }) } + await window.api.setServerEndianness({ + uuid: syncUuid, + littleEndian: !!state.littleEndian[syncUuid] + }) + const unitIdsWithData = extractUnitIdsWithData(serverRegisters) for (const unitId of unitIdsWithData) { await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) - const littleEndian = !!state.littleEndian[syncUuid] const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = - await syncRegistersWithBackend(serverRegisters, unitId, syncUuid, littleEndian) + await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) const inputUsedAddresses = getUsedAddresses(inputRegisterRegisterValues) const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) @@ -393,8 +403,6 @@ export const useServerZustand = create< }, addRegister: async (addParams) => { const { uuid, unitId, params } = addParams - // Get littleEndian from global state - const littleEndian = get().littleEndian[uuid] ?? false set((state) => { const registers = unitRegisters(state, uuid, unitId) @@ -404,13 +412,7 @@ export const useServerZustand = create< ) }) - // Send to backend with littleEndian from global state - await window.api.addReplaceServerRegister({ - uuid, - unitId, - params, - littleEndian - }) + await window.api.addReplaceServerRegister({ uuid, unitId, params }) }, removeRegister: (removeParams) => { const { uuid, unitId, registerType, address } = removeParams @@ -496,13 +498,17 @@ export const useServerZustand = create< state.littleEndian[uuid] = littleEndian }) + // Told before the registers are sent, because main encodes them with + // the order it holds at that moment. + await window.api.setServerEndianness({ uuid, littleEndian }) + const serverRegisters = currentState.serverRegisters[uuid] if (!serverRegisters) return const unitIdsWithData = extractUnitIdsWithData(serverRegisters) for (const unitId of unitIdsWithData) { - await syncRegistersWithBackend(serverRegisters, unitId, uuid, littleEndian) + await syncRegistersWithBackend(serverRegisters, unitId, uuid) } }, replaceServerRegisters: (unitId, registers) => { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 5b2c5226..d01aaf19 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -8,6 +8,7 @@ import type { SetBooleanParameters, SyncBoolsParameters, SyncRegisterValueParams, + ServerEndianness, WriteParameters, RegisterMapping, ResetRegistersParams, @@ -63,6 +64,7 @@ export const IPC_CHANNELS = [ 'add_replace_server_register', 'remove_server_register', 'sync_server_register', + 'set_server_endianness', 'reset_registers', 'set_bool', 'reset_bools', @@ -211,6 +213,12 @@ export interface IpcHandlerSpec { return: void } + /** Set the byte order a server encodes its registers in */ + ['set_server_endianness']: { + args: [ServerEndianness] + return: void + } + /** Reset all server registers */ ['reset_registers']: { args: [ResetRegistersParams] diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index e868bf82..f249b79c 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -136,8 +136,7 @@ export type ServerConfig = z.infer export const AddRegisterParamsSchema = z.object({ uuid: z.string().min(1), unitId: UnitIdStringSchema, - params: RegisterParamsSchema, - littleEndian: z.boolean() + params: RegisterParamsSchema }) export type AddRegisterParams = z.infer export const RemoveRegisterParamsSchema = z.object({ @@ -155,11 +154,25 @@ export type RemoveRegisterParams = z.infer export const SyncRegisterValueParamsSchema = z.object({ uuid: z.string().min(1), unitId: UnitIdStringSchema, - registerValues: z.array(RegisterParamsSchema), - littleEndian: z.boolean() + registerValues: z.array(RegisterParamsSchema) }) + export type SyncRegisterValueParams = z.infer +/** + * The byte order a server encodes its registers in. + * + * It belongs to the server rather than to a register: the v1 to v2 migration + * took it off each register, and every write that carried it afterwards read + * the same field of the same server. The server keeps it, and a write says + * which registers to encode rather than how. + */ +export const ServerEndiannessSchema = z.object({ + uuid: z.string().min(1), + littleEndian: z.boolean() +}) +export type ServerEndianness = z.infer + export const ResetRegistersParamsSchema = z.object({ uuid: z.string().min(1), unitId: UnitIdStringSchema, From 154b167915dc81173f430c045755ba71a8cc3a36 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 14:57:48 +0200 Subject: [PATCH 05/91] fix: keep the bools a unit carries when they are all off `checkHasConfig` asked whether any coil or discrete input was on, where the question is whether the user put one there. `addBool` writes `{ value: false }`, so a coil added and left off is an entry like any other, and a unit holding only those answered no. Four callers ask it, and all four were wrong in the same way: the save filter left the unit out of the file, the load skipped it on the way back in, the unit id menu drew it as empty, and `extractUnitIdsWithData` did not sync it to main at startup. Counting entries rather than values fixes all four. `serverUtils.test.ts` had the old answer pinned as `returns false when coils only have falsy values`; that test now states the opposite and names the coil it is about. The e2e scene adds two coils, saves, clears and reopens, and goes red on the old rule. --- CHANGELOG.md | 5 +++ e2e/specs/01-main/05-file-io.spec.ts | 42 +++++++++++++++++++++++- src/shared/__tests__/serverUtils.test.ts | 15 +++++++-- src/shared/utils.ts | 18 ++++++---- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7c1915..70c4620c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 occurred in the main process" dialog and no window at all. Your client stays connected and polling the whole time, and the server keeps the connections it has, which was already true and is now covered by a test. +- **A saved configuration keeps the coils you added and left off.** A unit + whose only content was coils or discrete inputs, none of them switched on, + was written to the file as nothing at all, and opening that file gave you an + empty unit back. The bits you place are configuration whether they are on or + off, and they are saved and loaded as such. - **A comma typed into a number field no longer resets those settings at the next start.** The unit ID, port, address and length fields read a comma as a decimal separator, and a value carrying one was stored as no number at all. diff --git a/e2e/specs/01-main/05-file-io.spec.ts b/e2e/specs/01-main/05-file-io.spec.ts index a723d343..5a525fcc 100644 --- a/e2e/specs/01-main/05-file-io.spec.ts +++ b/e2e/specs/01-main/05-file-io.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { test, expect } from '../../fixtures/electron-app' -import { navigateToServer, navigateToClient, selectUnitId } from '../../fixtures/helpers' +import { addBool, navigateToServer, navigateToClient, selectUnitId } from '../../fixtures/helpers' import { resolve } from 'path' import { tmpdir } from 'os' @@ -153,6 +153,46 @@ test.describe.serial('File I/O — open, save, clear server and client configs', await fs.unlink(savePath).catch(() => {}) }) + // ─── Bools that are off are still configuration ─────────────────────── + + /** + * A coil is added off, so a unit holding nothing else used to be dropped from + * the file: `checkHasConfig` read the value where it should have counted the + * entry, and both the save and the load asked it. + */ + test('save and reopen a unit whose coils are all off', async ({ electronApp, mainPage }) => { + await mainPage.getByTestId('server-clear-btn').click() + await mainPage.waitForTimeout(500) + + await addBool(mainPage, 'coils', 0) + await addBool(mainPage, 'coils', 1) + await expect(mainPage.getByTestId('section-coils')).toContainText('(2)') + + const savePath = resolve(tmpdir(), `modbux-test-coils-off-${Date.now()}.json`) + await electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + + await mainPage.getByTestId('server-save-btn').click() + await mainPage.waitForTimeout(1000) + + const fs = await import('fs/promises') + const config = JSON.parse(await fs.readFile(savePath, 'utf-8')) + expect(Object.keys(config.serverRegistersPerUnit['0'].coils)).toEqual(['0', '1']) + + await mainPage.getByTestId('server-clear-btn').click() + await mainPage.waitForTimeout(500) + await expect(mainPage.getByTestId('section-coils')).toContainText('(0)') + + await mainPage.getByTestId('server-open-file-input').setInputFiles(savePath) + await mainPage.waitForTimeout(1000) + await expect(mainPage.getByTestId('section-coils')).toContainText('(2)') + + await fs.unlink(savePath).catch(() => {}) + }) + // ─── Legacy v1 config migration ─────────────────────────────────────── test('load legacy v1 config (server-v1-legacy.json) — verify migration', async ({ mainPage }) => { diff --git a/src/shared/__tests__/serverUtils.test.ts b/src/shared/__tests__/serverUtils.test.ts index f9247121..466c44c1 100644 --- a/src/shared/__tests__/serverUtils.test.ts +++ b/src/shared/__tests__/serverUtils.test.ts @@ -155,8 +155,19 @@ describe('checkHasConfig', () => { expect(checkHasConfig({ ...emptyRegisters, coils: { '0': { value: true } } })).toBe(true) }) - it('returns false when coils only have falsy values', () => { - expect(checkHasConfig({ ...emptyRegisters, coils: { '0': { value: false } } })).toBe(false) + /** + * `addBool` writes `{ value: false }`, so this is the coil the user added and + * left off. Counting the value instead of the entry dropped its unit from a + * saved config. + */ + it('returns true for a coil that is there and off', () => { + expect(checkHasConfig({ ...emptyRegisters, coils: { '0': { value: false } } })).toBe(true) + }) + + it('returns true for a discrete input that is there and off', () => { + expect(checkHasConfig({ ...emptyRegisters, discrete_inputs: { '0': { value: false } } })).toBe( + true + ) }) it('returns true when discrete_inputs have truthy values', () => { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index fb87fb47..22e40e16 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -212,13 +212,19 @@ export const getUsedAddresses = (registers: RegisterParams[]): number[] => { return Array.from(addressSet) } +/** + * Whether a unit carries anything the user put there. + * + * A bool counts because it exists, not because it is on. `addBool` writes + * `{ value: false }`, so the coil you add and leave off is an entry like any + * other, and reading the value instead dropped exactly that unit from a saved + * config. + */ export const checkHasConfig = (reg: ServerRegisters | undefined): boolean => { - const coils = reg?.coils ?? {} - const hasCoils = Object.values(coils).some((v) => v.value) - const discrete = reg?.discrete_inputs ?? {} - const hasDiscrete = Object.values(discrete).some((v) => v.value) - const hasInput = Object.values(reg?.input_registers ?? []).length > 0 - const hasHolding = Object.values(reg?.holding_registers ?? []).length > 0 + const hasCoils = Object.keys(reg?.coils ?? {}).length > 0 + const hasDiscrete = Object.keys(reg?.discrete_inputs ?? {}).length > 0 + const hasInput = Object.keys(reg?.input_registers ?? {}).length > 0 + const hasHolding = Object.keys(reg?.holding_registers ?? {}).length > 0 return hasCoils || hasDiscrete || hasInput || hasHolding } From 9318172b290b0fd03bb72248464492d94485e53b Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 15:27:39 +0200 Subject: [PATCH 06/91] refactor: say once what the two stores both do `refreshSerialPorts` and `refreshServerSerialPorts` were one function under two names, and they had diverged: only the server copy released its loading flag when the call failed. That flag disables both refresh buttons, so a flag left true is a button that never comes back. One `loadSerialPorts` serves both now, and the server store's two fields and its action drop the `server` prefix that guarded a clash between two separate stores. `validateSerialPort` had the same shape and gets the same `finally`. Nothing a user reaches changes: `listSerialPorts` in `modbusClient` catches its own failures and answers `[]`, so today only a change in main reaches the `finally`. That is also what kept the divergence invisible. The two branches of `init` held the same 26 lines, differing by a blank line and a two-line comment. `syncUuidToBackend` is those lines once. RTU and TCP differ in what they do to open the transport, not in what they put on it. The four serial setters were one five-line function written four times. `setSerialOption` is that function once. The restart they asked for was spelled one way in `restartRtuIfActive` and another in `applyServerCom`, and `restartRtuServer` is now both, taking `applyServerCom`'s shape: the stop is unconditional, because `stopRtuServer` returns at once when nothing is running and an empty COM field is a server that has to come down. `restartRtuIfActive` had no `.catch`, so a rejected `stopRtuServer` was an unhandled rejection. `defaultSerialPortOptions` states the four serial defaults once. The server store restated what `defaultConnectionConfig.rtu.options` already held, four fields with four identical values. Two specs for two paths that had none: the flag both stores release, and the RTU branch of `init` with the setters that follow it. Every rule was mutated one at a time and each went red for its own test. The `.catch` is the exception, because no assertion sees it, but vitest exits 1 on the unhandled rejection left without it. Measured: lint 0, typecheck 0, `yarn test` 50 files / 982 passed, and 100 e2e passed across `03-server-config`, `16-client-rtu` and `23-server-rtu`. `stores/F7`, `F8`, `F12` and `F14`, grouped as A19 in `tmp/AUDIT-triage.md`. A19's fifth finding, `F13`, named the two `clear` functions, and `8ca2b10` removed those. --- .../ServerRtuConfig/ServerRtuConfig.tsx | 10 +- .../src/context/__tests__/serialPorts.test.ts | 93 +++++++ .../context/__tests__/serverRtuMode.test.ts | 238 ++++++++++++++++++ src/renderer/src/context/client.zustand.ts | 24 +- src/renderer/src/context/serialPorts.ts | 33 +++ src/renderer/src/context/server.zustand.ts | 237 ++++++++--------- .../src/context/server.zustand.types.ts | 6 +- src/shared/default.ts | 20 +- 8 files changed, 499 insertions(+), 162 deletions(-) create mode 100644 src/renderer/src/context/__tests__/serialPorts.test.ts create mode 100644 src/renderer/src/context/__tests__/serverRtuMode.test.ts create mode 100644 src/renderer/src/context/serialPorts.ts diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index 61ee1de3..42757df5 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -26,8 +26,8 @@ import React, { useCallback, useEffect, useState } from 'react' // COM Port Input (onBlur pattern) const ComInput = meme(() => { const comFromStore = useServerZustand((z) => z.serialConfig?.com ?? '') - const loading = useServerZustand((z) => z.serverSerialPortsLoading) - const ports = useServerZustand((z) => z.serverSerialPorts) + const loading = useServerZustand((z) => z.serialPortsLoading) + const ports = useServerZustand((z) => z.serialPorts) const [localCom, setLocalCom] = useState(comFromStore) const inputWidth = useComInputWidth(ports) @@ -127,10 +127,10 @@ const RtuStatus = meme(() => { // // COM Port Actions const ComActions = meme(() => { - const loading = useServerZustand((z) => z.serverSerialPortsLoading) + const loading = useServerZustand((z) => z.serialPortsLoading) const onRefresh = (): void => { - useServerZustand.getState().refreshServerSerialPorts() + useServerZustand.getState().refreshSerialPorts() } return ( @@ -164,7 +164,7 @@ const ComActions = meme(() => { // COM Port (composite) const Com = meme((): JSX.Element => { useEffect(() => { - useServerZustand.getState().refreshServerSerialPorts() + useServerZustand.getState().refreshSerialPorts() }, []) return ( diff --git a/src/renderer/src/context/__tests__/serialPorts.test.ts b/src/renderer/src/context/__tests__/serialPorts.test.ts new file mode 100644 index 00000000..2915f764 --- /dev/null +++ b/src/renderer/src/context/__tests__/serialPorts.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +// +// Both stores refresh the same port list through the same helper, and the flag +// that gates their refresh buttons has to come down whatever the boundary +// answers. These go red on a store that releases it only on the success path. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SerialPortInfo } from '@shared' +import { stubRenderer } from './stubRenderer' + +const ports: SerialPortInfo[] = [{ path: '/dev/ttyUSB0', manufacturer: 'FTDI' }] + +/** Replaces one channel's answer, leaving the rest of the boundary alone. */ +const answerWith = (method: string, answer: () => Promise): void => { + const w = window as unknown as { api: Record } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, requested: string): unknown => + requested === method ? answer : Reflect.get(target, requested) + }) +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +describe('the client store refreshing its port list', () => { + it('holds what main answered and lets the flag go', async () => { + answerWith('listSerialPorts', () => Promise.resolve(ports)) + const { useClientZustand } = await import('../client.zustand') + + await useClientZustand.getState().refreshSerialPorts() + + expect(useClientZustand.getState().serialPorts).toEqual(ports) + expect(useClientZustand.getState().serialPortsLoading).toBe(false) + }) + + it('lets the flag go when main does not answer', async () => { + answerWith('listSerialPorts', () => Promise.reject(new Error('no boundary'))) + const { useClientZustand } = await import('../client.zustand') + + await expect(useClientZustand.getState().refreshSerialPorts()).rejects.toThrow('no boundary') + + expect(useClientZustand.getState().serialPortsLoading).toBe(false) + }) +}) + +describe('the server store refreshing its port list', () => { + it('holds what main answered and lets the flag go', async () => { + answerWith('listSerialPorts', () => Promise.resolve(ports)) + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().refreshSerialPorts() + + expect(useServerZustand.getState().serialPorts).toEqual(ports) + expect(useServerZustand.getState().serialPortsLoading).toBe(false) + }) + + it('lets the flag go when main does not answer', async () => { + answerWith('listSerialPorts', () => Promise.reject(new Error('no boundary'))) + const { useServerZustand } = await import('../server.zustand') + + await expect(useServerZustand.getState().refreshSerialPorts()).rejects.toThrow('no boundary') + + expect(useServerZustand.getState().serialPortsLoading).toBe(false) + }) +}) + +describe('the client store validating a port', () => { + it('answers what main said and lets the flag go', async () => { + const valid = { valid: true, message: '/dev/ttyUSB0 opened' } + answerWith('validateSerialPort', () => Promise.resolve(valid)) + const { useClientZustand } = await import('../client.zustand') + + await expect(useClientZustand.getState().validateSerialPort('/dev/ttyUSB0')).resolves.toEqual( + valid + ) + + expect(useClientZustand.getState().serialPortValidating).toBe(false) + }) + + it('lets the flag go when main does not answer', async () => { + answerWith('validateSerialPort', () => Promise.reject(new Error('no boundary'))) + const { useClientZustand } = await import('../client.zustand') + + await expect(useClientZustand.getState().validateSerialPort('/dev/ttyUSB0')).rejects.toThrow( + 'no boundary' + ) + + expect(useClientZustand.getState().serialPortValidating).toBe(false) + }) +}) diff --git a/src/renderer/src/context/__tests__/serverRtuMode.test.ts b/src/renderer/src/context/__tests__/serverRtuMode.test.ts new file mode 100644 index 00000000..6b45f6a7 --- /dev/null +++ b/src/renderer/src/context/__tests__/serverRtuMode.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom +// +// What a store in RTU mode puts on the boundary, which is what a helper shared +// with TCP mode could quietly take away: the order `init` sends things in, the +// uuid it sends them for, and the restart a serial option asks for. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CURRENT_SERVER_ZUSTAND_VERSION, + MAIN_SERVER_UUID, + SERVER_ZUSTAND_STORAGE_KEY +} from '@shared' +import { stubRenderer } from './stubRenderer' + +/** Every channel the store called, in the order it called them. */ +let calls: { method: string; payload: unknown }[] = [] + +const recordingBoundary = (rejects: string[] = []): void => { + const w = window as unknown as { api: Record } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, method: string): unknown => { + const answer = Reflect.get(target, method) as (payload: unknown) => Promise + return (payload: unknown): Promise => { + calls.push({ method, payload }) + if (rejects.includes(method)) return Promise.reject(new Error(`${method} refused`)) + if (method === 'createServer') return Promise.resolve(502) + return answer(payload) + } + } + }) +} + +const methods = (): string[] => calls.map((call) => call.method) +const payloadOf = (method: string): unknown => calls.find((call) => call.method === method)?.payload + +/** + * Runs out the restart a setter floated. + * + * A setter answers void and leaves a promise chain behind it, so a `waitFor` on + * the calls it has made so far passes on the half of the chain that has run. + * A timeout of 0 is a macrotask, and every pending microtask goes first. + */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +/** One holding register on the default unit, so there is something to send. */ +const persisted = (com: string): string => + JSON.stringify({ + state: { + selectedUuid: 'a-second-server', + uuids: [MAIN_SERVER_UUID], + serverRegisters: { + [MAIN_SERVER_UUID]: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 10: { + value: 1, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } + } + } + } + } + }, + usedAddresses: { + [MAIN_SERVER_UUID]: { '0': { holding_registers: [10], input_registers: [] } } + }, + port: { [MAIN_SERVER_UUID]: '502' }, + unitId: { [MAIN_SERVER_UUID]: '0' }, + name: {}, + littleEndian: { [MAIN_SERVER_UUID]: true }, + serverMode: 'rtu', + serialConfig: { com, options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } } + }, + version: CURRENT_SERVER_ZUSTAND_VERSION + }) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + calls = [] + stubRenderer() +}) + +describe('init in RTU mode', () => { + it('opens the port, then sends the main server everything it holds', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + calls = [] + + await useServerZustand.getState().init() + + expect(methods()).toEqual([ + 'startRtuServer', + 'setServerEndianness', + 'syncBools', + 'syncServerRegister' + ]) + expect(payloadOf('startRtuServer')).toEqual({ + uuid: MAIN_SERVER_UUID, + serialConfig: { + com: '/dev/ttyUSB0', + options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } + } + }) + expect(payloadOf('setServerEndianness')).toEqual({ + uuid: MAIN_SERVER_UUID, + littleEndian: true + }) + }) + + it('marks the main server ready and selects it', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().init() + + expect(useServerZustand.getState().ready[MAIN_SERVER_UUID]).toBe(true) + expect(useServerZustand.getState().selectedUuid).toBe(MAIN_SERVER_UUID) + }) + + it('sends the registers anyway when no COM port is configured', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + calls = [] + + await useServerZustand.getState().init() + + expect(methods()).toEqual(['setServerEndianness', 'syncBools', 'syncServerRegister']) + expect(useServerZustand.getState().ready[MAIN_SERVER_UUID]).toBe(true) + }) + + it('opens no TCP server', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().init() + + expect(methods()).not.toContain('createServer') + }) +}) + +describe('a serial option changed in RTU mode', () => { + it('is stored and restarts the server on the new value', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + useServerZustand.getState().setServerBaudRate('19200') + await settle() + + expect(methods()).toEqual(['stopRtuServer', 'startRtuServer']) + expect(useServerZustand.getState().serialConfig?.options.baudRate).toBe('19200') + expect(payloadOf('startRtuServer')).toEqual({ + uuid: MAIN_SERVER_UUID, + serialConfig: { + com: '/dev/ttyUSB0', + options: { baudRate: '19200', dataBits: 8, stopBits: 1, parity: 'none' } + } + }) + }) + + it('carries the parity, the data bits and the stop bits the same way', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + useServerZustand.getState().setServerParity('even') + useServerZustand.getState().setServerDataBits(7) + useServerZustand.getState().setServerStopBits(2) + await settle() + + expect(methods().filter((method) => method === 'startRtuServer')).toHaveLength(3) + expect(useServerZustand.getState().serialConfig?.options).toEqual({ + baudRate: '9600', + dataBits: 7, + stopBits: 2, + parity: 'even' + }) + }) + + it('takes the port down without opening it again when the COM field is empty', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + useServerZustand.getState().setServerBaudRate('19200') + await settle() + + expect(methods()).toEqual(['stopRtuServer']) + expect(useServerZustand.getState().serialConfig?.options.baudRate).toBe('19200') + }) + + it('keeps the value when the restart fails', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary(['stopRtuServer']) + const { useServerZustand } = await import('../server.zustand') + calls = [] + + useServerZustand.getState().setServerBaudRate('19200') + await settle() + + expect(methods()).toEqual(['stopRtuServer']) + expect(useServerZustand.getState().serialConfig?.options.baudRate).toBe('19200') + }) +}) + +describe('a serial option changed in TCP mode', () => { + it('is stored and touches no RTU server', async () => { + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().init() + calls = [] + + useServerZustand.getState().setServerBaudRate('19200') + await settle() + + expect(useServerZustand.getState().serialConfig?.options.baudRate).toBe('19200') + expect(methods()).toEqual([]) + }) +}) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 0e3d309f..144a5ec8 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -19,6 +19,7 @@ import { repairPersisted } from '@shared' import { useDataZustand } from './data.zustand' +import { loadSerialPorts } from './serialPorts' import { onEvent } from '@renderer/events' /** @@ -496,25 +497,18 @@ export const useClientZustand = create< serialPorts: [], serialPortsLoading: false, serialPortValidating: false, - refreshSerialPorts: async () => { - set((state) => { - state.serialPortsLoading = true - }) - const ports = await window.api.listSerialPorts() - set((state) => { - state.serialPorts = ports - state.serialPortsLoading = false - }) - }, + refreshSerialPorts: () => loadSerialPorts(set), validateSerialPort: async (portPath) => { set((state) => { state.serialPortValidating = true }) - const result = await window.api.validateSerialPort(portPath) - set((state) => { - state.serialPortValidating = false - }) - return result + try { + return await window.api.validateSerialPort(portPath) + } finally { + set((state) => { + state.serialPortValidating = false + }) + } } })), { diff --git a/src/renderer/src/context/serialPorts.ts b/src/renderer/src/context/serialPorts.ts new file mode 100644 index 00000000..7c896738 --- /dev/null +++ b/src/renderer/src/context/serialPorts.ts @@ -0,0 +1,33 @@ +import { SerialPortInfo } from '@shared' + +/** The two fields a store keeps a serial port list in. */ +export interface SerialPortsSlice { + serialPorts: SerialPortInfo[] + serialPortsLoading: boolean +} + +/** + * Fills a store's port list, and releases its loading flag either way. + * + * Both stores hold one of these, and both refresh buttons are disabled while + * the flag is set, so a flag left true is a button that never comes back. + * `listSerialPorts` in `modbusClient` catches its own failures and answers + * `[]`, so today only a change in main reaches the `finally`. + */ +export const loadSerialPorts = async ( + set: (recipe: (state: State) => void) => void +): Promise => { + set((state) => { + state.serialPortsLoading = true + }) + try { + const ports = await window.api.listSerialPorts() + set((state) => { + state.serialPorts = ports + }) + } finally { + set((state) => { + state.serialPortsLoading = false + }) + } +} diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 1d6b86e2..d199f9da 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -27,7 +27,9 @@ import { SERVER_ZUSTAND_STORAGE_KEY, registerWidth, ServerSerialConfig, + SerialPortOptions, ModbusBaudRate, + defaultSerialPortOptions, keepCorrupt, repairPersisted } from '@shared' @@ -38,6 +40,7 @@ import { syncBoolsWithBackend, syncRegistersWithBackend } from './server.zustand.helpers' +import { loadSerialPorts } from './serialPorts' const getDefaultServerRegisters = (): ServerRegisters => ({ coils: {}, @@ -46,10 +49,10 @@ const getDefaultServerRegisters = (): ServerRegisters => ({ holding_registers: {} }) -const defaultSerialConfig: ServerSerialConfig = { +const getDefaultSerialConfig = (): ServerSerialConfig => ({ com: '', - options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } -} + options: { ...defaultSerialPortOptions } +}) const getDefaultUsedAddresses = (): UsedAddresses => ({ input_registers: [], @@ -78,15 +81,83 @@ const unitUsedAddresses = ( unitId: UnitIdString ): UsedAddresses => (usedAddressesOf(state, uuid)[unitId] ??= getDefaultUsedAddresses()) -/** Restart RTU server only if in RTU mode and COM port is set */ -const restartRtuIfActive = (get: () => ServerZustand): void => { - const state = get() - if (state.serverMode !== 'rtu') return - const serialConfig = state.serialConfig ?? defaultSerialConfig - if (!serialConfig.com.trim()) return - window.api.stopRtuServer().then(() => { - window.api.startRtuServer({ uuid: MAIN_SERVER_UUID, serialConfig }) +/** The recipe half of the store's `set`, for a helper that writes through it. */ +type ServerSet = (recipe: (state: ServerZustand) => void) => void + +/** + * Hands main everything a uuid holds, then marks it ready. + * + * The byte order goes first, because main encodes each register with the order + * it holds at that moment. Both modes send the same thing afterwards: RTU and + * TCP differ in what they do to open the transport, not in what they put on it. + */ +const syncUuidToBackend = async ( + set: ServerSet, + get: () => ServerZustand, + syncUuid: string +): Promise => { + const serverRegisters = get().serverRegisters[syncUuid] ?? {} + set((state) => { + state.serverRegisters[syncUuid] ??= {} + }) + + await window.api.setServerEndianness({ + uuid: syncUuid, + littleEndian: !!get().littleEndian[syncUuid] + }) + + for (const unitId of extractUnitIdsWithData(serverRegisters)) { + await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) + const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = + await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) + + set((state) => { + const addresses = unitUsedAddresses(state, syncUuid, unitId) + addresses['input_registers'] = getUsedAddresses(inputRegisterRegisterValues) + addresses['holding_registers'] = getUsedAddresses(holdingRegisterRegisterValues) + }) + } + + set((state) => { + state.ready[syncUuid] = true + }) +} + +/** + * Puts the RTU server back on the serial settings the store now holds. + * + * The stop is unconditional: `stopRtuServer` returns at once when nothing is + * running, and an empty COM field is a server that has to come down rather + * than one to leave alone. The start is what the field gates. + * + * Every caller is a setter that cannot wait, so the failure is swallowed here. + * Main reports it through the `backend_message` event. + */ +const restartRtuServer = async (get: () => ServerZustand): Promise => { + const { serverMode, serialConfig = getDefaultSerialConfig() } = get() + if (serverMode !== 'rtu') return + + try { + await window.api.stopRtuServer() + if (!serialConfig.com.trim()) return + await window.api.startRtuServer({ uuid: MAIN_SERVER_UUID, serialConfig }) + } catch { + // Reported through backend_message. + } +} + +/** One serial option, then the restart that makes the server speak it. */ +const setSerialOption = ( + set: ServerSet, + get: () => ServerZustand, + key: Key, + value: SerialPortOptions[Key] +): void => { + set((state) => { + state.serialConfig ??= getDefaultSerialConfig() + state.serialConfig.options[key] = value }) + void restartRtuServer(get) } /** @@ -118,9 +189,9 @@ export const useServerZustand = create< name: { [MAIN_SERVER_UUID]: undefined }, littleEndian: { [MAIN_SERVER_UUID]: false }, serverMode: 'tcp' as const, - serialConfig: defaultSerialConfig, - serverSerialPorts: [], - serverSerialPortsLoading: false, + serialConfig: getDefaultSerialConfig(), + serialPorts: [], + serialPortsLoading: false, rtuServerActive: false, clean: (uuid) => set((state) => { @@ -204,54 +275,23 @@ export const useServerZustand = create< }) if (mode === 'rtu') { - // RTU mode: start RTU server with main server UUID - const serialConfig = state.serialConfig ?? defaultSerialConfig - const syncUuid = MAIN_SERVER_UUID + // RTU serves the main server's registers, so that is the only uuid + // there is anything to open for. + const serialConfig = state.serialConfig ?? getDefaultSerialConfig() // Only start if COM port is configured if (serialConfig.com.trim()) { try { - await window.api.startRtuServer({ uuid: syncUuid, serialConfig }) + await window.api.startRtuServer({ uuid: MAIN_SERVER_UUID, serialConfig }) } catch { // Error is reported via backend_message event } } - // Sync registers for main server - let serverRegisters = state.serverRegisters[syncUuid] - if (!serverRegisters) { - serverRegisters = {} - set((state) => { - state.serverRegisters[syncUuid] = {} - }) - } - - // Before anything is encoded: main keeps the byte order per server and - // reads it where it builds a register. - await window.api.setServerEndianness({ - uuid: syncUuid, - littleEndian: !!state.littleEndian[syncUuid] - }) - - const unitIdsWithData = extractUnitIdsWithData(serverRegisters) - for (const unitId of unitIdsWithData) { - await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) - const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = - await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) - - const inputUsedAddresses = getUsedAddresses(inputRegisterRegisterValues) - const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) - - set((state) => { - const addresses = unitUsedAddresses(state, syncUuid, unitId) - addresses['input_registers'] = inputUsedAddresses - addresses['holding_registers'] = holdingUsedAddresses - }) - } + await syncUuidToBackend(set, get, MAIN_SERVER_UUID) set((state) => { - state.ready[syncUuid] = true - state.selectedUuid = syncUuid + state.selectedUuid = MAIN_SERVER_UUID }) } else { // TCP mode: existing flow @@ -266,39 +306,7 @@ export const useServerZustand = create< state.port[syncUuid] = String(actualPort) }) - let serverRegisters = state.serverRegisters[syncUuid] - if (!serverRegisters) { - serverRegisters = {} - set((state) => { - state.serverRegisters[syncUuid] = {} - }) - } - - await window.api.setServerEndianness({ - uuid: syncUuid, - littleEndian: !!state.littleEndian[syncUuid] - }) - - const unitIdsWithData = extractUnitIdsWithData(serverRegisters) - - for (const unitId of unitIdsWithData) { - await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) - const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = - await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) - - const inputUsedAddresses = getUsedAddresses(inputRegisterRegisterValues) - const holdingUsedAddresses = getUsedAddresses(holdingRegisterRegisterValues) - - set((state) => { - const addresses = unitUsedAddresses(state, syncUuid, unitId) - addresses['input_registers'] = inputUsedAddresses - addresses['holding_registers'] = holdingUsedAddresses - }) - } - - set((state) => { - state.ready[syncUuid] = true - }) + await syncUuidToBackend(set, get, syncUuid) } if (state.uuids.length === 0) { @@ -533,63 +541,18 @@ export const useServerZustand = create< }, setServerCom: (com) => { set((state) => { - if (!state.serialConfig) state.serialConfig = { ...defaultSerialConfig } + if (!state.serialConfig) state.serialConfig = getDefaultSerialConfig() state.serialConfig.com = com }) // State-only — applied on blur via applyServerCom }, - applyServerCom: async () => { - const currentState = get() - if (currentState.serverMode !== 'rtu') return - const serialConfig = currentState.serialConfig ?? defaultSerialConfig - await window.api.stopRtuServer() - if (serialConfig.com.trim()) { - await window.api.startRtuServer({ uuid: MAIN_SERVER_UUID, serialConfig }) - } - }, - setServerBaudRate: (baudRate: ModbusBaudRate) => { - set((state) => { - if (!state.serialConfig) state.serialConfig = { ...defaultSerialConfig } - state.serialConfig.options.baudRate = baudRate - }) - restartRtuIfActive(get) - }, - setServerParity: (parity) => { - set((state) => { - if (!state.serialConfig) state.serialConfig = { ...defaultSerialConfig } - state.serialConfig.options.parity = parity - }) - restartRtuIfActive(get) - }, - setServerDataBits: (dataBits) => { - set((state) => { - if (!state.serialConfig) state.serialConfig = { ...defaultSerialConfig } - state.serialConfig.options.dataBits = dataBits - }) - restartRtuIfActive(get) - }, - setServerStopBits: (stopBits) => { - set((state) => { - if (!state.serialConfig) state.serialConfig = { ...defaultSerialConfig } - state.serialConfig.options.stopBits = stopBits - }) - restartRtuIfActive(get) - }, - refreshServerSerialPorts: async () => { - set((state) => { - state.serverSerialPortsLoading = true - }) - try { - const ports = await window.api.listSerialPorts() - set((state) => { - state.serverSerialPorts = ports - }) - } finally { - set((state) => { - state.serverSerialPortsLoading = false - }) - } - }, + applyServerCom: () => restartRtuServer(get), + setServerBaudRate: (baudRate: ModbusBaudRate) => + setSerialOption(set, get, 'baudRate', baudRate), + setServerParity: (parity) => setSerialOption(set, get, 'parity', parity), + setServerDataBits: (dataBits) => setSerialOption(set, get, 'dataBits', dataBits), + setServerStopBits: (stopBits) => setSerialOption(set, get, 'stopBits', stopBits), + refreshSerialPorts: () => loadSerialPorts(set), /** * A read, and nothing else. * diff --git a/src/renderer/src/context/server.zustand.types.ts b/src/renderer/src/context/server.zustand.types.ts index ce687c5f..fdb39549 100644 --- a/src/renderer/src/context/server.zustand.types.ts +++ b/src/renderer/src/context/server.zustand.types.ts @@ -99,8 +99,8 @@ export type ServerZustand = { setServerParity: (parity: Parity) => void setServerDataBits: (dataBits: number) => void setServerStopBits: (stopBits: number) => void - serverSerialPorts: SerialPortInfo[] - serverSerialPortsLoading: boolean - refreshServerSerialPorts: () => Promise + serialPorts: SerialPortInfo[] + serialPortsLoading: boolean + refreshSerialPorts: () => Promise rtuServerActive: boolean } & PersistedServerZustand diff --git a/src/shared/default.ts b/src/shared/default.ts index eefb30f8..fa612710 100644 --- a/src/shared/default.ts +++ b/src/shared/default.ts @@ -3,9 +3,25 @@ import { ConnectionConfig, RegisterConfig, RegisterData, - RegisterDataWords + RegisterDataWords, + SerialPortOptions } from './types' +/** + * What a serial port opens with until someone says otherwise. + * + * `ConnectionConfigRtuSchema` and `ServerSerialConfigSchema` are both + * `{ com: z.string(), options: SerialPortOptionsSchema }`, so the client's + * connection and the server's RTU mode start from these four values. Copied at + * each use, so a store writing one option leaves the other's alone. + */ +export const defaultSerialPortOptions: SerialPortOptions = { + baudRate: '9600', + dataBits: 8, + stopBits: 1, + parity: 'none' +} + export const defaultConnectionConfig: ConnectionConfig = { unitId: 1, protocol: 'ModbusTcp', @@ -15,7 +31,7 @@ export const defaultConnectionConfig: ConnectionConfig = { }, rtu: { com: 'COM3', - options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } + options: { ...defaultSerialPortOptions } } } export const defaultRegisterConfig: RegisterConfig = { From ac22731ccf287b31f530b589e4b4a87ade2499d5 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 16:06:10 +0200 Subject: [PATCH 07/91] refactor: read a persisted store back through one repair Both stores ended their module with the same eleven lines: `repairPersisted`, and on a reset a `console.warn`, a `keepCorrupt` and a `setState` carrying `configReset`. Four of the eleven were byte-identical and the other seven differed only in the schema, the store, the version constant, the storage key and the noun in the warn. C3 put them there when it replaced the two `clear` functions, which is `stores/F13` in the audit and why that finding is retired rather than built. `repairPersistedStore` holds the read, the warn and the copy, and answers a repair only when something was lost. The write stays with each store: `setState` takes the store's own state type and the schema infers the persisted half of it, so a helper that wrote would have to be generic over both to say those agree, and the caller already holds both. That is what keeps this free of a cast. The warn names the storage key rather than the store, so it reads `client.zustand repaired`. No test asserted the old text. The explanation of why this reports through `console` and a store field instead of notistack now sits once, on the helper, where the server store used to point at the client store for it. `corruptedConfig.test.ts` already covered both stores across the lost field, the newer version and the whole blob, and it discriminates this: answering a repair when nothing was lost, dropping `keepCorrupt`, and comparing the versions the other way each turn it red, two of the three in both stores. Measured: lint 0, typecheck 0, `yarn test` 50 files / 982 passed, and 50 e2e passed across `03-server-config` and `02-standalone/01-persistence`. --- src/renderer/src/context/client.zustand.ts | 36 ++++--------- .../src/context/repairPersistedStore.ts | 54 +++++++++++++++++++ src/renderer/src/context/server.zustand.ts | 29 ++++------ 3 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/context/repairPersistedStore.ts diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 144a5ec8..a2baa416 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -14,12 +14,11 @@ import { CURRENT_CLIENT_ZUSTAND_VERSION, migrateClientState, carryFormerClientState, - CLIENT_ZUSTAND_STORAGE_KEY, - keepCorrupt, - repairPersisted + CLIENT_ZUSTAND_STORAGE_KEY } from '@shared' import { useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' +import { repairPersistedStore } from './repairPersistedStore' import { onEvent } from '@renderer/events' /** @@ -530,31 +529,14 @@ export const useClientZustand = create< const clientZustand = useClientZustand.getState() -/** - * 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 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 repair = repairPersisted( - PersistedClientZustandSchema, - clientZustand, - useClientZustand.getInitialState(), - persistedVersion !== undefined && persistedVersion > CURRENT_CLIENT_ZUSTAND_VERSION -) +// Keep the fields that parsed and default the rest, then say which went. +const repair = repairPersistedStore(useClientZustand, PersistedClientZustandSchema, { + storageKey: CLIENT_ZUSTAND_STORAGE_KEY, + persistedVersion, + currentVersion: CURRENT_CLIENT_ZUSTAND_VERSION +}) -if (repair.reset !== undefined) { - console.warn('client config repaired', repair.reset) - keepCorrupt(localStorage, CLIENT_ZUSTAND_STORAGE_KEY) - useClientZustand.setState({ ...repair.state, configReset: repair.reset }) -} +if (repair) useClientZustand.setState({ ...repair.state, configReset: repair.reset }) // Sync the main process state with the front end clientZustand.init() diff --git a/src/renderer/src/context/repairPersistedStore.ts b/src/renderer/src/context/repairPersistedStore.ts new file mode 100644 index 00000000..258c85d7 --- /dev/null +++ b/src/renderer/src/context/repairPersistedStore.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { ConfigReset, keepCorrupt, repairPersisted } from '@shared' + +/** What a store lost on the way in, for a store that lost something. */ +interface StoreRepair { + state: State + reset: ConfigReset +} + +interface PersistedAt { + storageKey: string + /** What the blob on disk carried, which only `migrate` is offered. */ + persistedVersion: number | undefined + currentVersion: number +} + +/** + * Reads a store's persisted state back through its schema, and answers what + * failed or undefined when the whole blob parsed. + * + * The write stays with the caller. `setState` takes the store's own state type + * and a schema infers the persisted half of it, so a helper that wrote would + * have to be generic over both to say those agree. The caller already holds + * both and needs no generic to do it. + * + * Both stores call this while the module graph is still evaluating, which is + * why it reports through `console` and a store field rather than notistack. + * The standalone `enqueueSnackbar` is assigned inside the SnackbarProvider + * constructor, and that provider is built by `createRoot().render()` in + * `main.tsx`, so calling it from here throws out of module scope and nothing + * below the call runs: no init, no event listeners, and no React render + * either. `MessageReceiver` reads `configReset` 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. + */ +export const repairPersistedStore = ( + store: { getState: () => unknown; getInitialState: () => object }, + schema: z.ZodObject, + { storageKey, persistedVersion, currentVersion }: PersistedAt +): StoreRepair>> | undefined => { + const repair = repairPersisted( + schema, + store.getState(), + store.getInitialState(), + persistedVersion !== undefined && persistedVersion > currentVersion + ) + if (repair.reset === undefined) return undefined + + console.warn(`${storageKey} repaired`, repair.reset) + keepCorrupt(localStorage, storageKey) + return { state: repair.state, reset: repair.reset } +} diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index d199f9da..ab64d414 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -29,9 +29,7 @@ import { ServerSerialConfig, SerialPortOptions, ModbusBaudRate, - defaultSerialPortOptions, - keepCorrupt, - repairPersisted + defaultSerialPortOptions } from '@shared' import { onEvent } from '@renderer/events' import { round } from 'lodash' @@ -41,6 +39,7 @@ import { syncRegistersWithBackend } from './server.zustand.helpers' import { loadSerialPorts } from './serialPorts' +import { repairPersistedStore } from './repairPersistedStore' const getDefaultServerRegisters = (): ServerRegisters => ({ coils: {}, @@ -614,26 +613,16 @@ export const useServerZustand = create< ) ) -/** - * Keep the fields that parsed and default the rest, then say which went. - * - * 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 serverZustand = useServerZustand.getState() -const repair = repairPersisted( - PersistedServerZustandSchema, - serverZustand, - useServerZustand.getInitialState(), - persistedVersion !== undefined && persistedVersion > CURRENT_SERVER_ZUSTAND_VERSION -) +// Keep the fields that parsed and default the rest, then say which went. +const repair = repairPersistedStore(useServerZustand, PersistedServerZustandSchema, { + storageKey: SERVER_ZUSTAND_STORAGE_KEY, + persistedVersion, + currentVersion: 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 }) -} +if (repair) useServerZustand.setState({ ...repair.state, configReset: repair.reset }) // Init server useServerZustand.getState().init() From 5ed99ef2b10ea9ffbc0e0d5bd11f1773e9f27c26 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 17:04:52 +0200 Subject: [PATCH 08/91] fix: tell main the mapping that replaced the whole of the previous one `setRegisterMapping` sends main a copy per cell edit. `replaceRegisterMapping` and `clearRegisterMapping` sent nothing, so main went on grouping its reads out of the mapping the renderer had thrown away. What kept that from showing was LoadButton and ClearConfigButton each turning read configuration off by hand after the swap, and a poll firing in between read the old mapping anyway. The store does both now, in one action and in the order that leaves no window: read configuration off first, because it is the one thing that makes main read the mapping at all, then the mapping itself, now rather than after the debounce. Clearing is replacing with an empty mapping, so it is said once, and those two by-hand lines are gone. A config file that fails to load now leaves read configuration where it was. The line that turned it off ran whether the load threw or not, and a load that threw replaced nothing. `registerMappingToMain.test.ts` names both directions: what main is handed and in what order goes red on the previous store, and what the store itself holds plus the cell edit debounce stays green either way. Turning the toggle off after the flush instead of before reddens only the two order tests, and the debounced sync in place of the flush reddens the same five as the revert. Measured: lint 0, typecheck 0, `yarn test` 51 files / 991 passed, and 113 e2e across 05-file-io, 14-client-config-io, 15-client-toolbar and 17-huawei-smartlogger. --- .../ClearConfigButton/ClearConfigButton.tsx | 1 - .../LoadButton/LoadButton.tsx | 1 - .../__tests__/registerMappingToMain.test.ts | 182 ++++++++++++++++++ src/renderer/src/context/client.zustand.ts | 39 ++-- 4 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/context/__tests__/registerMappingToMain.test.ts 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 4543891d..0a7fbcca 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 @@ -11,7 +11,6 @@ const ClearConfigButton = meme((): JSX.Element => { const handleClick = useCallback(() => { 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 7dd21158..e6e9efdc 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 @@ -67,7 +67,6 @@ const LoadButton = meme((): JSX.Element => { openingRef.current = false setOpening(false) showMapping() - useClientZustand.getState().setReadConfiguration(false) }, [enqueueSnackbar] ) diff --git a/src/renderer/src/context/__tests__/registerMappingToMain.test.ts b/src/renderer/src/context/__tests__/registerMappingToMain.test.ts new file mode 100644 index 00000000..c2c90ec5 --- /dev/null +++ b/src/renderer/src/context/__tests__/registerMappingToMain.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom +// +// Main reads the register mapping only while read configuration is on, and it +// reads the copy the renderer last sent it. `setRegisterMapping` sent one per +// edit; replacing or clearing the whole mapping sent nothing, so main kept +// grouping its reads out of the mapping the renderer had thrown away. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RegisterMapping } from '@shared' +import { stubRenderer } from './stubRenderer' + +type Call = { method: string; payload: unknown } + +const calls: Call[] = [] + +/** Every `window.api` call in order, still answered by the stub underneath. */ +const recordCalls = (): void => { + const answers = window.api as unknown as Record + window.api = new Proxy( + {}, + { + get: (_target, method: string): ((payload: unknown) => unknown) => { + const answer = answers[method] + if (typeof answer !== 'function') throw new Error(`stubRenderer answers no ${method}`) + return (payload: unknown): unknown => { + calls.push({ method, payload }) + return (answer as (payload: unknown) => unknown)(payload) + } + } + } + ) as never +} + +const methods = (): string[] => calls.map((call) => call.method) + +/** What main was last handed, or a failure naming the channel that never fired. */ +const lastPayload = (method: string): unknown => { + const call = calls.findLast((candidate) => candidate.method === method) + if (!call) throw new Error(`${method} was never called`) + return call.payload +} + +/** + * The store, with the calls `init` makes at import time already dropped. + * + * `init` turns read configuration off and asks main for the client state, and + * every test here reads the calls a later action makes. + */ +const loadStore = async (): Promise => { + const store = await import('../client.zustand') + calls.length = 0 + return store +} + +const loaded: RegisterMapping = { + coils: { 7: { dataType: 'int16' } }, + discrete_inputs: {}, + holding_registers: { 65535: { dataType: 'uint16', comment: 'the last one' } }, + input_registers: {} +} + +beforeEach(() => { + vi.resetModules() + vi.useRealTimers() + localStorage.clear() + calls.length = 0 + stubRenderer() + recordCalls() +}) + +describe('a mapping that replaces the whole of the previous one', () => { + it('reaches main, types the current one included and not', async () => { + const { useClientZustand } = await loadStore() + + useClientZustand.getState().replaceRegisterMapping(loaded) + + expect(lastPayload('setRegisterMapping')).toEqual(loaded) + }) + + it('reaches main before the debounce a cell edit is on', async () => { + vi.useFakeTimers() + const { useClientZustand } = await loadStore() + + useClientZustand.getState().replaceRegisterMapping(loaded) + + expect(methods()).toContain('setRegisterMapping') + }) + + it('turns read configuration off first, so no read answers out of the old one', async () => { + const { useClientZustand } = await loadStore() + useClientZustand.getState().setReadConfiguration(true) + calls.length = 0 + + useClientZustand.getState().replaceRegisterMapping(loaded) + + expect(methods()).toEqual(['setReadConfiguration', 'setRegisterMapping']) + expect(lastPayload('setReadConfiguration')).toBe(false) + expect(useClientZustand.getState().readConfiguration).toBe(false) + }) + + it('is what the store holds', async () => { + const { useClientZustand } = await loadStore() + + useClientZustand.getState().replaceRegisterMapping(loaded) + + expect(useClientZustand.getState().registerMapping).toEqual(loaded) + }) +}) + +describe('a cleared mapping', () => { + it('reaches main empty, for all four types', async () => { + const { useClientZustand } = await loadStore() + useClientZustand.getState().replaceRegisterMapping(loaded) + calls.length = 0 + + useClientZustand.getState().clearRegisterMapping() + + expect(lastPayload('setRegisterMapping')).toEqual({ + coils: {}, + discrete_inputs: {}, + holding_registers: {}, + input_registers: {} + }) + }) + + it('turns read configuration off first', async () => { + const { useClientZustand } = await loadStore() + useClientZustand.getState().replaceRegisterMapping(loaded) + useClientZustand.getState().setReadConfiguration(true) + calls.length = 0 + + useClientZustand.getState().clearRegisterMapping() + + expect(methods()).toEqual(['setReadConfiguration', 'setRegisterMapping']) + expect(useClientZustand.getState().readConfiguration).toBe(false) + }) + + it('leaves the store holding four empty records', async () => { + const { useClientZustand } = await loadStore() + useClientZustand.getState().replaceRegisterMapping(loaded) + + useClientZustand.getState().clearRegisterMapping() + + expect(useClientZustand.getState().registerMapping).toEqual({ + coils: {}, + discrete_inputs: {}, + holding_registers: {}, + input_registers: {} + }) + }) +}) + +describe('a single cell edit', () => { + it('still reaches main, and waits the debounce out first', async () => { + vi.useFakeTimers() + const { useClientZustand } = await loadStore() + + useClientZustand.getState().setRegisterMapping(42, 'dataType', 'int16') + + expect(methods()).not.toContain('setRegisterMapping') + + vi.advanceTimersByTime(150) + + expect(lastPayload('setRegisterMapping')).toEqual({ + coils: {}, + discrete_inputs: {}, + holding_registers: { 42: { dataType: 'int16' } }, + input_registers: {} + }) + }) + + it('leaves read configuration where the user put it', async () => { + vi.useFakeTimers() + const { useClientZustand } = await loadStore() + useClientZustand.getState().setRegisterMapping(42, 'dataType', 'int16') + useClientZustand.getState().setReadConfiguration(true) + + useClientZustand.getState().setRegisterMapping(43, 'dataType', 'int16') + vi.advanceTimersByTime(150) + + expect(useClientZustand.getState().readConfiguration).toBe(true) + }) +}) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index a2baa416..902b1002 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -14,7 +14,8 @@ import { CURRENT_CLIENT_ZUSTAND_VERSION, migrateClientState, carryFormerClientState, - CLIENT_ZUSTAND_STORAGE_KEY + CLIENT_ZUSTAND_STORAGE_KEY, + RegisterMapping } from '@shared' import { useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' @@ -51,6 +52,14 @@ export const flushRegisterMappingToMain = (): void => { window.api.setRegisterMapping(useClientZustand.getState().registerMapping) } +/** A mapping with nothing configured, one empty record per register type. */ +const emptyRegisterMapping = (): RegisterMapping => ({ + coils: {}, + discrete_inputs: {}, + holding_registers: {}, + input_registers: {} +}) + /** * Drop the rows on screen, unless something is about to replace them. * @@ -124,12 +133,7 @@ export const useClientZustand = create< set((state) => { state.configReset = undefined }), - registerMapping: { - coils: {}, - discrete_inputs: {}, - holding_registers: {}, - input_registers: {} - }, + registerMapping: emptyRegisterMapping(), setRegisterMapping: (register, key, value) => { const type = get().registerConfig.type @@ -155,19 +159,18 @@ export const useClientZustand = create< syncRegisterMappingToMain() }, - replaceRegisterMapping: (registerMapping) => + replaceRegisterMapping: (registerMapping) => { + // Read configuration is the one thing that makes main read the mapping, + // so turning it off first leaves no read answering out of the mapping + // this call throws away. `syncRegisterMappingToMain` debounces for + // rapid cell edits, and a whole new mapping is not one. + get().setReadConfiguration(false) set((state) => { state.registerMapping = registerMapping - }), - clearRegisterMapping: () => - set((state) => { - state.registerMapping = { - coils: {}, - discrete_inputs: {}, - holding_registers: {}, - input_registers: {} - } - }), + }) + flushRegisterMappingToMain() + }, + clearRegisterMapping: () => get().replaceRegisterMapping(emptyRegisterMapping()), transactions: [], addTransaction: (transaction) => set((state) => { From 2d460459893f5e47cd772c2fa3de4cb03a2a7ae2 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 18:09:05 +0200 Subject: [PATCH 09/91] fix: let main end a scan it started The scan's lifetime was written in two dialogs and in main, and neither side owned any of it. Both dialogs stopped polling before scanning, where main answered the same question by refusing the scan. A drop mid-scan stopped polling and left both scans running. Cancelling a unit id scan set a flag the loop never read, so every remaining iteration sent a client_state message across the boundary and did nothing else. Main owns it now. scanUnitIds and scanRegisters refuse a scan at a client that is not connected, which is the question _read already asks before a read, and they stop polling themselves. Both loops break on the connect state, so a connection that goes mid-scan ends the scan at the read in flight rather than after the whole range. _setDisconnected stops both scans alongside stopPolling, so the flag the dialogs read to enable Close is false in the same client_state that reports the disconnect. The unit id loop breaks on its own cancel flag. The dialogs lose their stopPolling calls, and ScanRegisters loses the clearScanUnitIdResults that belongs to the other dialog. Each rule above was mutated alone against modbusClient.test.ts, and each turned red exactly the test that names it. --- CHANGELOG.md | 4 + .../modules/__tests__/modbusClient.test.ts | 212 +++++++++++++++++- src/main/modules/modbusClient.ts | 50 +++-- .../ScanRegisters/ScanRegisters.tsx | 3 - .../MenuButton/ScanUnitIds/ScanUnitIds.tsx | 2 - 5 files changed, 237 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c4620c..627dd030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 settings back to their defaults. Those fields hold whole numbers now, so the comma never lands, and what your client sends and what the field shows can no longer drift apart. +- **A scan ends when the connection does.** Losing the connection partway + through left the scan walking the rest of the address range against a client + that was gone, raising an error for every chunk it tried, and the scan dialog + would not close while that ran. The scan now stops at the read that failed. ### Changed diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index b0b706dd..a8b11597 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -585,42 +585,100 @@ describe('ModbusClient', () => { expect(client.state.scanningRegisters).toBe(false) }) - it('scanUnitIds emits warning when polling is active', async () => { + it('scanUnitIds stops polling and scans', async () => { await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) client.startPolling() await vi.advanceTimersByTimeAsync(100) + expect(client.state.polling).toBe(true) - await client.scanUnitIds({ - range: [1, 5], + const scanPromise = client.scanUnitIds({ + range: [5, 6], address: 0, length: 1, registerTypes: ['holding_registers'], timeout: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise - const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message.includes('Cannot scan while polling'))).toBe(true) - - client.stopPolling() + expect(client.state.polling).toBe(false) + const results = getWindowCalls('scan_unit_id_result') + expect(results.map((c) => c[1].id)).toEqual([5, 6]) }) - it('scanRegisters emits warning when polling is active', async () => { + it('scanRegisters stops polling and scans', async () => { await connectClient() + setupHoldingRegisterReadMock([0]) client.startPolling() await vi.advanceTimersByTimeAsync(100) + expect(client.state.polling).toBe(true) - await client.scanRegisters({ - addressRange: [0, 100], + // The poll reads address 0 for 10 registers, so a scan that starts at 50 + // is the only thing that can have asked for these two. + const scanPromise = client.scanRegisters({ + addressRange: [50, 69], length: 10, timeout: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + expect(client.state.polling).toBe(false) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(50, 10) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(60, 10) + }) + + it('scanUnitIds refuses while disconnected', async () => { + await client.scanUnitIds({ + range: [1, 3], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message.includes('Cannot scan while polling'))).toBe(true) + expect(messages.some((m) => m[1].message === 'Cannot scan, not connected')).toBe(true) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(client.state.scanningUnitIds).toBe(false) + }) - client.stopPolling() + it('scanRegisters refuses while disconnected', async () => { + await client.scanRegisters({ addressRange: [0, 99], length: 10, timeout: 1000 }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot scan, not connected')).toBe(true) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(client.state.scanningRegisters).toBe(false) + }) + + it('scanRegisters refuses while a reconnect is in flight', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + // The port object is still open here. What says no is the connect state. + fireClientEvent('close') + expect(client.state.connectState).toBe('connecting') + + await client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot scan, not connected')).toBe(true) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + }) + + it('scanRegisters refuses when the port closed under a connected state', async () => { + await connectClient() + setupHoldingRegisterReadMock([0]) + mockModbusRTU.isOpen = false + + await client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot scan, not connected')).toBe(true) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() }) }) @@ -1416,6 +1474,91 @@ describe('ModbusClient', () => { expect(client.state.scanningUnitIds).toBe(false) }) + // The test above reads the results, which stop arriving either way. This one + // reads the loop: `_scanUnitIds` sets the unit id before it reads the flag, + // so the last id handed to `setID` is the last iteration that ran. + it('leaves the remaining unit ids unvisited after a cancel', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + mockModbusRTU.setID.mockClear() + + const scanPromise = client.scanUnitIds({ + range: [1, 100], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + + await vi.advanceTimersByTimeAsync(50) + client.stopScanningUnitIds() + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + const lastId = mockModbusRTU.setID.mock.calls.at(-1)?.[0] + expect(lastId).toBeLessThan(20) + }) + + it('stops a unit id scan when the connection drops mid-scan', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + // 'error' rather than 'close': close schedules a reconnect, and a + // reconnect calls setID again, which is what this asserts on. + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + fireClientEvent('error', new Error('socket hang up')) + throw new Error('Port Not Open') + }) + mockModbusRTU.setID.mockClear() + + const scanPromise = client.scanUnitIds({ + range: [1, 100], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + const lastId = mockModbusRTU.setID.mock.calls.at(-1)?.[0] + expect(lastId).toBe(1) + }) + + // Read at the moment the disconnect returns, not after the scan. The + // dialogs read this flag to decide whether Close is live, and the loop sets + // it false on its own way out, so an assertion at the end passes either way. + it('a disconnect clears the unit id scan flag before the loop does', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + let scanningAtDisconnect: boolean | undefined + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + await client.disconnect() + scanningAtDisconnect = client.state.scanningUnitIds + return { data: [0], buffer: Buffer.alloc(2) } + }) + + const scanPromise = client.scanUnitIds({ + range: [1, 100], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + expect(scanningAtDisconnect).toBe(false) + }) + it('scans all four register types', async () => { await connectClient() mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) @@ -1709,6 +1852,51 @@ describe('ModbusClient', () => { expect(client.state.scanningRegisters).toBe(false) }) + it('stops a register scan when the connection drops mid-scan', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + fireClientEvent('error', new Error('socket hang up')) + throw new Error('Port Not Open') + }) + mockModbusRTU.readHoldingRegisters.mockClear() + + const scanPromise = client.scanRegisters({ + addressRange: [0, 999], + length: 10, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + const lastAddress = mockModbusRTU.readHoldingRegisters.mock.calls.at(-1)?.[0] + expect(lastAddress).toBe(0) + }) + + // Read at the moment the disconnect returns, not after the scan. The + // dialogs read this flag to decide whether Close is live, and the loop sets + // it false on its own way out, so an assertion at the end passes either way. + it('a disconnect clears the register scan flag before the loop does', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + let scanningAtDisconnect: boolean | undefined + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + await client.disconnect() + scanningAtDisconnect = client.state.scanningRegisters + return { data: [100], buffer: Buffer.from([0x00, 0x64]) } + }) + + const scanPromise = client.scanRegisters({ + addressRange: [0, 999], + length: 10, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + expect(scanningAtDisconnect).toBe(false) + }) + it('handles read errors during register scan', async () => { await connectClient() mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 442e4348..06e25d63 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -184,6 +184,12 @@ export class ModbusClient { private _setDisconnected = (): void => { this._clientState.connectState = 'disconnected' this.stopPolling() + // A scan is a read loop like polling is, so it ends here too. The loops + // break on the connect state as well, so what these two add is the flag + // reaching the dialogs in this `client_state` rather than at the end of the + // read in flight. + this.stopScanningUnitIds() + this.stopScanningRegisters() this._sendClientState() } @@ -737,18 +743,29 @@ export class ModbusClient { // // + // Scanning + // + // Polling and scanning are two read loops over one port, so starting a scan + // stops the poll. That is a consequence of scanning rather than something the + // user asked for, so it is taken here and neither dialog does it. + /** + * Whether a scan may start, with the message a refused one gets. + * + * A scan does not go through `_read`, so it asks here what `_read` asks + * before it reads. + */ + private _canScan = (): boolean => { + if (this._clientState.connectState === 'connected' && this._client.isOpen) return true + this._emitMessage({ message: 'Cannot scan, not connected', variant: 'warning', error: null }) + return false + } + // // // Scan Unit ID public scanUnitIds = async (params: ScanUnitIDParameters): Promise => { - if (this._clientState.polling) { - this._emitMessage({ - message: 'Cannot scan while polling is enabled', - variant: 'warning', - error: undefined - }) - return - } + if (!this._canScan()) return + this.stopPolling() this._client.setTimeout(params.timeout) this._clientState.scanningUnitIds = true @@ -759,7 +776,11 @@ export class ModbusClient { this._totalScans = (range[1] - range[0] + 1) * params.registerTypes.length this._scansDone = 0 - for (let id = range[0]; id <= range[1]; id++) await this._scanUnitIds({ id, ...params }) + for (let id = range[0]; id <= range[1]; id++) { + await this._scanUnitIds({ id, ...params }) + if (!this._clientState.scanningUnitIds) break + if (this._clientState.connectState !== 'connected') break + } this._clientState.scanningUnitIds = false this._sendClientState() @@ -869,14 +890,8 @@ export class ModbusClient { // // Scan Registers public scanRegisters = async (params: ScanRegistersParameters): Promise => { - if (this._clientState.polling) { - this._emitMessage({ - message: 'Cannot scan while polling is enabled', - variant: 'warning', - error: undefined - }) - return - } + if (!this._canScan()) return + this.stopPolling() const { unitId } = this._appState.connectionConfig this._client.setID(unitId) @@ -895,6 +910,7 @@ export class ModbusClient { await this._scanRegister(address, length) await this._sendScanProgress() if (!this._clientState.scanningRegisters) break + if (this._clientState.connectState !== 'connected') break await new Promise((resolve) => setTimeout(resolve, 5)) } 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 5e6a25e5..267ba13a 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 @@ -192,8 +192,6 @@ const ScanButton = meme((): JSX.Element => { return } - window.api.stopPolling() - const scanRegistersZustand = useScanRegistersZustand.getState() const clientZustand = useClientZustand.getState() const dataZustand = useDataZustand.getState() @@ -201,7 +199,6 @@ const ScanButton = meme((): JSX.Element => { // 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 (!clientZustand.registerConfig.advancedMode) clientZustand.setAdvancedMode(true) - clientZustand.clearScanUnitIdResults() clientZustand.setScanProgress(0) dropPendingScanRows() dataZustand.setRegisterData([]) 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 61d0a0aa..d3730d38 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 @@ -208,8 +208,6 @@ const ScanButton = meme((): JSX.Element => { return } - window.api.stopPolling() - const scanUnitIdZustand = useScanUnitIdZustand.getState() const clientZustand = useClientZustand.getState() clientZustand.clearScanUnitIdResults() From 54694d574bd6cf2bdd3b905296ac0ce6b64cc142 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 5 Sep 2026 18:54:57 +0200 Subject: [PATCH 10/91] fix: give both Start buttons the same answer about polling ScanUnitIds greyed its Start button out while the client was polling and ScanRegisters did not, so the same question got two answers. Since main stops polling for a scan itself, the disabled button was the one place that still said no: the call that would have stopped the poll sat behind a button you could not press. The gate is not gone for good. Once logging lands, a poll that breaks off without saying so is the wrong behaviour and refusing the scan is the right one, but then it belongs on the menu buttons that open the dialogs rather than on Start. --- .../RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 d3730d38..0d0080d6 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 @@ -199,7 +199,6 @@ const SelectRegisterTypes = meme((): JSX.Element => { // Scan button const ScanButton = meme((): JSX.Element => { 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(() => { @@ -229,7 +228,7 @@ const ScanButton = meme((): JSX.Element => { return ( + ) +}) + +export default ScanUnitIdsButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx rename to src/renderer/src/components/client/ScanProgress/ScanProgress.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx b/src/renderer/src/components/client/ScanProgress/__tests__/TimeoutInput.test.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/__tests__/TimeoutInput.test.tsx rename to src/renderer/src/components/client/ScanProgress/__tests__/TimeoutInput.test.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx similarity index 99% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx rename to src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx index 267ba13a..af359d66 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx @@ -18,7 +18,7 @@ import { ScanGridToggle, ScanProgress, ScanTimeoutField -} from '../../ScanProgress/ScanProgress' +} from '../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' 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/ScanRegisters/scanRegisters.zustand.ts similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts rename to src/renderer/src/components/client/ScanRegisters/scanRegisters.zustand.ts diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx similarity index 93% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx rename to src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx index 0d0080d6..7a194ec8 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -18,7 +18,6 @@ import useScanUnitIdColumns from './_columns' 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' // // @@ -294,33 +293,6 @@ const ScanResultGrid = meme(() => { ) }) -// -// -// Scan unit ids button -export const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { - 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. - const handleOpen = useCallback(() => { - useScanUnitIdZustand.getState().setOpen(true) - setAnchor(null) - }, [setAnchor]) - - return ( - - ) -}) - // // // MAIN diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx b/src/renderer/src/components/client/ScanUnitIds/_columns.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx rename to src/renderer/src/components/client/ScanUnitIds/_columns.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts b/src/renderer/src/components/client/ScanUnitIds/scanUnitIds.zustand.ts similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/scanUnitIds.zustand.ts rename to src/renderer/src/components/client/ScanUnitIds/scanUnitIds.zustand.ts diff --git a/src/renderer/src/containers/Client.tsx b/src/renderer/src/containers/Client.tsx index b4aa987e..59fa2c59 100644 --- a/src/renderer/src/containers/Client.tsx +++ b/src/renderer/src/containers/Client.tsx @@ -6,8 +6,8 @@ import HomeButton from '@renderer/components/shared/HomeButton' import RegisterConfig from '../components/client/RegisterConfig/RegisterConfig' import ClientGrids from '@renderer/components/client/ClientGrids/ClientGrids' import ConnectionConfig from '@renderer/components/client/ConnectionConfig/ConnectionConfig' -import ScanRegisters from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' -import ScanUnitIds from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds' +import ScanRegisters from '@renderer/components/client/ScanRegisters/ScanRegisters' +import ScanUnitIds from '@renderer/components/client/ScanUnitIds/ScanUnitIds' import { useClientZustand } from '@renderer/context/client.zustand' const Client = meme(() => { From 2622a656e1ffcfbc4ebcc4b56962403adfd38846 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 15 Sep 2026 22:13:32 +0200 Subject: [PATCH 80/91] fix: a register the server holds is drawn as the value it holds `register.value` is the decoded composite, folded a word at a time from the `register_value` events. Answering the word at the base address put a raw word there instead: a fixed float of 3.14 read 16456, an int16 of -5 read 65531, and a unix register read as a 1970 date. Nothing corrected it, because those events go out before the answer does and are dropped for want of an entry. The channel answers the words it wrote, and the store folds them through the merge the event feeds, which is a named `applyRegisterValue` now. Same decoder, same 50 ms batcher, so what lands is what landed before `012cabd`. The three tests that missed this were uint16 and a bitmap, one word each. The submit buttons go back on in a `finally`. `invoke` rejects when a main handler throws, and only a refusal is answered rather than thrown, so a throw out of `createRegisters` or the `ValueGenerator` constructor left every button off until the dialog was closed and opened again. `04-add-register-modal` pressed Escape and waited 300 ms. Measured twice in a six spec run: the backdrop was still up when the next test clicked, and the click never landed. It waits for the modal to go now. --- e2e/fixtures/helpers.ts | 15 ++++-- .../01-main/04-add-register-modal.spec.ts | 7 +-- .../modules/__tests__/modbusServer.test.ts | 31 ++++++------ src/main/modules/modbusServer.ts | 24 +++++----- .../AddRegister/addRegister.zustand.ts | 2 + .../AddRegister/addRegisterActions.tsx | 41 ++++++++++++---- .../__tests__/serverStoreWrites.test.ts | 22 +++++---- .../src/context/__tests__/stubRenderer.ts | 7 +-- src/renderer/src/context/server.zustand.ts | 48 ++++++++++++++----- src/shared/types/ipc.ts | 6 +-- 10 files changed, 132 insertions(+), 71 deletions(-) diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts index 351baf6a..4dad7296 100644 --- a/e2e/fixtures/helpers.ts +++ b/e2e/fixtures/helpers.ts @@ -215,11 +215,18 @@ export async function addRegister( if (reg.next) { await p.getByTestId('add-reg-next-btn').click() // The modal stays open, and the submit waits for main to take the - // register before it clears the fields and moves the address on. The - // address leaving the one just written is what says that has happened: - // filling the next register before it does gets overwritten by the clear. + // register before it clears the fields and moves the address on. Filling + // the next register before that lands gets overwritten by the clear. + // + // Two ways it lands: the address moves past the one just written, or + // there is nothing free above it and the field keeps that address and is + // marked In use. A UTF-8 register at 65524 is the second. await expect(addressInput).toBeVisible() - await expect(addressInput).not.toHaveValue(String(reg.address)) + await expect(async () => { + const address = await addressInput.inputValue() + const inUse = await p.getByTestId('add-reg-address-in-use').isVisible() + expect(address !== String(reg.address) || inUse).toBe(true) + }).toPass() } else { await p.getByTestId('add-reg-submit-btn').click() // After submit, modal closes diff --git a/e2e/specs/01-main/04-add-register-modal.spec.ts b/e2e/specs/01-main/04-add-register-modal.spec.ts index 3c9668c1..6d4fe465 100644 --- a/e2e/specs/01-main/04-add-register-modal.spec.ts +++ b/e2e/specs/01-main/04-add-register-modal.spec.ts @@ -110,11 +110,12 @@ test.describe.serial('AddRegister modal — state management and validation', () expect(await intervalInput.inputValue()).toBe('5') // Comment reset to empty - expect(await commentInput.inputValue()).toBe('') + await expect(commentInput).toHaveValue('') - // Close modal + // Close modal, and leave it closed: the next test opens it again, and a + // backdrop still up swallows that click. await mainPage.keyboard.press('Escape') - await mainPage.waitForTimeout(300) + await expect(mainPage.getByTestId('add-reg-address-input')).not.toBeVisible() }) test('Add & Next for UTF-8: string reset, address advances by register length', async ({ diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 85182467..67de83ea 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -191,7 +191,7 @@ describe('ModbusServer', () => { const unitIdNumber = Number(unitId) /** A register the unit hosts, which is what makes the server answer for it at all. */ - const hostUnit = (): number => + const hostUnit = (): number[] => server.addRegister({ uuid, unitId, @@ -272,32 +272,32 @@ describe('ModbusServer', () => { ) }) - // The `register_value` above goes out before this answer does, so the - // renderer's store has no entry to put it in and drops it. The answer is - // what that store writes. - it('answers the word it wrote at the address', () => { + // The `register_value` events above go out before this answer does, so the + // renderer's store has no entry to put them in and drops them. The answer + // is what that store folds instead. + it('answers the words it wrote from the address on', () => { const answer = server.addRegister({ uuid, unitId, params: { address: 0, registerType: 'holding_registers', - dataType: 'uint16', + dataType: 'int32', comment: 'test register', - value: 1234, + value: 70000, min: undefined, max: undefined, interval: undefined } }) - expect(answer).toBe(1234) + expect(answer).toEqual([1, 4464]) }) // `ValueGenerator` writes its first value from its constructor and sends it // as an event, which the renderer's store drops for want of an entry. The - // range excludes 0, so answering 0 cannot pass by luck. - it('answers the first word a generator wrote', () => { + // range excludes 0, so answering an empty register cannot pass by luck. + it('answers the first words a generator wrote', () => { const answer = server.addRegister({ uuid, unitId, @@ -313,13 +313,14 @@ describe('ModbusServer', () => { } }) - expect(answer).toBeGreaterThanOrEqual(100) - expect(answer).toBeLessThanOrEqual(200) + expect(answer).toHaveLength(1) + expect(answer[0]).toBeGreaterThanOrEqual(100) + expect(answer[0]).toBeLessThanOrEqual(200) }) // `none` holds its address open and writes nothing, so there is no word to // answer with. - it('answers 0 for a register with no data type', () => { + it('answers no words for a register with no data type', () => { const answer = server.addRegister({ uuid, unitId, @@ -335,7 +336,7 @@ describe('ModbusServer', () => { } }) - expect(answer).toBe(0) + expect(answer).toEqual([]) }) // `none` reaches a register map only from a config file. It holds its @@ -2136,7 +2137,7 @@ describe('ModbusServer', () => { options: { ...defaultSerialPortOptions } } - const hostUnit = (id: UnitIdString, address: number, value: number): number => + const hostUnit = (id: UnitIdString, address: number, value: number): number[] => server.addRegister({ uuid, unitId: id, diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 4eb4fca7..d69c7324 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -470,13 +470,14 @@ export class ModbusServer { * If a generator already exists at the address, it is disposed and replaced. * If a fixed value is provided, sets the register directly. * - * Answers the word now held at `address`, which is 0 for `none` because that - * writes nothing. The renderer's store waits for this answer before it - * writes, and every `register_value` below goes out before the answer does, - * so the value would otherwise reach a store with no entry to put it in and - * be dropped. - */ - public addRegister = ({ uuid, unitId, params }: AddRegisterParams): number => { + * Answers the words now held from `address` on, which is nothing for `none` + * because that writes none. The renderer's store waits for this answer before + * it writes, and every `register_value` below goes out before the answer + * does, so those words would otherwise reach a store with no entry to put + * them in and be dropped. The store folds what comes back through the same + * merge the event feeds, which is where a word becomes a value. + */ + public addRegister = ({ uuid, unitId, params }: AddRegisterParams): number[] => { const littleEndian = this._littleEndian.get(uuid) ?? false const { address, @@ -514,7 +515,7 @@ export class ModbusServer { // `none` is an address held open with nothing in it, so there is nothing to // write and nothing to generate. The generator above is disposed either way, // which is what editing a register to `none` has to do. - if (dataType === 'none') return 0 + if (dataType === 'none') return [] // If a fixed value is provided, set the register directly const fixedValue = !interval && value !== undefined @@ -535,7 +536,7 @@ export class ModbusServer { }) }) this._setServerData(uuid, unitId, serverData) - return serverData[registerType][address] ?? 0 + return registers } // Otherwise, add a value generator for this register @@ -560,8 +561,9 @@ export class ModbusServer { ) // `ValueGenerator` writes its first value from its own constructor, so this - // reads what it just put there rather than answering 0 for a minute. - return serverData[registerType][address] ?? 0 + // reads what it just put there rather than answering nothing for a minute. + const width = registerWidth(dataType, length) + return serverData[registerType].slice(address, address + width) } /** 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 d0050333..b731409f 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 @@ -401,6 +401,8 @@ export const useAddRegisterZustand = create void, + submit: () => Promise +): Promise => { + setSubmitting(true) + try { + return await submit() + } finally { + setSubmitting(false) + } +} + export const AddButtons = meme(() => { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) const dirty = useAddRegisterZustand((z) => isFormDirty(z, z.pristine)) @@ -31,9 +52,9 @@ export const AddButtons = meme(() => { const [submitting, setSubmitting] = useState(false) const handleAddAndClose = useCallback(async () => { - setSubmitting(true) - const result = await useAddRegisterZustand.getState().submit(edit) - setSubmitting(false) + const result = await withSubmitting(setSubmitting, () => + useAddRegisterZustand.getState().submit(edit) + ) if (!result) return const addRegisterZustand = useAddRegisterZustand.getState() addRegisterZustand.resetToDefaults() @@ -41,9 +62,9 @@ export const AddButtons = meme(() => { }, [edit]) const handleAddAndNext = useCallback(async () => { - setSubmitting(true) - const result = await useAddRegisterZustand.getState().submit(false) - setSubmitting(false) + const result = await withSubmitting(setSubmitting, () => + useAddRegisterZustand.getState().submit(false) + ) if (!result) return const { address, dataType } = result const addRegisterZustand = useAddRegisterZustand.getState() @@ -62,9 +83,9 @@ export const AddButtons = meme(() => { }, []) const handleEditSubmit = useCallback(async () => { - setSubmitting(true) - const result = await useAddRegisterZustand.getState().submit(true) - setSubmitting(false) + const result = await withSubmitting(setSubmitting, () => + useAddRegisterZustand.getState().submit(true) + ) if (!result) return const addRegisterZustand = useAddRegisterZustand.getState() addRegisterZustand.setRegisterType(undefined) diff --git a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts index 5d9f84da..a3353e0b 100644 --- a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts @@ -70,10 +70,11 @@ describe('a server that was just created', () => { expect(state.usedAddresses[NEW_UUID]?.['0']?.['holding_registers']).toEqual([10]) }) - // Main encodes the register and sends its `register_value` before it answers - // this call, so the event reaches a store with no entry yet and is dropped. - // The word comes back with the answer instead. - it('holds the word main answered with', async () => { + // Main encodes the register and sends its `register_value` words before it + // answers this call, so they reach a store with no entry yet and are dropped. + // They come back with the answer instead, and go through the same merge and + // the same 50 ms batcher the event feeds. + it('holds what main answered with, decoded', async () => { stubCreateServer(5020) const { useServerZustand } = await import('../server.zustand') await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) @@ -82,7 +83,7 @@ describe('a server that was just created', () => { window.api = new Proxy(boundary, { get: (target, method: string): unknown => method === 'addReplaceServerRegister' - ? (): Promise => Promise.resolve(7) + ? (): Promise => Promise.resolve([0xffff]) : Reflect.get(target, method) }) as never @@ -92,17 +93,20 @@ describe('a server that was just created', () => { params: { address: 10, registerType: 'holding_registers', - dataType: 'uint16', + dataType: 'int16', comment: '', - value: 7, + value: -1, min: undefined, max: undefined, interval: undefined } }) - const state = useServerZustand.getState() - expect(state.serverRegisters[NEW_UUID]?.['0']?.holding_registers[10]?.value).toBe(7) + await vi.waitFor(() => + expect( + useServerZustand.getState().serverRegisters[NEW_UUID]?.['0']?.holding_registers[10]?.value + ).toBe(-1) + ) }) // A grid drawing a register the server does not serve is what this avoids. diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts index 9b2fc78a..4a97f01e 100644 --- a/src/renderer/src/context/__tests__/stubRenderer.ts +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -39,9 +39,10 @@ const disconnected: ClientState = { const answers: Record Promise> = { updateConnectionConfig: answerConfig(ConnectionConfigSchema.deepPartial()), updateRegisterConfig: answerConfig(RegisterConfigSchema.deepPartial()), - // The word main answers with, which for a payload it refuses is nothing. - addReplaceServerRegister: (payload: unknown): Promise => - Promise.resolve(AddRegisterParamsSchema.safeParse(payload).success ? 0 : undefined), + // The words main answers with, which for a payload it refuses is nothing at + // all rather than an empty list. + addReplaceServerRegister: (payload: unknown): Promise => + Promise.resolve(AddRegisterParamsSchema.safeParse(payload).success ? [] : undefined), getClientState: () => Promise.resolve(disconnected), getAppVersion: () => Promise.resolve('0.0.0-test'), listSerialPorts: () => Promise.resolve([]) diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 81ac40d8..00136cec 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -20,7 +20,8 @@ import { CURRENT_SERVER_ZUSTAND_VERSION, SERVER_ZUSTAND_STORAGE_KEY, registerWidth, - ModbusBaudRate + ModbusBaudRate, + RegisterValue } from '@shared' import { onEvent } from '@renderer/events' import { round } from 'lodash' @@ -299,25 +300,37 @@ export const useServerZustand = create< // refuses leaves the grid showing what the server actually holds. The // shape is A3's, on the one server channel carrying a whole register. // - // The answer is the word rather than a yes, because main sends the - // `register_value` for a fixed register from inside the same call, and - // that event arrives before this one resolves. `setRegisterValue` drops - // a value for an address it has no entry for, so a yes would leave - // every fixed register reading 0 until something wrote it again. + // The answer carries the words rather than a yes, because main sends + // the `register_value` for each of them from inside the same call, and + // those events arrive before this one resolves. `applyRegisterValue` + // drops a word for an address it has no entry for, so a yes would leave + // every register reading 0 until something wrote it again. // - // What this answers goes back out to the caller, because Add & Next + // Whether it was taken goes back out to the caller, because Add & Next // asks for the next free address and that reads the map written below. - const value = await window.api.addReplaceServerRegister({ uuid, unitId, params }) - if (value === undefined) return false + const words = await window.api.addReplaceServerRegister({ uuid, unitId, params }) + if (words === undefined) return false set((state) => { const registers = unitRegisters(state, uuid, unitId) - registers[params.registerType][params.address] = { value, params } + registers[params.registerType][params.address] = { value: 0, params } unitUsedAddresses(state, uuid, unitId)[params.registerType] = getUsedAddresses( Object.values(registers[params.registerType]).map((r) => r.params) ) }) + // The entry exists now, so the words go through the merge that turns + // them into the value the grid draws. + for (const [offset, value] of words.entries()) { + applyRegisterValue({ + uuid, + unitId, + registerType: params.registerType, + address: params.address + offset, + value + }) + } + return true }, removeRegister: (removeParams) => { @@ -508,8 +521,15 @@ const delayedRegister = new ServerDelayedSetter { +/** + * Folds one word main wrote into the entry that holds the address. + * + * Two callers: the `register_value` event, and `addRegister`, which replays the + * words main answers with. Main sends those events from inside the call the + * store is waiting on, so they arrive before the entry exists and are dropped + * here for want of one. + */ +export const applyRegisterValue = (payload: RegisterValue): void => { const serverZustand = useServerZustand.getState() // Handle coils and discrete inputs @@ -688,7 +708,9 @@ onEvent('register_value', (payload) => { }) delayedRegister.trigger() -}) +} + +onEvent('register_value', applyRegisterValue) // RTU server status onEvent('rtu_server_status', ({ active }) => { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index e360fd34..c64cbce3 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -205,12 +205,12 @@ export interface IpcHandlerSpec { } /** - * Add or replace a server register, and answer the word now held at its - * address. Undefined means the payload was refused and nothing changed. + * Add or replace a server register, and answer the words now held from its + * address on. Undefined means the payload was refused and nothing changed. */ ['add_replace_server_register']: { args: [AddRegisterParams] - return: number | undefined + return: number[] | undefined } /** Remove a register value on the server */ From 49681373281fc971fe42d75a827ba26ec7d3ac1a Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 00:20:54 +0200 Subject: [PATCH 81/91] fix: the unit id scan logs the requests it sends `_scanUnitIds` called the readers and never `_logTransaction`, so the scan's request per unit id and per register type never reached the transaction log, and what it filed in modbus-serial's `_transactions` stayed there for the session. The key is now read before the probe and the transaction logged after it, the way `_scanRegister` does. --- CHANGELOG.md | 2 + .../modules/__tests__/modbusClient.test.ts | 68 +++++++++++++++++++ src/main/modules/modbusClient.ts | 6 +- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306b697d..dd99db0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 write with a poll or a read running alongside it. A write with nothing connected now says so rather than answering "Port Not Open", and an empty coil list is refused for FC15 the way it already was for FC5. +- **The transaction log now shows a unit id scan.** The scan sends a request per + unit id and per register type, and the log listed none of them. - **A config file Modbux refuses now says which register and which field.** One malformed register was reported as `serverRegistersPerUnit.1: Invalid input`, naming neither the address nor what was wrong with it, and a unit id outside diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index a9d0bcb0..a4af7379 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -2407,6 +2407,74 @@ describe('ModbusClient', () => { expect(results[0]?.[1].registerTypes).toContain('holding_registers') expect(results[0]?.[1].registerTypes).not.toContain('input_registers') }) + + it('logs the transaction of every probe it made', async () => { + await connectClient() + mockModbusRTU.readCoils.mockImplementation(async (address: number, length: number) => { + fileTransaction({ ...createMockTransaction(address, length), nextCode: 1 }) + return { data: [false], buffer: Buffer.alloc(1) } + }) + setupHoldingRegisterReadMock([0]) + + const scanPromise = client.scanUnitIds({ + range: [5, 6], + address: 3, + length: 1, + registerTypes: ['coils', 'holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + const transactions = getWindowCalls('transaction') + expect(transactions.map((call) => call[1].code)).toEqual([1, 3, 1, 3]) + expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) + }) + + it('logs the transaction of a probe that was refused', async () => { + await connectClient() + mockModbusRTU.readCoils.mockImplementation(async (address: number, length: number) => { + fileTransaction(createMockTransaction(address, length)) + throw Object.assign(new Error('Illegal function'), { modbusCode: 1 }) + }) + + const scanPromise = client.scanUnitIds({ + range: [5, 5], + address: 3, + length: 1, + registerTypes: ['coils'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + const transactions = getWindowCalls('transaction') + expect(transactions.map((call) => call[1].errorMessage)).toEqual(['Illegal function']) + expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) + }) + + it('logs every probe on a serial port, where they share one key', async () => { + await connectClient() + mockModbusRTU._port._transactionIdWrite = undefined + setupHoldingRegisterReadMock([0]) + + const scanPromise = client.scanUnitIds({ + range: [5, 6], + address: 3, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + const transactions = getWindowCalls('transaction') + expect(transactions.map((call) => call[1].id.split('__')[0])).toEqual([ + 'undefined', + 'undefined' + ]) + expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) + }) }) describe('scan registers full flow', () => { diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index b00dd4da..96ccc738 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -1016,13 +1016,17 @@ export class ModbusClient { return } + let errorMessage: string | undefined + const transactionIdKey = this._nextTransactionIdKey() try { await this._readers[registerType](address, length) result.registerTypes.push(registerType) } catch (error) { - result.errorMessage[registerType] = (error as Error).message + errorMessage = (error as Error).message + result.errorMessage[registerType] = errorMessage if (isModbusException(error)) result.refusedRegisterTypes.push(registerType) } + this._logTransaction(transactionIdKey, errorMessage) await this._sendScanProgress() } From a75e3a38a068bc45981621298fdc14be01e4ca1b Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 00:41:40 +0200 Subject: [PATCH 82/91] fix: a bit toggle reads the word the toggle before it wrote `ServerBitMapDetail.handleToggle` writes the whole word back, and it read that word from `register.value`, which is two steps behind: the store writes it only once main answers, and hands it to the grid after 50 ms of quiet. Two clicks inside that window both derived from the word before the first, so the second cleared the bit the first had set. Measured at every gap from 0 to 80 ms: two bits back to back gave 2 where 3 was asked for, and a double click on one bit left it on. It reads `pendingRegisterValue` now, which answers the word the batcher is holding and falls back on the entry, the way `applyRegisterValue` already read the same pair. That leaves the round trip, which a double click is inside, so the panel keeps the word it last sent until its own write is answered. --- CHANGELOG.md | 4 + .../01-main/30-server-bit-toggle.spec.ts | 57 +++++++++++ .../ServerBitMapDetail/ServerBitMapDetail.tsx | 40 +++++--- .../__tests__/ServerBitMapDetail.test.tsx | 97 +++++++++++++++++-- .../__tests__/pendingRegisterValue.test.ts | 95 ++++++++++++++++++ src/renderer/src/context/server.zustand.ts | 32 +++++- 6 files changed, 306 insertions(+), 19 deletions(-) create mode 100644 e2e/specs/01-main/30-server-bit-toggle.spec.ts create mode 100644 src/renderer/src/context/__tests__/pendingRegisterValue.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd99db0d..a9975c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 list is refused for FC15 the way it already was for FC5. - **The transaction log now shows a unit id scan.** The scan sends a request per unit id and per register type, and the log listed none of them. +- **Two bit toggles in a row now both land.** A bit in the server's bitmap panel + is set by writing the whole word back, and the panel read that word from + before the toggle before it, so a second toggle in quick succession cleared + the bit the first had set. A double click on one bit left it on. - **A config file Modbux refuses now says which register and which field.** One malformed register was reported as `serverRegistersPerUnit.1: Invalid input`, naming neither the address nor what was wrong with it, and a unit id outside diff --git a/e2e/specs/01-main/30-server-bit-toggle.spec.ts b/e2e/specs/01-main/30-server-bit-toggle.spec.ts new file mode 100644 index 00000000..d2a17ccf --- /dev/null +++ b/e2e/specs/01-main/30-server-bit-toggle.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '../../fixtures/electron-app' +import { addRegister, cleanServerState } from '../../fixtures/helpers' + +// A toggle writes the whole word back, so it has to read the word the toggle +// before it wrote. The store gets that word one IPC round trip late and hands +// it on after 50 ms of quiet, and clicks arrive faster than either. +test.describe.serial('Server bit toggles in quick succession', () => { + test('clean server state', async ({ mainPage }) => { + await cleanServerState(mainPage) + }) + + test('add a bitmap register at 0 and open its bit panel', async ({ mainPage }) => { + await addRegister(mainPage, { + registerType: 'holding_registers', + dataType: 'BITMAP', + address: 0, + mode: 'fixed', + value: '0' + }) + await mainPage.getByTestId('server-bitmap-expand-0').click() + await expect(mainPage.getByTestId('server-bit-circle-0')).toBeVisible() + }) + + test('two bits back to back are both set', async ({ mainPage }) => { + await mainPage.getByTestId('server-bit-circle-0').click() + await mainPage.getByTestId('server-bit-circle-1').click() + + await expect(mainPage.getByTestId('server-reg-value-holding_registers-0')).toHaveText('3') + }) + + test('two bits back to back are both cleared', async ({ mainPage }) => { + await mainPage.getByTestId('server-bit-circle-0').click() + await mainPage.getByTestId('server-bit-circle-1').click() + + await expect(mainPage.getByTestId('server-reg-value-holding_registers-0')).toHaveText('0') + }) + + test('a double click on one bit leaves it where it was', async ({ mainPage }) => { + await mainPage.getByTestId('server-bit-circle-0').dblclick() + + await expect(mainPage.getByTestId('server-reg-value-holding_registers-0')).toHaveText('0') + await expect(mainPage.getByTestId('server-bit-circle-0')).toHaveAttribute( + 'data-active', + 'false' + ) + }) + + test('a single toggle still sets its bit', async ({ mainPage }) => { + await mainPage.getByTestId('server-bit-circle-3').click() + + await expect(mainPage.getByTestId('server-reg-value-holding_registers-0')).toHaveText('8') + }) + + test('clean up', async ({ mainPage }) => { + await cleanServerState(mainPage) + }) +}) 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 c349ead4..214e5ff5 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx @@ -1,8 +1,8 @@ import Box from '@mui/material/Box' import { ServerRegisterEntry, BitMapConfig, getBit } from '@shared' -import { useServerZustand } from '@renderer/context/server.zustand' +import { pendingRegisterValue, useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import ServerBit from '../../shared/ServerBit' interface ServerBitMapDetailProps { @@ -18,21 +18,39 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele const uuid = useServerZustand((z) => z.selectedUuid) const unitId = useServerZustand((z) => z.getUnitId(z.selectedUuid)) + // A toggle sends the word before it with one bit moved, and the store does + // not hold the word a toggle wrote until main has answered it. So a toggle + // that arrives inside that round trip reads this instead. + const wordInFlight = useRef(undefined) + const handleToggle = useCallback( - (bitIndex: number) => { + async (bitIndex: number) => { const serverZustand = useServerZustand.getState() - const currentValue = register.value + const currentValue = wordInFlight.current ?? pendingRegisterValue(uuid, unitId, register) const newValue = getBit(currentValue, bitIndex) ? currentValue & ~(1 << bitIndex) : currentValue | (1 << bitIndex) - serverZustand.addRegister({ - uuid, - unitId, - params: { ...params, value: newValue, min: undefined, max: undefined, interval: undefined } - }) + wordInFlight.current = newValue + try { + await serverZustand.addRegister({ + uuid, + unitId, + params: { + ...params, + value: newValue, + min: undefined, + max: undefined, + interval: undefined + } + }) + } finally { + // Only the last toggle clears it. An earlier one clearing would hand + // the next toggle a word that is a toggle behind. + if (wordInFlight.current === newValue) wordInFlight.current = undefined + } }, - [register.value, params, uuid, unitId] + [register, params, uuid, unitId] ) const handleCommentChange = useCallback( @@ -89,7 +107,7 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele bitIndex={bitIndex} active={getBit(register.value, bitIndex)} comment={bitConfig?.[String(bitIndex)]?.comment} - onToggle={() => handleToggle(bitIndex)} + onToggle={() => void handleToggle(bitIndex)} onCommentChange={(c) => handleCommentChange(bitIndex, c)} /> ))} diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx index efe19210..61a676ad 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx @@ -17,11 +17,18 @@ const serverState = { addRegister: mockAddRegister } +/** Answers the entry's own value, which is what the real one does with an empty batcher. */ +const mockPendingRegisterValue = vi.fn( + (_uuid: string, _unitId: string, entry: ServerRegisterEntry): number => entry.value +) + vi.mock('@renderer/context/server.zustand', () => ({ useServerZustand: Object.assign( (selector: (state: typeof serverState) => unknown) => selector(serverState), { getState: () => serverState } - ) + ), + pendingRegisterValue: (uuid: string, unitId: string, entry: ServerRegisterEntry): number => + mockPendingRegisterValue(uuid, unitId, entry) })) import ServerBitMapDetail from '../ServerBitMapDetail' @@ -48,14 +55,16 @@ const bitmapAt100 = ( }) /** The register params the panel wrote back, or a failure naming what is missing. */ -const writtenParams = (): ServerRegisterEntry['params'] => { - const call = mockAddRegister.mock.calls[0]?.[0] - if (!call) throw new Error('addRegister was never called') - return call.params +const writtenParams = (call = 0): ServerRegisterEntry['params'] => { + const written = mockAddRegister.mock.calls[call]?.[0] + if (!written) throw new Error(`addRegister was called ${mockAddRegister.mock.calls.length} times`) + return written.params } beforeEach(() => { - mockAddRegister.mockClear() + mockAddRegister.mockReset() + mockPendingRegisterValue.mockReset() + mockPendingRegisterValue.mockImplementation((_uuid, _unitId, entry) => entry.value) }) describe('which bits the panel shows as on', () => { @@ -112,6 +121,82 @@ describe('toggling a bit', () => { expect(params.max).toBeUndefined() expect(params.interval).toBeUndefined() }) + + // `register.value` is a write behind for as long as the batcher holds one, + // and a toggle writes the whole word back, so reading it dropped every bit + // set since. + it('reads the word the batcher is holding, not the one the entry shows', async () => { + mockPendingRegisterValue.mockReturnValue(8) + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + + expect(writtenParams().value).toBe(9) + }) +}) + +describe('a toggle that arrives before the one before it has landed', () => { + /** A write main has taken the call for and not answered yet. */ + const heldWrite = (): (() => void) => { + let answer: () => void = () => {} + mockAddRegister.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = (): void => resolve(true) + }) + ) + return () => answer() + } + + it('moves its bit in the word that one sent', async () => { + const answerFirst = heldWrite() + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + await userEvent.click(screen.getByTestId('server-bit-circle-1')) + + expect(writtenParams(1).value).toBe(3) + answerFirst() + }) + + it('clears a bit the toggle before it set', async () => { + const answerFirst = heldWrite() + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + + expect(writtenParams(1).value).toBe(0) + answerFirst() + }) + + // Discriminates the guard on the clear from an unconditional one: with two + // writes out and the first answered, the word the second sent is still the + // only one the third can build on. + it('keeps the newest word when an older write answers first', async () => { + const answerFirst = heldWrite() + const answerSecond = heldWrite() + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + await userEvent.click(screen.getByTestId('server-bit-circle-1')) + answerFirst() + await userEvent.click(screen.getByTestId('server-bit-circle-2')) + + expect(writtenParams(2).value).toBe(7) + answerSecond() + }) + + it('goes back to the store once the answer is in', async () => { + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + // A client wrote the top bit while the panel was open, and the store has it. + mockPendingRegisterValue.mockReturnValue(0x8000) + await userEvent.click(screen.getByTestId('server-bit-circle-1')) + + expect(writtenParams(1).value).toBe(0x8002) + }) }) describe('the bit comments', () => { diff --git a/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts b/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts new file mode 100644 index 00000000..8ad8721d --- /dev/null +++ b/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +// +// A reader that derives its next value from this one cannot wait for the 50 ms +// batcher: the word it writes back would drop everything written since. The bit +// panel is that reader. +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { MAIN_SERVER_UUID, ServerRegister, ServerRegisters } from '@shared' +import { stubRenderer } from './stubRenderer' + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + localStorage.clear() + stubRenderer() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** One bitmap register, as the add dialog would have built it. */ +const entry = (address: number, value: number): ServerRegister[string] => ({ + value, + params: { + address, + registerType: 'holding_registers', + dataType: 'bitmap', + comment: '', + value, + min: undefined, + max: undefined, + interval: undefined + } +}) + +/** The store with one bitmap register at address 10, on the default unit. */ +const seeded = async (): Promise => { + const store = await import('../server.zustand') + store.useServerZustand.getState().clean(MAIN_SERVER_UUID) + store.useServerZustand.getState().replaceServerRegisters('0', { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { 10: entry(10, 1) } + }) + return store +} + +/** The entry the store holds, or a failure saying it is not there. */ +const stored = (store: typeof import('../server.zustand')): ServerRegister[string] => { + const registers: ServerRegisters | undefined = + store.useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0'] + const found = registers?.holding_registers[10] + if (!found) throw new Error('the register at address 10 is not there') + return found +} + +describe('pendingRegisterValue', () => { + it('answers the entry when the batcher holds nothing', async () => { + const store = await seeded() + + expect(store.pendingRegisterValue(MAIN_SERVER_UUID, '0', stored(store))).toBe(1) + }) + + it('answers the word a write left in the batcher, before the entry has it', async () => { + const store = await seeded() + + store.applyRegisterValue({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'holding_registers', + address: 10, + value: 3 + }) + + expect(stored(store).value).toBe(1) + expect(store.pendingRegisterValue(MAIN_SERVER_UUID, '0', stored(store))).toBe(3) + }) + + it('answers the entry again once the batcher has flushed', async () => { + const store = await seeded() + + store.applyRegisterValue({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'holding_registers', + address: 10, + value: 3 + }) + await vi.advanceTimersByTimeAsync(100) + + expect(stored(store).value).toBe(3) + expect(store.pendingRegisterValue(MAIN_SERVER_UUID, '0', stored(store))).toBe(3) + }) +}) diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 00136cec..da339bf4 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -21,6 +21,7 @@ import { SERVER_ZUSTAND_STORAGE_KEY, registerWidth, ModbusBaudRate, + RegisterType, RegisterValue } from '@shared' import { onEvent } from '@renderer/events' @@ -521,6 +522,33 @@ const delayedRegister = new ServerDelayedSetter `${uuid}-${unitId}-${registerType}-${address}` + +/** + * The value the entry is about to hold, for a reader that cannot wait. + * + * `entry.value` is behind by whatever the batcher is holding, and the batcher + * hands over after 50 ms of quiet on any register. A reader that only draws the + * value can be behind; one that derives its next value from this one cannot, + * because the word it writes back would drop everything written since. + */ +export const pendingRegisterValue = ( + uuid: string, + unitId: UnitIdString, + entry: ServerRegisterEntry +): number => + Number( + delayedRegister.getValue( + batchKey(uuid, unitId, entry.params.registerType, entry.params.address) + ) ?? entry.value + ) + /** * Folds one word main wrote into the entry that holds the address. * @@ -538,7 +566,7 @@ export const applyRegisterValue = (payload: RegisterValue): void => { const entry = serverZustand.serverRegisters[uuid]?.[unitId]?.[registerType]?.[address] if (entry === undefined) return - const cacheKey = `${uuid}-${unitId}-${registerType}-${address}` + const cacheKey = batchKey(uuid, unitId, registerType, address) const currentBool = delayedBool.getValue(cacheKey) ?? entry.value if (currentBool !== booleanValue) { @@ -579,7 +607,7 @@ export const applyRegisterValue = (payload: RegisterValue): void => { } // Extract the parameters and current composite value (from cache when state isn't updated yet) - const cacheKey = `${uuid}-${unitId}-${registerType}-${entryAddress}` + const cacheKey = batchKey(uuid, unitId, registerType, entryAddress) const currentValue = delayedRegister.getValue(cacheKey) ?? serverRegisterEntry.value const { dataType } = serverRegisterEntry.params // Get littleEndian from global server state From fb0e428ee1f6c4e0b4850fd63eb17a0442fbdb40 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 00:49:30 +0200 Subject: [PATCH 83/91] fix: a write or a scan refused for a shut port reports the disconnect `_read`, `write` and `_canScan` each asked `connectState !== 'connected' || !isOpen` and answered it differently: `_read` called `_setDisconnected`, the other two returned with a message and left the state saying connected. So a write or a scan refused for a port that closed underneath left the app showing a connection it no longer had, and which button you pressed decided whether it noticed. `_requireConnected` takes the verb its message names and corrects the state for all three. `_canScan` is gone, because that was the whole of it. The one caller with nobody to answer is a poll's own read, which passes `quiet`: the close that ended the poll has already spoken. --- CHANGELOG.md | 4 ++ .../modules/__tests__/modbusClient.test.ts | 65 +++++++++++++++++++ src/main/modules/modbusClient.ts | 58 ++++++++--------- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9975c76..4dbef771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 list is refused for FC15 the way it already was for FC5. - **The transaction log now shows a unit id scan.** The scan sends a request per unit id and per register type, and the log listed none of them. +- **A write or a scan refused for a lost connection now reports it.** Both said + they could not reach the device and left the app showing it as connected. A + read already corrected the state, so which one you pressed decided whether the + app noticed. - **Two bit toggles in a row now both land.** A bit in the server's bitmap panel is set by writing the whole word back, and the panel read that word from before the toggle before it, so a second toggle in quick succession cleared diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index a4af7379..29c05566 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1037,6 +1037,71 @@ describe('ModbusClient', () => { }) }) + describe('a port that closed under a connected state', () => { + /** Connected as far as the state knows, and shut underneath. */ + const closedUnderneath = async (): Promise => { + await connectClient() + setupHoldingRegisterReadMock([100]) + mockModbusRTU.isOpen = false + } + + it('a refused write reports the disconnect', async () => { + await closedUnderneath() + + await client.write({ address: 5, type: 'coils', value: [true], single: true }) + + expect(client.state.connectState).toBe('disconnected') + expect(getLastClientState().connectState).toBe('disconnected') + }) + + it('a refused register scan reports the disconnect', async () => { + await closedUnderneath() + + await client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) + + expect(client.state.connectState).toBe('disconnected') + expect(getLastClientState().connectState).toBe('disconnected') + }) + + it('a refused unit id scan reports the disconnect', async () => { + await closedUnderneath() + + await client.scanUnitIds({ + range: [1, 3], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + + expect(client.state.connectState).toBe('disconnected') + expect(getLastClientState().connectState).toBe('disconnected') + }) + + // A poll asked for one read and gets one message per read otherwise, and + // the close that shut the port has already said what happened. + it('a poll reports the disconnect without saying it cannot read', async () => { + await closedUnderneath() + + client.startPolling() + await vi.advanceTimersByTimeAsync(100) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot read, not connected')).toBe(false) + expect(client.state.polling).toBe(false) + expect(getLastClientState().connectState).toBe('disconnected') + }) + + it('a read the user asked for says it cannot read', async () => { + await closedUnderneath() + + await client.read() + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot read, not connected')).toBe(true) + }) + }) + describe('read with data', () => { it('reads holding registers and sends data', async () => { await connectClient() diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 96ccc738..23ac1869 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -239,6 +239,30 @@ export class ModbusClient { this._sendClientState() } + /** + * Whether the port is open, with the message a caller that finds it shut gets. + * + * Three callers ask it, `_read`, `write` and either scan, and the state they + * find is one to correct rather than to report around: `connected` over a + * closed port leaves the view reading connected until something says + * otherwise, and `_setDisconnected` is what says it. + * + * A poll's own read is the one caller with nobody to answer, and it is the + * one that passes `quiet`: the close that ended it has already spoken. + */ + private _requireConnected = (verb: string, quiet = false): boolean => { + if (this._clientState.connectState === 'connected' && this._client.isOpen) return true + if (!quiet) { + this._emitMessage({ + message: `Cannot ${verb}, not connected`, + variant: 'warning', + error: null + }) + } + this._setDisconnected() + return false + } + /** * The read loop that owns the client, named, or nothing. * @@ -531,17 +555,7 @@ export class ModbusClient { } private _read = async (): Promise => { - if (this._clientState.connectState !== 'connected' || !this._client.isOpen) { - if (!this._clientState.polling) { - this._emitMessage({ - message: 'Cannot read, not connected', - variant: 'warning', - error: null - }) - } - this._setDisconnected() - return - } + if (!this._requireConnected('read', this._clientState.polling)) return // Set unit id before reading (in case of TCP) const { unitId } = this._appState.connectionConfig @@ -788,10 +802,7 @@ export class ModbusClient { // `writeFC5`, `writeFC6`, `writeFC15` and `writeFC16` answer a closed port // with a `PortNotOpenError` before they file a transaction, so a write down // a closed port has nothing of its own to log. - if (this._clientState.connectState !== 'connected' || !this._client.isOpen) { - this._emitMessage({ message: 'Cannot write, not connected', variant: 'warning', error: null }) - return - } + if (!this._requireConnected('write')) return const owner = this._readLoopOwner() if (owner) { @@ -950,23 +961,10 @@ export class ModbusClient { // Polling and scanning are two read loops over one port, so starting a scan // stops the poll. That is a consequence of scanning rather than something the // user asked for, so it is taken here and neither dialog does it. - /** - * Whether a scan may start, with the message a refused one gets. - * - * A scan does not go through `_read`, so it asks here what `_read` asks - * before it reads. - */ - private _canScan = (): boolean => { - if (this._clientState.connectState === 'connected' && this._client.isOpen) return true - this._emitMessage({ message: 'Cannot scan, not connected', variant: 'warning', error: null }) - return false - } - - // // // Scan Unit ID public scanUnitIds = async (params: ScanUnitIDParameters): Promise => { - if (!this._canScan()) return + if (!this._requireConnected('scan')) return this.stopPolling() this._client.setTimeout(params.timeout) @@ -1046,7 +1044,7 @@ export class ModbusClient { // // Scan Registers public scanRegisters = async (params: ScanRegistersParameters): Promise => { - if (!this._canScan()) return + if (!this._requireConnected('scan')) return this.stopPolling() const { unitId } = this._appState.connectionConfig From 8decf692be169cc3ead828d2ef72063ef1865aa7 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 09:01:52 +0200 Subject: [PATCH 84/91] fix: a register added in the split out server window survives a restart Both windows hold the server store and both persist it to one key, so the copy that writes last is the one the next launch reads. The window not showing the server never hears about a register the other one added, and one generator tick puts its copy back over the key. `sendTo` folds into `send(event, payload, to?)`, and `register_value` and `rtu_server_status` now go to the window showing the server. Those two are the only recurring writes a window not drawing the server makes: the rest are `init` at module load, the config reset acknowledgement, and actions on components that window has unmounted. Closing the split window hands the events back to a copy that has not moved since load, so the store re-reads the key at that point. Measured on a 1 s generator: without this, five of eight samples half a second apart had lost the register while the split was up, and after the close the key held it in none of eight. --- CHANGELOG.md | 5 + .../03-split-view-persistence.spec.ts | 108 +++++++++++ src/main/__tests__/ipc.test.ts | 6 +- src/main/__tests__/windows.test.ts | 16 +- src/main/ipc.ts | 14 +- .../modules/__tests__/modbusClient.test.ts | 14 +- .../modules/__tests__/modbusServer.test.ts | 176 +++++++++++------- .../modules/__tests__/valueGenerator.test.ts | 3 +- src/main/modules/modbusClient.ts | 2 +- src/main/modules/modbusServer.ts | 50 +++-- .../modules/modbusServer/valueGenerator.ts | 18 +- src/main/windows.ts | 82 ++++---- .../__tests__/serverStoreRehydrate.test.ts | 86 +++++++++ .../src/context/__tests__/stubRenderer.ts | 37 +++- src/renderer/src/context/server.zustand.ts | 24 +++ 15 files changed, 471 insertions(+), 170 deletions(-) create mode 100644 e2e/specs/02-standalone/03-split-view-persistence.spec.ts create mode 100644 src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dbef771..49bea5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is set by writing the whole word back, and the panel read that word from before the toggle before it, so a second toggle in quick succession cleared the bit the first had set. A double click on one bit left it on. +- **A register added in the split out server window now survives a restart.** + Each window kept its own copy of the server setup and both wrote it to the + same place, so the window you were not looking at wrote its copy over what you + had just added. One generator was enough to keep that happening, and closing + the split window left the stale copy as the only one. - **A config file Modbux refuses now says which register and which field.** One malformed register was reported as `serverRegistersPerUnit.1: Invalid input`, naming neither the address nor what was wrong with it, and a unit id outside diff --git a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts new file mode 100644 index 00000000..f7ce1463 --- /dev/null +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -0,0 +1,108 @@ +import { + test, + expect, + _electron as electron, + type ElectronApplication, + type Page +} from '@playwright/test' +import { + addRegister, + cleanServerState, + navigateToHome, + splitOutServerWindow +} from '../../fixtures/helpers' +import { launchOptions } from '../../fixtures/launch' +import { keepOutput } from '../../fixtures/electron-app' + +let app: ElectronApplication +let page: Page + +async function launchApp(clearStorage: boolean): Promise { + app = await electron.launch(launchOptions()) + keepOutput(app) + if (clearStorage) { + await app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) + } + let searchCount = 0 + while (searchCount < 10) { + searchCount++ + const found = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) + const [firstWindow] = app.windows() + if (found && firstWindow && app.windows().length === 1) { + page = firstWindow + break + } + await new Promise((r) => setTimeout(r, 1000)) + } + if (!page) throw new Error('Modbux main window not found!') + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(500) +} + +/** + * Both windows hold the server store and both persist it to one key, so the + * copy that writes last is the one the next launch reads. A generator keeps the + * main window's copy writing, which is what made the split out window's + * register go missing. + */ +test.describe.serial('A register added in the split out server window', () => { + let serverPage: Page + + test.afterAll(async () => { + if (app) await app.close() + }) + + test('a generator in the main window, then the split', async () => { + await launchApp(true) + await cleanServerState(page) + await addRegister(page, { + address: 0, + registerType: 'holding_registers', + dataType: 'UINT16', + mode: 'generator', + min: '0', + max: '1000', + interval: '1' + }) + await navigateToHome(page) + + serverPage = await splitOutServerWindow(app, page) + await serverPage.waitForTimeout(1000) + }) + + test('a register added in the split out window', async () => { + await addRegister(serverPage, { + address: 77, + registerType: 'holding_registers', + dataType: 'UINT16', + mode: 'fixed', + value: '42' + }) + await expect(serverPage.getByTestId('server-reg-value-holding_registers-77')).toHaveText('42') + // The generator fires every second, and each tick is a write from the + // window that is not showing the server. + await serverPage.waitForTimeout(4000) + }) + + test('the split window closes and the main window keeps it', async () => { + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() === 'Server') + .forEach((w) => w.close()) + }) + await page.waitForTimeout(3000) + }) + + test('it is still there after a restart', async () => { + await app.close() + await new Promise((r) => setTimeout(r, 1000)) + await launchApp(false) + + await page.getByTestId('home-server-btn').click() + await expect(page.getByTestId('server-reg-value-holding_registers-77')).toHaveText('42') + }) +}) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index e5032f79..812bdb6c 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -30,7 +30,7 @@ import { import type { Windows } from '../windows' import { createIpcHandle, initIpc } from '../ipc' -/** The addressee `sendTo` was given, beside the message it carried. */ +/** The addressee `send` was given, beside the message it carried. */ interface SentMessage { to: unknown message: BackendMessage @@ -39,8 +39,8 @@ interface SentMessage { const createWindows = (): { windows: Windows; sent: SentMessage[] } => { const sent: SentMessage[] = [] const windows = { - send: (_event: string, message: BackendMessage) => sent.push({ to: 'all', message }), - sendTo: (to: unknown, _event: string, message: BackendMessage) => sent.push({ to, message }) + send: (_event: string, message: BackendMessage, to: unknown = 'all') => + sent.push({ to, message }) } as unknown as Windows return { windows, sent } } diff --git a/src/main/__tests__/windows.test.ts b/src/main/__tests__/windows.test.ts index f6b152cf..d7b13bf7 100644 --- a/src/main/__tests__/windows.test.ts +++ b/src/main/__tests__/windows.test.ts @@ -110,7 +110,7 @@ describe('Windows', () => { }) }) - describe('sendTo', () => { + describe('an addressed send', () => { const message = { message: 'refused', variant: 'error', error: null } as never it('reaches the contents it was handed and nothing else', () => { @@ -121,7 +121,7 @@ describe('Windows', () => { mainWindow.webContents.send.mockClear() serverWindow.webContents.send.mockClear() - windows.sendTo(mainWindow.webContents as never, 'backend_message', message) + windows.send('backend_message', message, mainWindow.webContents as never) expect(mainWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) expect(serverWindow.webContents.send).not.toHaveBeenCalled() @@ -135,7 +135,7 @@ describe('Windows', () => { mainWindow.webContents.send.mockClear() serverWindow.webContents.send.mockClear() - windows.sendTo('main', 'backend_message', message) + windows.send('backend_message', message, 'main') expect(mainWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) expect(serverWindow.webContents.send).not.toHaveBeenCalled() @@ -149,7 +149,7 @@ describe('Windows', () => { mainWindow.webContents.send.mockClear() serverWindow.webContents.send.mockClear() - windows.sendTo('serverView', 'backend_message', message) + windows.send('backend_message', message, 'serverView') expect(serverWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) expect(mainWindow.webContents.send).not.toHaveBeenCalled() @@ -161,13 +161,13 @@ describe('Windows', () => { windows.main = mainWindow as never mainWindow.webContents.send.mockClear() - windows.sendTo('serverView', 'backend_message', message) + windows.send('backend_message', message, 'serverView') expect(mainWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) }) it('says nothing when the addressee has no window', () => { - expect(() => windows.sendTo('serverView', 'backend_message', message)).not.toThrow() + expect(() => windows.send('backend_message', message, 'serverView')).not.toThrow() }) it('skips destroyed contents', () => { @@ -176,7 +176,7 @@ describe('Windows', () => { mainWindow.webContents.send.mockClear() mainWindow.webContents.isDestroyed.mockReturnValue(true) - windows.sendTo('main', 'backend_message', message) + windows.send('backend_message', message, 'main') expect(mainWindow.webContents.send).not.toHaveBeenCalled() }) @@ -188,7 +188,7 @@ describe('Windows', () => { throw new Error('Object has been destroyed') }) - expect(() => windows.sendTo('main', 'backend_message', message)).not.toThrow() + expect(() => windows.send('backend_message', message, 'main')).not.toThrow() }) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b2e3ad36..d39af462 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -84,11 +84,15 @@ export const createIpcHandle = const result = schema.safeParse(args[0]) if (!result.success) { - windows.sendTo(event.sender, 'backend_message', { - message: 'Invalid request, nothing was changed', - variant: 'error', - error: `${channel}: ${formatZodError(result.error)}` - }) + windows.send( + 'backend_message', + { + message: 'Invalid request, nothing was changed', + variant: 'error', + error: `${channel}: ${formatZodError(result.error)}` + }, + event.sender + ) return undefined } diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 29c05566..b9444d53 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -152,15 +152,11 @@ let addressedTo: any[][] = [] const createMockWindows = (): Windows => ({ - send: vi.fn((event: string, ...args: unknown[]) => { - sentToWindows.push([event, ...args.map((arg) => structuredClone(arg))]) - }), - // `sendTo` puts the addressee first and the event second, and a test that - // reads the payload wants the same shape either way. The addressee is - // recorded beside it, because who a message reached is its own question. - sendTo: vi.fn((target: unknown, event: string, ...args: unknown[]) => { - sentToWindows.push([event, ...args.map((arg) => structuredClone(arg))]) - addressedTo.push([target, event]) + // The addressee is recorded apart from the payload, because who a message + // reached is its own question. `undefined` there is every window. + send: vi.fn((event: string, payload: unknown, to?: unknown) => { + sentToWindows.push([event, structuredClone(payload)]) + addressedTo.push([to, event]) }) }) as unknown as Windows diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 67de83ea..d16d7b0d 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -181,7 +181,7 @@ const lastSerialPortOptions = (): unknown => { return call[2] } -const createMockWindows = (): Windows => ({ send: vi.fn(), sendTo: vi.fn() }) as unknown as Windows +const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows describe('ModbusServer', () => { let server: ModbusServer @@ -221,26 +221,16 @@ describe('ModbusServer', () => { vi.useRealTimers() }) + /** Every `send` of `event`, as `[event, payload, addressee]`. */ const getWindowCalls = (event: string) => (windows.send as ReturnType).mock.calls.filter((c) => c[0] === event) - /** What `sendTo` carried for `event`, with the addressee stripped off the front. */ - const getAddressedCalls = (event: string) => - (windows.sendTo as ReturnType).mock.calls - .filter((c) => c[1] === event) - .map((c) => c.slice(1)) - - /** Both recorders, for a test that wants only what happens after a setup step. */ const clearWindowCalls = (): void => { ;(windows.send as ReturnType).mockClear() - ;(windows.sendTo as ReturnType).mockClear() } - /** Who each `sendTo` of `event` was addressed to. */ - const getAddressees = (event: string) => - (windows.sendTo as ReturnType).mock.calls - .filter((c) => c[1] === event) - .map((c) => c[0]) + /** Who each `send` of `event` was addressed to. `undefined` is every window. */ + const getAddressees = (event: string) => getWindowCalls(event).map((c) => c[2]) describe('addRegister with static value', () => { it('writes a uint16 value to the correct address', () => { @@ -268,7 +258,8 @@ describe('ModbusServer', () => { registerType: 'holding_registers', address: 0, value: 1234 - }) + }), + 'serverView' ) }) @@ -417,14 +408,16 @@ describe('ModbusServer', () => { expect.objectContaining({ address: 10, value: 1 // high word - }) + }), + 'serverView' ) expect(windows.send).toHaveBeenCalledWith( 'register_value', expect.objectContaining({ address: 11, value: 4464 // low word (0x1170) - }) + }), + 'serverView' ) }) @@ -454,11 +447,13 @@ describe('ModbusServer', () => { // 70000 = 0x00011170, low word first expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 10, value: 4464 }) + expect.objectContaining({ address: 10, value: 4464 }), + 'serverView' ) expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 11, value: 1 }) + expect.objectContaining({ address: 11, value: 1 }), + 'serverView' ) }) @@ -484,7 +479,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 10, value: 1 }) + expect.objectContaining({ address: 10, value: 1 }), + 'serverView' ) }) @@ -510,7 +506,8 @@ describe('ModbusServer', () => { registerType: 'input_registers', address: 5, value: 999 - }) + }), + 'serverView' ) }) }) @@ -538,7 +535,8 @@ describe('ModbusServer', () => { expect.objectContaining({ address: 0, value: 50 - }) + }), + 'serverView' ) }) @@ -584,7 +582,8 @@ describe('ModbusServer', () => { expect.objectContaining({ address: 0, value: 99 - }) + }), + 'serverView' ) // Old generator should be disposed (no more ticks from it) @@ -795,13 +794,17 @@ describe('ModbusServer', () => { state: true }) - expect(windows.send).toHaveBeenCalledWith('register_value', { - uuid, - unitId, - registerType: 'coils', - address: 10, - value: true - }) + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + { + uuid, + unitId, + registerType: 'coils', + address: 10, + value: true + }, + 'serverView' + ) }) it('sets a discrete_input value', () => { @@ -813,13 +816,17 @@ describe('ModbusServer', () => { state: false }) - expect(windows.send).toHaveBeenCalledWith('register_value', { - uuid, - unitId, - registerType: 'discrete_inputs', - address: 5, - value: false - }) + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + { + uuid, + unitId, + registerType: 'discrete_inputs', + address: 5, + value: false + }, + 'serverView' + ) }) // ! Coverage-only: exercises FALSE branch of !perUnitMap.has(unitId) in setBool @@ -831,7 +838,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 1, value: true }) + expect.objectContaining({ address: 1, value: true }), + 'serverView' ) }) }) @@ -990,7 +998,8 @@ describe('ModbusServer', () => { expect.objectContaining({ address: 10, value: 200 - }) + }), + 'serverView' ) }) @@ -1064,7 +1073,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 0, value: 42 }) + expect.objectContaining({ address: 0, value: 42 }), + 'serverView' ) }) @@ -1108,7 +1118,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ unitId: '2', value: 99 }) + expect.objectContaining({ unitId: '2', value: 99 }), + 'serverView' ) }) }) @@ -1155,14 +1166,16 @@ describe('ModbusServer', () => { expect.objectContaining({ unitId: '1', value: 111 - }) + }), + 'serverView' ) expect(windows.send).toHaveBeenCalledWith( 'register_value', expect.objectContaining({ unitId: '2', value: 222 - }) + }), + 'serverView' ) }) }) @@ -1209,7 +1222,7 @@ describe('ModbusServer', () => { ) await server.createServer({ uuid, port: 5021 }) - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Error closing server')).toBe(true) }) @@ -1256,7 +1269,7 @@ describe('ModbusServer', () => { portAvailableResults = new Array(10000).fill(false) const port = await server.createServer({ uuid, port: 5020 }) expect(port).toBe(15020) // 5020 + 10000 - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'No available port found')).toBe(true) }) }) @@ -1285,7 +1298,7 @@ describe('ModbusServer', () => { it('emits error when server not found', async () => { await server.deleteServer('non-existent') - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message.includes('No server found'))).toBe(true) }) @@ -1296,7 +1309,7 @@ describe('ModbusServer', () => { ) await server.deleteServer(uuid) - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Error closing server')).toBe(true) }) }) @@ -1383,7 +1396,7 @@ describe('ModbusServer', () => { portAvailableResults = ['EACCES'] const port = await server.setPort({ uuid, port: 502 }) expect(port).toBe(5020) // Returns current port, not requested - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Port 502 requires elevated privileges')).toBe( true ) @@ -1396,7 +1409,7 @@ describe('ModbusServer', () => { portAvailableResults = ['EADDRINUSE'] const port = await server.setPort({ uuid, port: 5021 }) expect(port).toBe(5020) // Returns current port - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Port 5021 is already in use')).toBe(true) }) @@ -1406,7 +1419,7 @@ describe('ModbusServer', () => { expect(port).toBe(5020) // Returns requested port (no current server) // ServerTCP should never have been called expect(ServerTCP).not.toHaveBeenCalled() - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Port 5020 is already in use')).toBe(true) }) @@ -1422,7 +1435,7 @@ describe('ModbusServer', () => { expect(port).toBe(5020) expect(vi.mocked(ServerTCP).mock.calls[1]?.[1]).toEqual({ host: '0.0.0.0', port: 5020 }) - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Port 5021 is already in use')).toBe(true) }) @@ -1434,7 +1447,7 @@ describe('ModbusServer', () => { const port = await server.setPort({ uuid, port: 5021 }) expect(port).toBe(5020) - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect( messages.some((m) => m[1].message === 'The server could not be restarted on port 5020') ).toBe(true) @@ -1451,7 +1464,7 @@ describe('ModbusServer', () => { expect(port).toBe(5020) expect(vi.mocked(ServerTCP).mock.calls.length).toBe(callsBefore) - const messages = getAddressedCalls('backend_message') + const messages = getWindowCalls('backend_message') expect( messages.some((m) => m[1].message === 'A server needs a port between 1 and 65535') ).toBe(true) @@ -1528,10 +1541,23 @@ describe('ModbusServer', () => { const statusCalls = getWindowCalls('rtu_server_status') expect(statusCalls.some((c) => c[1].active === true)).toBe(true) - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message.includes('/dev/ttyUSB0'))).toBe(true) }) + // `rtuServerActive` is drawn by `ServerRtuConfig` and read nowhere else, so + // a broadcast is the window not showing the server writing its own copy of + // the server store over the key the other window owns. + it('sends the status to the window showing the server', async () => { + await server.startRtuServer({ uuid, serialConfig }) + clearWindowCalls() + + lastInstance(ServerSerial)._handlers['initialized']() + await server.stopRtuServer() + + expect(getAddressees('rtu_server_status')).toEqual(['serverView', 'serverView']) + }) + it('emits error status on socketError event', async () => { await server.startRtuServer({ uuid, serialConfig }) @@ -1543,7 +1569,7 @@ describe('ModbusServer', () => { const statusCalls = getWindowCalls('rtu_server_status') expect(statusCalls.some((c) => c[1].active === false)).toBe(true) - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message.includes('port gone'))).toBe(true) }) @@ -1554,7 +1580,7 @@ describe('ModbusServer', () => { fireOpenCallback(new Error('cannot open /dev/ttyUSB0')) expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) - expect(getAddressedCalls('backend_message').map((c) => c[1].message)).toEqual([ + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server error: cannot open /dev/ttyUSB0' ]) }) @@ -1569,7 +1595,7 @@ describe('ModbusServer', () => { instance._handlers['initialized']() expect(getWindowCalls('rtu_server_status').map((c) => c[1].active)).toEqual([true]) - expect(getAddressedCalls('backend_message').map((c) => c[1].message)).toEqual([ + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server started on /dev/ttyUSB0' ]) }) @@ -1627,7 +1653,7 @@ describe('ModbusServer', () => { fireSerialPathEvent(instance, 'close', new Error('Disconnected')) expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect( messageCalls.some((c) => c[1].message === 'RTU server disconnected from /dev/ttyUSB0') ).toBe(true) @@ -1644,7 +1670,7 @@ describe('ModbusServer', () => { // `stopRtuServer` says 'RTU server stopped' for a server that was up, and // the close above is what takes it down. - expect(getAddressedCalls('backend_message')).toEqual([]) + expect(getWindowCalls('backend_message')).toEqual([]) }) it('says nothing about a close it asked for itself', async () => { @@ -1656,7 +1682,7 @@ describe('ModbusServer', () => { clearWindowCalls() fireSerialPathEvent(instance, 'close', undefined) - expect(getAddressedCalls('backend_message')).toEqual([]) + expect(getWindowCalls('backend_message')).toEqual([]) expect(getWindowCalls('rtu_server_status')).toEqual([]) }) @@ -1671,7 +1697,7 @@ describe('ModbusServer', () => { fireSerialPathEvent(instance, 'error', new Error('Disconnected')) fireSerialPathEvent(instance, 'close', new Error('Disconnected')) - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.map((c) => c[1].message)).toEqual(['RTU server error: Disconnected']) }) @@ -1689,7 +1715,7 @@ describe('ModbusServer', () => { // What proves the live server is still up is its own close, which reports // only while `_rtuActive` holds. fireSerialPathEvent(live, 'close', new Error('Disconnected')) - expect(getAddressedCalls('backend_message').map((c) => c[1].message)).toEqual([ + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server disconnected from /dev/ttyUSB0' ]) }) @@ -1704,7 +1730,7 @@ describe('ModbusServer', () => { // A port that opens late, after the server it belongs to was replaced. replaced._handlers['initialized']() - expect(getAddressedCalls('backend_message').map((c) => c[1].message)).toEqual([ + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server started on /dev/ttyUSB0' ]) expect(getWindowCalls('rtu_server_status').map((c) => c[1].active)).toEqual([false, true]) @@ -1719,7 +1745,7 @@ describe('ModbusServer', () => { await server.startRtuServer({ uuid, serialConfig }) lastInstance(ServerSerial)._handlers['initialized']() - expect(getAddressedCalls('backend_message').map((c) => c[1].message)).toEqual([ + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server started on /dev/ttyUSB0' ]) }) @@ -1735,7 +1761,7 @@ describe('ModbusServer', () => { fireSerialPathEvent(replaced, 'close', new Error('Disconnected')) expect(getWindowCalls('rtu_server_status')).toEqual([]) - expect(getAddressedCalls('backend_message')).toEqual([]) + expect(getWindowCalls('backend_message')).toEqual([]) }) }) @@ -1771,7 +1797,7 @@ describe('ModbusServer', () => { clearWindowCalls() await server.stopRtuServer() - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message === 'RTU server stopped')).toBe(true) }) @@ -1781,7 +1807,7 @@ describe('ModbusServer', () => { clearWindowCalls() await server.stopRtuServer() - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message === 'RTU server stopped')).toBe(false) }) @@ -1794,7 +1820,7 @@ describe('ModbusServer', () => { await server.stopRtuServer() // No error message emitted - const messageCalls = getAddressedCalls('backend_message') + const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].variant === 'error')).toBe(false) }) }) @@ -2036,7 +2062,14 @@ describe('ModbusServer', () => { expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ uuid, unitId, registerType: 'coils', address: 10, value: true }) + expect.objectContaining({ + uuid, + unitId, + registerType: 'coils', + address: 10, + value: true + }), + 'serverView' ) }) @@ -2098,7 +2131,8 @@ describe('ModbusServer', () => { registerType: 'holding_registers', address: 20, value: 12345 - }) + }), + 'serverView' ) }) @@ -2325,7 +2359,7 @@ describe('ModbusServer', () => { const warning = 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.' const warnings = (): unknown[] => - getAddressedCalls('backend_message').filter((c) => c[1].message === warning) + 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) diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index 4cdb5a74..4a045f47 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -49,7 +49,8 @@ describe('ValueGenerator', () => { unitId: '1', registerType: 'holding_registers', address: 0 - }) + }), + 'serverView' ) gen.dispose() diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 23ac1869..0070bf0d 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -193,7 +193,7 @@ export class ModbusClient { // Events /** The client view never leaves the main window, so its messages go there. */ private _emitMessage = (message: BackendMessage): void => { - this._windows.sendTo('main', 'backend_message', message) + this._windows.send('backend_message', message, 'main') } private _sendClientState = (): void => { this._windows.send('client_state', this._clientState) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index d69c7324..93194424 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -287,7 +287,7 @@ export class ModbusServer { variant: 'default' | 'error' | 'success' | 'warning' | 'info' error?: Error }): void { - this._windows.sendTo('serverView', 'backend_message', { message, variant, error }) + this._windows.send('backend_message', { message, variant, error }, 'serverView') } /** @@ -527,13 +527,17 @@ export class ModbusServer { registers.forEach((register, index) => { const registerAddress = address + index serverData[registerType][registerAddress] = register - this._windows.send('register_value', { - uuid, - unitId, - registerType, - address: registerAddress, - value: register - }) + this._windows.send( + 'register_value', + { + uuid, + unitId, + registerType, + address: registerAddress, + value: register + }, + 'serverView' + ) }) this._setServerData(uuid, unitId, serverData) return registers @@ -653,7 +657,11 @@ export class ModbusServer { const serverData = this._unitData(uuid, unitId) serverData[registerType][address] = state this._setServerData(uuid, unitId, serverData) - this._windows.send('register_value', { uuid, unitId, registerType, address, value: state }) + this._windows.send( + 'register_value', + { uuid, unitId, registerType, address, value: state }, + 'serverView' + ) } /** @@ -691,7 +699,7 @@ export class ModbusServer { if (generation !== this._rtuGeneration) return this._rtuActive = false this._emitMessage({ message, variant: 'error', error }) - this._windows.send('rtu_server_status', { active: false }) + this._windows.send('rtu_server_status', { active: false }, 'serverView') } /** @@ -761,7 +769,7 @@ export class ModbusServer { message: `RTU server started on ${serialConfig.com}`, variant: 'success' }) - this._windows.send('rtu_server_status', { active: true }) + this._windows.send('rtu_server_status', { active: true }, 'serverView') this._warnBroadcastUnit(uuid) }) @@ -793,7 +801,7 @@ export class ModbusServer { this._rtuActive = false this._rtuGeneration++ this._broadcastWarningSent = false - this._windows.send('rtu_server_status', { active: false }) + this._windows.send('rtu_server_status', { active: false }, 'serverView') if (wasActive) { this._emitMessage({ message: 'RTU server stopped', variant: 'warning' }) } @@ -922,13 +930,17 @@ export class ModbusServer { if (!serverData) return serverData[registerType][address] = value - this._windows.send('register_value', { - uuid, - unitId, - registerType, - address, - value - } as RegisterValue) + this._windows.send( + 'register_value', + { + uuid, + unitId, + registerType, + address, + value + } as RegisterValue, + 'serverView' + ) } /** diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 20a3e44e..395eea68 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -112,13 +112,17 @@ export class ValueGenerator implements RegisterValueGenerator { registers.forEach((register, index) => { const registerAddress = this._address + index this._serverData[this._registerType][registerAddress] = register - this._windows.send('register_value', { - uuid: this._uuid, - unitId: this._unitId, - registerType: this._registerType, - address: registerAddress, - value: register - }) + this._windows.send( + 'register_value', + { + uuid: this._uuid, + unitId: this._unitId, + registerType: this._registerType, + address: registerAddress, + value: register + }, + 'serverView' + ) }) } diff --git a/src/main/windows.ts b/src/main/windows.ts index 94cee036..51051e56 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -6,6 +6,9 @@ interface WindowsObject { server: BrowserWindow | null } +/** Who an addressed event goes to. `send` without one reaches every window. */ +export type IpcEventTarget = WebContents | 'main' | 'serverView' + export class Windows { private _windows: WindowsObject @@ -17,33 +20,15 @@ export class Windows { } /** - * Send an event to every open window. + * Send an event to every open window, or to the one `to` names. * - * The guard is per window rather than around the loop, because a throw on one - * window costs every window after it the event. `Object.values` puts `main` - * first, so without it a stale main handle is what the server window's - * `window_update` goes missing behind. - */ - public send = (event: E, ...args: IpcEventPayloadMap[E]): void => { - try { - Object.values(this._windows).forEach((w) => { - if (w && !w.isDestroyed() && w.webContents && !w.webContents.isDestroyed()) { - w.webContents.send(event, ...args) - } - }) - } catch (error) { - /** - * A window that passes the guard can still be gone by the time the send - * lands, and on macos the app outlives its windows, so this is where that - * shows up. - */ - } - } - - /** - * Send an event to one addressee. + * Both windows load the same renderer, so both hold the client store and the + * server store, and the listeners those stores install run in both. An event + * that changes a store only one view draws leaves the other window writing + * its own copy over the same key, and the copy that writes last is the one on + * disk. That is what `to` is for. * - * A `WebContents` is the window that asked: `ipcMain.handle` hands the + * A `WebContents` is the window that asked. `ipcMain.handle` hands the * invoking contents to every handler, so a refused payload reports where it * came from rather than everywhere. * @@ -52,29 +37,44 @@ export class Windows { * one while it exists and the main window otherwise, the same question * `PrivilegedPortModal` answers by where it is mounted. * - * Both windows render `MessageReceiver`, so without an addressee a message - * about one view snackbars in the other as well. + * The guard is per window rather than around the loop, because a throw on one + * window costs every window after it the event. `Object.values` puts `main` + * first, so without it a stale main handle is what the server window's + * `window_update` goes missing behind. */ - public sendTo = ( - target: WebContents | 'main' | 'serverView', + public send = ( event: E, - ...args: IpcEventPayloadMap[E] + payload: IpcEventPayloadMap[E][0], + to?: IpcEventTarget ): void => { - const contents = this._contentsFor(target) - try { - if (contents && !contents.isDestroyed()) contents.send(event, ...args) - } catch (error) { - // Gone between the guard and the send, the same way `send` describes. + for (const contents of this._contentsFor(to)) { + try { + if (!contents.isDestroyed()) contents.send(event, payload) + } catch (error) { + /** + * A window that passes the guard can still be gone by the time the send + * lands, and on macos the app outlives its windows, so this is where + * that shows up. + */ + } } } - private _contentsFor(target: WebContents | 'main' | 'serverView'): WebContents | null { - if (target === 'main') return this._windows.main?.webContents ?? null - if (target === 'serverView') { - const window = this._windows.server ?? this._windows.main - return window?.webContents ?? null + /** The contents an event goes to, with the windows that are gone left out. */ + private _contentsFor(to?: IpcEventTarget): WebContents[] { + if (to === undefined) { + return Object.values(this._windows).flatMap((window) => this._liveContents(window)) + } + if (to === 'main') return this._liveContents(this._windows.main) + if (to === 'serverView') { + return this._liveContents(this._windows.server ?? this._windows.main) } - return target + return [to] + } + + private _liveContents(window: BrowserWindow | null): WebContents[] { + if (!window || window.isDestroyed() || !window.webContents) return [] + return [window.webContents] } // Main window access diff --git a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts new file mode 100644 index 00000000..cc6d2c2e --- /dev/null +++ b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +// +// Both windows hold this store and both persist it to one key. Main addresses +// the two events that change it to the window showing the server, so while the +// split is up this copy hears nothing, and when that window closes the events +// come back to a copy that has not moved since load. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_SERVER_ZUSTAND_VERSION, SERVER_ZUSTAND_STORAGE_KEY } from '@shared' +import { fireEvent, stubRenderer } from './stubRenderer' + +const register = (address: number): Record => ({ + value: 1, + params: { + address, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } +}) + +const store = (addresses: number[]): string => + JSON.stringify({ + state: { + selectedUuid: 'u', + uuids: ['u'], + port: { u: '502' }, + name: { u: 'bench' }, + unitId: { u: '0' }, + littleEndian: { u: false }, + usedAddresses: {}, + serverRegisters: { + u: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: Object.fromEntries( + addresses.map((address) => [String(address), register(address)]) + ) + } + } + } + }, + version: CURRENT_SERVER_ZUSTAND_VERSION + }) + +const held = async (): Promise => { + const { useServerZustand } = await import('../server.zustand') + return Object.keys(useServerZustand.getState().serverRegisters.u?.['0']?.holding_registers ?? {}) +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +describe('the server window closing', () => { + it('re-reads what that window wrote while it was open', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0])) + await import('../server.zustand') + expect(await held()).toEqual(['0']) + + // What the split out window persisted, which this copy never heard about. + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0, 77])) + + fireEvent('window_update', { main: true, server: true }) + fireEvent('window_update', { main: true, server: false }) + + expect(await held()).toEqual(['0', '77']) + }) + + // `window_update` fires whenever either window handle moves, and the main + // window gets one at launch with no server window in it. Re-reading there + // would undo the repair the store has just done to a config it refused. + it('does not re-read when no window was ever split', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0])) + await import('../server.zustand') + + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0, 77])) + fireEvent('window_update', { main: true, server: false }) + + expect(await held()).toEqual(['0']) + }) +}) diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts index 4a97f01e..2dc07cc1 100644 --- a/src/renderer/src/context/__tests__/stubRenderer.ts +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -48,11 +48,32 @@ const answers: Record Promise> = { listSerialPorts: () => Promise.resolve([]) } +/** + * The listeners the stores registered, so a test can deliver an event. + * + * `stubRenderer` empties it, and `fireEvent` calls what is in it. A store + * registers at module scope, so the import has to come after the stub. + */ +const listeners = new Map void>>() + +/** Delivers `event` to every listener a store registered for it. */ +export const fireEvent = (event: string, ...args: unknown[]): void => { + for (const listener of listeners.get(event) ?? []) listener(...args) +} + export const stubRenderer = (): void => { + listeners.clear() const w = window as unknown as { electron: unknown; api: unknown } w.electron = { ipcRenderer: { - on: (): (() => void) => (): void => {}, + on: (event: string, listener: (...args: unknown[]) => void): (() => void) => { + const forEvent = listeners.get(event) ?? [] + // The preload hands a listener the Electron event first, and a store + // reads its payload off the second argument. + forEvent.push((...args) => listener(undefined, ...args)) + listeners.set(event, forEvent) + return (): void => {} + }, send: (): void => {}, invoke: async (): Promise => undefined } @@ -60,8 +81,12 @@ export const stubRenderer = (): void => { w.api = new Proxy( {}, { - get: (_target, method: string): ((payload: unknown) => Promise) => - answers[method] ?? ((): Promise => Promise.resolve(undefined)) + // A boolean the preload exposes, not a channel: a function here is truthy + // and would make every window look like the server window. + get: (_target, method: string): unknown => + method === 'isServerWindow' + ? false + : (answers[method] ?? ((): Promise => Promise.resolve(undefined))) } ) } @@ -81,9 +106,11 @@ export const recordApiCalls = (calls: ApiCall[]): void => { window.api = new Proxy( {}, { - get: (_target, method: string): ((payload: unknown) => unknown) => { + get: (_target, method: string): unknown => { const answer = answers[method] - if (typeof answer !== 'function') throw new Error(`stubRenderer answers no ${method}`) + // `isServerWindow` is a boolean the preload exposes rather than a + // channel, so there is no call to record. + if (typeof answer !== 'function') return answer return (payload: unknown): unknown => { calls.push({ method, payload }) return (answer as (payload: unknown) => unknown)(payload) diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index da339bf4..914eb7f9 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -744,3 +744,27 @@ onEvent('register_value', applyRegisterValue) onEvent('rtu_server_status', ({ active }) => { useServerZustand.setState({ rtuServerActive: active }) }) + +/** Whether the split out server window is the one main has been writing to. */ +let serverWindowOwnsTheKey = false + +/** + * Re-read the key the split out window has been writing. + * + * Both windows hold this store and both persist it, and main addresses the two + * events that change it to the window showing the server. So while the split is + * up this copy hears nothing, and the moment that window closes the events come + * back here and write what this copy has held since load. Measured 16 Sep 2026 + * without this: a register added in the split out window stood in the key at + * the close and was gone from it three seconds later. + */ +onEvent('window_update', ({ server }) => { + if (window.api.isServerWindow) return + if (server) { + serverWindowOwnsTheKey = true + return + } + if (!serverWindowOwnsTheKey) return + serverWindowOwnsTheKey = false + useServerZustand.persist.rehydrate() +}) From 0d202c2a22f3331e9f54a62f4768ae990ae69901 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 09:23:32 +0200 Subject: [PATCH 85/91] fix: a window taking the server view back asks what the RTU server is doing `rtu_server_status` fires on a change and now goes to the window showing the server, `rtuServerActive` is not in `partialize`, and no channel asked for it. So a window that was not the one showing the server at the last change had nothing to catch up on, and `ServerRtuConfig` is the one reader of the flag. `get_rtu_server_status` answers it, and the store asks at the two moments the flag can be wrong: a window coming up, and the main window taking the view back from the split out one. The first of those two predates the addressing. The handle now goes null on `closed` rather than `close`. It is what tells the main window to re-read the key, and on `close` the window being closed is still running and can persist once more after that read. `recordApiCalls` keeps the guard that names a channel it has no stub for, and answers `isServerWindow` the way the preload does. `valueGenerator`'s mock drops the `sendTo` the class no longer has, and the relaunch in the e2e spec clears the page it is about to look for. --- .../03-split-view-persistence.spec.ts | 3 +++ src/main/index.ts | 5 ++++- src/main/ipc.ts | 1 + .../modules/__tests__/modbusServer.test.ts | 13 +++++++++++++ .../modules/__tests__/valueGenerator.test.ts | 2 +- src/main/modules/modbusServer.ts | 5 +++++ .../__tests__/serverStoreRehydrate.test.ts | 18 +++++++++++++++++- .../src/context/__tests__/stubRenderer.ts | 6 ++++-- src/renderer/src/context/server.zustand.ts | 18 +++++++++++++++++- src/shared/types/ipc.ts | 13 +++++++++++++ 10 files changed, 78 insertions(+), 6 deletions(-) diff --git a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts index f7ce1463..5f5e1761 100644 --- a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -18,6 +18,9 @@ let app: ElectronApplication let page: Page async function launchApp(clearStorage: boolean): Promise { + // Cleared first, so the search below has to find this launch's own window + // rather than passing on the one the previous launch left here. + page = undefined as unknown as Page app = await electron.launch(launchOptions()) keepOutput(app) if (clearStorage) { diff --git a/src/main/index.ts b/src/main/index.ts index de07c250..9c131685 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -153,7 +153,10 @@ onIpcEvent('open_server_window', () => { windows.server.loadFile(join(__dirname, '../renderer/index.html')) } - windows.server.on('close', () => { + // `closed`, not `close`. The handle going null is what tells the main window + // to re-read the key this window has been writing, and on `close` this + // renderer is still running and can persist once more after that read. + windows.server.on('closed', () => { windows.server = null }) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d39af462..12f1a056 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -211,6 +211,7 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { StartRtuServerParamsSchema ) ipcHandle('stop_rtu_server', () => server.stopRtuServer()) + ipcHandle('get_rtu_server_status', () => server.rtuActive) ipcHandle('stop_all_tcp_servers', () => server.stopAllTcpServers()) // App Version diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index d16d7b0d..fa3470be 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1558,6 +1558,19 @@ describe('ModbusServer', () => { expect(getAddressees('rtu_server_status')).toEqual(['serverView', 'serverView']) }) + // `rtu_server_status` fires on a change, so a window that was not the one + // showing the server at the last change has this to ask instead. + it('answers whether the RTU server is up', async () => { + expect(server.rtuActive).toBe(false) + + await server.startRtuServer({ uuid, serialConfig }) + lastInstance(ServerSerial)._handlers['initialized']() + expect(server.rtuActive).toBe(true) + + await server.stopRtuServer() + expect(server.rtuActive).toBe(false) + }) + it('emits error status on socketError event', async () => { await server.startRtuServer({ uuid, serialConfig }) diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index 4a045f47..876a55be 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -3,7 +3,7 @@ import { ValueGenerator } from '../modbusServer/valueGenerator' import type { ServerData, UnitIdString } from '@shared' import type { Windows } from '../../windows' -const createMockWindows = (): Windows => ({ send: vi.fn(), sendTo: vi.fn() }) as unknown as Windows +const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows const createServerData = (): ServerData => ({ coils: new Array(65536).fill(false), diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 93194424..21ff24d6 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -144,6 +144,11 @@ export class ModbusServer { private _rtuServer: ServerSerial | null = null private _rtuUuid: string | null = null private _rtuActive: boolean = false + + /** Whether the RTU server is running, for a window that has to ask. */ + public get rtuActive(): boolean { + return this._rtuActive + } private _rtuGeneration: number = 0 private _broadcastWarningSent: boolean = false private _windows: Windows diff --git a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts index cc6d2c2e..25eea06d 100644 --- a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts @@ -6,7 +6,7 @@ // come back to a copy that has not moved since load. import { beforeEach, describe, expect, it, vi } from 'vitest' import { CURRENT_SERVER_ZUSTAND_VERSION, SERVER_ZUSTAND_STORAGE_KEY } from '@shared' -import { fireEvent, stubRenderer } from './stubRenderer' +import { type ApiCall, fireEvent, recordApiCalls, stubRenderer } from './stubRenderer' const register = (address: number): Record => ({ value: 1, @@ -83,4 +83,20 @@ describe('the server window closing', () => { expect(await held()).toEqual(['0']) }) + + // `rtuServerActive` is not persisted, so re-reading the key leaves it where + // it was, and the split out window is the one that heard the last change. + it('asks main for the RTU status, at load and at the close', async () => { + const calls: ApiCall[] = [] + recordApiCalls(calls) + await import('../server.zustand') + + const asked = (): number => calls.filter((c) => c.method === 'getRtuServerStatus').length + expect(asked()).toBe(1) + + fireEvent('window_update', { main: true, server: true }) + fireEvent('window_update', { main: true, server: false }) + + expect(asked()).toBe(2) + }) }) diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts index 2dc07cc1..5a1ca32a 100644 --- a/src/renderer/src/context/__tests__/stubRenderer.ts +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -45,6 +45,7 @@ const answers: Record Promise> = { Promise.resolve(AddRegisterParamsSchema.safeParse(payload).success ? [] : undefined), getClientState: () => Promise.resolve(disconnected), getAppVersion: () => Promise.resolve('0.0.0-test'), + getRtuServerStatus: () => Promise.resolve(false), listSerialPorts: () => Promise.resolve([]) } @@ -107,10 +108,11 @@ export const recordApiCalls = (calls: ApiCall[]): void => { {}, { get: (_target, method: string): unknown => { - const answer = answers[method] // `isServerWindow` is a boolean the preload exposes rather than a // channel, so there is no call to record. - if (typeof answer !== 'function') return answer + if (method === 'isServerWindow') return answers[method] + const answer = answers[method] + if (typeof answer !== 'function') throw new Error(`stubRenderer answers no ${method}`) return (payload: unknown): unknown => { calls.push({ method, payload }) return (answer as (payload: unknown) => unknown)(payload) diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 914eb7f9..3580373f 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -745,7 +745,22 @@ onEvent('rtu_server_status', ({ active }) => { useServerZustand.setState({ rtuServerActive: active }) }) -/** Whether the split out server window is the one main has been writing to. */ +/** + * Ask main whether the RTU server is up. + * + * `rtu_server_status` fires on a change and goes to the window showing the + * server, so a window that was not that window at the last change never heard + * it. Both moments where that leaves a wrong dot ask here: a window coming up, + * and the main window taking the view back from the split out one. + */ +const readRtuServerStatus = async (): Promise => { + const active = await window.api.getRtuServerStatus() + useServerZustand.setState({ rtuServerActive: active }) +} + +readRtuServerStatus() + +/** Whether a split out server window has been the one writing the key. */ let serverWindowOwnsTheKey = false /** @@ -767,4 +782,5 @@ onEvent('window_update', ({ server }) => { if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false useServerZustand.persist.rehydrate() + readRtuServerStatus() }) diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index c64cbce3..06b0d4f8 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -88,6 +88,7 @@ export const IPC_CHANNELS = [ 'set_read_configuration', 'start_rtu_server', 'stop_rtu_server', + 'get_rtu_server_status', 'stop_all_tcp_servers', 'get_privileged_port_status', 'apply_privileged_port_fix', @@ -315,6 +316,18 @@ export interface IpcHandlerSpec { return: void } + /** + * Whether the RTU server is running right now. + * + * `rtu_server_status` is addressed to the window showing the server, so a + * window that was not that window when the last one went out has nothing to + * catch up on. This is how it asks, the same shape as `get_client_state`. + */ + ['get_rtu_server_status']: { + args: [] + return: boolean + } + /** Stop all running TCP servers (cleanup before RTU switch) */ ['stop_all_tcp_servers']: { args: [] From bfcbe59aa3a3c4366b449fec2c2fc4f6ab60f914 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 09:27:29 +0200 Subject: [PATCH 86/91] docs: the changelog line for the RTU status light --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49bea5fb..fe749986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 same place, so the window you were not looking at wrote its copy over what you had just added. One generator was enough to keep that happening, and closing the split window left the stale copy as the only one. +- **The RTU status light is right in a window that just opened.** It is set from + a message the server sends when it starts or stops, so a window opened after + that showed the server as stopped while it was running. The window now asks. - **A config file Modbux refuses now says which register and which field.** One malformed register was reported as `serverRegistersPerUnit.1: Invalid input`, naming neither the address nor what was wrong with it, and a unit id outside From 2b7f481081406cac203ba7d43e3cec9ec8079fa6 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 10:06:20 +0200 Subject: [PATCH 87/91] docs: the e2e generator interval is in seconds and caps at ten --- e2e/fixtures/types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/fixtures/types.ts b/e2e/fixtures/types.ts index 7140b69a..9e290f94 100644 --- a/e2e/fixtures/types.ts +++ b/e2e/fixtures/types.ts @@ -1,3 +1,8 @@ +/** + * `interval` is the seconds the field is labelled in, and its mask caps at 10. + * Measured 16 Sep 2026: both '100' and '1000' stored 10000 ms, which is a tick + * every ten seconds and longer than a spec usually waits. + */ export type RegisterDef = { registerType: 'holding_registers' | 'input_registers' address: number From 127ec92ccf1d295cc8a037227f8f285d7bdb182b Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 10:29:55 +0200 Subject: [PATCH 88/91] fix: the split out server window no longer writes the client store back Six client events reached every window: client_state, register_data, transaction, scan_unit_id_result, address_groups and scan_progress. Both windows load the same renderer, so the split out server window runs client.zustand as well, and its copy stops moving at what it loaded. Zustand's persist writes the whole partialized state on every setState, in node_modules/zustand/middleware.js, so an event delivered there puts that frozen copy over the shared key. Probed with the store on window in each renderer and a write counter per window. While the main window polled, the key held the read length it had just stored in 62 of 64 samples, and three restarts read it back, so the event path rarely wins the race. One write in the split out window with the main window quiet put its frozen length of 10 over the 7 the main window had stored, and the next launch read 10. All six now go to the main window, the way backend_message already did. The client view never leaves it. In the split out window Server renders MessageReceiver, the only reader of the client store there, and it reads configReset, which no event writes, so nothing has to re-read the key when the split closes. No CHANGELOG line: nothing a user can press lost a setting across the three restarts measured. --- .../modules/__tests__/modbusClient.test.ts | 44 +++++++++++++++++++ src/main/modules/modbusClient.ts | 23 +++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index b9444d53..5386c24e 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -563,6 +563,50 @@ describe('ModbusClient', () => { }) }) + /** + * Both windows load the same renderer, so the split out server window holds + * `client.zustand` too and persists it to the same key. An event delivered + * there writes its frozen copy over what the main window stored. + */ + describe('the window a client event reaches', () => { + it('addresses every one of them to the main window', async () => { + await connectClient() + setupHoldingRegisterReadMock([100, 200]) + await client.read() + + const unitIdScan = client.scanUnitIds({ + range: [5, 6], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await unitIdScan + + const registerScan = client.scanRegisters({ + addressRange: [50, 69], + length: 10, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await registerScan + + expect(new Set(addressedTo.map(([, event]) => event))).toEqual( + new Set([ + 'backend_message', + 'client_state', + 'register_data', + 'transaction', + 'scan_unit_id_result', + 'address_groups', + 'scan_progress' + ]) + ) + expect(new Set(addressedTo.map(([to]) => to))).toEqual(new Set(['main'])) + }) + }) + describe('disconnect', () => { it('transitions through disconnecting to disconnected', async () => { await connectClient() diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 0070bf0d..8cc0cc41 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -191,31 +191,40 @@ export class ModbusClient { } // Events - /** The client view never leaves the main window, so its messages go there. */ + /** + * Every client event goes to the main window, the only window that draws the + * client view. + * + * Both windows load the same renderer, so the split out server window holds + * `client.zustand` too, frozen at what it loaded, and persist writes the + * whole partialized state on every `setState`. One write there put a read + * length of 10 over the 7 the main window had just stored, and the next + * launch read 10. + */ private _emitMessage = (message: BackendMessage): void => { this._windows.send('backend_message', message, 'main') } private _sendClientState = (): void => { - this._windows.send('client_state', this._clientState) + this._windows.send('client_state', this._clientState, 'main') } private _sendData = (data: RegisterData[]): void => { - this._windows.send('register_data', data) + this._windows.send('register_data', data, 'main') } private _sendTransaction = (transaction: Transaction): void => { - this._windows.send('transaction', transaction) + this._windows.send('transaction', transaction, 'main') } private _sendUnitIdResult = (result: ScanUnitIDResult): void => { - this._windows.send('scan_unit_id_result', result) + this._windows.send('scan_unit_id_result', result, 'main') } private _sendGroups = (groups: AddressGroup[]): void => { - this._windows.send('address_groups', groups) + this._windows.send('address_groups', groups, 'main') } private _sendScanProgress = async (): Promise => { this._scansDone++ const progress = round((this._scansDone / this._totalScans) * 100, 2) - this._windows.send('scan_progress', progress) + this._windows.send('scan_progress', progress, 'main') await new Promise((resolve) => setTimeout(resolve, 5)) } From b7d1f91fbcdfdc1a7e755a50d9e6619dc25326ae Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 12:01:33 +0200 Subject: [PATCH 89/91] refactor: a component that owns nothing is a file again Twenty-six folders under src/renderer/src/components held one file and nothing else. CONTRIBUTING says a component that owns nothing stays a file, and names SliderComponent.tsx and HomeButton.tsx as the example, so those twenty-six were the rule's only exceptions. They are files beside their parent now, and no folder under components holds one file and nothing else. The other rule in play was 94d9304's, that a component does not lie loose beside its parent. Three of the five components that commit moved own nothing today, so it created three of the twenty-six. Ownership is the rule that is written down, and that is the one kept. CONTRIBUTING gains the clause that decided the count. A __tests__ of its own is ownership, which leaves RegisterConfig, ScanProgress, ServerBitMapDetail and ServerGrid/shared as folders. Without it they would have folded too, and each of their tests would have moved into the parent's folder among the tests of everything beside it. Nothing but import paths changed: twenty-six renames, no content edit in any moved file, and every other line in the diff is an import. Lint 0, typecheck 0, 1238 unit tests, and 104 e2e tests across 01-home-navigation, 03-server-config, 10-split-view, 15-client-toolbar and 26-server-layout. --- CONTRIBUTING.md | 4 +++- .../{BitIndicator => }/BitIndicator.tsx | 2 +- .../BitMapDetailPanel/BitMapDetailPanel.tsx | 2 +- .../BitSettingsPopover.tsx | 0 .../{BitMapRow => }/BitMapRow.tsx | 2 +- .../ClientGrids/RegisterGrid/RegisterGrid.tsx | 2 +- .../{ClearButton => }/ClearButton.tsx | 0 .../ClearConfigButton.tsx | 0 .../ClearFiltersButton.tsx | 0 .../{LoadButton => }/LoadButton.tsx | 0 .../LoadDummyDataButton.tsx | 2 +- .../MenuButton/MenuButton.tsx | 10 ++++----- .../MenuConnectionOptions.tsx | 0 .../MenuRegisterOptions.tsx | 0 .../ScanRegistersButton.tsx | 2 +- .../ScanUnitIdsButton.tsx | 2 +- .../MenuButton/__tests__/MenuOptions.test.tsx | 4 ++-- .../{PollButton => }/PollButton.tsx | 0 .../{RawButton => }/RawButton.tsx | 0 .../{ReadButton => }/ReadButton.tsx | 0 .../RegisterGridToolbar.tsx | 22 +++++++++---------- .../{SaveButton => }/SaveButton.tsx | 0 .../{ShowLogButton => }/ShowLogButton.tsx | 0 .../{TimeSettings => }/TimeSettings.tsx | 0 .../ToggleEndianButton.tsx | 0 .../ConnectionConfig/ConnectionConfig.tsx | 4 ++-- .../{RtuConfig => }/RtuConfig.tsx | 0 .../{TcpConfig => }/TcpConfig.tsx | 0 .../{OpenSaveClear => }/OpenSaveClear.tsx | 0 .../{SelectServer => }/SelectServer.tsx | 0 .../server/ServerConfig/ServerConfig.tsx | 2 +- .../{ServerRtuConfig => }/ServerRtuConfig.tsx | 0 .../{ServerBooleans => }/ServerBooleans.tsx | 6 ++--- .../server/ServerGrid/ServerGrid.tsx | 2 +- .../{ServerPartTitle => }/ServerPartTitle.tsx | 4 ++-- .../ServerRegisters/ServerRegisters.tsx | 2 +- src/renderer/src/containers/Server.tsx | 4 ++-- 37 files changed, 40 insertions(+), 38 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/client/ClientGrids/RegisterGrid/{BitMapRow => }/BitMapRow.tsx (97%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ClearButton => }/ClearButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ClearConfigButton => }/ClearConfigButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ClearFiltersButton => }/ClearFiltersButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{LoadButton => }/LoadButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/{LoadDummyDataButton => }/LoadDummyDataButton.tsx (96%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/{MenuConnectionOptions => }/MenuConnectionOptions.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/{MenuRegisterOptions => }/MenuRegisterOptions.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/{ScanRegistersButton => }/ScanRegistersButton.tsx (95%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/{ScanUnitIdsButton => }/ScanUnitIdsButton.tsx (95%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{PollButton => }/PollButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{RawButton => }/RawButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ReadButton => }/ReadButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{SaveButton => }/SaveButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ShowLogButton => }/ShowLogButton.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{TimeSettings => }/TimeSettings.tsx (100%) rename src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/{ToggleEndianButton => }/ToggleEndianButton.tsx (100%) rename src/renderer/src/components/client/ConnectionConfig/{RtuConfig => }/RtuConfig.tsx (100%) rename src/renderer/src/components/client/ConnectionConfig/{TcpConfig => }/TcpConfig.tsx (100%) rename src/renderer/src/components/server/{OpenSaveClear => }/OpenSaveClear.tsx (100%) rename src/renderer/src/components/server/{SelectServer => }/SelectServer.tsx (100%) rename src/renderer/src/components/server/ServerConfig/{ServerRtuConfig => }/ServerRtuConfig.tsx (100%) rename src/renderer/src/components/server/ServerGrid/{ServerBooleans => }/ServerBooleans.tsx (98%) rename src/renderer/src/components/server/ServerGrid/{ServerPartTitle => }/ServerPartTitle.tsx (96%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0350d5d..2a1fcfc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,7 +167,9 @@ 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 +`__tests__` of its own is enough, because the alternative is its test sitting in +the parent's folder among the tests of everything beside it. 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. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx similarity index 98% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx index 80d51e9a..0a9cc87e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx @@ -8,7 +8,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/BitSettingsPopover' +import BitSettingsPopover from './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 f72cbae9..318be035 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 { useClientZustand } from '@renderer/context/client.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' import { BitColor, BitMapConfig } from '@shared' -import BitIndicator from './BitIndicator/BitIndicator' +import BitIndicator from './BitIndicator' interface BitMapDetailPanelProps { address: number diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow.tsx similarity index 97% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow.tsx index f9c1c006..ec5d3d41 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow.tsx @@ -4,7 +4,7 @@ import { useBitMapZustand } from '@renderer/context/bitmap.zustand' import { useClientZustand } from '@renderer/context/client.zustand' import { BITMAP_DATATYPE } from '@shared' import { useEffect, useRef } from 'react' -import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' +import BitMapDetailPanel from './BitMapDetailPanel/BitMapDetailPanel' // ───────────────────────────────────────────────────────────────────────────── // BitMapRow, used as `slots.row` in the DataGrid. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index 116951a4..c5ec2341 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -19,7 +19,7 @@ import { import { BITMAP_DATATYPE, RegisterData, scalableDataTypes } from '@shared' import { alpha } from '@mui/material/styles' import { showMapping } from '@renderer/context/data.zustand' -import BitMapRow from './BitMapRow/BitMapRow' +import BitMapRow from './BitMapRow' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' import { COMPACT_ROW_HEIGHT, ROW_HEIGHT } from './rowHeight' // diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx 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.tsx similarity index 96% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx index 88c33e30..58cf996e 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.tsx @@ -3,7 +3,7 @@ import { useDataZustand } from '@renderer/context/data.zustand' import { useClientZustand } from '@renderer/context/client.zustand' import { RegisterData, getDummyRegisterData } from '@shared' import { useCallback } from 'react' -import type { SetAnchorProps } from '../MenuButton' +import type { SetAnchorProps } from './MenuButton' import Button from '@mui/material/Button' const LoadDummyDataButton = meme(({ setAnchor }: SetAnchorProps) => { 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 f7124f1f..3830b10e 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,11 +1,11 @@ import { useScanRegistersZustand } from '@renderer/components/client/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useRef, useState } from 'react' -import LoadDummyDataButton from './LoadDummyDataButton/LoadDummyDataButton' -import MenuConnectionOptions from './MenuConnectionOptions/MenuConnectionOptions' -import MenuRegisterOptions from './MenuRegisterOptions/MenuRegisterOptions' -import ScanRegistersButton from './ScanRegistersButton/ScanRegistersButton' -import ScanUnitIdsButton from './ScanUnitIdsButton/ScanUnitIdsButton' +import LoadDummyDataButton from './LoadDummyDataButton' +import MenuConnectionOptions from './MenuConnectionOptions' +import MenuRegisterOptions from './MenuRegisterOptions' +import ScanRegistersButton from './ScanRegistersButton' +import ScanUnitIdsButton from './ScanUnitIdsButton' import FormGroup from '@mui/material/FormGroup' import Button from '@mui/material/Button' import Settings from '@mui/icons-material/Settings' 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.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions.tsx 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.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions.tsx 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.tsx similarity index 95% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton.tsx index e9c10aeb..988a64bd 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.tsx @@ -3,7 +3,7 @@ import { useScanRegistersZustand } from '@renderer/components/client/ScanRegiste import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' -import type { SetAnchorProps } from '../MenuButton' +import type { SetAnchorProps } from './MenuButton' const ScanRegistersButton = meme(({ setAnchor }: SetAnchorProps) => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton/ScanUnitIdsButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx similarity index 95% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton/ScanUnitIdsButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx index 553c5220..64ad3237 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton/ScanUnitIdsButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx @@ -3,7 +3,7 @@ import { useScanUnitIdZustand } from '@renderer/components/client/ScanUnitIds/sc import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' -import type { SetAnchorProps } from '../MenuButton' +import type { SetAnchorProps } from './MenuButton' const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') 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 8613f8f0..41a51819 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 @@ -15,8 +15,8 @@ vi.hoisted(() => { import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { useClientZustand } from '@renderer/context/client.zustand' -import MenuRegisterOptions from '../MenuRegisterOptions/MenuRegisterOptions' -import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOptions' +import MenuRegisterOptions from '../MenuRegisterOptions' +import MenuConnectionOptions from '../MenuConnectionOptions' // The options menu groups register options / connection options / actions, // each section carrying its own trailing divider so empty sections never diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx 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 96fb2118..bb3b77bc 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -1,17 +1,17 @@ import Box from '@mui/material/Box' import { meme } from '@renderer/components/shared/inputs/meme' -import PollButton from './PollButton/PollButton' -import ReadButton from './ReadButton/ReadButton' -import ToggleEndianButton from './ToggleEndianButton/ToggleEndianButton' -import TimeSettings from './TimeSettings/TimeSettings' -import LoadButton from './LoadButton/LoadButton' -import SaveButton from './SaveButton/SaveButton' -import ClearConfigButton from './ClearConfigButton/ClearConfigButton' -import ClearButton from './ClearButton/ClearButton' -import ShowLogButton from './ShowLogButton/ShowLogButton' +import PollButton from './PollButton' +import ReadButton from './ReadButton' +import ToggleEndianButton from './ToggleEndianButton' +import TimeSettings from './TimeSettings' +import LoadButton from './LoadButton' +import SaveButton from './SaveButton' +import ClearConfigButton from './ClearConfigButton' +import ClearButton from './ClearButton' +import ShowLogButton from './ShowLogButton' import MenuButton from './MenuButton/MenuButton' -import RawButton from './RawButton/RawButton' -import ClearFiltersButton from './ClearFiltersButton/ClearFiltersButton' +import RawButton from './RawButton' +import ClearFiltersButton from './ClearFiltersButton' import { useClientZustand } from '@renderer/context/client.zustand' import TextField from '@mui/material/TextField' import { ChangeEvent, useCallback } from 'react' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton/SaveButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/SaveButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx similarity index 100% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 0df35ec6..92456454 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -7,10 +7,10 @@ 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 RtuConfig from './RtuConfig' import SerialGroupModal from '@renderer/components/client/SerialGroupModal/SerialGroupModal' import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/serialGroupModal.zustand' -import TcpConfig from './TcpConfig/TcpConfig' +import TcpConfig from './TcpConfig' import { useClientZustand } from '@renderer/context/client.zustand' import { Protocol } from '@shared' import { ElementType, useCallback } from 'react' diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx similarity index 100% rename from src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx rename to src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx similarity index 100% rename from src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx rename to src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear.tsx similarity index 100% rename from src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx rename to src/renderer/src/components/server/OpenSaveClear.tsx diff --git a/src/renderer/src/components/server/SelectServer/SelectServer.tsx b/src/renderer/src/components/server/SelectServer.tsx similarity index 100% rename from src/renderer/src/components/server/SelectServer/SelectServer.tsx rename to src/renderer/src/components/server/SelectServer.tsx diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index 86cecc71..ee622610 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -16,7 +16,7 @@ import Select, { SelectChangeEvent } from '@mui/material/Select' import { UnitIdString, UnitIdStringSchema } from '@shared' import MenuItem from '@mui/material/MenuItem' import React, { useCallback, useState } from 'react' -import ServerRtuConfig from './ServerRtuConfig/ServerRtuConfig' +import ServerRtuConfig from './ServerRtuConfig' const ModeToggle = meme(() => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx similarity index 100% rename from src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx rename to src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx similarity index 98% rename from src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx rename to src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx index f5eba573..72f2302f 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx @@ -8,10 +8,10 @@ import { alpha } from '@mui/material/styles' import { useServerZustand } from '@renderer/context/server.zustand' import { BooleanRegisters, ServerBoolEntry } from '@shared' import { ElementType, useCallback, useEffect, useMemo, useState } from 'react' -import ServerPartTitle from '../ServerPartTitle/ServerPartTitle' +import ServerPartTitle from './ServerPartTitle' import { meme } from '@renderer/components/shared/inputs/meme' -import useServerGridZustand from '../serverGrid.zustand' -import ServerBit from '../shared/ServerBit' +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' diff --git a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx index f64f9f70..835f8e6a 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx @@ -1,4 +1,4 @@ -import ServerBooleans from './ServerBooleans/ServerBooleans' +import ServerBooleans from './ServerBooleans' import ServerRegisters from './ServerRegisters/ServerRegisters' import AddRegister from './ServerRegisters/AddRegister/AddRegister' import Box from '@mui/material/Box' diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx similarity index 96% rename from src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx rename to src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx index 4f1dab77..4e4c6ebe 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx @@ -6,8 +6,8 @@ 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/addRegister.zustand' -import useServerGridZustand from '../serverGrid.zustand' +import { useAddRegisterZustand } from './ServerRegisters/AddRegister/addRegister.zustand' +import useServerGridZustand from './serverGrid.zustand' const AddButton = meme(({ type }: { type: RegisterType }) => { const handleClick = useCallback(() => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index b3dc051d..7078d369 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -16,7 +16,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { gridSurface } from '@renderer/theme' import { useCallback, useMemo, useState } from 'react' import { useAddRegisterZustand } from './AddRegister/addRegister.zustand' -import ServerPartTitle from '../ServerPartTitle/ServerPartTitle' +import ServerPartTitle from '../ServerPartTitle' import useServerGridZustand from '../serverGrid.zustand' import ServerBitMapDetail from './ServerBitMapDetail/ServerBitMapDetail' diff --git a/src/renderer/src/containers/Server.tsx b/src/renderer/src/containers/Server.tsx index 00ffd849..0a7d5978 100644 --- a/src/renderer/src/containers/Server.tsx +++ b/src/renderer/src/containers/Server.tsx @@ -2,9 +2,9 @@ import { meme } from '@renderer/components/shared/inputs/meme' import HomeButton from '@renderer/components/shared/HomeButton' import MessageReceiver from '@renderer/components/shared/MessageReceiver' import { useServerZustand } from '@renderer/context/server.zustand' -import OpenSaveClear from '../components/server/OpenSaveClear/OpenSaveClear' +import OpenSaveClear from '../components/server/OpenSaveClear' import ServerConfig from '../components/server/ServerConfig/ServerConfig' -import SelectServer from '@renderer/components/server/SelectServer/SelectServer' +import SelectServer from '@renderer/components/server/SelectServer' import TextField from '@mui/material/TextField' import Fade from '@mui/material/Fade' import Box from '@mui/material/Box' From ba50bbf61fe49ac6ef45f3b29f5b88e62aa3c269 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 12:26:54 +0200 Subject: [PATCH 90/91] refactor: the bitmap store declares its type in a sibling file Four of the five stores in context/ held their state type in a .zustand.types.ts sibling and bitmap.zustand.ts declared it inline. All five do now. BitMapZustand moves with its two doc comments and nothing else changes. The finding, stores/F27, argued for leaving it: three members, nothing outside needs the type, so the file was right and the pattern was what was inconsistent. Jens took the other side. One shape for five stores is worth more than the one file that was already fine. Lint 0, typecheck 0, 1238 unit tests. No e2e: nothing but a type moved. --- src/renderer/src/context/bitmap.zustand.ts | 18 +----------------- .../src/context/bitmap.zustand.types.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/context/bitmap.zustand.types.ts diff --git a/src/renderer/src/context/bitmap.zustand.ts b/src/renderer/src/context/bitmap.zustand.ts index 0ba66e47..8c495814 100644 --- a/src/renderer/src/context/bitmap.zustand.ts +++ b/src/renderer/src/context/bitmap.zustand.ts @@ -1,23 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { create } from 'zustand' import { mutative } from 'zustand-mutative' - -interface BitMapZustand { - /** Address of the currently expanded bitmap row, null if none. */ - expandedAddress: number | null - toggleExpanded: (address: number) => void - collapse: () => void - /** - * How tall the expanded detail panel renders, measured rather than assumed. - * - * The grid's row positions come from `getRowHeight`, so the panel's height - * has to reach it or the rows below sit under the ones above them and the - * last of them cannot be scrolled to. No constant can say it: the panel is - * four rows of bit cards above 560px of container width and eight below. - */ - detailHeight: number - setDetailHeight: (height: number) => void -} +import { BitMapZustand } from './bitmap.zustand.types' export const useBitMapZustand = create( mutative((set) => ({ diff --git a/src/renderer/src/context/bitmap.zustand.types.ts b/src/renderer/src/context/bitmap.zustand.types.ts new file mode 100644 index 00000000..9c5cfeaa --- /dev/null +++ b/src/renderer/src/context/bitmap.zustand.types.ts @@ -0,0 +1,16 @@ +export interface BitMapZustand { + /** Address of the currently expanded bitmap row, null if none. */ + expandedAddress: number | null + toggleExpanded: (address: number) => void + collapse: () => void + /** + * How tall the expanded detail panel renders, measured rather than assumed. + * + * The grid's row positions come from `getRowHeight`, so the panel's height + * has to reach it or the rows below sit under the ones above them and the + * last of them cannot be scrolled to. No constant can say it: the panel is + * four rows of bit cards above 560px of container width and eight below. + */ + detailHeight: number + setDetailHeight: (height: number) => void +} From 141061b83cd1c9047bbfce691ce8a7b7914b5455 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 16 Sep 2026 12:56:02 +0200 Subject: [PATCH 91/91] refactor: the twenty cosmetic findings the triage never named Every finding id in the six area documents was checked against tmp/AUDIT-triage.md, area prefix included. Twenty are absent: B-19, B-20, B-23, B-24, CU-16, CU-18, CU-19, CU-20, modbus/F21, modbus/F22, modbus/F24, server-ui/F18, server-ui/F19, stores/F18, F19, F20, F21, F22, F23 and F26. All twenty are criterion 3, size S, and each was measured against the code before it was touched. What was dead is gone. The preload's `process.contextIsolated` else branch could not run, and it held the repo's only two @ts-ignore, so the count is zero now. `offIpcEvent`, the `State` interface and the `shared/inputs` barrel had no importer; the barrel re-exported three defaults, which `export *` does not carry. `ipcInvoke`, SCAN_TIMEOUT_MIN, SCAN_TIMEOUT_MAX, outcomeOf, outcomeClass and ScanOutcome keep their bodies and lose the `export` nothing used. The two shift key listeners on Home fed `homeShiftKeyDown`, which nothing read. The `open_server_window` block at module scope read `hideHomeButton && !isServerWindow`, where `hideHomeButton` is initialised to `isServerWindow`, so it asked whether false was true; the macOS split it meant to restore needs a persisted flag, and nothing persists one. `PersistedLayoutZustandSchema` named a store with no persist at all, and the two fields it listed move into LayoutZustand. `bitmap.zustand`'s `collapse` had no caller. The client store's `version: '-'` was not in ClientZustand and duplicated the layout store's. Seven of the ten Modbus exception codes were referenced nowhere. Keeping them as the protocol's table costs a lint disable, which CONTRIBUTING forbids, so the three this server sends stay and the rest go. The commented-out socket-destroying debug harness in `createServer` goes with them. Four places said one thing and did another. `IpcHandlerSpec` named a promise for three channels and a value for the rest; it names the value everywhere now, because every call crosses the boundary and the preload wraps the lot. `process.argv.slice(2)` assumed the flag never lands in the first two slots, and main passes `--is-server-window` now. `rowHeight={40}` sat beside `getRowHeight={() => 'auto'}` in two grids, and getRowHeight wins. `lenght` is `length`, in the store, its type, the component that reads it and the test. Lint 0, typecheck 0, 1238 unit tests, and 167 e2e tests over 06-client-server, 10-split-view, 12-scan-unitids, 15-client-toolbar and the three standalone specs. --- src/main/index.ts | 2 +- src/main/ipc.ts | 7 ----- src/main/modules/modbusClient.ts | 1 - src/main/modules/modbusServer.ts | 22 +++++---------- src/main/state.ts | 6 ---- src/preload/index.ts | 27 ++++++------------ src/renderer/src/components/UpdateBanner.tsx | 2 +- .../RegisterGrid/columns/binary.tsx | 3 +- .../TransactionGrid/TransactionGrid.tsx | 1 - .../ClientGrids/TransactionGrid/_columns.tsx | 10 ------- .../client/RegisterConfig/RegisterConfig.tsx | 2 +- .../client/ScanProgress/ScanProgress.tsx | 4 +-- .../client/ScanUnitIds/ScanUnitIds.tsx | 1 - .../client/ScanUnitIds/_columns.tsx | 6 ++-- .../src/components/shared/inputs/index.ts | 3 -- src/renderer/src/containers/Home.tsx | 28 +------------------ .../__tests__/writeAfterAccept.test.ts | 2 +- src/renderer/src/context/bitmap.zustand.ts | 5 ---- .../src/context/bitmap.zustand.types.ts | 1 - src/renderer/src/context/client.zustand.ts | 7 ++--- .../src/context/client.zustand.types.ts | 2 +- src/renderer/src/context/layout.zustand.ts | 16 +---------- .../src/context/layout.zustand.types.ts | 19 ++++--------- src/shared/types/ipc.ts | 10 ++++--- 24 files changed, 43 insertions(+), 144 deletions(-) delete mode 100644 src/renderer/src/components/shared/inputs/index.ts diff --git a/src/main/index.ts b/src/main/index.ts index 9c131685..a9c6bd4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -139,7 +139,7 @@ onIpcEvent('open_server_window', () => { sandbox: false, nodeIntegration: false, contextIsolation: true, - additionalArguments: ['is-server-window'] + additionalArguments: ['--is-server-window'] }, title: 'Server', backgroundColor: '#181818' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 12f1a056..d0f51d0b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -249,10 +249,3 @@ export function onIpcEvent( listener(ev, ...(args as IpcEventPayloadMap[E])) }) } - -/** - * Remove all listeners for a specific IPC event on the main process. - */ -export function offIpcEvent(event: E): void { - ipcMain.removeAllListeners(event) -} diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 8cc0cc41..3ba8a08b 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -869,7 +869,6 @@ export class ModbusClient { try { if (single) { - // Wrtie single coil await new Promise((resolve, reject) => this._client.writeFC5(unitId, address, first, (err, data) => { if (err) { diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 21ff24d6..3de84d75 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -44,15 +44,15 @@ const getDefaultServerData = (): { holding_registers: new Array(65536).fill(0) }) -export const ILLEGAL_FUNCTION = 1 +/** + * The three Modbus exception codes this server sends. + * + * The other seven of the protocol's table stood here as exported constants + * nothing named. Keeping them costs a lint disable, which is a worse comment + * than none. `modbusServer.test.ts` asserts on all three of these. + */ export const ILLEGAL_DATA_ADDRESS = 2 -export const ILLEGAL_DATA_VALUE = 3 export const SERVER_DEVICE_FAILURE = 4 -export const ACKNOWLEDGE = 5 -export const SERVER_DEVICE_BUSY = 6 -export const NEGATIVE_ACKNOWLEDGE = 7 -export const MEMORY_PARITY_ERROR = 8 -export const GATEWAY_PATH_UNAVAILABLE = 10 export const GATEWAY_TARGET_FAILED = 11 export const DEFAULT_MOBUS_PORT = 502 @@ -348,14 +348,6 @@ export class ModbusServer { ): 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' }), diff --git a/src/main/state.ts b/src/main/state.ts index b813b2e8..015f7193 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -32,12 +32,6 @@ export const withoutUndefined = (value: T): T => { return kept as T } -export interface State { - connectionConfig: ConnectionConfig - registerConfig: RegisterConfig - registerMapping?: RegisterMapping -} - export class AppState { // Copies, because a field initialiser that names an export makes main's state // that export, until the first update replaces the tree. diff --git a/src/preload/index.ts b/src/preload/index.ts index 3329d358..f26aa729 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,10 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { CamelCase, IPC_CHANNELS, IpcHandlerMap, snakeToCamel } from '@shared' -const passedArgs = process.argv.slice(2) -const isServerWindow = passedArgs.includes('is-server-window') +const isServerWindow = process.argv.includes('--is-server-window') -export const ipcInvoke = ( +const ipcInvoke = ( channel: C, ...args: IpcHandlerMap[C]['args'] ): Promise => { @@ -74,19 +73,11 @@ const api = { ...handlers } as Api -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('api', api) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = api +// Every window this app opens sets `contextIsolation: true`, so there is no +// branch here for the case where it is off. +try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) +} catch (error) { + console.error(error) } diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 9559a43b..6b9bf8ee 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -40,7 +40,7 @@ const UpdateBanner = meme((): 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 + // Asked for directly rather than read off the layout 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() 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 dadfbb93..b1712eb9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/binary.tsx @@ -8,7 +8,6 @@ interface WordLedDisplayProps { } const WordLedDisplay = meme(({ value = 0 }: WordLedDisplayProps): JSX.Element => { - // Zorg dat we exact 16 bits hebben const bits = value .toString(2) .padStart(16, '0') @@ -44,7 +43,7 @@ const WordLedDisplay = meme(({ value = 0 }: WordLedDisplayProps): JSX.Element => width: 7, aspectRatio: 1, borderRadius: '50%', - backgroundColor: on ? theme.palette.primary.main : theme.palette.background.default // groen of grijs + backgroundColor: on ? theme.palette.primary.main : theme.palette.background.default })} /> ))} diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index 1e61b191..16f3824e 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -91,7 +91,6 @@ const TransactionGridContent = meme(() => { columns={columns} autoHeight={false} density="compact" - rowHeight={40} columnHeaderHeight={48} initialState={{ pagination: { paginationModel: { pageSize: 20, page: 0 } } }} getRowHeight={() => 'auto'} diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx index 9604fd50..224eabe3 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx @@ -32,15 +32,6 @@ const addressColumn: GridColDef = { maxWidth: 60 } -// const lengthColumn: GridColDef = { -// field: 'responseLength', -// headerName: 'Length', -// sortable: false, -// disableColumnMenu: true, -// minWidth: 60, -// maxWidth: 60 -// } - const functionColumn: GridColDef = { field: 'code', headerName: 'Fn', @@ -100,7 +91,6 @@ const useTransactionGridColumns = (): GridColDef[] => { timestampColumn, unitIdColumn, addressColumn, - // lengthColumn, functionColumn, requestColumn, responseColumn, diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 521c6e61..b7dc4735 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -75,7 +75,7 @@ const Address = meme(() => { // Length const Length = meme(() => { const length = useClientZustand((z) => String(z.registerConfig.length)) - const lengthValid = useClientZustand((z) => z.valid.lenght) + const lengthValid = useClientZustand((z) => z.valid.length) const address = useClientZustand((z) => z.registerConfig.address) const readConfiguration = useClientZustand((z) => z.readConfiguration) diff --git a/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx index 2ea4216b..ad87ab6a 100644 --- a/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx @@ -45,8 +45,8 @@ export const ScanProgress = meme(() => { * behind it. Typing 500 into an empty field gave 10000. Leaving the field is * late enough to correct it. */ -export const SCAN_TIMEOUT_MIN = 100 -export const SCAN_TIMEOUT_MAX = 10000 +const SCAN_TIMEOUT_MIN = 100 +const SCAN_TIMEOUT_MAX = 10000 export const clampScanTimeout = (timeout: number): number => Math.min(SCAN_TIMEOUT_MAX, Math.max(SCAN_TIMEOUT_MIN, timeout)) diff --git a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx index 7a194ec8..dc9ba4af 100644 --- a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -258,7 +258,6 @@ const ScanResultGrid = meme(() => { columns={columns} autoHeight={false} density="compact" - rowHeight={40} columnHeaderHeight={48} getRowHeight={() => 'auto'} // Results are listed per unit ID and that is the order you look them up diff --git a/src/renderer/src/components/client/ScanUnitIds/_columns.tsx b/src/renderer/src/components/client/ScanUnitIds/_columns.tsx index 5f23022b..205e7ab7 100644 --- a/src/renderer/src/components/client/ScanUnitIds/_columns.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/_columns.tsx @@ -11,9 +11,9 @@ import Box from '@mui/material/Box' * talking, and it answered this particular question with no; nothing coming * back at all means there is no unit at that address. Both used to be red. */ -export type ScanOutcome = 'answered' | 'refused' | 'silent' | 'unasked' +type ScanOutcome = 'answered' | 'refused' | 'silent' | 'unasked' -export const outcomeOf = (row: ScanUnitIDResult, type: RegisterType): ScanOutcome => +const outcomeOf = (row: ScanUnitIDResult, type: RegisterType): ScanOutcome => row.registerTypes.includes(type) ? 'answered' : row.refusedRegisterTypes.includes(type) @@ -30,7 +30,7 @@ const OUTCOME_LABEL: Record = { } /** The class the grid paints the cell with. Styled where the grid is built. */ -export const outcomeClass = (outcome: ScanOutcome): string => +const outcomeClass = (outcome: ScanOutcome): string => outcome === 'unasked' ? '' : `scan-${outcome}` const unitIdColumn: GridColDef = { diff --git a/src/renderer/src/components/shared/inputs/index.ts b/src/renderer/src/components/shared/inputs/index.ts deleted file mode 100644 index 9178ed7a..00000000 --- a/src/renderer/src/components/shared/inputs/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './HostInput' -export * from './UintInput' -export * from './UnitIdInput' diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 90ca6879..0a3dbb9d 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -6,7 +6,7 @@ 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' +import { useCallback } from 'react' import modbuxImage from '../../../../resources/icon.png' import ClientIcon from '@renderer/svg/Client' import ServerIcon from '@renderer/svg/Server' @@ -94,30 +94,6 @@ const ServerButton = meme((): JSX.Element => { ) }) -// -// -// Listens to the shift key -const useShiftKeyListener = (): void => { - const keyDownListener = useCallback((event: KeyboardEvent): void => { - const layoutZustand = useLayoutZustand.getState() - if (event.key === 'Shift') layoutZustand.setHomeShiftKeyDown(true) - }, []) - - const keyUpListener = useCallback((event: KeyboardEvent): void => { - const layoutZustand = useLayoutZustand.getState() - if (event.key === 'Shift') layoutZustand.setHomeShiftKeyDown(false) - }, []) - - useEffect(() => { - window.addEventListener('keydown', keyDownListener) - window.addEventListener('keyup', keyUpListener) - return (): void => { - window.removeEventListener('keydown', keyDownListener) - window.removeEventListener('keyup', keyUpListener) - } - }, [keyDownListener, keyUpListener]) -} - const bottomElementsCommonSx: SxProps = { position: 'fixed', bottom: 12, @@ -173,8 +149,6 @@ const Version = meme((): JSX.Element => { // // MAIN const Home = meme(() => { - useShiftKeyListener() - return ( { await useClientZustand.getState().setLength('', false) - expect(useClientZustand.getState().valid.lenght).toBe(false) + expect(useClientZustand.getState().valid.length).toBe(false) }) }) diff --git a/src/renderer/src/context/bitmap.zustand.ts b/src/renderer/src/context/bitmap.zustand.ts index 8c495814..92620408 100644 --- a/src/renderer/src/context/bitmap.zustand.ts +++ b/src/renderer/src/context/bitmap.zustand.ts @@ -12,11 +12,6 @@ export const useBitMapZustand = create - set((state) => { - state.expandedAddress = null - }), - detailHeight: 0, setDetailHeight: (height): void => set((state) => { diff --git a/src/renderer/src/context/bitmap.zustand.types.ts b/src/renderer/src/context/bitmap.zustand.types.ts index 9c5cfeaa..9ed563fb 100644 --- a/src/renderer/src/context/bitmap.zustand.types.ts +++ b/src/renderer/src/context/bitmap.zustand.types.ts @@ -2,7 +2,6 @@ export interface BitMapZustand { /** Address of the currently expanded bitmap row, null if none. */ expandedAddress: number | null toggleExpanded: (address: number) => void - collapse: () => void /** * How tall the expanded detail panel renders, measured rather than assumed. * diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index c63a68a7..d2e88404 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -217,7 +217,7 @@ export const useClientZustand = create< valid: { host: true, com: true, - lenght: true + length: true }, // // @@ -399,7 +399,7 @@ export const useClientZustand = create< // kept here and never sent. if (!valid) { set((state) => { - state.valid.lenght = false + state.valid.length = false state.registerConfig.length = newLength }) return @@ -408,7 +408,7 @@ export const useClientZustand = create< if (!(await window.api.updateRegisterConfig({ length: newLength }))) return set((state) => { - state.valid.lenght = true + state.valid.length = true state.registerConfig.length = newLength }) clearRegisterDataWhenIdle() @@ -490,7 +490,6 @@ export const useClientZustand = create< set((state) => { state.scanProgress = scanProgress }), - version: '-', // Serial port discovery serialPorts: [], diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 4ca9c48b..824b92c5 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -22,7 +22,7 @@ import z from 'zod' interface Valid { host: boolean com: boolean - lenght: boolean + length: boolean } export const PersistedClientZustandSchema = z.object({ diff --git a/src/renderer/src/context/layout.zustand.ts b/src/renderer/src/context/layout.zustand.ts index b55927d0..8f8cea5d 100644 --- a/src/renderer/src/context/layout.zustand.ts +++ b/src/renderer/src/context/layout.zustand.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { mutative } from 'zustand-mutative' import { AppType, LayoutZustand } from './layout.zustand.types' -import { onEvent, sendEvent } from '@renderer/events' +import { onEvent } from '@renderer/events' const isServerWindow = window.api.isServerWindow @@ -14,7 +14,6 @@ export const useLayoutZustand = create { state.version = version }), - homeShiftKeyDown: false, hideHomeButton: isServerWindow, showClientRawValues: false, showGridWhileScanning: true, @@ -27,11 +26,6 @@ export const useLayoutZustand = create - set((state) => { - state.homeShiftKeyDown = down - }), - setHideHomeButton: (hide) => set((state) => { state.hideHomeButton = hide @@ -53,14 +47,6 @@ export const useLayoutZustand = create { if (isServerWindow) return diff --git a/src/renderer/src/context/layout.zustand.types.ts b/src/renderer/src/context/layout.zustand.types.ts index c8e4a944..097384b7 100644 --- a/src/renderer/src/context/layout.zustand.types.ts +++ b/src/renderer/src/context/layout.zustand.types.ts @@ -1,28 +1,19 @@ -import z from 'zod' +export type AppType = 'client' | 'server' -const AppTypeSchema = z.enum(['client', 'server']) -export type AppType = z.infer - -export const PersistedLayoutZustandSchema = z.object({ - showLog: z.boolean(), - appType: AppTypeSchema.optional() -}) -export type PersistedLayoutZustand = z.infer - -export type LayoutZustand = { +export interface 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 /** Whether the register grid keeps filling while a scan runs. */ showGridWhileScanning: boolean + showLog: boolean + appType: AppType | undefined toggleShowClientRawValues: () => void - setHomeShiftKeyDown: (down: boolean) => void setHideHomeButton: (hide: boolean) => void toggleShowLog: () => void toggleShowGridWhileScanning: () => void setShowLog: (show: boolean) => void setAppType: (appType: AppType | undefined) => void -} & PersistedLayoutZustand +} diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 06b0d4f8..00a00c1e 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -102,7 +102,9 @@ export type IpcChannel = (typeof IPC_CHANNELS)[number] /** * IpcHandlerMap associates each IpcChannel with: * - args: the argument types that the renderer needs to pass - * - return: the type that the handler in the main/backend returns + * - return: the value the handler answers, never the promise around it. + * Every call crosses the boundary, so every one of them is async, and + * `IpcListener` takes `Promise | R` while the preload wraps the lot. * * ! NOTE: The keys below MUST exactly match IpcChannel. * ! If you add a channel to IPC_CHANNELS, add it here. @@ -259,13 +261,13 @@ export interface IpcHandlerSpec { /** Set the server port */ ['set_server_port']: { args: [CreateServerParams] - return: Promise + return: number | undefined } /** Create a new server */ ['create_server']: { args: [CreateServerParams] - return: Promise + return: number | undefined } /** Delete an existing server (UUID) */ @@ -343,7 +345,7 @@ export interface IpcHandlerSpec { /** Lower the Linux unprivileged-port floor via pkexec */ ['apply_privileged_port_fix']: { args: [PrivilegedPortFixMode] - return: Promise + return: PrivilegedPortFixResult | undefined } /** Report whether this user may open a serial port on Linux */