diff --git a/.claude/hooks/__tests__/guards.test.mjs b/.claude/hooks/__tests__/guards.test.mjs index 23de63ea..86b6213b 100644 --- a/.claude/hooks/__tests__/guards.test.mjs +++ b/.claude/hooks/__tests__/guards.test.mjs @@ -101,7 +101,9 @@ describe('git-restore-guard', () => { it('fires on a checkout naming a path that exists', () => { const cwd = dirtyRepo() - expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain('a.ts') + expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain( + 'a.ts' + ) }) it('fires on git restore', () => { const cwd = dirtyRepo() diff --git a/.claude/hooks/__tests__/prose-trigger.test.mjs b/.claude/hooks/__tests__/prose-trigger.test.mjs index ae65dcbe..5aaa2453 100644 --- a/.claude/hooks/__tests__/prose-trigger.test.mjs +++ b/.claude/hooks/__tests__/prose-trigger.test.mjs @@ -16,7 +16,10 @@ const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'prose-trigger. /** What the hook says, or '' when it declined. Throws when it exits non-zero. */ const fire = (toolInput, sessionId = `test-${Math.random()}`) => { - const payload = JSON.stringify({ session_id: sessionId, ...(toolInput ? { tool_input: toolInput } : {}) }) + const payload = JSON.stringify({ + session_id: sessionId, + ...(toolInput ? { tool_input: toolInput } : {}) + }) const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' }) return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' } diff --git a/.claude/hooks/__tests__/test-trigger.test.mjs b/.claude/hooks/__tests__/test-trigger.test.mjs index dea379af..07bb0390 100644 --- a/.claude/hooks/__tests__/test-trigger.test.mjs +++ b/.claude/hooks/__tests__/test-trigger.test.mjs @@ -48,9 +48,7 @@ describe('test-trigger stays quiet on', () => { describe('test-trigger reaches a test written through Bash', () => { it('fires on a heredoc naming a test path', () => - expect( - fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" }) - ).not.toBe('')) + expect(fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" })).not.toBe('')) it('fires on a heredoc carrying a test call', () => expect(fire({ command: "python3 - <<'PY'\ns = \"it('works', () => {})\"\nPY" })).not.toBe('')) it('stays quiet on a heredoc that writes neither', () => diff --git a/.claude/hooks/bulk-edit-guard.mjs b/.claude/hooks/bulk-edit-guard.mjs index c38c4e41..1ed73c2a 100644 --- a/.claude/hooks/bulk-edit-guard.mjs +++ b/.claude/hooks/bulk-edit-guard.mjs @@ -23,8 +23,8 @@ const REMINDER = 'A scripted edit fails quietly in three directions: it removes what you did not name, it ' + 'eats half a sentence in prose and leaves no symbol behind, or it raises before writing and ' + 'changes nothing. After it runs: `git diff --stat -- ` (empty means ' + - 'nothing happened), `git diff | grep \'^-\' | grep -E \'const |function |export \'` (what ' + - 'left, by name), and for prose `git diff | grep -E \'^[-+][[:space:]]*(//|\\*)\'`. Not ' + + "nothing happened), `git diff | grep '^-' | grep -E 'const |function |export '` (what " + + "left, by name), and for prose `git diff | grep -E '^[-+][[:space:]]*(//|\\*)'`. Not " + '`--word-diff`, which prefixes every line with a space so those filters return nothing.' /** Every firing after it asks the question instead of repeating the rule. */ diff --git a/.claude/hooks/git-restore-guard.mjs b/.claude/hooks/git-restore-guard.mjs index 2839c0e2..f9326eb1 100644 --- a/.claude/hooks/git-restore-guard.mjs +++ b/.claude/hooks/git-restore-guard.mjs @@ -79,7 +79,7 @@ const rest = atRisk.length > 10 ? ', and more' : '' const REMINDER = isStash ? `These files hold unstaged changes: ${listed}${rest}. \`git stash\` with nothing to stash ` + 'succeeds anyway, so a later `git stash pop` takes whatever was already on the stack, ' + - 'possibly another branch\'s work. To carry work to another branch, `git checkout ` ' + + "possibly another branch's work. To carry work to another branch, `git checkout ` " + 'brings it along when nothing conflicts. To measure another commit, use a worktree.' : `These files hold unstaged changes: ${listed}${rest}. \`${verb(command)}\` naming a path ` + 'restores from the index, and for an unstaged file the index is HEAD, so it deletes ' + diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md index 84c1bcba..47877ed4 100644 --- a/.claude/skills/audit/SKILL.md +++ b/.claude/skills/audit/SKILL.md @@ -13,14 +13,14 @@ finding, including the one-line obvious ones. Split by what the code shares, not by directory size. One area per run. -| area | what it is | -| --- | --- | -| `modbus` | `main/modules/modbusClient.ts`, `modbusServer.ts` and `modbusServer/` | -| `boundary` | `main/ipc.ts`, `preload/`, `shared/types/ipc.ts`, `main/state.ts`, `main/windows.ts` | -| `shared` | `shared/` minus `types/ipc.ts`: schemas, migrations, pure helpers | -| `stores` | `renderer/src/context/` | -| `client-ui` | `renderer/src/components/client/` | -| `server-ui` | `renderer/src/components/server/`, `components/shared/`, `containers/` | +| area | what it is | +| ----------- | ------------------------------------------------------------------------------------ | +| `modbus` | `main/modules/modbusClient.ts`, `modbusServer.ts` and `modbusServer/` | +| `boundary` | `main/ipc.ts`, `preload/`, `shared/types/ipc.ts`, `main/state.ts`, `main/windows.ts` | +| `shared` | `shared/` minus `types/ipc.ts`: schemas, migrations, pure helpers | +| `stores` | `renderer/src/context/` | +| `client-ui` | `renderer/src/components/client/` | +| `server-ui` | `renderer/src/components/server/`, `components/shared/`, `containers/` | ## Before you start @@ -35,17 +35,17 @@ Apply all eight. Do not merge them and do not skip the cosmetic ones. 1. **What the conformance suite cannot see.** The store-versus-component IPC rule, the folder-per-component judgement, and anything else CONTRIBUTING - states as prose under *The rules no test can see*. + states as prose under _The rules no test can see_. 2. **Duplication.** Search for the shape, not the name. Two functions doing one job often share no word, so a search for what one is called returns neither. Report identical copies too, because they diverge later. A duplication claim - carries its own burden of proof, under *Verification*. + carries its own burden of proof, under _Verification_. → [WHY: a meter is a claim too](./references/a-meter-is-a-claim.md) 3. **Dead code.** Unused exports, unreachable branches, config entries pointing at deleted paths, comments naming files that are gone. 4. **Deferred comments.** Every `TODO`, `FIXME`, `for now`, `later`, `until we`, with its exact location and text. -5. **Test coverage.** What is *not* covered. Think like someone with a field +5. **Test coverage.** What is _not_ covered. Think like someone with a field device, not like the author: a unit id of 0 and of 248, an address at 65535 with a data type needing four registers, a serial port that disappears mid-read, a config file from two versions ago. @@ -60,6 +60,7 @@ Apply all eight. Do not merge them and do not skip the cosmetic ones. alone. Count code lines and test lines separately: a file that looks like the worst offender is sometimes a thin one with a large test block bolted on, and the two call for opposite conclusions. + 7. **Architecture fit.** Does this block RTU over TCP, a second client, the gateway idea, or anything `CHANGELOG.md` says is coming? 8. **What modbus-serial actually does.** For `modbus`, `boundary` and `shared`: @@ -113,11 +114,11 @@ and its output) · `proposal` · `recipe` if reproduced. **severity — what the app does to a user decides it, not how much it annoys you.** -| | | -| --- | --- | +| | | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **blocking** | A user gets a wrong answer, a crash, or lost configuration. A malformed frame reaching the socket. A register read as the wrong type. Also: dead code that makes something look covered when it is not. | -| **annoying** | Right behaviour, wrong construction. Duplication, a rule enforced in one of two places, untested logic. Nothing a user sees today; the next change here is where it bites. | -| **cosmetic** | Neither. A stale comment, an unused export, a name that misleads. | +| **annoying** | Right behaviour, wrong construction. Duplication, a rule enforced in one of two places, untested logic. Nothing a user sees today; the next change here is where it bites. | +| **cosmetic** | Neither. A stale comment, an unused export, a name that misleads. | **size — how much work the proposal is, not how large the defect is.** **S** is one file and no decision. **M** touches several call sites or needs a small @@ -168,14 +169,14 @@ Two exceptions, where the burden runs the other way: ## What a finding is worth once it is written -| part | trust | -| --- | --- | -| the file, the symbol, the evidence | high, and verifiable | -| the claim, if reproduced | high | -| the claim, if only read | good | -| **the recipe** | **low** — the most common defect in an audit is a recipe describing an input that does not trigger the behaviour | -| **the proposal** | **low** — a guess by someone who did not read the rest of the file | -| any summary or state | **low** — a compression, and compressions interpret | +| part | trust | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| the file, the symbol, the evidence | high, and verifiable | +| the claim, if reproduced | high | +| the claim, if only read | good | +| **the recipe** | **low** — the most common defect in an audit is a recipe describing an input that does not trigger the behaviour | +| **the proposal** | **low** — a guess by someone who did not read the rest of the file | +| any summary or state | **low** — a compression, and compressions interpret | Open the file, reproduce, then decide. Being written down is not evidence. diff --git a/.claude/skills/audit/references/a-meter-is-a-claim.md b/.claude/skills/audit/references/a-meter-is-a-claim.md index 7c17cad7..ace3dff3 100644 --- a/.claude/skills/audit/references/a-meter-is-a-claim.md +++ b/.claude/skills/audit/references/a-meter-is-a-claim.md @@ -21,7 +21,7 @@ fix. Expanding the glob with `globSync` answers the question that was asked; a rule about the shape of the string answers a different one. **It matched the name and not the thing.** Renaming `rootState` to `clientState` -with a regex renamed the `clientState` *field* on the store as well, producing +with a regex renamed the `clientState` _field_ on the store as well, producing `clientState.clientState`. The language service knows which binding an identifier is on; a search and replace knows only the characters. diff --git a/.claude/skills/audit/references/one-bug-three-reports.md b/.claude/skills/audit/references/one-bug-three-reports.md index ef5d1e8b..a73fd298 100644 --- a/.claude/skills/audit/references/one-bug-three-reports.md +++ b/.claude/skills/audit/references/one-bug-three-reports.md @@ -3,11 +3,11 @@ Six areas were audited in parallel. The agents could not see each other, which is what makes a refutation reviewer worth having and also what produces this: -| the area | names it as | claim | -| --- | --- | --- | -| modbus | `modbusServer.ts removeRegister` | clears a fixed 24 registers where `addRegister` wrote `length ?? 10` | -| server-ui | `DeleteButton` and `addRegister.zustand.ts submit` | removing a utf8 register blanks unrelated registers past its end | -| shared | `addressGrouping.ts getRegisterLength` | returns 24 with no `nextAddress`, and takes no `length` parameter at all | +| the area | names it as | claim | +| --------- | -------------------------------------------------- | ------------------------------------------------------------------------ | +| modbus | `modbusServer.ts removeRegister` | clears a fixed 24 registers where `addRegister` wrote `length ?? 10` | +| server-ui | `DeleteButton` and `addRegister.zustand.ts submit` | removing a utf8 register blanks unrelated registers past its end | +| shared | `addressGrouping.ts getRegisterLength` | returns 24 with no `nextAddress`, and takes no `length` parameter at all | One defect. Three blocking findings. Three different files named, and **none of the three is where the fix belongs**: the width is stated in several places that diff --git a/.claude/skills/handover/SKILL.md b/.claude/skills/handover/SKILL.md index 83993f3b..96417a80 100644 --- a/.claude/skills/handover/SKILL.md +++ b/.claude/skills/handover/SKILL.md @@ -16,10 +16,10 @@ never from what was interesting. Two paths. `/compact` writes its own summary of the conversation, so step 4 has no reader on that path and step 4b replaces it. -| next | steps | -| --- | --- | -| `/clear`, or a new session | 1, 2, 3, 4, 5 | -| `/compact` | 1, 2, 3, 4b, 5 | +| next | steps | +| -------------------------- | -------------- | +| `/clear`, or a new session | 1, 2, 3, 4, 5 | +| `/compact` | 1, 2, 3, 4b, 5 | ## 1. Inventory, by destination @@ -36,13 +36,13 @@ Then sort each item by **where it belongs**, not by how interesting it was. `/todo` owns the destinations: invoke it rather than deciding here. Two things it does not own: -| what it is | where it goes | -| --- | --- | +| what it is | where it goes | +| ------------------------------------------------------ | ------------------------------------ | | state: branch, commits, what is green, what is running | the prompt, or the compact paragraph | -| the next instruction | the user writes it, step 5 | +| the next instruction | the user writes it, step 5 | -**One question makes it mechanical.** For each item: *if a future session needed -this and I were gone, where would it look?* A file, then it goes in the file. +**One question makes it mechanical.** For each item: _if a future session needed +this and I were gone, where would it look?_ A file, then it goes in the file. Nowhere, because it only matters for the next hour, then the prompt. **"It would ask me" is the item most likely to be dropped and the one that must be written.** @@ -94,7 +94,7 @@ standing list here, so the paragraph carries itself: state has files, the repo answers its own shape, and a sentence repeating either spends the space that was actually at risk. -What is left is a judgement about *this* session. Hand over the line to type and +What is left is a judgement about _this_ session. Hand over the line to type and nothing else: ```sh diff --git a/.claude/skills/precommit/SKILL.md b/.claude/skills/precommit/SKILL.md index 91cfc213..1bdbd9f6 100644 --- a/.claude/skills/precommit/SKILL.md +++ b/.claude/skills/precommit/SKILL.md @@ -8,7 +8,7 @@ description: Run the checklist before committing or merging — read the diff, l ## While you work **`git add` before you mutate anything.** `git checkout` naming a path restores -from the *index*, which on an unstaged file is HEAD — so the command undoing one +from the _index_, which on an unstaged file is HEAD — so the command undoing one mutation deletes everything else you wrote in that file. **A scripted edit leaves no name behind.** After any edit you did not type line @@ -39,7 +39,7 @@ git diff --staged Read **every** changed file, and untracked files in full: a new file has no diff, and is where a fresh copy of something the project already owns lands. -Then, against `CONTRIBUTING.md` *Code style* and `CLAUDE.md`: +Then, against `CONTRIBUTING.md` _Code style_ and `CLAUDE.md`: - **A store selector returning an object** is a whole-store subscription wearing a selector's clothes. One selector per field. @@ -86,11 +86,11 @@ for minutes, and running it per commit is how a branch stops being worked on. **Report what every step above produced.** A waiver covers the permission, never the checklist. -| what you are about to do | ask first? | -| --- | --- | -| `git commit` | no | -| `git push`, `gh` | **yes** — the line is whether it leaves the machine | -| merge | **yes**, and show the squash message first | +| what you are about to do | ask first? | +| ------------------------ | --------------------------------------------------- | +| `git commit` | no | +| `git push`, `gh` | **yes** — the line is whether it leaves the machine | +| merge | **yes**, and show the squash message first | ## 6. Commit diff --git a/.claude/skills/precommit/references/what-a-script-moved.md b/.claude/skills/precommit/references/what-a-script-moved.md index e61e5628..1f70a858 100644 --- a/.claude/skills/precommit/references/what-a-script-moved.md +++ b/.claude/skills/precommit/references/what-a-script-moved.md @@ -2,14 +2,14 @@ **A split left six section banners naming the wrong thing.** `AddRegister.tsx` was 900 lines and 27 components; a script cut it into five files on each `const` -line. A banner sits *above* the component it introduces, so every one of them +line. A banner sits _above_ the component it introduces, so every one of them stayed behind with the component before it. -| the banner | what ended up under it | -| --- | --- | -| `// Min Max components` | `IntervalInputForward` | -| `// Fixed Or Generator` | `CommentField` | -| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file | +| the banner | what ended up under it | +| ------------------------------------------------- | ---------------------- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file | Jens found the first by reading the diff. The sweep that found the other five was a script too: diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md index 16fa3a09..f4ad4a4e 100644 --- a/.claude/skills/prose/SKILL.md +++ b/.claude/skills/prose/SKILL.md @@ -12,17 +12,17 @@ stands, and **a claim you did not measure does not stay.** You just wrote a sentence containing one of these. Run its command now. -| the sentence contains | the command | -| --- | --- | -| a quoted message — a snackbar, an error, test output | run it and copy the line out of the output | -| a number — including "both", "all three", "each", "the only remaining" | the command that counts it, pasted with its output | -| a reference — a file, a symbol, a commit | `git show :` and `grep -rn '' src/` | -| a cause — "because", "this closes", "it is missing X" | `grep -rn '' src/` over every caller, and show both sides | -| a date or an order — "pre-existing", "added after", "still" | `git log -S '' --format='%h %ad %s' --date=short` | -| a qualifier — "mostly", "except", a parenthesis | read the hedge back; ask whether the claim in front survives | -| the shape of the code — "X now calls Y", "the copy is gone" | `grep` and `git diff`, never a green suite | -| **a command the reader is told to run** | run it, and read its output the way its reader will | -| **you are adding to a comment or a section that already exists** | read the whole block first — see *read the block* below | +| the sentence contains | the command | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------- | +| a quoted message — a snackbar, an error, test output | run it and copy the line out of the output | +| a number — including "both", "all three", "each", "the only remaining" | the command that counts it, pasted with its output | +| a reference — a file, a symbol, a commit | `git show :` and `grep -rn '' src/` | +| a cause — "because", "this closes", "it is missing X" | `grep -rn '' src/` over every caller, and show both sides | +| a date or an order — "pre-existing", "added after", "still" | `git log -S '' --format='%h %ad %s' --date=short` | +| a qualifier — "mostly", "except", a parenthesis | read the hedge back; ask whether the claim in front survives | +| the shape of the code — "X now calls Y", "the copy is gone" | `grep` and `git diff`, never a green suite | +| **a command the reader is told to run** | run it, and read its output the way its reader will | +| **you are adding to a comment or a section that already exists** | read the whole block first — see _read the block_ below | **A fact from outside this repository has no command here.** Cite the source, or cut the sentence. @@ -54,12 +54,12 @@ Four ways it is still wrong: not one you just measured. **The default is not to count.** A sentence with no number in it is the one to -write unless counting earns its place. *"The suite is green"*, *"its callers are -`AddButtons`, `DeleteButton` and the edit submit"* — neither can go stale. +write unless counting earns its place. _"The suite is green"_, _"its callers are +`AddButtons`, `DeleteButton` and the edit submit"_ — neither can go stale. **"Did you measure it" is the wrong gate**, and it passes the failures. A measured figure fails when **the command counted one set and the sentence names -another**. So the question is not *did I run it* but **which set did the command +another**. So the question is not _did I run it_ but **which set did the command count, and is that the noun in the sentence?** **A figure about work in progress does not go in at all** — steps done, files @@ -73,8 +73,8 @@ the sentence. **The reader has the diff.** Files, functions, call sites — `git show` answers all of it, correctly, forever. -Naming a mechanism is a count too: *"the linter would catch it"*, *"nothing else -reads this"* — a claim about a set you did not enumerate. +Naming a mechanism is a count too: _"the linter would catch it"_, _"nothing else +reads this"_ — a claim about a set you did not enumerate. **Check the last item in any list of three.** The first two get verified and the third rides along on the pattern they set. @@ -101,7 +101,7 @@ introduces the thing under it; after any split or reorder, check that it still names what follows. **What it costs is paid by a reviewer, and it is more than one reading.** Two -comments in one block that disagree cost a *second measurement*, because the only +comments in one block that disagree cost a _second measurement_, because the only way to tell which is the false claim is to go and run the thing. **In a test file, the sentence naming what the test discriminates stays and the @@ -116,12 +116,12 @@ Ask of each sentence: - Does a test or a schema already hold this fact? Then it needs no prose. - Is this the third rewrite of this paragraph? Delete it. -| what | how long | -| --- | --- | -| a commit message | what changed and why. The evidence is in the diff, not here | +| what | how long | +| ----------------- | ---------------------------------------------------------------- | +| a commit message | what changed and why. The evidence is in the diff, not here | | a CHANGELOG entry | what a user can now do. Grouped by feature, never by code change | -| a code comment | what the reader cannot see from the code. If it argues, cut it | -| an issue reply | casual, brief, first person. No release-notes formatting | +| a code comment | what the reader cannot see from the code. If it argues, cut it | +| an issue reply | casual, brief, first person. No release-notes formatting | ## References diff --git a/.claude/skills/prose/references/figures.md b/.claude/skills/prose/references/figures.md index c3ce2cbe..18761909 100644 --- a/.claude/skills/prose/references/figures.md +++ b/.claude/skills/prose/references/figures.md @@ -3,9 +3,9 @@ Why the prohibition is flat rather than conditional. History, not state. **A measured figure that counted the wrong set.** The component census behind -the `meme` decision was written as *"102 wrapped, 81 not, 183 total"*. The +the `meme` decision was written as _"102 wrapped, 81 not, 183 total"_. The command had run. It counted declarations matching `^const [A-Z]`, and the -sentence named *components* — two different sets, because a component declared +sentence named _components_ — two different sets, because a component declared `export const` is invisible to that pattern. ```sh @@ -18,8 +18,8 @@ every retelling was a reflow rather than a re-run. The repair was not a better count: it was running the same meter over `main` and over the branch, so the difference could be attributed to the meter rather than to the work. -**A cause asserted from a neighbouring fact.** *"The schemas already exist; this -is wiring, not authoring"* was written about the IPC validation step. Thirty-seven +**A cause asserted from a neighbouring fact.** _"The schemas already exist; this +is wiring, not authoring"_ was written about the IPC validation step. Thirty-seven Zod schemas did exist, and that was the neighbouring fact. None of them described an IPC argument: twelve of the thirteen argument types were hand-written interfaces. @@ -31,12 +31,12 @@ grep -rn "export interface WriteParameters\|export type WriteParameters" src/sha The sentence sized a step. It was wrong by the whole authoring half. -**A grep that matched names instead of concerns.** *"Four channels are called -from both a store and a component"* came from intersecting two lists of channel +**A grep that matched names instead of concerns.** _"Four channels are called +from both a store and a component"_ came from intersecting two lists of channel names. Two of the four were not duplicates at all: in the store, `read` follows an endianness flip and `stopScanningUnitIds` is a reload cleanup; in the components both are a button. Same channel, different reason, both correct. -The command answered *"which names appear on both sides"*. The sentence claimed -*"which concerns are duplicated"*. Nothing about running it again would have +The command answered _"which names appear on both sides"_. The sentence claimed +_"which concerns are duplicated"_. Nothing about running it again would have caught that. diff --git a/.claude/skills/prose/references/the-block-not-the-sentence.md b/.claude/skills/prose/references/the-block-not-the-sentence.md index 32b60e0b..5eff6b65 100644 --- a/.claude/skills/prose/references/the-block-not-the-sentence.md +++ b/.claude/skills/prose/references/the-block-not-the-sentence.md @@ -4,19 +4,19 @@ Why `/prose` prunes the block and not the sentence. **A comment that survived a move stops being true without being edited.** `AddRegister.tsx` was split into five files by a script that cut on each `const` -line. A section banner sits *above* the component it introduces, so every banner +line. A section banner sits _above_ the component it introduces, so every banner stayed behind with the component before it. Jens found the first one by reading the diff: a `// MAIN` at the end of `addRegisterActions.tsx`, introducing nothing. A sweep found five more of the same shape, one of them mislabelling a live component: -| banner | what was under it | -| --- | --- | -| `// Min Max components` | `IntervalInputForward` | -| `// Fixed Or Generator` | `CommentField` | -| `// MAIN`, `// Comment`, `// Shared submit logic` | end of file | -| `// Toggle endianness button removed` | end of file, and already dead before the split | +| banner | what was under it | +| ------------------------------------------------- | ---------------------------------------------- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | end of file | +| `// Toggle endianness button removed` | end of file, and already dead before the split | Nothing failed. Lint passed, typecheck passed, 591 tests passed, and 86 E2E specs passed. A banner is prose, and prose has no suite. diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index 004f3d0c..21dc642c 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -13,22 +13,22 @@ regression lands in a neighbouring path of the same function. Two steps, no transitive closure: 1. **The callers** of what you are changing. `grep -rn '' src/` — then ask - per caller whether the rule is true of *it*. + per caller whether the rule is true of _it_. 2. **The input forms** that reach it. A register type, a data type, an endianness, a unit id, an address at the top of its range. Enumerate the axes; do not assume the value you have in mind is the shape. Then: what of that radius is covered — not whether tests exist, whether they -touch *this* — what the behaviour should be across all of it, write those, run +touch _this_ — what the behaviour should be across all of it, write those, run them. Anything already failing that your change does not turn green goes to the user with the output. Never absorbed, never left because it was there first. ## Which suite can see it -| | sees | cannot see | -| --- | --- | --- | -| **vitest** | pure functions, schemas, migrations, stores, a component in isolation | Electron, IPC, the real DataGrid, anything across two windows | -| **Playwright** | the app as a user drives it, both windows, a real Modbus socket | anything without a `data-testid` to address it | +| | sees | cannot see | +| -------------- | --------------------------------------------------------------------- | ------------------------------------------------------------- | +| **vitest** | pure functions, schemas, migrations, stores, a component in isolation | Electron, IPC, the real DataGrid, anything across two windows | +| **Playwright** | the app as a user drives it, both windows, a real Modbus socket | anything without a `data-testid` to address it | `yarn test` strips types rather than checking them, so a wrong annotation passes it and only `yarn typecheck` says so. @@ -38,10 +38,10 @@ Say so when you write it, because the fast loop will not protect it. ## Every fix ships a pair -| the test | what it guards | when it is red | -| --- | --- | --- | -| the **state that must not recur** | the exact input the finding named | before the fix | -| the **state that must keep working** | the behaviour beside it, which the fix could break | never | +| the test | what it guards | when it is red | +| ------------------------------------ | -------------------------------------------------- | -------------- | +| the **state that must not recur** | the exact input the finding named | before the fix | +| the **state that must keep working** | the behaviour beside it, which the fix could break | never | Apply the fix and run both; revert it and run both again. The first must go red and the second must stay green. If both stay green, the pair does not test the @@ -70,12 +70,12 @@ git status --porcelain # empty, or the restore did not take exactly like proof for the other. - **A condition is as many mutations as it has clauses.** Per clause: delete it, which asks whether it is load-bearing, and put the neighbouring rule in its - place, which asks whether it is the *right* one. The second finds the + place, which asks whether it is the _right_ one. The second finds the survivors — a rival that refuses the same input you happened to write is invisible to a deletion. - **Edit by line number or by a unique string, not by the first match.** `sed`, `replace(old, new, 1)` and a first-hit search all take the first one, and a - codebase repeats lines. If a mutation reports *no tests* rather than a failure, + codebase repeats lines. If a mutation reports _no tests_ rather than a failure, it broke the file: that is your quoting, not the code. - **Assert what the code did, not what it said.** The value in the store, the cell in the grid, the bytes on the wire. Not that a handler was called. @@ -106,6 +106,9 @@ mid-read. Everything that "nobody would do" — someone with a field device does - **The number in the filename is the order.** A spec that needs what an earlier one built is a spec that breaks when run alone. - **The DataGrid virtualises both axes**, so a column far enough right or a row - far enough down is not in the DOM. `MODBUX_E2E=1` is set by the fixture for - exactly that, and it is never set in a shipped build. + far enough down is not in the DOM. `scrollToRow` and `scrollToColumn` in + `e2e/fixtures/helpers.ts` bring one into it, and `cell`, `expectCell` and + `expectCellContains` scroll to the column themselves. How much fits depends + on the window, so a spec that reaches a far column without scrolling passes + on one machine and fails on another. - **A failure that does not reproduce is reported, not re-run into silence.** diff --git a/.claude/skills/test/references/five-rules-five-mutations.md b/.claude/skills/test/references/five-rules-five-mutations.md index 24f993cc..ad3e5816 100644 --- a/.claude/skills/test/references/five-rules-five-mutations.md +++ b/.claude/skills/test/references/five-rules-five-mutations.md @@ -24,8 +24,8 @@ helper, not a covered rule. **A sixth run reported `Tests no tests`**, which looks like a mutation nothing covers and was a quoting error in the script doing the mutating: the file no -longer parsed, so vitest collected nothing. A mutation run that reports *no -tests* rather than a failure has broken the file, and says nothing about +longer parsed, so vitest collected nothing. A mutation run that reports _no +tests_ rather than a failure has broken the file, and says nothing about coverage. The restore is checked rather than assumed: diff --git a/.claude/skills/todo/SKILL.md b/.claude/skills/todo/SKILL.md index ff5f4067..db1c1b19 100644 --- a/.claude/skills/todo/SKILL.md +++ b/.claude/skills/todo/SKILL.md @@ -14,24 +14,24 @@ handled. Before choosing a place, choose whether there is one: - **larger** — tell the user, and let them decide - **the user deferred it** — now it goes somewhere, and the rest of this decides where -**The tell is the sentence you are about to write.** *"Pre-existing"*, *"older -than this branch"*, *"not mine"*, *"pulled in by proximity"*. Each of those can +**The tell is the sentence you are about to write.** _"Pre-existing"_, _"older +than this branch"_, _"not mine"_, _"pulled in by proximity"_. Each of those can be true, and none of them answers whether the fix is small. ## Then: state, or history? -| what you are writing | where it goes | -| --- | --- | -| something to **do**, on this branch or the next | `TODO.md`, one line | -| something a **user** would recognise as a bug or a request | a GitHub issue | -| **why** — a decision, a measurement, an approach that failed | the memory directory | -| a **preference** the user stated, or how they want to work | the memory directory, never the repository | -| something already in the **plan being executed** | there, and not twice | -| what a user can now **do** | `CHANGELOG.md`, at the release | +| what you are writing | where it goes | +| ------------------------------------------------------------ | ------------------------------------------ | +| something to **do**, on this branch or the next | `TODO.md`, one line | +| something a **user** would recognise as a bug or a request | a GitHub issue | +| **why** — a decision, a measurement, an approach that failed | the memory directory | +| a **preference** the user stated, or how they want to work | the memory directory, never the repository | +| something already in the **plan being executed** | there, and not twice | +| what a user can now **do** | `CHANGELOG.md`, at the release | -**The tell is the tense.** *"Enable `require-await`"* is a task. *"Two handlers +**The tell is the tense.** _"Enable `require-await`"_ is a task. _"Two handlers stayed async because reading the version off the store took their only await, -and lint has the rule off"* is a record. If your sentence explains, it is not a +and lint has the rule off"_ is a record. If your sentence explains, it is not a task, however true it is. **The other tell is length.** A task is one line, maybe three. The moment you diff --git a/.claude/skills/todo/references/the-explanation-that-ate-the-task.md b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md index 25a46ffb..58891d1f 100644 --- a/.claude/skills/todo/references/the-explanation-that-ate-the-task.md +++ b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md @@ -14,7 +14,7 @@ awk 'END{printf "%.1f lines per open item\n", NR/o} /^[[:space:]]*- \[ \]/{o++}' **Read a rise, not a level.** Anything shorter than the current mean lowers it, so six one-line tasks move the number as far as a large cut does. What it -measures is *prose per task*, and the only thing that raises it is prose. +measures is _prose per task_, and the only thing that raises it is prose. The shape is the same at document scale. Ploxc's `precommit` skill records going 5398, 5523, 5699 and 5799 words over four review rounds, one correct step diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d22eb919..30e3cdfa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,7 +4,6 @@ about: Create a report to help us improve title: '' labels: '' assignees: '' - --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. Windows, MacOS] - - Version [e.g. 1.3.7] + +- OS: [e.g. Windows, MacOS] +- Version [e.g. 1.3.7] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index df81be2b..2fa942cf 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,7 +4,6 @@ about: Suggest an idea for this project title: '' labels: '' assignees: '' - --- **Is your feature request related to a problem? Please describe.** diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7b2f5050..f92095d6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -158,14 +158,14 @@ jobs: # nothing about the packaged app, and the packaged app is what ships. The # job still ends red, and now it ends red with both answers in it. - name: Package - if: '!cancelled() && inputs.mode != ''dev''' + if: "!cancelled() && inputs.mode != 'dev'" shell: bash run: | yarn electron-builder --dir --publish never || { sleep 15; yarn electron-builder --dir --publish never; } - name: E2E against dist/ - if: '!cancelled() && inputs.mode != ''dev''' + if: "!cancelled() && inputs.mode != 'dev'" shell: bash run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --config playwright.packaged.config.ts --trace retain-on-failure diff --git a/.vscode/settings.json b/.vscode/settings.json index 584d075a..b9a8ba51 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,12 +8,7 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact" - ], + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": "always" }, @@ -21,4 +16,4 @@ "editor.formatOnPaste": true, "editor.formatOnType": true, "C_Cpp.errorSquiggles": "disabled" -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fab0e7..fe749986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A DATETIME write now carries the date you typed.** The write dialog took a + value field whose range was stated in seconds while the register is encoded + from milliseconds, so every value it accepted fell before the year 2000 and + went out as 2000/01/01 00:00:00. The field now takes milliseconds over the + range the format carries, which is 2000 through 2127. A date past 2127 used to + be written with the year wrapped, so a register set to 2200 read back as 2072; + it now stops at the end of 2127. +- **An address freed on load is offered again.** A saved setup comes back + without a register Modbux cannot serve, and the addresses that register stood + on stayed marked In use, so the Add dialog refused an address the grid showed + nothing at. +- **A write no longer logs another request as its own.** The transaction log + took the last request that went out, listed it as the write and took its entry + away, so a read still waiting for an answer timed out. That happened after a + write Modbux refuses, such as a UTF-8 register through FC16, and after any + 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 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 + 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. +- **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 + 0 to 255 produced a message that listed all 256 of them. +- **A UTF-8 value no longer shows two characters from the register after it.** + The string ran to the end of the group of addresses it was read in, one + register past the last one that group holds. +- **Writing a value as UTF-8 no longer writes a zero.** The write dialog lists + UTF-8 with the number types and has no field for the characters, so the write + went out as one register of 0 over whatever the address held. Modbux now says + it cannot write the value as UTF-8 and sends nothing. +- **The date picker now writes the date it shows.** Opening a stored UNIX or + DATETIME register to edit it put the current time in the field instead of the + date the register holds, so changing the comment or the address wrote today + over it. Add & Next left the picker showing the current time over a field it + had cleared, so the next Add wrote 1970/01/01 for a UNIX register and + 2000/01/01 for a DATETIME. +- **The bottom of the register list is reachable with a bitmap row open.** The + client grid reserved the same height for every row, so the bit panel that + opens below a bitmap register pushed everything after it down by a height the + grid did not know about, and the last rows ended below the furthest it would + scroll. The row now takes the height the panel needs, at any window width. +- **Add & Next no longer replaces the register it just added.** With no free + address left above the one you added, the dialog stayed on that address + without marking it, and Add was still live: pressing it wrote over the + register you had just made and lost its comment. The address is now marked In + use and Add goes off. +- **The date picker no longer takes a date the register cannot carry.** A + DATETIME runs out at the end of 2127 and a UNIX timestamp on 2106/02/07, and + the picker took any year it could show: a UNIX register set to 2200 read back + as 2063/11/24. Such a date is now marked wrong, and Add stays off until you + pick one the register holds. +- **A timestamp row now shows only a date the registers really hold.** The + server's DATETIME row decoded the four registers itself, without the format's + invalid flag and without checking the fields it read, so whatever a client had + written into them came back as a plausible date. A day its month does not + have, such as the 31st of February, came out as the words `Invalid DateTime`, + in the client grid as well. Both now show nothing, and a UNIX register holding + 0 shows 1970/01/01 00:00:00 where it used to show a dash. - **The server no longer answers for units it does not have.** On a shared RS-485 line it replied to every address on the bus, including the ones belonging to the real devices on it, so its frame went out at the same moment @@ -28,6 +103,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 coil from the one you opened to the end of the range, so everything you had not touched went out as off. The dialog now opens showing what the last read returned, which is what goes back to the device. +- **The server window now tells you what went wrong in it.** With the server in + its own window, every message from the backend went to the main window + instead, which is the one on the client view. A port the server refused, a + port already taken, an RTU port that disconnected: the field snapped back and + nothing was said where you were looking. Each message now goes to the window + it is about. - **An empty value field no longer writes a zero.** The box turned red and the write went out anyway, as a 0, because an empty field is what JavaScript reads as zero. The write buttons are now off until the field holds a number, and the @@ -47,7 +128,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A disconnect that hangs no longer costs you auto-reconnect.** When closing the connection took too long, the client was replaced by a fresh one that nobody was listening to, so for the rest of the session a dropped connection - went unreported and was never reconnected. + went unreported and was never reconnected. The client it replaced went the + other way: Modbux kept listening to it, and on a serial port it kept the port + open, so its own close could arrive later as a connection lost on the + connection that had taken its place. Modbux now stops listening to it. On a + serial port it also says that the port may stay open until you close Modbux, + which is what a "Cannot lock port" on the next connect means. - **The transaction log no longer marks a good read as failed.** Reading a configuration reads one group of addresses at a time, and once one group failed, every group after it was logged carrying that group's error. @@ -91,6 +177,61 @@ 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. + The next start could not read it back and put the connection or the register + 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. +- **Starting the poll again right after stopping it no longer runs two read + loops at once.** Pressing Stop and then Poll while a read was still on its way + left the old loop running beside the new one, so the device was asked twice as + often and two requests could sit on one connection. Only one loop runs now, + however fast the buttons are pressed. +- **Read configuration is only offered where there is something to read.** The + button went live as soon as an address carried anything at all, a comment + included. On coils and discrete inputs a comment is the only thing you can + add, so labelling one was enough to switch the button on, and pressing it + emptied the grid and disabled the address and length fields until you pressed + it again. It now counts the addresses you gave a data type, which is what it + reads. +- **A scan of coils or discrete inputs now shows every one it read.** The scan + asks for the chunk size the dialog is set to, and the grid was filled with the + read length from the toolbar instead, so the default chunk of 100 against a + read length of 10 kept ten coils out of every hundred it found. Every address + in the chunk now arrives. +- **The RTU server now opens at the data bits and stop bits you set.** Both were + handed to the serial library in a place it does not read, so every serial + server opened at 8 data bits and 1 stop bit whatever the panel showed. Baud + rate and parity always arrived, and the client was never affected. +- **Nothing else goes out in the middle of a read.** Modbux waited for a poll + and for both scans before it read or wrote, and not for a read of its own, so + anything asked for while a long read was still on the wire put a second + request on the line. Measured over RTU with two reads: the registers the first + one was fetching came back empty. Read is now off for as long as a read runs, + and a write asked for in that moment is refused with a message rather than + sent. +- **Cancelling a connect to a serial port now cancels it.** The Connect button + turns into a Cancel while the port is opening. Pressing it used to change + nothing: a moment later the app reported "Connected over Modbus RTU" and sat + there holding the port. The cancelled connect now closes the port it opened + and says nothing. Pressing Connect again before it has let that port go says + so rather than opening a second one. Over TCP the cancel always worked. +- **Read configuration now reads.** Turning it on while connected showed your + configured registers with a value of 0 in every one of them until you pressed + Read, and a 0 you have not read looks exactly like a 0 the device holds. The + values are now there as soon as the rows are. Disconnected it still shows the + mapping alone, and it asks for nothing while a poll or a scan is running. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index be8ef339..d1cdc803 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,9 @@ src/renderer/ the React UI # What breaks if you do not know it -- **Nothing in `src/shared` may import from `src/main`.** All three processes - import shared; it is the one layer that may not reach back. +- **Nothing in `src/shared` may import from `src/main`, or from `electron`.** + All three processes import shared; it is the one layer that may not reach + back. - **One store selector per field.** `useClientZustand((z) => z.a)` and then `((z) => z.b)`, never one selector returning an object. The renderer has zero whole-store subscriptions and zero `useShallow`, and that is why it renders a @@ -40,7 +41,7 @@ src/renderer/ the React UI `src/__tests__/conformance.test.ts` asserts the conventions this codebase has settled, so breaking one fails `yarn test`. What each means, and the ones no test -can see, is in CONTRIBUTING.md under *Code style*. +can see, is in CONTRIBUTING.md under _Code style_. # The rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2565e456..2a1fcfc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing to Modbux -Everything below is a rule rather than a suggestion. The ones under *The -conventions this codebase has already settled* are asserted by +Everything below is a rule rather than a suggestion. The ones under _The +conventions this codebase has already settled_ are asserted by `src/__tests__/conformance.test.ts`, so breaking one fails `yarn test` instead of waiting for a reviewer. @@ -75,7 +75,7 @@ two-thousand-row grid without either. back a store function puts that function in the dependency list, and a dependency list naming something the component does not own is a list no reader can check. What the component holds goes in the list; what the store holds is -read through `getState()`. That also covers a *value* the component wants at a +read through `getState()`. That also covers a _value_ the component wants at a moment rather than on every change: read that way, it causes no render. Two shapes, and which one you write depends on whether the component adds @@ -107,8 +107,11 @@ doing rather than a choice: `useGridApiContext` and `useGridApiRef` are exported by none of the subpaths `@mui/x-data-grid` declares, so they come from the root. -**Nothing in `src/shared` imports from `src/main`.** All three processes import -shared; it is the one layer that may not reach back. +**Nothing in `src/shared` imports from `src/main`, or from `electron`.** All +three processes import shared; it is the one layer that may not reach back. +Electron is the same rule read through the other specifier: `Windows` held two +`BrowserWindow`s from inside shared and passed, because it named `electron` +rather than `@main`. **Every interactive element carries a `data-testid`.** Buttons, fields, sliders, selects and grid action cells. Containers do not, because the e2e suite reaches @@ -117,6 +120,16 @@ and a `Select`'s options through `getByRole('option')`. The `Select` itself carries one. A picker takes the attribute through `slotProps`, which still counts as carrying it. +**The three lists that name a channel agree, and so do the two that name an +event.** `IPC_CHANNELS`, `IpcHandlerSpec` and the `ipcHandle` calls in +`main/ipc.ts`; `IPC_EVENTS` and `IpcEventPayloadMap`. Typecheck catches one +direction only: a channel in `IPC_CHANNELS` and not in the spec fails to index, +while a channel in the spec and not in `IPC_CHANNELS` is dropped by the mapped +type in silence, and one in both with no handler rejects at runtime instead. A +channel and an event name is lowercase segments, because that is where +`snakeToCamel` and `CamelCase` cannot disagree: `set_2wire` would be the method +`set_2wire` and the type `set2wire`. + **Every channel that carries an object declares a schema.** TypeScript covers a bare primitive, and a channel taking no argument has nothing to guard. The rest take an object or a union, and that is where a hand-edited config file arrives. The @@ -154,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. @@ -173,6 +188,7 @@ chore: bump version to 2.0.0 ``` **Rules:** + - `feat` = entirely new functionality - `fix` = something was broken, now it's not - `refactor` = same behavior, different code @@ -186,20 +202,20 @@ Don't use `feat` for a bug fix. Don't use `fix` for a refactor. Mean what you sa ### What to run -| Command | What it does | -|---------|-------------| -| `yarn test` | Unit tests (Vitest) | -| `yarn test:watch` | Unit tests in watch mode | -| `yarn test:e2e` | Build + the e2e suite (Playwright) | -| `yarn test:e2e:packaged` | Same specs against the packaged app. Run before releasing. | -| `yarn test:e2e:hardware` | The `99-hardware` specs. Needs an Arduino; skips without one. | -| `yarn presentation` | Build + regenerate the documentation screenshots | -| `yarn verify` | Lint + typecheck + unit + e2e. Run this before pushing. | -| `yarn test:e2e:scan-perf` | What a mounted grid costs during a scan. A measurement, not a check. | -| `yarn test:e2e:privileged-port` | The port 502 modal. Linux, and someone at the keyboard. | -| `yarn test:all:mac` | Everything this platform can run, ending with the hardware specs. | -| `yarn test:all:windows` | Everything this platform can run. No socat, so the socat serial specs skip. | -| `yarn test:all:linux` | Everything, including the one that waits for a person. | +| Command | What it does | +| ------------------------------- | --------------------------------------------------------------------------- | +| `yarn test` | Unit tests (Vitest) | +| `yarn test:watch` | Unit tests in watch mode | +| `yarn test:e2e` | Build + the e2e suite (Playwright) | +| `yarn test:e2e:packaged` | Same specs against the packaged app. Run before releasing. | +| `yarn test:e2e:hardware` | The `99-hardware` specs. Needs an Arduino; skips without one. | +| `yarn presentation` | Build + regenerate the documentation screenshots | +| `yarn verify` | Lint + typecheck + unit + e2e. Run this before pushing. | +| `yarn test:e2e:scan-perf` | What a mounted grid costs during a scan. A measurement, not a check. | +| `yarn test:e2e:privileged-port` | The port 502 modal. Linux, and someone at the keyboard. | +| `yarn test:all:mac` | Everything this platform can run, ending with the hardware specs. | +| `yarn test:all:windows` | Everything this platform can run. No socat, so the socat serial specs skip. | +| `yarn test:all:linux` | Everything, including the one that waits for a person. | `test:e2e` covers `01-main` and `02-standalone`. Two suites sit outside it and are invoked on purpose: diff --git a/e2e/fixtures/config-files/client-huawei-smartlogger.json b/e2e/fixtures/config-files/client-huawei-smartlogger.json index c736e069..8a957808 100644 --- a/e2e/fixtures/config-files/client-huawei-smartlogger.json +++ b/e2e/fixtures/config-files/client-huawei-smartlogger.json @@ -9,7 +9,11 @@ "holding_registers": { "40000": { "dataType": "unix", "scalingFactor": 1, "comment": "Date & Time (UTC)" }, "40002": { "dataType": "uint32", "scalingFactor": 1, "comment": "City" }, - "40004": { "dataType": "uint16", "scalingFactor": 1, "comment": "Daylight Saving Time Enabled" }, + "40004": { + "dataType": "uint16", + "scalingFactor": 1, + "comment": "Daylight Saving Time Enabled" + }, "40005": { "dataType": "int32", "scalingFactor": 1, "comment": "Time zone offset (s)" }, "40007": { "dataType": "uint16", "scalingFactor": 1, "comment": "DST state" }, "40008": { "dataType": "uint16", "scalingFactor": 1, "comment": "DST offset (min)" }, @@ -22,9 +26,21 @@ "40016": { "dataType": "uint16", "scalingFactor": 1, "comment": "Second" }, "40204": { "dataType": "uint16", "scalingFactor": 1, "comment": "Transfer trip" }, "40420": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Active Adjustment (kW)" }, - "40422": { "dataType": "int32", "scalingFactor": 0.1, "comment": "Reactive Adjustment (kVar)" }, - "40424": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Active Adjustment VOLATILE (kW)" }, - "40426": { "dataType": "int32", "scalingFactor": 0.1, "comment": "Reactive Adjustment VOLATILE (kVar)" }, + "40422": { + "dataType": "int32", + "scalingFactor": 0.1, + "comment": "Reactive Adjustment (kVar)" + }, + "40424": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Active Adjustment VOLATILE (kW)" + }, + "40426": { + "dataType": "int32", + "scalingFactor": 0.1, + "comment": "Reactive Adjustment VOLATILE (kVar)" + }, "40428": { "dataType": "uint16", "scalingFactor": 0.1, "comment": "Active adjustment %" }, "40429": { "dataType": "int16", @@ -39,11 +55,23 @@ "40532": { "dataType": "int16", "scalingFactor": 0.001, "comment": "Power Factor" }, "40543": { "dataType": "uint16", "scalingFactor": 1, "comment": "Plant status Qinghai" }, "40544": { "dataType": "int32", "scalingFactor": 0.001, "comment": "Reactive Power (kVar)" }, - "40550": { "dataType": "uint64", "scalingFactor": 0.01, "comment": "CO2 Reduction (kg) large" }, + "40550": { + "dataType": "uint64", + "scalingFactor": 0.01, + "comment": "CO2 Reduction (kg) large" + }, "40554": { "dataType": "int32", "scalingFactor": 0.1, "comment": "DC current 2 (A) large" }, "40560": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Total energy (kWh)" }, - "40562": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Total energy daily (kWh)" }, - "40564": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Daily power gen duration (h)" }, + "40562": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Total energy daily (kWh)" + }, + "40564": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Daily power gen duration (h)" + }, "40566": { "dataType": "uint16", "scalingFactor": 1, "comment": "Plant status Xinjiang" }, "40567": { "dataType": "uint16", "scalingFactor": 1, "comment": "Plant status Ningxia" }, "40568": { "dataType": "uint32", "scalingFactor": 1, "comment": "Active alarm seq" }, @@ -54,10 +82,26 @@ "40575": { "dataType": "uint16", "scalingFactor": 0.1, "comment": "Voltage AB (V)" }, "40576": { "dataType": "uint16", "scalingFactor": 0.1, "comment": "Voltage BC (V)" }, "40577": { "dataType": "uint16", "scalingFactor": 0.1, "comment": "Voltage CA (V)" }, - "40685": { "dataType": "uint16", "scalingFactor": 0.01, "comment": "Inverter Efficiency (%)" }, - "40693": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Max reactive adjustment (kVar)" }, - "40695": { "dataType": "int32", "scalingFactor": 0.1, "comment": "Min reactive adjustment (kVar)" }, - "40697": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Max active adjustment (kW)" }, + "40685": { + "dataType": "uint16", + "scalingFactor": 0.01, + "comment": "Inverter Efficiency (%)" + }, + "40693": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Max reactive adjustment (kVar)" + }, + "40695": { + "dataType": "int32", + "scalingFactor": 0.1, + "comment": "Min reactive adjustment (kVar)" + }, + "40697": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Max active adjustment (kW)" + }, "40699": { "dataType": "uint16", "scalingFactor": 1, "comment": "Locked/Unlocked" }, "40700": { "dataType": "bitmap", @@ -77,19 +121,47 @@ "40713": { "dataType": "utf8", "scalingFactor": 1, "comment": "ESN" }, "40736": { "dataType": "uint16", "scalingFactor": 1, "comment": "Device access status" }, "40737": { "dataType": "uint16", "scalingFactor": 1, "comment": "Active Mode" }, - "40738": { "dataType": "uint32", "scalingFactor": 0.1, "comment": "Target active power (kW)" }, + "40738": { + "dataType": "uint32", + "scalingFactor": 0.1, + "comment": "Target active power (kW)" + }, "40740": { "dataType": "uint16", "scalingFactor": 1, "comment": "Reactive Mode" }, "40741": { "dataType": "uint16", "scalingFactor": 1, "comment": "Reactive power curve mode" }, "40742": { "dataType": "int32", "scalingFactor": 0.001, "comment": "Reactive power target" }, "40802": { "dataType": "uint32", "scalingFactor": 1, "comment": "Active Scheduling %" }, - "41124": { "dataType": "uint16", "scalingFactor": 0.001, "comment": "CO2 coefficient (kg/kWh)" }, - "41934": { "dataType": "uint32", "scalingFactor": 0.001, "comment": "PV Module capacity (kW)" }, - "41936": { "dataType": "uint32", "scalingFactor": 0.001, "comment": "Rated plant capacity (kW)" }, - "41938": { "dataType": "uint32", "scalingFactor": 0.001, "comment": "Total rated inverter capacity (kW)" }, - "41940": { "dataType": "uint32", "scalingFactor": 0.001, "comment": "Conversion coefficient" }, + "41124": { + "dataType": "uint16", + "scalingFactor": 0.001, + "comment": "CO2 coefficient (kg/kWh)" + }, + "41934": { + "dataType": "uint32", + "scalingFactor": 0.001, + "comment": "PV Module capacity (kW)" + }, + "41936": { + "dataType": "uint32", + "scalingFactor": 0.001, + "comment": "Rated plant capacity (kW)" + }, + "41938": { + "dataType": "uint32", + "scalingFactor": 0.001, + "comment": "Total rated inverter capacity (kW)" + }, + "41940": { + "dataType": "uint32", + "scalingFactor": 0.001, + "comment": "Conversion coefficient" + }, "41942": { "dataType": "uint16", "scalingFactor": 1, "comment": "Communication status" }, "41947": { "dataType": "uint16", "scalingFactor": 1, "comment": "Comm abnormal shutdown" }, - "41948": { "dataType": "uint16", "scalingFactor": 1, "comment": "Comm abnormal detection time (s)" }, + "41948": { + "dataType": "uint16", + "scalingFactor": 1, + "comment": "Comm abnormal detection time (s)" + }, "41949": { "dataType": "uint16", "scalingFactor": 1, "comment": "Auto start comm recovery" }, "42017": { "dataType": "uint16", "scalingFactor": 1, "comment": "SystemTime: Year" }, "42018": { "dataType": "uint16", "scalingFactor": 1, "comment": "SystemTime: Month" }, @@ -97,7 +169,11 @@ "42020": { "dataType": "uint16", "scalingFactor": 1, "comment": "SystemTime: Hour" }, "42021": { "dataType": "uint16", "scalingFactor": 1, "comment": "SystemTime: Minute" }, "42022": { "dataType": "uint16", "scalingFactor": 1, "comment": "SystemTime: Second" }, - "42150": { "dataType": "uint16", "scalingFactor": 0.01, "comment": "Current error during scanning" }, + "42150": { + "dataType": "uint16", + "scalingFactor": 0.01, + "comment": "Current error during scanning" + }, "50000": { "dataType": "bitmap", "scalingFactor": 1, diff --git a/e2e/fixtures/config-files/client-iem3000-error.json b/e2e/fixtures/config-files/client-iem3000-error.json index a513a495..e9773c2d 100644 --- a/e2e/fixtures/config-files/client-iem3000-error.json +++ b/e2e/fixtures/config-files/client-iem3000-error.json @@ -1,117 +1,117 @@ { - "version": 2, - "modbuxVersion": "2.0.0", - "name": "Arduino iEM3000 Simulation (error test)", - "littleEndian": false, - "registerMapping": { - "coils": {}, - "discrete_inputs": {}, - "input_registers": {}, - "holding_registers": { - "2999": { - "dataType": "float", - "comment": "Current L1 (A)" - }, - "3001": { - "dataType": "float", - "comment": "Current L2 (A)" - }, - "3003": { - "dataType": "float", - "comment": "Current L3 (A)" - }, - "3019": { - "dataType": "float", - "comment": "Voltage L1-L2 (V)" - }, - "3021": { - "dataType": "float", - "comment": "Voltage L2-L3 (V)" - }, - "3023": { - "dataType": "float", - "comment": "Voltage L3-L1 (V)" - }, - "3027": { - "dataType": "float", - "comment": "Voltage L1-N (V)" - }, - "3029": { - "dataType": "float", - "comment": "Voltage L2-N (V)" - }, - "3031": { - "dataType": "float", - "comment": "Voltage L3-N (V)" - }, - "3053": { - "dataType": "float", - "comment": "Active Power L1 (W)" - }, - "3055": { - "dataType": "float", - "comment": "Active Power L2 (W)" - }, - "3057": { - "dataType": "float", - "comment": "Active Power L3 (W)" - }, - "3059": { - "dataType": "float", - "comment": "Active Power Total (W)" - }, - "3061": { - "dataType": "float", - "comment": "Reactive Power L1 (VAr)" - }, - "3063": { - "dataType": "float", - "comment": "Reactive Power L2 (VAr)" - }, - "3065": { - "dataType": "float", - "comment": "Reactive Power L3 (VAr)" - }, - "3067": { - "dataType": "float", - "comment": "Reactive Power Total (VAr)" - }, - "3069": { - "dataType": "float", - "comment": "Apparent Power L1 (VA)" - }, - "3071": { - "dataType": "float", - "comment": "Apparent Power L2 (VA)" - }, - "3073": { - "dataType": "float", - "comment": "Apparent Power L3 (VA)" - }, - "3075": { - "dataType": "float", - "comment": "Apparent Power Total (VA)" - }, - "3077": { - "dataType": "float", - "comment": "Power Factor L1" - }, - "3079": { - "dataType": "float", - "comment": "Power Factor L2" - }, - "3081": { - "dataType": "float", - "comment": "Power Factor L3" - }, - "3083": { - "dataType": "float", - "comment": "Power Factor Total" - }, - "3109": { - "dataType": "float", - "comment": "Frequency (Hz)" - } - } - } + "version": 2, + "modbuxVersion": "2.0.0", + "name": "Arduino iEM3000 Simulation (error test)", + "littleEndian": false, + "registerMapping": { + "coils": {}, + "discrete_inputs": {}, + "input_registers": {}, + "holding_registers": { + "2999": { + "dataType": "float", + "comment": "Current L1 (A)" + }, + "3001": { + "dataType": "float", + "comment": "Current L2 (A)" + }, + "3003": { + "dataType": "float", + "comment": "Current L3 (A)" + }, + "3019": { + "dataType": "float", + "comment": "Voltage L1-L2 (V)" + }, + "3021": { + "dataType": "float", + "comment": "Voltage L2-L3 (V)" + }, + "3023": { + "dataType": "float", + "comment": "Voltage L3-L1 (V)" + }, + "3027": { + "dataType": "float", + "comment": "Voltage L1-N (V)" + }, + "3029": { + "dataType": "float", + "comment": "Voltage L2-N (V)" + }, + "3031": { + "dataType": "float", + "comment": "Voltage L3-N (V)" + }, + "3053": { + "dataType": "float", + "comment": "Active Power L1 (W)" + }, + "3055": { + "dataType": "float", + "comment": "Active Power L2 (W)" + }, + "3057": { + "dataType": "float", + "comment": "Active Power L3 (W)" + }, + "3059": { + "dataType": "float", + "comment": "Active Power Total (W)" + }, + "3061": { + "dataType": "float", + "comment": "Reactive Power L1 (VAr)" + }, + "3063": { + "dataType": "float", + "comment": "Reactive Power L2 (VAr)" + }, + "3065": { + "dataType": "float", + "comment": "Reactive Power L3 (VAr)" + }, + "3067": { + "dataType": "float", + "comment": "Reactive Power Total (VAr)" + }, + "3069": { + "dataType": "float", + "comment": "Apparent Power L1 (VA)" + }, + "3071": { + "dataType": "float", + "comment": "Apparent Power L2 (VA)" + }, + "3073": { + "dataType": "float", + "comment": "Apparent Power L3 (VA)" + }, + "3075": { + "dataType": "float", + "comment": "Apparent Power Total (VA)" + }, + "3077": { + "dataType": "float", + "comment": "Power Factor L1" + }, + "3079": { + "dataType": "float", + "comment": "Power Factor L2" + }, + "3081": { + "dataType": "float", + "comment": "Power Factor L3" + }, + "3083": { + "dataType": "float", + "comment": "Power Factor Total" + }, + "3109": { + "dataType": "float", + "comment": "Frequency (Hz)" + } + } + } } diff --git a/e2e/fixtures/config-files/client-large-config.json b/e2e/fixtures/config-files/client-large-config.json index 6b2e1c55..8347b8c7 100644 --- a/e2e/fixtures/config-files/client-large-config.json +++ b/e2e/fixtures/config-files/client-large-config.json @@ -610,4 +610,4 @@ } } } -} \ No newline at end of file +} diff --git a/e2e/fixtures/config-files/server-large-config.json b/e2e/fixtures/config-files/server-large-config.json index 7fb055ab..3842e3e0 100644 --- a/e2e/fixtures/config-files/server-large-config.json +++ b/e2e/fixtures/config-files/server-large-config.json @@ -1212,4 +1212,4 @@ } } } -} \ No newline at end of file +} diff --git a/e2e/fixtures/config-files/server-large-perf.json b/e2e/fixtures/config-files/server-large-perf.json index 654d5fec..4e306266 100644 --- a/e2e/fixtures/config-files/server-large-perf.json +++ b/e2e/fixtures/config-files/server-large-perf.json @@ -19405,4 +19405,4 @@ } } } -} \ No newline at end of file +} diff --git a/e2e/fixtures/config-files/server-scan-perf.json b/e2e/fixtures/config-files/server-scan-perf.json index 156df740..c9761264 100644 --- a/e2e/fixtures/config-files/server-scan-perf.json +++ b/e2e/fixtures/config-files/server-scan-perf.json @@ -20012,4 +20012,4 @@ "input_registers": {} } } -} \ No newline at end of file +} diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts index 557c6c1b..4dad7296 100644 --- a/e2e/fixtures/helpers.ts +++ b/e2e/fixtures/helpers.ts @@ -39,6 +39,68 @@ export function cellLocator(p: Page, rowId: number, field: string): Locator { return p.locator(`.MuiDataGrid-row[data-id="${rowId}"] [data-field="${field}"]`) } +/** + * Scroll the DataGrid sideways until the thing named is in the DOM. + * + * The grid virtualises both axes. How far right a column has to be before it + * leaves the DOM depends on the window, and the window depends on the screen, + * so a spec that names a far column without this passes on one machine and + * fails on another. CI is the case that matters: nothing pins the size there. + * + * With a row id it waits for that row's cell rather than the column header, + * because the header of a column can be rendered while its cells are not. + * Starts from the left each time, so a scroll a previous call left behind + * cannot hide the target. Returns at once when it is already there, which is + * every call on a window wide enough. + */ +export async function scrollToColumn(p: Page, field: string, rowId?: number): Promise { + const grid = p.locator('.register-grid') + const target = + rowId === undefined + ? grid.locator(`.MuiDataGrid-columnHeader[data-field="${field}"]`) + : grid.locator(`.MuiDataGrid-row[data-id="${rowId}"] [data-field="${field}"]`) + if ((await target.count()) > 0) return + + const scroller = grid.locator('.MuiDataGrid-virtualScroller') + if ((await scroller.count()) === 0) return + + const scrollWidth = await scroller.evaluate((el: HTMLElement) => el.scrollWidth) + const step = 150 + for (let pos = 0; pos <= scrollWidth; pos += step) { + await scroller.evaluate((el: HTMLElement, x: number) => (el.scrollLeft = x), pos) + await p.waitForTimeout(80) + if ((await target.count()) > 0) return + } +} + +/** + * Assert a register grid column is there, or is not, having looked the whole + * width over first. + * + * Presence rather than visibility: `scrollToColumn` stops as soon as the header + * is in the DOM, which is a buffer's width before it is on screen. A column + * virtualisation drops is absent for the same reason a column that was never + * added is, so the absent case needs the full walk as much as the present one. + */ +export async function expectColumn(p: Page, field: string, present: boolean): Promise { + await scrollToColumn(p, field) + await expect( + p.locator(`.register-grid .MuiDataGrid-columnHeader[data-field="${field}"]`) + ).toHaveCount(present ? 1 : 0) +} + +/** + * Bring a cell into the DOM on both axes, the row first. + * + * Every reader below goes through this, because how many rows and columns fit + * depends on the window and CI pins no size. A Windows runner with a smaller + * screen renders fewer of both than this machine does. + */ +export async function scrollToCell(p: Page, rowId: number, field: string): Promise { + await scrollToRow(p, rowId) + await scrollToColumn(p, field, rowId) +} + /** * Read a cell's current text — a single snapshot, no retry. * @@ -49,6 +111,7 @@ export function cellLocator(p: Page, rowId: number, field: string): Locator { * snapshot has no second chance. */ export async function cell(p: Page, rowId: number, field: string): Promise { + await scrollToCell(p, rowId, field) return ((await cellLocator(p, rowId, field).textContent()) ?? '').trim() } @@ -72,6 +135,7 @@ export async function expectCell( field: string, expected: string | RegExp ): Promise { + await scrollToCell(p, rowId, field) await expect(cellLocator(p, rowId, field)).toHaveText(expected) } @@ -83,6 +147,7 @@ export async function expectCellContains( expected: string, options?: { ignoreCase?: boolean } ): Promise { + await scrollToCell(p, rowId, field) await expect(cellLocator(p, rowId, field)).toContainText(expected, options) } @@ -149,8 +214,19 @@ export async function addRegister( if (reg.next) { await p.getByTestId('add-reg-next-btn').click() - // After "Add & Next", modal stays open with cleared address + // The modal stays open, and the submit waits for main to take the + // 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(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 @@ -524,10 +600,19 @@ export const disableClientRawMode = (p: Page): Promise => setClientRawMode // ─── Shared helpers extracted from specs ─────────────────────────── -/** Scroll the DataGrid virtual scroller so a specific row is visible */ +/** + * Scroll the DataGrid virtual scroller so a specific row is visible. + * + * Scoped to the register grid where there is one: the transaction log is a + * second DataGrid, and an unscoped scroller is a strict-mode failure whenever + * the log is open. + */ export async function scrollToRow(p: Page, rowId: number): Promise { - const row = p.locator(`.MuiDataGrid-row[data-id="${rowId}"]`) - const scroller = p.locator('.MuiDataGrid-virtualScroller') + const registerGrid = p.locator('.register-grid') + const grid = + (await registerGrid.count()) > 0 ? registerGrid : p.locator('.MuiDataGrid-root').first() + const row = grid.locator(`.MuiDataGrid-row[data-id="${rowId}"]`) + const scroller = grid.locator('.MuiDataGrid-virtualScroller') // If already visible, nothing to do if ((await row.count()) > 0 && (await row.isVisible())) return @@ -542,7 +627,7 @@ export async function scrollToRow(p: Page, rowId: number): Promise { } } -/** Read a cell value, scrolling to the row if needed */ +/** Read a cell value, scrolling to its row and its column if needed */ export async function scrollCell(p: Page, rowId: number, field: string): Promise { await scrollToRow(p, rowId) return cell(p, rowId, field) @@ -570,6 +655,9 @@ export async function writeRegister( fc: 'fc6' | 'fc16', dataType?: string ): Promise { + // The write button lives in the rightmost column, which column + // virtualisation drops on a window narrow enough. + await scrollToColumn(p, 'actions', address) await p.getByTestId(`write-action-${address}`).click() await expect(p.getByTestId('write-value-input')).toBeVisible() @@ -588,6 +676,9 @@ export async function writeRegister( /** Write a coil state via the write dialog */ export async function writeCoil(p: Page, address: number, state: boolean): Promise { + // The write button lives in the rightmost column, which column + // virtualisation drops on a window narrow enough. + await scrollToColumn(p, 'actions', address) await p.getByTestId(`write-action-${address}`).click() await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() @@ -619,6 +710,9 @@ export async function writeCoilsFc15( address: number, states: Record ): Promise { + // The write button lives in the rightmost column, which column + // virtualisation drops on a window narrow enough. + await scrollToColumn(p, 'actions', address) await p.getByTestId(`write-action-${address}`).click() await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index d9d0c51a..15a489a2 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -117,11 +117,9 @@ export function launchOptions(userDataDir?: string): LaunchOptions { const profile = userDataDir ?? (isolate ? isolatedUserDataDir() : undefined) const args = profile ? [`--user-data-dir=${profile}`] : [] - // MODBUX_E2E turns off DataGrid virtualisation, so a locator finds the column - // or row it names instead of only the ones the current window happens to - // render. ELECTRON_ENABLE_LOGGING sends Chromium's own logging to stderr, - // which the fixture keeps: without it the app says nothing on its way out. - const env = { ...process.env, MODBUX_E2E: '1', ELECTRON_ENABLE_LOGGING: '1' } + // ELECTRON_ENABLE_LOGGING sends Chromium's own logging to stderr, which the + // fixture keeps: without it the app says nothing on its way out. + const env = { ...process.env, ELECTRON_ENABLE_LOGGING: '1' } if (!isPackaged) return { args: [join(ROOT, 'out/main/index.js'), ...args], env } 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 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/e2e/specs/01-main/04-add-register-modal.spec.ts b/e2e/specs/01-main/04-add-register-modal.spec.ts index 6602ec95..6d4fe465 100644 --- a/e2e/specs/01-main/04-add-register-modal.spec.ts +++ b/e2e/specs/01-main/04-add-register-modal.spec.ts @@ -92,8 +92,10 @@ test.describe.serial('AddRegister modal — state management and validation', () // Modal should still be open await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeVisible() - // Address advanced from 100 (UINT32 = 2 regs) to 102 - expect(await addressInput.inputValue()).toBe('102') + // Address advanced from 100 (UINT32 = 2 regs) to 102. The submit waits for + // main to take the register, so a snapshot of the field gets no second + // chance at it. + await expect(addressInput).toHaveValue('102') // Generator mode preserved await expect(mainPage.getByTestId('add-reg-generator-btn')).toHaveClass(/Mui-selected/) @@ -108,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 ({ @@ -137,7 +140,7 @@ test.describe.serial('AddRegister modal — state management and validation', () await mainPage.waitForTimeout(300) // Address advanced from 200 by 5 registers to 205 - expect(await addressInput.inputValue()).toBe('205') + await expect(addressInput).toHaveValue('205') // UTF-8 type preserved await expect(mainPage.getByTestId('add-reg-type-select')).toContainText('UTF-8') // Register length preserved @@ -389,6 +392,41 @@ test.describe.serial('AddRegister modal — state management and validation', () await mainPage.waitForTimeout(300) }) + test('Add & Next with nothing free above marks the address it is left on', async ({ + mainPage + }) => { + await mainPage.getByTestId('add-holding_registers-btn').click() + await mainPage.waitForTimeout(300) + + await selectDataType(mainPage, 'UINT16') + const addressInput = mainPage.getByTestId('add-reg-address-input').locator('input') + await addressInput.fill('65535') + await mainPage.getByTestId('add-reg-comment-input').locator('input').fill('keepme') + await mainPage.waitForTimeout(200) + + await mainPage.getByTestId('add-reg-next-btn').click() + await mainPage.waitForTimeout(300) + + // There is no address above 65535, so the form stays on the one it just + // wrote. Unmarked, with Add still live, the next press replaced that + // register and lost its comment. + await expect(addressInput).toHaveValue('65535') + await expect(mainPage.getByTestId('add-reg-address-in-use')).toBeVisible() + await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeDisabled() + + await mainPage.keyboard.press('Escape') + await mainPage.waitForTimeout(300) + + // The comment proves which register survived. + await mainPage.getByTestId('server-edit-reg-holding_registers-65535').click() + await mainPage.waitForTimeout(500) + expect(await mainPage.getByTestId('add-reg-comment-input').locator('input').inputValue()).toBe( + 'keepme' + ) + await mainPage.getByTestId('add-reg-remove-btn').click() + await mainPage.waitForTimeout(500) + }) + test('submit disabled when address is empty', async ({ mainPage }) => { await mainPage.getByTestId('add-holding_registers-btn').click() await mainPage.waitForTimeout(300) 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/e2e/specs/01-main/09-write-operations.spec.ts b/e2e/specs/01-main/09-write-operations.spec.ts index 3f1a7e86..6a45b857 100644 --- a/e2e/specs/01-main/09-write-operations.spec.ts +++ b/e2e/specs/01-main/09-write-operations.spec.ts @@ -76,13 +76,26 @@ test.describe.serial('Write Operations', () => { dataType: 'FLOAT', mode: 'fixed', value: '1.5', - comment: 'write test float' + comment: 'write test float', + next: true + }, + true, + true + ) + await addRegister( + mainPage, + { + registerType: 'holding_registers', + address: 6, + dataType: 'DATETIME', + mode: 'fixed-datetime', + comment: 'write test datetime' }, true, true ) - await expect(mainPage.getByTestId('section-holding_registers')).toContainText('(4)') + await expect(mainPage.getByTestId('section-holding_registers')).toContainText('(5)') // Add coils await addCoils(mainPage, 0, true) @@ -146,6 +159,23 @@ test.describe.serial('Write Operations', () => { await clearData(mainPage) }) + // The value field is milliseconds, and its range used to be stated in + // seconds, so everything it accepted fell before the year 2000 the format + // starts at and went out as the clamp floor. + test('write DATETIME via FC16', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '10') + await writeRegister(mainPage, 6, '1717245296789', 'fc16', 'DATETIME') + }) + + test('verify DATETIME written value', async ({ mainPage }) => { + await readRegisters(mainPage, '6', '4') + await expectCell(mainPage, 6, 'hex', '0018') + await expectCell(mainPage, 7, 'hex', '0601') + await expectCell(mainPage, 8, 'hex', '0C22') + await expectCell(mainPage, 9, 'hex', 'DDD5') + await clearData(mainPage) + }) + test('write negative INT16 value', async ({ mainPage }) => { await readRegisters(mainPage, '0', '6') await writeRegister(mainPage, 0, '-100', 'fc6', 'INT16') diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index baca82df..144cacfe 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -68,6 +68,26 @@ test.describe.serial('Split View — Server in separate window', () => { expect(master.readyState).toBe('open') }) + /** + * 0 is a port number the way "any" is a name, and `ModbusServer.setPort` + * refuses it. The message used to go to every window, and the server window + * had no listener, so in split view the field snapped back to its old port + * with nothing said in the window the user was looking at. + */ + test('a refused port reports in the window showing the server', async () => { + const portInput = serverPage.getByTestId('server-port-input').locator('input') + await portInput.click() + await portInput.selectText() + await portInput.pressSequentially('0') + await portInput.blur() + + await expect(serverPage.locator('.notistack-SnackbarContainer')).toContainText( + 'A server needs a port between 1 and 65535', + { timeout: 5000 } + ) + await expect(portInput).toHaveValue(String(serverPort)) + }) + test('close server window and verify main returns to normal', async ({ electronApp, mainPage diff --git a/e2e/specs/01-main/15-client-toolbar.spec.ts b/e2e/specs/01-main/15-client-toolbar.spec.ts index 54f93f67..276a7124 100644 --- a/e2e/specs/01-main/15-client-toolbar.spec.ts +++ b/e2e/specs/01-main/15-client-toolbar.spec.ts @@ -12,6 +12,7 @@ import { cleanServerState, loadServerConfig, expectCell, + expectColumn, openColumnMenu } from '../../fixtures/helpers' import { resolve } from 'path' @@ -59,10 +60,9 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await mainPage.keyboard.press('Escape') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="word_int16"]')).not.toBeVisible() - await expect(header.locator('[data-field="word_uint16"]')).not.toBeVisible() - await expect(header.locator('[data-field="word_float"]')).not.toBeVisible() + await expectColumn(mainPage, 'word_int16', false) + await expectColumn(mainPage, 'word_uint16', false) + await expectColumn(mainPage, 'word_float', false) }) test('enabling advanced mode shows value columns', async ({ mainPage }) => { @@ -84,15 +84,14 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await mainPage.keyboard.press('Escape') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="word_int16"]')).toBeVisible() - await expect(header.locator('[data-field="word_uint16"]')).toBeVisible() - await expect(header.locator('[data-field="word_int32"]')).toBeVisible() - await expect(header.locator('[data-field="word_uint32"]')).toBeVisible() - await expect(header.locator('[data-field="word_float"]')).toBeVisible() + await expectColumn(mainPage, 'word_int16', true) + await expectColumn(mainPage, 'word_uint16', true) + await expectColumn(mainPage, 'word_int32', true) + await expectColumn(mainPage, 'word_uint32', true) + await expectColumn(mainPage, 'word_float', true) - // 64-bit columns should not be visible (explicitly disabled above) - await expect(header.locator('[data-field="word_int64"]')).not.toBeVisible() + // 64-bit columns should not be there (explicitly disabled above) + await expectColumn(mainPage, 'word_int64', false) }) test('enabling 64-bit shows int64, uint64, double columns', async ({ mainPage }) => { @@ -102,10 +101,9 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await mainPage.waitForTimeout(200) await mainPage.keyboard.press('Escape') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="word_int64"]')).toBeVisible() - await expect(header.locator('[data-field="word_uint64"]')).toBeVisible() - await expect(header.locator('[data-field="word_double"]')).toBeVisible() + await expectColumn(mainPage, 'word_int64', true) + await expectColumn(mainPage, 'word_uint64', true) + await expectColumn(mainPage, 'word_double', true) }) test('disabling advanced mode hides all value columns', async ({ mainPage }) => { @@ -117,10 +115,9 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await mainPage.waitForTimeout(200) await mainPage.keyboard.press('Escape') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="word_int16"]')).not.toBeVisible() - await expect(header.locator('[data-field="word_float"]')).not.toBeVisible() - await expect(header.locator('[data-field="word_int64"]')).not.toBeVisible() + await expectColumn(mainPage, 'word_int16', false) + await expectColumn(mainPage, 'word_float', false) + await expectColumn(mainPage, 'word_int64', false) }) test('re-enable advanced mode with 64-bit for remaining tests', async ({ mainPage }) => { @@ -336,10 +333,9 @@ test.describe.serial('Client toolbar — display options and utilities', () => { test(`[${regType}] grid shows bit column, no dataType column`, async ({ mainPage }) => { await readRegisters(mainPage, '0', '8') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="bit"]')).toBeVisible() - await expect(header.locator('[data-field="dataType"]')).not.toBeVisible() - await expect(header.locator('[data-field="hex"]')).not.toBeVisible() + await expectColumn(mainPage, 'bit', true) + await expectColumn(mainPage, 'dataType', false) + await expectColumn(mainPage, 'hex', false) }) } @@ -347,16 +343,14 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await selectRegisterType(mainPage, 'Coils') await readRegisters(mainPage, '0', '8') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="actions"]')).toBeVisible() + await expectColumn(mainPage, 'actions', true) }) test('[Discrete Inputs] no write action column (read-only)', async ({ mainPage }) => { await selectRegisterType(mainPage, 'Discrete Inputs') await readRegisters(mainPage, '0', '8') - const header = mainPage.locator('.MuiDataGrid-columnHeaders') - await expect(header.locator('[data-field="actions"]')).not.toBeVisible() + await expectColumn(mainPage, 'actions', false) }) // Verify 16-bit register features are back after switching diff --git a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts index b0cd959f..c28cea24 100644 --- a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts +++ b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts @@ -200,12 +200,14 @@ test.describe.serial('Huawei Smart Logger — comprehensive integration test', ( test('verify data types in grid config view', async ({ mainPage }) => { const grid = mainPage.locator('.MuiDataGrid-root') - // Data types visible in the top portion of the grid - await expect(grid).toContainText('UINT16', { timeout: 3000 }) - await expect(grid).toContainText('INT16') - await expect(grid).toContainText('UINT32') - await expect(grid).toContainText('INT32') - await expect(grid).toContainText('UINT64') + // Named by address rather than asked of the whole grid: the rows a grid + // holds in the DOM depend on the window, so "somewhere in the grid" is a + // question that answers differently per screen. + await expectCellContains(mainPage, 40004, 'dataType', 'UINT16', { ignoreCase: true }) + await expectCellContains(mainPage, 40429, 'dataType', 'INT16', { ignoreCase: true }) + await expectCellContains(mainPage, 40002, 'dataType', 'UINT32', { ignoreCase: true }) + await expectCellContains(mainPage, 40005, 'dataType', 'INT32', { ignoreCase: true }) + await expectCellContains(mainPage, 40550, 'dataType', 'UINT64', { ignoreCase: true }) // Scroll to bitmap rows (50000) to render them await scrollToRow(mainPage, 50000) diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index 3290b524..1cd99fb0 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -151,8 +151,8 @@ async function configureRegister(p: any, rowId: number, reg: ClientRegister): Pr // Interpolation (modal with x1, x2, y1, y2 fields) if (reg.interpolate) { - const interpBtn = row.locator('button[title="Interpolation"]') - await interpBtn.click() + const interpolationButton = row.getByTestId(`interpolation-action-${rowId}`) + await interpolationButton.click() const modal = p.locator('.MuiModal-root') await expect(modal).toBeVisible() @@ -163,8 +163,16 @@ async function configureRegister(p: any, rowId: number, reg: ClientRegister): Pr await field.fill(reg.interpolate[key]) } + // The button is dim at 0.2 while the interpolation is the identity and + // opaque once it is not. Nothing asserted that before. Both moments, + // because neither went red against the unsubscribed read this replaced + // and the pair says which one was tried. The one register this config + // interpolates maps 0..32767 onto 0.8..1. + await expect(interpolationButton).toHaveCSS('opacity', '1') + await p.keyboard.press('Escape') await expect(modal).not.toBeVisible() + await expect(interpolationButton).toHaveCSS('opacity', '1') } }) } diff --git a/e2e/specs/01-main/21-bitmap-settings.spec.ts b/e2e/specs/01-main/21-bitmap-settings.spec.ts index 05016ac6..786e1571 100644 --- a/e2e/specs/01-main/21-bitmap-settings.spec.ts +++ b/e2e/specs/01-main/21-bitmap-settings.spec.ts @@ -164,6 +164,74 @@ test.describe.serial('Bitmap settings — color, invert & config persistence', ( await fs.unlink(savePath).catch(() => {}) }) + // The grid places every row below this one from what `getRowHeight` answers, + // so a panel whose height reaches nothing leaves the rows under it sitting + // where the ones above them already are, and the last of them out of scroll + // reach by exactly the panel's height. Measured before the fix, with + // virtualisation forced on and 100 rows loaded: the last row's bottom sat at + // 1044 against a scroller ending at 911. + test('an expanded bitmap row is as tall as the panel inside it', async ({ mainPage }) => { + // The scroller only has a scroll model to be wrong about once the rows + // outgrow it, so this one reads more than the configured register. The + // mapping keeps the bitmap type with read configuration off. + const expandButton = mainPage.getByTestId('bitmap-expand-0') + if (await mainPage.getByTestId('bit-indicator-0').isVisible()) { + await expandButton.click() + await mainPage.waitForTimeout(300) + } + await disableReadConfiguration(mainPage) + await loadDummyData(mainPage, '0', '60') + + const outer = mainPage.locator('div[data-id="0"]').first() + // Scoped to the register grid: the transaction log is a second DataGrid, + // and an unscoped locator is a strict-mode failure whenever it is open. + const scroller = mainPage.locator('.register-grid .MuiDataGrid-virtualScroller') + const collapsedHeight = (await outer.boundingBox())?.height ?? 0 + const collapsedScroll = await scroller.evaluate((el: HTMLElement) => el.scrollHeight) + + await expandButton.click() + await mainPage.waitForTimeout(500) + + const expandedHeight = (await outer.boundingBox())?.height ?? 0 + const expandedScroll = await scroller.evaluate((el: HTMLElement) => el.scrollHeight) + const grew = expandedHeight - collapsedHeight + + expect(grew).toBeGreaterThan(0) + // The scroller's own model, not the rendered row. It came from a fixed + // rowHeight that the panel's height reached in no way, so this used to be + // zero and the last row ended below the furthest the grid would scroll. + expect(expandedScroll - collapsedScroll).toBeGreaterThanOrEqual(grew) + + // An address stops being a bitmap while its panel is open: the type cell is + // editable and nothing collapses the row on the way out. The panel goes, + // and the height reserved for it has to go too or a gap is left with no + // control to close it. + const typeCell = mainPage.locator('.MuiDataGrid-row[data-id="0"] [data-field="dataType"]') + await typeCell.dblclick() + await mainPage.getByRole('option', { name: 'UINT16', exact: true }).click() + await mainPage.keyboard.press('Enter') + await mainPage.waitForTimeout(500) + + // The scroller's model, not the rendered row: the row draws its own height + // from its content either way, and what the grid reserved for it is where + // the gap would be. + expect(await scroller.evaluate((el: HTMLElement) => el.scrollHeight)).toBe(collapsedScroll) + + await typeCell.dblclick() + await mainPage.getByRole('option', { name: 'BITMAP', exact: true }).click() + await mainPage.keyboard.press('Enter') + await mainPage.waitForTimeout(500) + + // Back to the one configured register, expanded, which is what the test + // below collapses. + await expandButton.click() + await mainPage.waitForTimeout(300) + await loadDummyData(mainPage, '0', '1') + await enableReadConfiguration(mainPage) + await expandButton.click() + await mainPage.waitForTimeout(300) + }) + test('collapse bitmap row', async ({ mainPage }) => { const expandBtn = mainPage.getByTestId('bitmap-expand-0') await expandBtn.click() diff --git a/e2e/specs/01-main/25-disconnect-messages.spec.ts b/e2e/specs/01-main/25-disconnect-messages.spec.ts index 5cbe3545..f8161d0c 100644 --- a/e2e/specs/01-main/25-disconnect-messages.spec.ts +++ b/e2e/specs/01-main/25-disconnect-messages.spec.ts @@ -1,14 +1,16 @@ /** * Disconnect messaging * - * A deliberate disconnect must not be reported as a failure. The suite never - * looked at these snackbars before, which is how "Connection closed - * unexpectedly" managed to appear on every disconnect without a test noticing. + * A deliberate disconnect must not be reported as a failure. * - * Both transports are covered. A serial port has always lost that race. A TCP - * socket only started losing it on the Electron this release moves to, where - * the close event comes back a tick earlier, so the TCP case here reads as a - * guard rather than as the bug it was written for. + * Nothing in Modbux suppresses that message any more. modbus-serial 8.0.25 + * takes its close relay off the port inside `close()`, so a close the app asked + * for reaches no handler: measured over TCP, over a socat pty and on an + * Arduino's USB serial port, with the handler logging every call. In 8.0.23, + * the version this spec was written against, `close()` removed only the `data` + * listeners and the relay stayed, which is what raised "Connection closed + * unexpectedly" beside "Disconnected from server". So what this spec watches is + * the library: it goes red if a bump puts that relay back. */ import { test, expect } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' diff --git a/e2e/specs/01-main/27-serial-line-settings.spec.ts b/e2e/specs/01-main/27-serial-line-settings.spec.ts new file mode 100644 index 00000000..972faa06 --- /dev/null +++ b/e2e/specs/01-main/27-serial-line-settings.spec.ts @@ -0,0 +1,242 @@ +import { test, expect } from '../../fixtures/electron-app' +import type { Page } from '@playwright/test' +import { + navigateToServer, + navigateToClient, + cleanServerState, + connectClientRTU, + disconnectClient +} from '../../fixtures/helpers' +import { spawn, execSync, type ChildProcess } from 'child_process' +import { existsSync, unlinkSync } from 'fs' +import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' + +const PTY_0 = '/tmp/ttyV0' +const PTY_1 = '/tmp/ttyV1' + +/** + * The cflags this spec reads, on and off, so a failure prints the whole set + * rather than one missing token. + */ +const CFLAGS = [ + 'cs5', + 'cs6', + 'cs7', + 'cs8', + 'parenb', + '-parenb', + 'parodd', + '-parodd', + 'cstopb', + '-cstopb' +] as const + +/** + * The line settings the port at `path` is currently open with. + * + * `tcsetattr` writes them onto the pty, so `stty` reads back what the binding + * applied rather than what Modbux believes it sent. Measured on a socat pair: + * opening one end at 19200 7E2 gives `speed 19200` and `cs7 parenb cstopb`, + * while the other end stays at the 9600 cs8 socat left it. + * + * `-a` is what makes an off flag appear as `-parenb`; without it both stty + * implementations print only what differs from their own idea of sane. BSD + * reads a device with `-f` and GNU with `-F`. + */ +const lineSettings = (path: string): { speed: number; flags: string[] } => { + const deviceFlag = process.platform === 'darwin' ? '-f' : '-F' + const output = execSync(`stty -a ${deviceFlag} ${path}`, { encoding: 'utf8', timeout: 5000 }) + const tokens = new Set(output.split(/[\s,;]+/)) + return { + speed: Number(/speed (\d+) baud/.exec(output)?.[1]), + flags: CFLAGS.filter((flag) => tokens.has(flag)) + } +} + +/** + * Assert what a port opened with, retrying while the server restarts. + * + * Every select restarts the RTU server, so the pty holds the previous frame + * until the new one lands, and a read between the close and the open throws. + * `toPass` covers both. + */ +const expectLineSettings = async ( + path: string, + expected: { speed: number; flags: string[] } +): Promise => { + await expect(async () => { + const actual = lineSettings(path) + expect(actual.speed).toBe(expected.speed) + for (const flag of expected.flags) expect(actual.flags).toContain(flag) + }).toPass({ timeout: 10_000 }) +} + +type SerialUi = { baudRate: string; parity: string; dataBits: string; stopBits: string } + +/** Drive the server's four serial selects. Each one restarts the RTU server. */ +const applyServerSerial = async (p: Page, ui: SerialUi): Promise => { + const selects: [string, string][] = [ + ['server-rtu-baudrate-select', ui.baudRate], + ['server-rtu-parity-select', ui.parity], + ['server-rtu-databits-select', ui.dataBits], + ['server-rtu-stopbits-select', ui.stopBits] + ] + + for (const [testId, option] of selects) { + await p.getByTestId(testId).click() + await p.getByRole('option', { name: option, exact: true }).click() + await expect(p.getByTestId(testId)).toContainText(option) + } +} + +/** + * Each case changes every axis the one before it set, so no assertion can pass + * on a frame left behind by the previous open. Together they cover all four + * data bit widths, both stop bit counts and all three parities. + * + * `parodd` is asserted only where `parenb` is on. Measured: the binding clears + * `parenb` for no parity and leaves the parity bit where the previous open put + * it, so a `-parodd` here would be asserting the case above. + */ +const CASES: { name: string; ui: SerialUi; speed: number; flags: string[] }[] = [ + { + name: '19200 7E2', + ui: { baudRate: '19200', parity: 'even', dataBits: '7', stopBits: '2' }, + speed: 19200, + flags: ['cs7', 'parenb', '-parodd', 'cstopb'] + }, + { + name: '115200 8O1', + ui: { baudRate: '115200', parity: 'odd', dataBits: '8', stopBits: '1' }, + speed: 115200, + flags: ['cs8', 'parenb', 'parodd', '-cstopb'] + }, + { + name: '4800 6N1', + ui: { baudRate: '4800', parity: 'none', dataBits: '6', stopBits: '1' }, + speed: 4800, + flags: ['cs6', '-parenb', '-cstopb'] + }, + { + name: '2400 5N2', + ui: { baudRate: '2400', parity: 'none', dataBits: '5', stopBits: '2' }, + speed: 2400, + flags: ['cs5', '-parenb', 'cstopb'] + } +] + +test.describe.serial('Serial line settings — what reaches the port', () => { + test.skip(!hasSocat, 'socat not available') + + let socatProcess: ChildProcess | null = null + + test('start socat virtual serial pair', async () => { + for (const link of [PTY_0, PTY_1]) { + try { + unlinkSync(link) + } catch { + /* ignore */ + } + } + + socatProcess = spawn(SOCAT_PATH, [ + '-d', + '-d', + `pty,raw,echo=0,link=${PTY_0}`, + `pty,raw,echo=0,link=${PTY_1}` + ]) + + const deadline = Date.now() + 2000 + while (Date.now() < deadline) { + if (existsSync(PTY_0) && existsSync(PTY_1)) break + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + expect(existsSync(PTY_0)).toBe(true) + expect(existsSync(PTY_1)).toBe(true) + }) + + test('clean server state', async ({ mainPage }) => { + await cleanServerState(mainPage) + }) + + test('put the server on the pty', async ({ mainPage }) => { + await mainPage.getByTestId('server-mode-rtu-btn').click() + await expect(mainPage.getByTestId('server-rtu-com-input')).toBeVisible() + + const comInput = mainPage.getByTestId('server-rtu-com-input').locator('input') + await comInput.fill(PTY_0) + await comInput.blur() + + await expect(mainPage.getByTestId('server-rtu-status')).toHaveAttribute( + 'title', + 'RTU server active', + { timeout: 5000 } + ) + }) + + // ─── The server end ─────────────────────────────────────────────────────── + + for (const serialCase of CASES) { + test(`the port opens at ${serialCase.name}`, async ({ mainPage }) => { + await applyServerSerial(mainPage, serialCase.ui) + await expectLineSettings(PTY_0, serialCase) + }) + } + + // ─── The client end ─────────────────────────────────────────────────────── + + test('the client opens at the settings it was given', async ({ mainPage }) => { + // The other end of the same pair, and a frame none of the server cases set, + // so what is read here cannot have come from the server. + await navigateToClient(mainPage) + await connectClientRTU(mainPage, '1', '38400', 'even', '8', '2') + + const comInput = mainPage.getByTestId('rtu-com-input').locator('input') + await comInput.fill(PTY_1) + + await mainPage.getByTestId('connect-btn').click() + await expect(mainPage.getByTestId('connect-btn')).toContainText('Disconnect', { + timeout: 10_000 + }) + + await expectLineSettings(PTY_1, { + speed: 38400, + flags: ['cs8', 'parenb', '-parodd', 'cstopb'] + }) + }) + + // ─── Cleanup ────────────────────────────────────────────────────────────── + + test('disconnect the client', async ({ mainPage }) => { + await disconnectClient(mainPage) + }) + + test('restore the server defaults and switch back to TCP', async ({ mainPage }) => { + await navigateToServer(mainPage) + await applyServerSerial(mainPage, { + baudRate: '9600', + parity: 'none', + dataBits: '8', + stopBits: '1' + }) + + await mainPage.getByTestId('server-mode-tcp-btn').click() + await expect(mainPage.getByTestId('server-port-input')).toBeVisible() + }) + + test('stop socat + remove symlinks', async () => { + if (socatProcess) { + socatProcess.kill() + socatProcess = null + } + + for (const link of [PTY_0, PTY_1]) { + try { + unlinkSync(link) + } catch { + /* ignore */ + } + } + }) +}) diff --git a/e2e/specs/01-main/28-connect-cancel.spec.ts b/e2e/specs/01-main/28-connect-cancel.spec.ts new file mode 100644 index 00000000..ab7ffcc1 --- /dev/null +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -0,0 +1,135 @@ +/** + * Cancelling a connect + * + * The Connect button is a Cancel for as long as the state is 'connecting', and + * it sends `disconnect`. On RTU the cancelled connect used to finish its open + * anyway and report "Connected over Modbus RTU" over the cancel, leaving the + * button on Disconnect and the port held. + * + * Both calls go through `window.api` in one tick, which is the window the + * button opens while a port is opening. + */ +import { test, expect } from '../../fixtures/electron-app' +import type { Page } from '@playwright/test' +import { + cleanServerState, + connectClientRTU, + disconnectClient, + navigateToClient, + navigateToServer +} from '../../fixtures/helpers' +import { spawn, type ChildProcess } from 'child_process' +import { existsSync, unlinkSync } from 'fs' + +const SOCAT_PATHS = ['/usr/local/bin/socat', '/usr/bin/socat'] +const SOCAT_PATH = SOCAT_PATHS.find((p) => existsSync(p)) ?? 'socat' +const hasSocat = existsSync(SOCAT_PATH) +const PTY_0 = '/tmp/ttyVCANCEL0' +const PTY_1 = '/tmp/ttyVCANCEL1' + +const SNACKBARS = '.notistack-SnackbarContainer' + +/** Every snackbar raised over the next two seconds, read as they come. */ +async function snackbarsOver(p: Page, milliseconds: number): Promise { + const seen: string[] = [] + const deadline = Date.now() + milliseconds + while (Date.now() < deadline) { + const text = await p + .locator(SNACKBARS) + .textContent({ timeout: 100 }) + .catch(() => null) + if (text) seen.push(text) + await p.waitForTimeout(50) + } + return [...new Set(seen)].join(' | ') +} + +test.describe.serial('Cancelling a connect', () => { + test.skip(!hasSocat, 'socat not available') + + let socatProcess: ChildProcess | null = null + + test('start socat virtual serial pair', async () => { + for (const pty of [PTY_0, PTY_1]) { + try { + unlinkSync(pty) + } catch { + /* ignore */ + } + } + + socatProcess = spawn(SOCAT_PATH, [ + '-d', + '-d', + `pty,raw,echo=0,link=${PTY_0}`, + `pty,raw,echo=0,link=${PTY_1}` + ]) + + const deadline = Date.now() + 2000 + while (Date.now() < deadline) { + if (existsSync(PTY_0) && existsSync(PTY_1)) break + await new Promise((r) => setTimeout(r, 100)) + } + + expect(existsSync(PTY_0)).toBe(true) + expect(existsSync(PTY_1)).toBe(true) + }) + + test('put the server on the pty', async ({ mainPage }) => { + await navigateToServer(mainPage) + await cleanServerState(mainPage) + await mainPage.getByTestId('server-mode-rtu-btn').click() + await expect(mainPage.getByTestId('server-rtu-com-input')).toBeVisible() + + const comInput = mainPage.getByTestId('server-rtu-com-input').locator('input') + await comInput.fill(PTY_0) + await comInput.blur() + + await expect(mainPage.getByTestId('server-rtu-status')).toHaveAttribute( + 'title', + 'RTU server active', + { timeout: 5000 } + ) + }) + + test('a cancelled connect stays disconnected', async ({ mainPage }) => { + await navigateToClient(mainPage) + await connectClientRTU(mainPage, '0', '9600', 'none', '8', '1') + await mainPage.getByTestId('rtu-com-input').locator('input').fill(PTY_1) + + await mainPage.evaluate(() => { + window.api.connect() + window.api.disconnect() + }) + + const raised = await snackbarsOver(mainPage, 2000) + expect(raised).not.toContain('Connected over') + await expect(mainPage.getByTestId('connect-btn')).toContainText('Connect') + }) + + test('and the port it let go of takes a connect', async ({ mainPage }) => { + await mainPage.getByTestId('connect-btn').click() + await expect(mainPage.getByTestId('connect-btn')).toContainText('Disconnect', { + timeout: 10_000 + }) + await disconnectClient(mainPage) + }) + + test('cleanup: back to TCP, stop socat', async ({ mainPage }) => { + await mainPage.getByTestId('protocol-tcp-btn').click() + await navigateToServer(mainPage) + await mainPage.getByTestId('server-mode-tcp-btn').click() + + if (socatProcess) { + socatProcess.kill() + socatProcess = null + } + for (const pty of [PTY_0, PTY_1]) { + try { + unlinkSync(pty) + } catch { + /* ignore */ + } + } + }) +}) diff --git a/e2e/specs/01-main/29-read-on-read-configuration.spec.ts b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts new file mode 100644 index 00000000..dc141f94 --- /dev/null +++ b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts @@ -0,0 +1,61 @@ +import { test } from '../../fixtures/electron-app' +import { + navigateToClient, + connectClient, + disconnectClient, + enableAdvancedMode, + enableReadConfiguration, + disableReadConfiguration, + cleanServerState, + loadServerConfig, + loadClientConfig, + selectRegisterType, + expectCell +} from '../../fixtures/helpers' +import { resolve } from 'path' + +const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') +const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') +const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-large-config.json') + +// Turning read configuration on fills the grid from the mapping, and every word +// in it is `dummyWords`, so address 0 reads 0 where the server holds 1. The +// store asks main for a read, and only while main can answer one: nothing +// presses Read here. `19-large-config` drives the same two configs and presses +// it. +test.describe.serial('Read configuration reads what it just put on screen', () => { + test('clean server state', async ({ mainPage }) => { + await cleanServerState(mainPage) + }) + + test('load server config with 120 holding registers', async ({ mainPage }) => { + await loadServerConfig(mainPage, SERVER_CONFIG) + }) + + test('navigate to client and load the matching mapping', async ({ mainPage }) => { + await navigateToClient(mainPage) + await enableAdvancedMode(mainPage) + await selectRegisterType(mainPage, 'Holding Registers') + await loadClientConfig(mainPage, CLIENT_CONFIG) + }) + + test('disconnected, it shows the mapping and no value', async ({ mainPage }) => { + await enableReadConfiguration(mainPage) + await expectCell(mainPage, 0, 'comment', 'reg-000') + await expectCell(mainPage, 0, 'word_uint16', '0') + await disableReadConfiguration(mainPage) + }) + + test('connected, the values are there without pressing Read', async ({ mainPage }) => { + await connectClient(mainPage, '127.0.0.1', '502', '0') + await enableReadConfiguration(mainPage) + + await expectCell(mainPage, 0, 'word_uint16', '1') + await expectCell(mainPage, 50, 'word_uint16', '51') + }) + + test('cleanup', async ({ mainPage }) => { + await disableReadConfiguration(mainPage) + await disconnectClient(mainPage) + }) +}) 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/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..5f5e1761 --- /dev/null +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -0,0 +1,111 @@ +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 { + // 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) { + 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/electron-builder.yml b/electron-builder.yml index 0e87614e..9cfaf0da 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,12 +3,12 @@ productName: Modbux directories: buildResources: build files: - - "!**/.vscode/*" - - "!src/*" - - "!electron.vite.config.{js,ts,mjs,cjs}" - - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" - - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" - - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}" + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' + - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' # node-gyp compiles a native module at install time, and a shipped app # compiles nothing: node-gyp-build is what loads the prebuilt binding at # runtime. It arrives because @serialport/bindings-cpp declares it, and it @@ -19,97 +19,97 @@ files: # written out by hand. A name that disappears makes its pattern match # nothing, which is harmless; a name that appears later is packed until this # is derived again. - - "!**/node_modules/@isaacs/cliui/**" - - "!**/node_modules/@isaacs/fs-minipass/**" - - "!**/node_modules/@npmcli/agent/**" - - "!**/node_modules/@npmcli/fs/**" - - "!**/node_modules/@pkgjs/parseargs/**" - - "!**/node_modules/abbrev/**" - - "!**/node_modules/agent-base/**" - - "!**/node_modules/ansi-regex/**" - - "!**/node_modules/ansi-styles/**" - - "!**/node_modules/balanced-match/**" - - "!**/node_modules/brace-expansion/**" - - "!**/node_modules/cacache/**" - - "!**/node_modules/chownr/**" - - "!**/node_modules/color-convert/**" - - "!**/node_modules/color-name/**" - - "!**/node_modules/concat-map/**" - - "!**/node_modules/cross-spawn/**" - - "!**/node_modules/eastasianwidth/**" - - "!**/node_modules/emoji-regex/**" - - "!**/node_modules/encoding/**" - - "!**/node_modules/env-paths/**" - - "!**/node_modules/err-code/**" - - "!**/node_modules/exponential-backoff/**" - - "!**/node_modules/fdir/**" - - "!**/node_modules/foreground-child/**" - - "!**/node_modules/fs-minipass/**" - - "!**/node_modules/fs.realpath/**" - - "!**/node_modules/glob/**" - - "!**/node_modules/graceful-fs/**" - - "!**/node_modules/http-cache-semantics/**" - - "!**/node_modules/http-proxy-agent/**" - - "!**/node_modules/https-proxy-agent/**" - - "!**/node_modules/iconv-lite/**" - - "!**/node_modules/imurmurhash/**" - - "!**/node_modules/inflight/**" - - "!**/node_modules/inherits/**" - - "!**/node_modules/ip-address/**" - - "!**/node_modules/is-fullwidth-code-point/**" - - "!**/node_modules/isexe/**" - - "!**/node_modules/jackspeak/**" - - "!**/node_modules/lru-cache/**" - - "!**/node_modules/make-fetch-happen/**" - - "!**/node_modules/minimatch/**" - - "!**/node_modules/minipass/**" - - "!**/node_modules/minipass-collect/**" - - "!**/node_modules/minipass-fetch/**" - - "!**/node_modules/minipass-flush/**" - - "!**/node_modules/minipass-pipeline/**" - - "!**/node_modules/minipass-sized/**" - - "!**/node_modules/minizlib/**" - - "!**/node_modules/negotiator/**" - - "!**/node_modules/node-gyp/**" - - "!**/node_modules/nopt/**" - - "!**/node_modules/once/**" - - "!**/node_modules/p-map/**" - - "!**/node_modules/package-json-from-dist/**" - - "!**/node_modules/path-is-absolute/**" - - "!**/node_modules/path-key/**" - - "!**/node_modules/path-scurry/**" - - "!**/node_modules/picomatch/**" - - "!**/node_modules/proc-log/**" - - "!**/node_modules/promise-retry/**" - - "!**/node_modules/retry/**" - - "!**/node_modules/safer-buffer/**" - - "!**/node_modules/semver/**" - - "!**/node_modules/shebang-command/**" - - "!**/node_modules/shebang-regex/**" - - "!**/node_modules/signal-exit/**" - - "!**/node_modules/smart-buffer/**" - - "!**/node_modules/socks/**" - - "!**/node_modules/socks-proxy-agent/**" - - "!**/node_modules/ssri/**" - - "!**/node_modules/string-width/**" - - "!**/node_modules/string-width-cjs/**" - - "!**/node_modules/strip-ansi/**" - - "!**/node_modules/strip-ansi-cjs/**" - - "!**/node_modules/tar/**" - - "!**/node_modules/tinyglobby/**" - - "!**/node_modules/undici/**" - - "!**/node_modules/unique-filename/**" - - "!**/node_modules/unique-slug/**" - - "!**/node_modules/which/**" - - "!**/node_modules/wrap-ansi/**" - - "!**/node_modules/wrap-ansi-cjs/**" - - "!**/node_modules/wrappy/**" - - "!**/node_modules/yallist/**" + - '!**/node_modules/@isaacs/cliui/**' + - '!**/node_modules/@isaacs/fs-minipass/**' + - '!**/node_modules/@npmcli/agent/**' + - '!**/node_modules/@npmcli/fs/**' + - '!**/node_modules/@pkgjs/parseargs/**' + - '!**/node_modules/abbrev/**' + - '!**/node_modules/agent-base/**' + - '!**/node_modules/ansi-regex/**' + - '!**/node_modules/ansi-styles/**' + - '!**/node_modules/balanced-match/**' + - '!**/node_modules/brace-expansion/**' + - '!**/node_modules/cacache/**' + - '!**/node_modules/chownr/**' + - '!**/node_modules/color-convert/**' + - '!**/node_modules/color-name/**' + - '!**/node_modules/concat-map/**' + - '!**/node_modules/cross-spawn/**' + - '!**/node_modules/eastasianwidth/**' + - '!**/node_modules/emoji-regex/**' + - '!**/node_modules/encoding/**' + - '!**/node_modules/env-paths/**' + - '!**/node_modules/err-code/**' + - '!**/node_modules/exponential-backoff/**' + - '!**/node_modules/fdir/**' + - '!**/node_modules/foreground-child/**' + - '!**/node_modules/fs-minipass/**' + - '!**/node_modules/fs.realpath/**' + - '!**/node_modules/glob/**' + - '!**/node_modules/graceful-fs/**' + - '!**/node_modules/http-cache-semantics/**' + - '!**/node_modules/http-proxy-agent/**' + - '!**/node_modules/https-proxy-agent/**' + - '!**/node_modules/iconv-lite/**' + - '!**/node_modules/imurmurhash/**' + - '!**/node_modules/inflight/**' + - '!**/node_modules/inherits/**' + - '!**/node_modules/ip-address/**' + - '!**/node_modules/is-fullwidth-code-point/**' + - '!**/node_modules/isexe/**' + - '!**/node_modules/jackspeak/**' + - '!**/node_modules/lru-cache/**' + - '!**/node_modules/make-fetch-happen/**' + - '!**/node_modules/minimatch/**' + - '!**/node_modules/minipass/**' + - '!**/node_modules/minipass-collect/**' + - '!**/node_modules/minipass-fetch/**' + - '!**/node_modules/minipass-flush/**' + - '!**/node_modules/minipass-pipeline/**' + - '!**/node_modules/minipass-sized/**' + - '!**/node_modules/minizlib/**' + - '!**/node_modules/negotiator/**' + - '!**/node_modules/node-gyp/**' + - '!**/node_modules/nopt/**' + - '!**/node_modules/once/**' + - '!**/node_modules/p-map/**' + - '!**/node_modules/package-json-from-dist/**' + - '!**/node_modules/path-is-absolute/**' + - '!**/node_modules/path-key/**' + - '!**/node_modules/path-scurry/**' + - '!**/node_modules/picomatch/**' + - '!**/node_modules/proc-log/**' + - '!**/node_modules/promise-retry/**' + - '!**/node_modules/retry/**' + - '!**/node_modules/safer-buffer/**' + - '!**/node_modules/semver/**' + - '!**/node_modules/shebang-command/**' + - '!**/node_modules/shebang-regex/**' + - '!**/node_modules/signal-exit/**' + - '!**/node_modules/smart-buffer/**' + - '!**/node_modules/socks/**' + - '!**/node_modules/socks-proxy-agent/**' + - '!**/node_modules/ssri/**' + - '!**/node_modules/string-width/**' + - '!**/node_modules/string-width-cjs/**' + - '!**/node_modules/strip-ansi/**' + - '!**/node_modules/strip-ansi-cjs/**' + - '!**/node_modules/tar/**' + - '!**/node_modules/tinyglobby/**' + - '!**/node_modules/undici/**' + - '!**/node_modules/unique-filename/**' + - '!**/node_modules/unique-slug/**' + - '!**/node_modules/which/**' + - '!**/node_modules/wrap-ansi/**' + - '!**/node_modules/wrap-ansi-cjs/**' + - '!**/node_modules/wrappy/**' + - '!**/node_modules/yallist/**' # node-gyp-build prefers a compiled build/Release over the prebuilds, and does # not fall back. A cross-built package would carry the build host's # architecture. Excluded, so the per-arch prebuild is used instead. - - "!**/node_modules/@serialport/bindings-cpp/build/**" + - '!**/node_modules/@serialport/bindings-cpp/build/**' asarUnpack: - resources/** win: diff --git a/resources/splash.html b/resources/splash.html index ac71ad35..a7eaa1a3 100644 --- a/resources/splash.html +++ b/resources/splash.html @@ -1,57 +1,77 @@ - + - - + Splash Window - + - +
- splash - MODBUX... + splash + MODBUX...
- - - - \ No newline at end of file + + diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index 97e06b57..120894b0 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -199,6 +199,24 @@ describe('shared does not import from main', () => { } expect(reaching).toEqual([]) }) + + // The rule above reads the specifier, and `Windows` reached `BrowserWindow` + // through `electron` rather than through `@main`, so it passed while holding + // two main process windows. The renderer bundle stayed clean only because + // rollup dropped a class nothing referenced. + it('has none of them importing electron', () => { + const reaching: string[] = [] + for (const file of files) { + eachNode(parse(file), (node) => { + const specifier = importedFrom(node) + if (specifier === null) return + if (specifier === 'electron' || specifier.startsWith('electron/')) { + reaching.push(`${at(file)}\t${specifier}`) + } + }) + } + expect(reaching).toEqual([]) + }) }) // @@ -592,6 +610,94 @@ describe('every configured path alias is used', () => { }) }) +// +// ─── The three lists that name a channel agree ─────────────────────────────── +// +// `IpcHandlerMap` is `{ [K in IpcChannel]: IpcHandlerSpec[K] }`. A channel in +// `IPC_CHANNELS` and not in the spec fails to index and typecheck catches it. +// The other direction is silent: the mapped type omits it, so there is no +// generated method, no handler and no error. A channel in both with no +// `ipcHandle` call is silent too, until `window.api.foo()` rejects at runtime +// with `No handler registered`. +// +// The same asymmetry sits between `IPC_EVENTS` and `IpcEventPayloadMap`. + +describe('the lists that name a channel agree', () => { + const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) + + /** The string literals of a `const` array declared in `ipc.ts`. */ + const literalsOf = (declaration: string): Set => { + const names = new Set() + eachNode(spec, (node) => { + if (!ts.isVariableDeclaration(node)) return + if (node.name.getText(spec) !== declaration) return + eachNode(node as unknown as ts.SourceFile, (child) => { + if (ts.isStringLiteral(child)) names.add(child.text) + }) + }) + return names + } + + /** The member names of an interface declared in `ipc.ts`. */ + const membersOf = (declaration: string): Set => { + const names = new Set() + eachNode(spec, (node) => { + if (!ts.isInterfaceDeclaration(node) || node.name.text !== declaration) return + for (const member of node.members) { + if (ts.isPropertySignature(member)) + names.add(member.name.getText(spec).replace(/[[\]']/g, '')) + } + }) + return names + } + + const channels = literalsOf('IPC_CHANNELS') + const specced = membersOf('IpcHandlerSpec') + const events = literalsOf('IPC_EVENTS') + const payloads = membersOf('IpcEventPayloadMap') + + /** Every channel `initIpc` registered a handler for. */ + const handled = new Set() + eachNode(parse(join(repoRoot, 'src/main/ipc.ts')), (node) => { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return + if (node.expression.text !== 'ipcHandle') return + const channel = node.arguments[0] + if (channel && ts.isStringLiteral(channel)) handled.add(channel.text) + }) + + const missingFrom = (from: Set, against: Set): string[] => + [...against].filter((name) => !from.has(name)).sort() + + it('finds channels and events to check', () => { + expect(channels.size).toBeGreaterThan(30) + expect(events.size).toBeGreaterThan(5) + }) + + it('has a spec entry for every channel and a channel for every spec entry', () => { + expect(missingFrom(specced, channels)).toEqual([]) + expect(missingFrom(channels, specced)).toEqual([]) + }) + + it('has a handler for every channel and a channel for every handler', () => { + expect(missingFrom(handled, channels)).toEqual([]) + expect(missingFrom(channels, handled)).toEqual([]) + }) + + it('has a payload entry for every event and an event for every payload entry', () => { + expect(missingFrom(payloads, events)).toEqual([]) + expect(missingFrom(events, payloads)).toEqual([]) + }) + + // `snakeToCamel` replaces `_([a-z])` and `CamelCase` capitalises whatever + // follows an underscore. So `set_2wire` becomes the method `set_2wire` and + // the type `set2wire`, and `window.api.set2wire` is undefined at runtime with + // typecheck green. Lowercase segments are where the two cannot disagree. + it('names every channel and event in lowercase segments', () => { + const odd = [...channels, ...events].filter((name) => !/^[a-z]+(_[a-z]+)*$/.test(name)) + expect(odd).toEqual([]) + }) +}) + // // ─── Every channel carrying an object declares a schema ────────────────────── // diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index 1fdf1435..812bdb6c 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -25,24 +25,38 @@ import { SyncBoolsParametersSchema, SyncRegisterValueParamsSchema, WriteParametersSchema, - type BackendMessage, - type Windows + type BackendMessage } from '@shared' +import type { Windows } from '../windows' import { createIpcHandle, initIpc } from '../ipc' -const createWindows = (): { windows: Windows; sent: BackendMessage[] } => { - const sent: BackendMessage[] = [] +/** The addressee `send` was given, beside the message it carried. */ +interface SentMessage { + to: unknown + message: BackendMessage +} + +const createWindows = (): { windows: Windows; sent: SentMessage[] } => { + const sent: SentMessage[] = [] const windows = { - send: (_event: string, payload: BackendMessage) => sent.push(payload) + send: (_event: string, message: BackendMessage, to: unknown = 'all') => + sent.push({ to, message }) } as unknown as Windows return { windows, sent } } +/** Stands in for the `WebContents` that `ipcMain.handle` hands every listener. */ +const SENDER = { id: 'the window that asked' } + /** Invokes the listener that was registered for `channel`. */ -const invoke = async (channel: string, payload?: unknown): Promise => { +const invoke = async ( + channel: string, + payload?: unknown, + sender: unknown = SENDER +): Promise => { const call = handle.mock.calls.find((c) => c[0] === channel) if (!call) throw new Error(`nothing registered for ${channel}`) - return (call[1] as (e: unknown, p?: unknown) => unknown)({}, payload) + return (call[1] as (e: unknown, p?: unknown) => unknown)({ sender }, payload) } beforeEach(() => handle.mockClear()) @@ -56,7 +70,7 @@ describe('createIpcHandle', () => { ipcHandle('update_connection_config', listener) await invoke('update_connection_config', { unitId: 3 }) - expect(listener).toHaveBeenCalledWith({}, { unitId: 3 }) + expect(listener).toHaveBeenCalledWith({ sender: SENDER }, { unitId: 3 }) expect(sent).toEqual([]) }) @@ -96,9 +110,23 @@ describe('createIpcHandle', () => { expect(listener).not.toHaveBeenCalled() expect(returned).toBeUndefined() expect(sent).toHaveLength(1) - expect(sent[0]?.variant).toBe('error') - expect(String(sent[0]?.error)).toContain('set_bool') - expect(String(sent[0]?.error)).toContain('unitId') + expect(sent[0]?.message.variant).toBe('error') + expect(String(sent[0]?.message.error)).toContain('set_bool') + expect(String(sent[0]?.message.error)).toContain('unitId') + }) + + // Broadcasting reached whichever windows were listening. In split view that + // was the main one, so a payload refused on a channel the server window owns + // reported into the window the user was not looking at. + it('reports the refusal to the window that asked', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const otherWindow = { id: 'a window that asked nothing' } + + ipcHandle('set_bool', vi.fn(), SetBooleanParametersSchema) + await invoke('set_bool', 'not a payload', otherWindow) + + expect(sent.map(({ to }) => to)).toEqual([otherWindow]) }) it('reports rather than throws, so the renderer never sees a rejected invoke', async () => { @@ -201,7 +229,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 +300,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 +309,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 +422,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 +440,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 }, @@ -429,10 +452,16 @@ describe('each guarded channel got its own schema', () => { com: '/dev/ttyUSB0', options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } } - } + }, + set_server_port: { uuid: 'server-1', port: 5020 }, + create_server: { uuid: 'server-1', port: 5020 }, + apply_privileged_port_fix: 'persist', + set_server_endianness: { uuid: 'server-1', littleEndian: true }, + delete_server: 'server-1', + reset_server: 'server-1' } - const start = (): { sent: BackendMessage[] } => { + const start = (): { sent: SentMessage[] } => { handle.mockClear() const { windows, sent } = createWindows() initIpc( @@ -445,20 +474,50 @@ describe('each guarded channel got its own schema', () => { return { sent } } + /** + * Every channel `initIpc` registered a schema for, asked of the app rather + * than listed by hand. + * + * `undefined` is the one payload every schema here refuses, so a channel that + * answers with a message has a schema and one that stays silent has none. A + * string would not do it: `ServerUuidSchema` takes one. + * + * The list above was written by hand and covered fourteen of the twenty, so a + * channel added with a schema did not join it. + */ + const guardedChannels = async (): Promise => { + const { sent } = start() + const registered = handle.mock.calls.map((call) => String(call[0])) + const guarded: string[] = [] + for (const channel of registered) { + const before = sent.length + await invoke(channel, undefined) + if (sent.length > before) guarded.push(channel) + } + return guarded + } + + it('covers every channel that guards its payload', async () => { + const guarded = await guardedChannels() + + expect(guarded.length).toBeGreaterThan(10) + expect(guarded.sort()).toEqual(Object.keys(validPayloads).sort()) + }) + it.each(Object.keys(validPayloads))('lets a valid %s payload through', async (channel) => { const { sent } = start() await invoke(channel, validPayloads[channel]) - expect(sent.map((message) => message.error)).toEqual([]) + expect(sent.map(({ message }) => message.error)).toEqual([]) }) - // A string reaches every one of these as an object was expected, so it is the - // one payload that is wrong for all of them and right for none. + // `undefined` is the payload every schema here refuses. A string used to be, + // until `delete_server` and `reset_server` took one. it.each(Object.keys(validPayloads))( 'guards %s against a payload that is not one', async (channel) => { const { sent } = start() - await invoke(channel, 'not a payload') - expect(sent.map((message) => String(message.error).split(':')[0])).toEqual([channel]) + await invoke(channel, undefined) + expect(sent.map(({ message }) => String(message.error).split(':')[0])).toEqual([channel]) } ) }) diff --git a/src/main/__tests__/state.test.ts b/src/main/__tests__/state.test.ts index edb46514..3549a9ad 100644 --- a/src/main/__tests__/state.test.ts +++ b/src/main/__tests__/state.test.ts @@ -42,6 +42,17 @@ describe('AppState', () => { it('has no register mapping initially', () => { expect(state.registerMapping).toBeUndefined() }) + + // Every nested object, because a spread would copy the top level and leave + // `tcp.options` shared. What writing into a shared one costs is in + // `modbusClient.test.ts`, where modbus-serial does it. + it('does not share the exported defaults', () => { + expect(state.connectionConfig).not.toBe(defaultConnectionConfig) + expect(state.connectionConfig.tcp).not.toBe(defaultConnectionConfig.tcp) + expect(state.connectionConfig.tcp.options).not.toBe(defaultConnectionConfig.tcp.options) + expect(state.connectionConfig.rtu.options).not.toBe(defaultConnectionConfig.rtu.options) + expect(state.registerConfig).not.toBe(defaultRegisterConfig) + }) }) describe('updateConnectionConfig', () => { diff --git a/src/shared/__tests__/windows.test.ts b/src/main/__tests__/windows.test.ts similarity index 62% rename from src/shared/__tests__/windows.test.ts rename to src/main/__tests__/windows.test.ts index 19bc8a25..d7b13bf7 100644 --- a/src/shared/__tests__/windows.test.ts +++ b/src/main/__tests__/windows.test.ts @@ -110,6 +110,88 @@ describe('Windows', () => { }) }) + describe('an addressed send', () => { + const message = { message: 'refused', variant: 'error', error: null } as never + + it('reaches the contents it was handed and nothing else', () => { + const mainWindow = createMockWindow() + const serverWindow = createMockWindow() + windows.main = mainWindow as never + windows.server = serverWindow as never + mainWindow.webContents.send.mockClear() + serverWindow.webContents.send.mockClear() + + windows.send('backend_message', message, mainWindow.webContents as never) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) + expect(serverWindow.webContents.send).not.toHaveBeenCalled() + }) + + it("'main' reaches the main window while the server window is open", () => { + const mainWindow = createMockWindow() + const serverWindow = createMockWindow() + windows.main = mainWindow as never + windows.server = serverWindow as never + mainWindow.webContents.send.mockClear() + serverWindow.webContents.send.mockClear() + + windows.send('backend_message', message, 'main') + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) + expect(serverWindow.webContents.send).not.toHaveBeenCalled() + }) + + it("'serverView' reaches the server window once it exists", () => { + const mainWindow = createMockWindow() + const serverWindow = createMockWindow() + windows.main = mainWindow as never + windows.server = serverWindow as never + mainWindow.webContents.send.mockClear() + serverWindow.webContents.send.mockClear() + + windows.send('backend_message', message, 'serverView') + + expect(serverWindow.webContents.send).toHaveBeenCalledWith('backend_message', message) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + }) + + // Unsplit there is one window, and it is the one showing the server. + it("'serverView' is the main window while no server window exists", () => { + const mainWindow = createMockWindow() + windows.main = mainWindow as never + mainWindow.webContents.send.mockClear() + + 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.send('backend_message', message, 'serverView')).not.toThrow() + }) + + it('skips destroyed contents', () => { + const mainWindow = createMockWindow() + windows.main = mainWindow as never + mainWindow.webContents.send.mockClear() + mainWindow.webContents.isDestroyed.mockReturnValue(true) + + windows.send('backend_message', message, 'main') + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('catches a send that lands after the window is gone', () => { + const mainWindow = createMockWindow() + windows.main = mainWindow as never + mainWindow.webContents.send.mockImplementation(() => { + throw new Error('Object has been destroyed') + }) + + expect(() => windows.send('backend_message', message, 'main')).not.toThrow() + }) + }) + /** * The setters send `window_update`, and they send it at the one moment a * window is going away, so this is where a stale handle is most likely to be diff --git a/src/main/index.ts b/src/main/index.ts index 2f940f85..a9c6bd4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,7 @@ import { AppState } from './state' import { ModbusClient } from './modules/modbusClient' import os from 'os' import { ModbusServer } from './modules/modbusServer' -import { Windows } from '@shared' +import { Windows } from './windows' if (is.dev && os.platform() === 'darwin') { app.disableHardwareAcceleration() @@ -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' @@ -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 f409bd20..d0f51d0b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5,7 +5,6 @@ import { IpcHandlerMap, IpcEvent, IpcEventPayloadMap, - Windows, formatZodError, WriteParametersSchema, AddRegisterParamsSchema, @@ -21,8 +20,11 @@ import { ScanUnitIDParametersSchema, StartRtuServerParamsSchema, SyncBoolsParametersSchema, - SyncRegisterValueParamsSchema + SyncRegisterValueParamsSchema, + ServerEndiannessSchema, + ServerUuidSchema } from '@shared' +import { Windows } from './windows' import { ModbusClient } from './modules/modbusClient' import { ModbusServer } from './modules/modbusServer' import { applyPrivilegedPortFix, getPrivilegedPortStatus } from './modules/privilegedPort' @@ -60,6 +62,11 @@ type PayloadSchema = * error crossing the IPC boundary surfaces in the renderer as an unhandled * rejection carrying the channel name and nothing else, which is exactly the * failure the Linux helpers avoid by returning results instead of throwing. + * + * It goes back to the window that asked. Broadcasting it reached whichever + * windows were listening, which in split view was the main one, so a payload + * refused on a channel the server window owns reported into a window the user + * was not looking at. */ export const createIpcHandle = (windows: Windows) => @@ -77,11 +84,15 @@ export const createIpcHandle = const result = schema.safeParse(args[0]) if (!result.success) { - windows.send('backend_message', { - message: 'Invalid request, nothing was changed', - variant: 'error', - error: `${channel}: ${formatZodError(result.error)}` - }) + windows.send( + 'backend_message', + { + message: 'Invalid request, nothing was changed', + variant: 'error', + error: `${channel}: ${formatZodError(result.error)}` + }, + event.sender + ) return undefined } @@ -103,14 +114,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() ) @@ -169,6 +186,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), @@ -177,10 +199,10 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { ipcHandle('set_bool', (_, params) => server.setBool(params), SetBooleanParametersSchema) ipcHandle('reset_bools', (_, params) => server.resetBools(params), ResetBoolsParamsSchema) ipcHandle('sync_bools', (_, params) => server.syncBools(params), SyncBoolsParametersSchema) - ipcHandle('reset_server', (_, uuid) => server.resetServer(uuid)) + ipcHandle('reset_server', (_, uuid) => server.resetServer(uuid), ServerUuidSchema) ipcHandle('set_server_port', (_, params) => server.setPort(params), CreateServerParamsSchema) ipcHandle('create_server', (_, params) => server.createServer(params), CreateServerParamsSchema) - ipcHandle('delete_server', (_, uuid) => server.deleteServer(uuid)) + ipcHandle('delete_server', (_, uuid) => server.deleteServer(uuid), ServerUuidSchema) // RTU Server ipcHandle( @@ -189,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 @@ -226,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/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index b0b706dd..5386c24e 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { Protocol, Windows } from '@shared' +import type { Protocol, RawTransaction } from '@shared' +import type { Windows } from '../../windows' import { AppState } from '../../state' // Track event handlers registered on the mock ModbusRTU client @@ -19,17 +20,74 @@ const fireClientEvent = (event: 'close' | 'error', ...args: unknown[]): void => handler(...args) } +type ConnectName = 'connectTCP' | 'connectTelnet' | 'connectRTUBuffered' + +/** What each connect was handed, copied before the library wrote into it. */ +let handedOver: Partial> = {} + +/** + * Keep a copy of the options a connect was handed, then write into them the way + * the library does. + * + * `apis/connection.js` assigns into the caller's own object before it + * constructs the port: `connectTCP` and `connectTelnet` write + * `options.timeout`, and `connectRTUBuffered` writes `options.platformOptions` + * while the `RTUBufferedPort` constructor writes `options.autoOpen`. A mock + * that writes nothing lets a caller hand over its own state unseen. + * + * The copy is what the shape assertions read, because a recorded argument is a + * reference and carries those writes by the time they run. + */ +const recordConnect = ( + name: ConnectName, + target: string, + options: Record, + writes: Record +): void => { + handedOver[name] = { target, options: { ...options } } + Object.assign(options, writes) +} + +/** + * A `ModbusRTU`, with the handlers registered on this one kept beside it. + * + * `clientEventHandlers` holds what the newest client registered, which is what + * every test firing an event on the live connection wants. A client the app + * abandons keeps answering to its own `handlers`, and that record is the only + * way to ask whether it still speaks. + */ const createMockModbusRTU = () => ({ isOpen: false, - on: vi.fn(function (this: unknown, event: string, handler: (...args: unknown[]) => void) { + handlers: {} as Record void>, + on: vi.fn(function ( + this: { handlers: Record void> }, + event: string, + handler: (...args: unknown[]) => void + ) { clientEventHandlers[event] = handler + this.handlers[event] = handler return this }), + removeAllListeners: vi.fn(function (this: { + handlers: Record void> + }) { + this.handlers = {} + clientEventHandlers = {} + }), setTimeout: vi.fn(), setID: vi.fn(), - connectTCP: vi.fn().mockResolvedValue(undefined), - connectRTUBuffered: vi.fn().mockResolvedValue(undefined), - connectTelnet: vi.fn().mockResolvedValue(undefined), + connectTCP: vi.fn(async (host: string, options: Record) => { + recordConnect('connectTCP', host, options, { timeout: 3000 }) + }), + connectRTUBuffered: vi.fn(async (path: string, options: Record) => { + recordConnect('connectRTUBuffered', path, options, { + autoOpen: false, + platformOptions: { vmin: 5, vtime: 0 } + }) + }), + connectTelnet: vi.fn(async (host: string, options: Record) => { + recordConnect('connectTelnet', host, options, { timeout: 3000 }) + }), close: vi.fn((cb: () => void) => cb()), destroy: vi.fn((cb: () => void) => cb()), readCoils: vi.fn().mockResolvedValue(undefined), @@ -41,15 +99,26 @@ const createMockModbusRTU = () => ({ writeFC15: vi.fn(), writeFC16: vi.fn(), _transactions: {} as Record, + // The port modbus-serial files transactions against. A TCP port counts from + // 1, and `RTUBufferedPort` defines no such field, which is why every serial + // transaction files under the string "undefined". + _port: { _transactionIdWrite: 1 } as { _transactionIdWrite: number | undefined }, isDebugEnabled: false }) let mockModbusRTU = createMockModbusRTU() +/** Every client constructed since the last `beforeEach`, oldest first. */ +let modbusInstances: Array> = [] + vi.mock('modbus-serial', () => { // Must use `function` (not arrow) so it can be called with `new` // eslint-disable-next-line @typescript-eslint/no-explicit-any const MockCtor: any = vi.fn().mockImplementation(function () { + // A client per construction, because the one the disconnect timeout + // replaces has to stay distinguishable from the one replacing it. + mockModbusRTU = createMockModbusRTU() + modbusInstances.push(mockModbusRTU) return mockModbusRTU }) MockCtor.getPorts = vi.fn().mockResolvedValue([]) @@ -69,7 +138,27 @@ const firstCallOrder = ( return order } -const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows +/** + * What the renderer would have received, in the order it would have received it. + * + * Electron structured-clones a payload on its way to a window, so the mock does + * too. Keeping the argument keeps a live reference, and `_sendClientState` sends + * `this._clientState` itself, so an assertion on an earlier send would read that + * state as it is now rather than as it was. + */ +let sentToWindows: any[][] = [] +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let addressedTo: any[][] = [] + +const createMockWindows = (): Windows => + ({ + // 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 describe('ModbusClient', () => { let client: ModbusClient @@ -79,11 +168,15 @@ describe('ModbusClient', () => { beforeEach(() => { vi.useFakeTimers() clientEventHandlers = {} + modbusInstances = [] + handedOver = {} mockModbusRTU = createMockModbusRTU() // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(ModbusRTU as any).getPorts.mockReset() // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(ModbusRTU as any).getPorts.mockResolvedValue([]) + sentToWindows = [] + addressedTo = [] windows = createMockWindows() appState = new AppState() client = new ModbusClient({ appState, windows }) @@ -93,16 +186,40 @@ describe('ModbusClient', () => { vi.useRealTimers() }) - // Helper: simulate a successful TCP connection + /** Connect over TCP and leave the port open, which `isOpen` is read for. */ const connectClient = async () => { - mockModbusRTU.connectTCP.mockImplementation(async () => { - mockModbusRTU.isOpen = true - }) + mockModbusRTU.connectTCP.mockImplementation( + async (host: string, options: Record) => { + recordConnect('connectTCP', host, options, { timeout: 3000 }) + mockModbusRTU.isOpen = true + } + ) await client.connect() } - const getWindowCalls = (event: string) => - (windows.send as ReturnType).mock.calls.filter((c) => c[0] === event) + const getWindowCalls = (event: string) => sentToWindows.filter((call) => call[0] === event) + + /** + * The nth client the app constructed, or a failure naming the one that was + * never built. Index 0 is the client `ModbusClient`'s constructor takes, and + * index 1 the one the disconnect timeout puts in its place. + */ + const constructedClient = (index: number) => { + const instance = modbusInstances[index] + if (!instance) throw new Error(`no client was constructed at index ${index}`) + return instance + } + + /** Fire an event on one client, or fail naming the handler it does not have. */ + const fireOn = ( + client: ReturnType, + event: 'close' | 'error', + ...args: unknown[] + ): void => { + const handler = client.handlers[event] + if (!handler) throw new Error(`that client has no '${event}' handler`) + handler(...args) + } const getLastClientState = () => { const calls = getWindowCalls('client_state') @@ -120,18 +237,58 @@ describe('ModbusClient', () => { responses: [Buffer.from([0x01, 0x03, length * 2, 0x00, 0x64])] }) + /** + * File a transaction the way modbus-serial does. + * + * `writeFCx` files under the port's current write id and the port increments + * it once the buffer is out, so a request filed here takes the key the client + * read before the call. The increment is skipped on a serial port, which has + * no id to increment. + */ + const fileTransaction = (transaction: RawTransaction = createMockTransaction()): string => { + const key = String(mockModbusRTU._port._transactionIdWrite) + mockModbusRTU._transactions = { ...mockModbusRTU._transactions, [key]: transaction } + const { _transactionIdWrite } = mockModbusRTU._port + if (_transactionIdWrite !== undefined) { + mockModbusRTU._port._transactionIdWrite = _transactionIdWrite + 1 + } + return key + } + // Helper: setup read mocks that return valid data and populate _transactions const setupHoldingRegisterReadMock = (data: number[] = [100]) => { const buf = Buffer.alloc(data.length * 2) data.forEach((v, i) => buf.writeUInt16BE(v, i * 2)) mockModbusRTU.readHoldingRegisters.mockImplementation( async (address: number, length: number) => { - mockModbusRTU._transactions = { '1': createMockTransaction(address, length) } + fileTransaction(createMockTransaction(address, length)) return { data, buffer: buf } } ) } + /** + * Hold every holding-register read open until the test lets it answer. + * + * A poll chain is only observable while its read is in flight: that is where + * a second `startPolling` finds it and where `stopPolling` leaves it holding + * no timer handle. + */ + const gateTheReads = () => { + const gates: Array<() => void> = [] + mockModbusRTU.readHoldingRegisters.mockImplementation( + () => + new Promise((resolve) => { + gates.push(() => resolve({ data: [100], buffer: Buffer.from([0x00, 0x64]) })) + }) + ) + return { + resolveAll: (): void => { + gates.forEach((gate) => gate()) + } + } + } + describe('initial state', () => { it('starts in disconnected state', () => { expect(client.state.connectState).toBe('disconnected') @@ -145,9 +302,9 @@ describe('ModbusClient', () => { it('transitions to connecting then connected on TCP success', async () => { await connectClient() - expect(mockModbusRTU.connectTCP).toHaveBeenCalledWith('192.168.1.10', { - port: 502, - timeout: 5000 + expect(handedOver.connectTCP).toEqual({ + target: '192.168.1.10', + options: { port: 502 } }) expect(getLastClientState().connectState).toBe('connected') @@ -156,6 +313,18 @@ describe('ModbusClient', () => { expect(messages.some((m) => m[1].variant === 'success')).toBe(true) }) + // The client view never leaves the main window, so broadcasting put a + // connection error in the server window too while the two were split. + it('sends its messages to the main window', async () => { + mockModbusRTU.connectTCP.mockRejectedValue(new Error('Connection refused')) + + await client.connect() + + const to = addressedTo.filter(([, event]) => event === 'backend_message').map(([t]) => t) + expect(to.length).toBeGreaterThan(0) + expect(new Set(to)).toEqual(new Set(['main'])) + }) + it('transitions to disconnected on connection failure', async () => { mockModbusRTU.connectTCP.mockRejectedValue(new Error('Connection refused')) @@ -169,7 +338,6 @@ describe('ModbusClient', () => { it('uses RTU when protocol is ModbusRtu', async () => { appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) - mockModbusRTU.connectRTUBuffered.mockResolvedValue(undefined) await client.connect() @@ -179,20 +347,156 @@ describe('ModbusClient', () => { it('uses encapsulated RTU over TCP (raw RTU frames via connectTelnet) when protocol is ModbusRtuOverTcp', async () => { appState.updateConnectionConfig({ protocol: 'ModbusRtuOverTcp' }) - mockModbusRTU.connectTelnet.mockResolvedValue(undefined) await client.connect() // connectTelnet sends the RTU frame (with CRC) unchanged over TCP. // connectTCP / connectTcpRTUBuffered would speak MBAP (plain Modbus TCP). - expect(mockModbusRTU.connectTelnet).toHaveBeenCalledWith('192.168.1.10', { - port: 502, - timeout: 5000 + expect(handedOver.connectTelnet).toEqual({ + target: '192.168.1.10', + options: { port: 502 } }) expect(mockModbusRTU.connectTCP).not.toHaveBeenCalled() expect(mockModbusRTU.connectRTUBuffered).not.toHaveBeenCalled() }) + it('keeps the tcp options it read out of the connect it made', async () => { + await connectClient() + + expect(appState.connectionConfig.tcp.options).toEqual({ port: 502 }) + }) + + it('keeps the serial options it read out of the connect it made', async () => { + appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) + + await client.connect() + + expect(appState.connectionConfig.rtu.options).toEqual({ + baudRate: '9600', + dataBits: 8, + stopBits: 1, + parity: 'none' + }) + }) + + /** + * A connect the user cancelled while the port was still opening. + * + * The open resolves after `disconnect` has run, which is the whole case: + * on a socat pty the app reported "Connected over Modbus RTU" and left the + * button on Disconnect. + */ + const cancelDuringOpen = async () => { + appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) + let finishOpen = (): void => {} + mockModbusRTU.connectRTUBuffered.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = (): void => { + mockModbusRTU.isOpen = true + resolve() + } + }) + ) + + const connecting = client.connect() + await client.disconnect() + finishOpen() + await connecting + } + + it('reports nothing for a connect that was cancelled while opening', async () => { + await cancelDuringOpen() + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message.includes('Connected over'))).toBe(false) + expect(getLastClientState().connectState).toBe('disconnected') + }) + + it('closes the port a cancelled connect opened', async () => { + await cancelDuringOpen() + + expect(mockModbusRTU.close).toHaveBeenCalled() + }) + + /** + * Connect, Cancel, Connect, with the first open still in flight. + * + * The cancel sets 'disconnected' as it returns, so the button is a live + * Connect again while the first port is still opening. `ModbusRTU` holds + * one port at a time, so a second attempt would put its port in the slot + * the first one is about to close. + */ + it('refuses a connect while an earlier one is still opening', async () => { + appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) + let finishOpen = (): void => {} + mockModbusRTU.connectRTUBuffered.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = (): void => { + mockModbusRTU.isOpen = true + resolve() + } + }) + ) + + const connecting = client.connect() + await client.disconnect() + await client.connect() + + expect(mockModbusRTU.connectRTUBuffered).toHaveBeenCalledTimes(1) + + finishOpen() + await connecting + + expect(getLastClientState().connectState).toBe('disconnected') + }) + + /** + * The refusal has to hold until the port is let go, not until the close is + * asked for. The next connect opens the same path, and a serial port that + * is still closing refuses that open. + */ + it('refuses a connect until the cancelled one has released its port', async () => { + appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) + let finishOpen = (): void => {} + mockModbusRTU.connectRTUBuffered.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = (): void => { + mockModbusRTU.isOpen = true + resolve() + } + }) + ) + let finishClose = (): void => {} + mockModbusRTU.close.mockImplementation((callback: () => void) => { + // `SerialPort.isOpen` is `(port?.isOpen ?? false) && !this.closing` and + // `close()` sets `closing` before it does anything, so the port reads + // shut from the call rather than from the callback. That is why the + // `isOpen` guard in `connect` does not cover this window. + mockModbusRTU.isOpen = false + finishClose = (): void => callback() + }) + + const connecting = client.connect() + await client.disconnect() + finishOpen() + await vi.advanceTimersByTimeAsync(0) + + await client.connect() + expect(mockModbusRTU.connectRTUBuffered).toHaveBeenCalledTimes(1) + + finishClose() + await connecting + + const retry = client.connect() + await vi.advanceTimersByTimeAsync(0) + expect(mockModbusRTU.connectRTUBuffered).toHaveBeenCalledTimes(2) + finishOpen() + await retry + }) + it('emits "Already connected" warning if client is open', async () => { mockModbusRTU.isOpen = true @@ -204,7 +508,6 @@ describe('ModbusClient', () => { it('sets unitId and timeout before connecting', async () => { appState.updateConnectionConfig({ unitId: 42 }) - mockModbusRTU.connectTCP.mockResolvedValue(undefined) await client.connect() @@ -260,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() @@ -299,14 +646,62 @@ describe('ModbusClient', () => { await vi.advanceTimersByTimeAsync(5500) await disconnectPromise - expect(mockModbusRTU.destroy).toHaveBeenCalled() + expect(constructedClient(0).destroy).toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + // Over TCP `destroy` does destroy the socket, so the message says so. + expect( + messages.some((m) => m[1].message === 'Disconnect timeout, the connection was dropped') + ).toBe(true) + }) + + it('says a serial port may stay open when the disconnect times out', async () => { + appState.updateConnectionConfig({ protocol: 'ModbusRtu' }) + mockModbusRTU.connectRTUBuffered.mockImplementation(async () => { + mockModbusRTU.isOpen = true + }) + await client.connect() + mockModbusRTU.close.mockImplementation(() => {}) + + const disconnectPromise = client.disconnect() + await vi.advanceTimersByTimeAsync(5500) + await disconnectPromise + const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message.includes('Disconnect timeout'))).toBe(true) + expect( + messages.some( + (m) => m[1].message === 'Disconnect timeout, the port may stay open until Modbux closes' + ) + ).toBe(true) + }) + + /** + * A client the app stops holding keeps its port, and on a serial port it + * keeps modbus-serial's close relay too: `RTUBufferedPort` declares no + * `destroy`, so `ModbusRTU.destroy` only calls back. Left listening, that + * port reports its own close as a connection lost on the connection that + * replaced it. + */ + it('leaves the client it abandons with a sink, and the one replacing it listening', async () => { + await connectClient() + mockModbusRTU.close.mockImplementation(() => {}) + + const disconnectPromise = client.disconnect() + await vi.advanceTimersByTimeAsync(5500) + await disconnectPromise + + // An `error` listener and nothing else: modbus-serial's `_onError` emits + // on the client, `destroy` leaves that relay on a serial port, and an + // `error` with no listener is what Node throws on. + expect(Object.keys(constructedClient(0).handlers)).toEqual(['error']) + expect(Object.keys(constructedClient(1).handlers).sort()).toEqual(['close', 'error']) + + const before = getWindowCalls('backend_message').length + fireOn(constructedClient(0), 'error', new Error('the port faulted')) + expect(getWindowCalls('backend_message').length).toBe(before) }) - // The mock constructor hands out one shared object, so the replacement is - // the same instance and its handlers are still attached. Emptying the - // record first is what makes the two below see the registration itself. + // `clientEventHandlers` holds the newest registration, so emptying it + // first is what makes the two below see the registration itself. it('re-registers its handlers on the client the timeout replaces', async () => { await connectClient() mockModbusRTU.close.mockImplementation(() => {}) @@ -450,45 +845,6 @@ describe('ModbusClient', () => { const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Connection closed unexpectedly')).toBe(true) }) - - it('suppresses close message when the port closes during a deliberate disconnect', async () => { - await connectClient() - - // A serial port emits 'close' while close() is running — before the - // callback that resolves disconnect(). Firing it afterwards, as a naive - // test does, misses the race entirely. - mockModbusRTU.close.mockImplementation((cb: () => void) => { - mockModbusRTU.isOpen = false - fireClientEvent('close') - cb() - }) - ;(windows.send as ReturnType).mockClear() - await client.disconnect() - - const messages = getWindowCalls('backend_message') - expect(messages.every((m) => m[1].message !== 'Connection closed unexpectedly')).toBe(true) - expect(messages.some((m) => m[1].message === 'Disconnected from server')).toBe(true) - }) - - it('still reports an unexpected close after an earlier deliberate disconnect', async () => { - // A disconnect whose port never emits 'close' leaves the flag set; the - // next connect has to clear it or the next real drop goes unreported. - await connectClient() - await client.disconnect() - - await connectClient() - await vi.advanceTimersByTimeAsync(11000) - for (let i = 0; i < 5; i++) { - fireClientEvent('close') - await vi.advanceTimersByTimeAsync(3500) - } - - ;(windows.send as ReturnType).mockClear() - fireClientEvent('close') - - const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message === 'Connection closed unexpectedly')).toBe(true) - }) }) describe('polling', () => { @@ -523,11 +879,33 @@ describe('ModbusClient', () => { await client.read() const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message === 'Already polling')).toBe(true) + expect(messages.some((m) => m[1].message === 'Cannot read during a poll')).toBe(true) client.stopPolling() }) + it('emits warning when trying to read during a unit id scan', async () => { + await connectClient() + gateTheReads() + + client.scanUnitIds({ + range: [5, 6], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(0) + expect(client.state.scanningUnitIds).toBe(true) + + await client.read() + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot read during a unit id scan')).toBe(true) + + client.stopScanningUnitIds() + }) + it('polls repeatedly at configured rate', async () => { await connectClient() setupHoldingRegisterReadMock() @@ -542,36 +920,53 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters.mock.calls.length).toBeGreaterThanOrEqual(3) }) - // ! Coverage-only: exercises early return in _poll when polling=false - it('exits _poll early when polling has been stopped mid-cycle', async () => { + it('arms no next read when polling has been stopped mid-cycle', async () => { await connectClient() - - // Use a controllable promise so we can stop polling while _read is in progress - let resolveRead!: () => void - mockModbusRTU.readHoldingRegisters.mockImplementation( - () => - new Promise((resolve) => { - resolveRead = () => resolve({ data: [100], buffer: Buffer.from([0x00, 0x64]) }) - }) - ) + const gates = gateTheReads() client.startPolling() - // _poll is now awaiting readHoldingRegisters + await vi.advanceTimersByTimeAsync(0) - // Stop polling while the read is still in progress + // The read is in flight, so the chain holds no timer handle to clear. client.stopPolling() + gates.resolveAll() + await vi.advanceTimersByTimeAsync(2000) - // Resolve the read — _poll continues and schedules a NEW setTimeout - resolveRead() - await vi.advanceTimersByTimeAsync(0) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + }) - // Advance past pollRate so the next _poll fires - await vi.advanceTimersByTimeAsync(2000) + it('starts no second read when startPolling is called while a read is in flight', async () => { + await connectClient() + gateTheReads() + + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + client.startPolling() + await vi.advanceTimersByTimeAsync(0) - // The second _poll should have returned early (polling=false) - // readHoldingRegisters was only called once (from the first _poll) expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) }) + + it('leaves one chain when a stop and a start land during a read', async () => { + await connectClient() + const gates = gateTheReads() + + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + // The first chain cannot take its read back, so both are in flight and + // what the second start must not leave behind is a second chain. + client.stopPolling() + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(2) + + gates.resolveAll() + await vi.advanceTimersByTimeAsync(1000) + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(3) + + client.stopPolling() + }) }) describe('scanning', () => { @@ -585,51 +980,174 @@ 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 - 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) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(50, 10) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(60, 10) }) - }) - - describe('read with data', () => { - it('reads holding registers and sends data', async () => { - await connectClient() - setupHoldingRegisterReadMock([100, 200]) - await client.read() + 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 === 'Cannot scan, not connected')).toBe(true) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(client.state.scanningUnitIds).toBe(false) + }) + + 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() + }) + }) + + 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() + setupHoldingRegisterReadMock([100, 200]) + + await client.read() const dataCalls = getWindowCalls('register_data') expect(dataCalls.length).toBe(1) @@ -660,6 +1178,25 @@ describe('ModbusClient', () => { expect(dataCalls.length).toBe(1) }) + // A toolbar read has one group, `[address, registerConfig.length]`, so the + // row count is that length whatever the device padded its answer to. + it('reads the configured length of coils, not what the byte held', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'coils', address: 0, length: 3 }) + mockModbusRTU.readCoils.mockImplementation(async (address: number, length: number) => { + mockModbusRTU._transactions = { '1': createMockTransaction(address, length) } + return { + data: [true, false, true, true, true, true, true, true], + buffer: Buffer.from([0xfd]) + } + }) + + await client.read() + + const sent = getWindowCalls('register_data')[0]?.[1] + expect(sent.map((row: { id: number }) => row.id)).toEqual([0, 1, 2]) + }) + it('reads discrete inputs and sends data', async () => { await connectClient() appState.updateRegisterConfig({ type: 'discrete_inputs' }) @@ -734,6 +1271,33 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(100, 1) }) + // The pair for the group above: a data type on a coil address parses, + // because the mapping schema is one object schema for all four types, and + // it is the only way a bit mapping gets one. + it('reads the toolbar window for a bit type, whatever the mapping carries', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'coils', address: 0, length: 10 }) + appState.setReadConfiguration(true) + appState.setRegisterMapping({ + coils: { + 0: { dataType: 'uint16' }, + 100: { dataType: 'uint16' } + }, + discrete_inputs: {}, + input_registers: {}, + holding_registers: {} + }) + mockModbusRTU.readCoils.mockResolvedValue({ + data: [true], + buffer: Buffer.from([0x01]) + }) + + await client.read() + + expect(mockModbusRTU.readCoils).toHaveBeenCalledTimes(1) + expect(mockModbusRTU.readCoils).toHaveBeenCalledWith(0, 10) + }) + it('handles read error and continues to next group', async () => { await connectClient() appState.setReadConfiguration(true) @@ -814,21 +1378,92 @@ describe('ModbusClient', () => { }) }) + // One port, one request at a time. A poll and the two scans were the three + // owners `_readLoopOwner` named, and a read in flight was not, so two callers + // asking at once put two requests on one line. + describe('a read while a read is in flight', () => { + it('sends one request and tells the second caller what is running', async () => { + await connectClient() + const gate = gateTheReads() + + const first = client.read() + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + const messages = getWindowCalls('backend_message') + expect(messages.at(-1)?.[1]).toMatchObject({ + message: 'Cannot read during another read', + variant: 'warning' + }) + + gate.resolveAll() + await first + }) + + it('says a read is running while it runs, and stops saying so', async () => { + await connectClient() + const gate = gateTheReads() + + const first = client.read() + expect(client.state.reading).toBe(true) + expect(getLastClientState().reading).toBe(true) + + gate.resolveAll() + await first + + expect(client.state.reading).toBe(false) + expect(getLastClientState().reading).toBe(false) + }) + + it('reads again once the first has answered', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + + await client.read() + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(2) + }) + + it('refuses a write while it runs, which shares the port', async () => { + await connectClient() + const gate = gateTheReads() + + const first = client.read() + await client.write({ + address: 0, + type: 'holding_registers', + value: 1, + dataType: 'uint16', + single: true + }) + + expect(mockModbusRTU.writeFC6).not.toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + expect(messages.at(-1)?.[1]).toMatchObject({ + message: 'Cannot write during another read', + variant: 'warning' + }) + + gate.resolveAll() + await first + }) + }) + describe('_logTransaction', () => { it('formats and sends transaction data after read', async () => { await connectClient() + mockModbusRTU._port._transactionIdWrite = 42 mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - mockModbusRTU._transactions = { - '42': { - nextAddress: 1, - nextDataAddress: 0, - nextCode: 3, - nextLength: 10, - _timeoutFired: false, - request: Buffer.from([0x01, 0x03, 0x00, 0x00, 0x00, 0x0a]), - responses: [Buffer.from([0x01, 0x03, 0x14])] - } - } + fileTransaction({ + nextAddress: 1, + nextDataAddress: 0, + nextCode: 3, + nextLength: 10, + _timeoutFired: false, + request: Buffer.from([0x01, 0x03, 0x00, 0x00, 0x00, 0x0a]), + responses: [Buffer.from([0x01, 0x03, 0x14])] + }) return { data: new Array(10).fill(0), buffer: Buffer.alloc(20) } }) @@ -865,7 +1500,7 @@ describe('ModbusClient', () => { callCount++ // Only the first read leaves a transaction behind, so a second call // that logged the same one again would show up as two. - if (callCount === 1) mockModbusRTU._transactions = { '1': createMockTransaction() } + if (callCount === 1) fileTransaction() return { data: [0], buffer: Buffer.alloc(2) } }) @@ -877,14 +1512,12 @@ describe('ModbusClient', () => { it('leaves a transaction it did not log alone', async () => { await connectClient() + // Key 1 is a request still in flight, key 2 the one this read finishes. + // modbus-serial drops a response whose entry is gone, so taking 1 out + // with 2 times that request out instead of answering it. + fileTransaction(createMockTransaction(50)) mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - // Key 1 is a request still in flight, key 2 the one this read - // finished. modbus-serial drops a response whose entry is gone, so - // taking 1 out with 2 times that request out instead of answering it. - mockModbusRTU._transactions = { - '1': createMockTransaction(50), - '2': createMockTransaction(0) - } + fileTransaction(createMockTransaction(0)) return { data: [0], buffer: Buffer.alloc(2) } }) @@ -911,7 +1544,7 @@ describe('ModbusClient', () => { it('includes error message in transaction on read failure', async () => { await connectClient() mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - mockModbusRTU._transactions = { '1': createMockTransaction() } + fileTransaction() throw new Error('Timed out') }) @@ -927,15 +1560,13 @@ describe('ModbusClient', () => { mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { // What modbus-serial leaves behind when the write never stashed a // copy: the bookkeeping fields, and nothing else. - mockModbusRTU._transactions = { - undefined: { - nextAddress: 1, - nextDataAddress: 0, - nextCode: 3, - nextLength: 10, - _timeoutFired: false - } - } + fileTransaction({ + nextAddress: 1, + nextDataAddress: 0, + nextCode: 3, + nextLength: 10, + _timeoutFired: false + }) return { data: [0], buffer: Buffer.alloc(2) } }) @@ -949,10 +1580,11 @@ describe('ModbusClient', () => { it('keeps the serial transaction key, which is not a number', async () => { await connectClient() + // RTU has no transaction ids, so modbus-serial files every serial + // transaction under this one key. + mockModbusRTU._port._transactionIdWrite = undefined mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - // RTU has no transaction ids, so modbus-serial files every serial - // transaction under this one key. - mockModbusRTU._transactions = { undefined: createMockTransaction() } + fileTransaction() return { data: [0], buffer: Buffer.alloc(2) } }) @@ -982,7 +1614,7 @@ describe('ModbusClient', () => { let callCount = 0 mockModbusRTU.readHoldingRegisters.mockImplementation(async (address: number) => { callCount++ - mockModbusRTU._transactions = { [String(callCount)]: createMockTransaction(address) } + fileTransaction(createMockTransaction(address)) if (failFirst && callCount === 1) throw new Error('read timeout') return { data: [100], buffer: Buffer.from([0x00, 0x64]) } }) @@ -1010,9 +1642,7 @@ describe('ModbusClient', () => { it('records timeout flag from transaction', async () => { await connectClient() mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - mockModbusRTU._transactions = { - '1': { ...createMockTransaction(), _timeoutFired: true } - } + fileTransaction({ ...createMockTransaction(), _timeoutFired: true }) return { data: [0], buffer: Buffer.alloc(2) } }) @@ -1155,6 +1785,44 @@ describe('ModbusClient', () => { expect(mockModbusRTU.writeFC6).not.toHaveBeenCalled() }) + // The dialog offers UTF-8 beside the numbers and has no string field, so + // the value went out as one register of zero over whatever was there. + it('refuses a write the value field cannot be encoded as', async () => { + await connectClient() + + await client.write({ + address: 0, + type: 'holding_registers', + value: 100, + dataType: 'utf8', + single: false + }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Modbux cannot write a value as UTF-8')).toBe( + true + ) + expect(mockModbusRTU.writeFC16).not.toHaveBeenCalled() + }) + + it('refuses a write for an address the mapping gives no type', async () => { + await connectClient() + + await client.write({ + address: 0, + type: 'holding_registers', + value: 100, + dataType: 'none', + single: false + }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Modbux cannot write a value as NONE')).toBe( + true + ) + expect(mockModbusRTU.writeFC16).not.toHaveBeenCalled() + }) + it('handles register write error via FC16', async () => { await connectClient() mockModbusRTU.writeFC16.mockImplementation( @@ -1195,8 +1863,196 @@ describe('ModbusClient', () => { }) }) - // ! Coverage-only: exercises FALSE branch of !polling check after write - it('skips auto-read after write when polling is active', async () => { + // What `_logTransaction` takes is the last entry in `_transactions`, and it + // deletes the entry it logs. So a write that files no transaction of its + // own logs somebody else's and takes it out of the table, and `_onReceive` + // drops a response whose entry is gone. + describe('the transaction a write logs', () => { + /** + * A read in flight, which is the entry the table holds when a write + * arrives. The read is gated so it stays in flight for the whole test: + * the read after a write would otherwise answer and log an entry of its + * own. + */ + const aReadInFlight = () => { + gateTheReads() + fileTransaction() + } + + it('logs nothing when the coil list is empty', async () => { + await connectClient() + aReadInFlight() + + await client.write({ address: 5, type: 'coils', value: [], single: true }) + + expect(getWindowCalls('transaction')).toHaveLength(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + it('logs nothing when FC15 is asked for an empty coil list', async () => { + await connectClient() + aReadInFlight() + + writeCoilsLikeTheLibrary(null) + + await client.write({ address: 5, type: 'coils', value: [], single: false }) + + expect(mockModbusRTU.writeFC15).not.toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'No coil value to write')).toBe(true) + expect(getWindowCalls('transaction')).toHaveLength(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + it('logs nothing when a single register is asked for a 32 bit value', async () => { + await connectClient() + aReadInFlight() + + await client.write({ + address: 0, + type: 'holding_registers', + value: 70000, + dataType: 'int32', + single: true + }) + + expect(getWindowCalls('transaction')).toHaveLength(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + it('logs nothing when the data type is one Modbux cannot write', async () => { + await connectClient() + aReadInFlight() + + await client.write({ + address: 0, + type: 'holding_registers', + value: 100, + dataType: 'utf8', + single: false + }) + + expect(getWindowCalls('transaction')).toHaveLength(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + /** + * FC5 and FC15 the way the library writes them. `index.js` answers a + * closed port with a `PortNotOpenError` and returns, and otherwise files + * the transaction before the buffer goes to the port, so a write that + * reaches the port leaves one behind whether the device answers or not. + */ + const writeCoilsLikeTheLibrary = (error: Error | null) => { + const write = (code: number) => + vi.fn( + ( + _unitId: number, + address: number, + _value: boolean | boolean[], + callback: (err: Error | null) => void + ) => { + if (mockModbusRTU.isOpen !== true) { + callback(new Error('Port Not Open')) + return + } + fileTransaction({ ...createMockTransaction(address), nextCode: code }) + callback(error) + } + ) + mockModbusRTU.writeFC5.mockImplementation(write(5)) + mockModbusRTU.writeFC15.mockImplementation(write(15)) + } + + it('refuses a write while not connected', async () => { + aReadInFlight() + writeCoilsLikeTheLibrary(null) + + await client.write({ address: 5, type: 'coils', value: [true], single: true }) + + expect(mockModbusRTU.writeFC5).not.toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + expect(messages.at(-1)?.[1]).toMatchObject({ + message: 'Cannot write, not connected', + variant: 'warning' + }) + expect(getWindowCalls('transaction')).toHaveLength(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + it('logs the transaction a write of its own filed', async () => { + await connectClient() + aReadInFlight() + writeCoilsLikeTheLibrary(null) + + await client.write({ address: 5, type: 'coils', value: [true], single: true }) + + const transactions = getWindowCalls('transaction') + expect(transactions).toHaveLength(1) + expect(transactions[0]?.[1].id).toContain('2__') + expect(transactions[0]?.[1].code).toBe(5) + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + }) + + // Nothing refuses a poll the user starts while a write is on the wire, + // so two requests are filed and only one of them is this write's. + it('logs its own while a read it did not start is in flight', async () => { + await connectClient() + const finishers: Array<() => void> = [] + mockModbusRTU.writeFC5.mockImplementation( + (_unitId: number, address: number, _value: boolean, callback: (err: null) => void) => { + fileTransaction({ ...createMockTransaction(address), nextCode: 5 }) + finishers.push(() => callback(null)) + } + ) + // The poll's read files its transaction and then waits for an answer + // that never comes, which is where the write finds it. + mockModbusRTU.readHoldingRegisters.mockImplementation( + (address: number) => + new Promise(() => { + fileTransaction(createMockTransaction(address)) + }) + ) + + const writePromise = client.write({ + address: 5, + type: 'coils', + value: [true], + single: true + }) + await vi.advanceTimersByTimeAsync(0) + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + + const [finishWrite] = finishers + if (!finishWrite) throw new Error('writeFC5 was never called') + finishWrite() + await writePromise + + const transactions = getWindowCalls('transaction') + expect(transactions).toHaveLength(1) + expect(transactions[0]?.[1].id).toContain('1__') + expect(transactions[0]?.[1].code).toBe(5) + // The read's entry, which `_onReceive` delivers its answer into. + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['2']) + + client.stopPolling() + }) + + it('logs the transaction of a write the device refused', async () => { + await connectClient() + aReadInFlight() + writeCoilsLikeTheLibrary(new Error('Modbus exception 2: Illegal data address')) + + await client.write({ address: 5, type: 'coils', value: [true], single: true }) + + const transactions = getWindowCalls('transaction') + expect(transactions).toHaveLength(1) + expect(transactions[0]?.[1].id).toContain('2__') + expect(transactions[0]?.[1].errorMessage).toBe('Modbus exception 2: Illegal data address') + }) + }) + + it('refuses a write during a poll', async () => { await connectClient() setupHoldingRegisterReadMock([100]) mockModbusRTU.writeFC5.mockImplementation( @@ -1206,18 +2062,72 @@ describe('ModbusClient', () => { client.startPolling() await vi.advanceTimersByTimeAsync(100) - // Reset read call count after polling has started - mockModbusRTU.readHoldingRegisters.mockClear() - await client.write({ address: 0, type: 'coils', value: [true], single: true }) - // No extra read should be triggered — polling is already active - // (the next read will come from the poll cycle, not from the write) - expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot write during a poll')).toBe(true) + expect(mockModbusRTU.writeFC5).not.toHaveBeenCalled() client.stopPolling() }) + it('refuses a write during a register scan', async () => { + await connectClient() + gateTheReads() + mockModbusRTU.writeFC5.mockImplementation( + (_uid: number, _addr: number, _val: boolean, cb: (err: null) => void) => cb(null) + ) + + client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) + await vi.advanceTimersByTimeAsync(0) + expect(client.state.scanningRegisters).toBe(true) + + await client.write({ address: 0, type: 'coils', value: [true], single: true }) + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Cannot write during a register scan')).toBe( + true + ) + expect(mockModbusRTU.writeFC5).not.toHaveBeenCalled() + + client.stopScanningRegisters() + }) + + it('skips the read after a write when a scan starts during it', async () => { + await connectClient() + gateTheReads() + const finishers: Array<() => void> = [] + mockModbusRTU.writeFC5.mockImplementation( + (_uid: number, _addr: number, _val: boolean, cb: (err: null) => void) => { + finishers.push(() => cb(null)) + } + ) + + const writePromise = client.write({ address: 0, type: 'coils', value: [true], single: true }) + await vi.advanceTimersByTimeAsync(0) + + // The scan owns the client from here, and its own first read is the one + // call that follows. + client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) + await vi.advanceTimersByTimeAsync(0) + expect(client.state.scanningRegisters).toBe(true) + + const [finishWrite] = finishers + if (!finishWrite) throw new Error('writeFC5 was never called') + // The read after a write would be refused and say so, and the user asked + // for neither, so the messages after the write are the ones before it. + const messagesBeforeFinish = getWindowCalls('backend_message').map((m) => m[1].message) + finishWrite() + await writePromise + + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toEqual( + messagesBeforeFinish + ) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + + client.stopScanningRegisters() + }) + it('triggers auto-read after write when not polling', async () => { await connectClient() setupHoldingRegisterReadMock([100]) @@ -1311,30 +2221,31 @@ describe('ModbusClient', () => { expect(results[0]?.[1].registerTypes).not.toContain('coils') }) - // ! Coverage-only: exercises scan-stop check after coils - it('stops scan after coils check when scanning is cancelled', async () => { + // The loop reads the flag once, at the top, so a cancel during any register + // type ends the unit before the next one. + it('reads no further register type for a unit cancelled mid-list', async () => { await connectClient() - mockModbusRTU.readCoils.mockImplementation(async () => { + mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) + mockModbusRTU.readDiscreteInputs.mockImplementation(async () => { client.stopScanningUnitIds() return { data: [true], buffer: Buffer.from([0x01]) } }) - mockModbusRTU.readDiscreteInputs.mockResolvedValue({ - data: [true], - buffer: Buffer.from([0x01]) - }) + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) + mockModbusRTU.readInputRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) const scanPromise = client.scanUnitIds({ range: [1, 5], address: 0, length: 1, - registerTypes: ['coils', 'discrete_inputs'], + registerTypes: ['coils', 'discrete_inputs', 'holding_registers', 'input_registers'], timeout: 1000 }) await vi.advanceTimersByTimeAsync(5000) await scanPromise - // discrete_inputs should NOT have been called since scan stopped after coils - expect(mockModbusRTU.readDiscreteInputs).not.toHaveBeenCalled() + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(mockModbusRTU.readInputRegisters).not.toHaveBeenCalled() + expect(getWindowCalls('scan_unit_id_result').length).toBe(0) }) // ! Coverage-only: exercises error path for discrete_inputs in scanUnitIds @@ -1362,39 +2273,43 @@ describe('ModbusClient', () => { expect(results[0]?.[1].registerTypes).not.toContain('discrete_inputs') }) - // ! Coverage-only: exercises scan-stop check after discrete_inputs - it('stops scan after discrete_inputs check when scanning is cancelled', async () => { + it('stops mid-scan when stopScanningUnitIds is called', async () => { await connectClient() - mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) - mockModbusRTU.readDiscreteInputs.mockImplementation(async () => { - client.stopScanningUnitIds() - return { data: [true], buffer: Buffer.from([0x01]) } - }) mockModbusRTU.readHoldingRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) const scanPromise = client.scanUnitIds({ - range: [1, 5], + range: [1, 100], address: 0, length: 1, - registerTypes: ['coils', 'discrete_inputs', 'holding_registers'], + registerTypes: ['holding_registers'], timeout: 1000 }) + + // Stop after a short time + await vi.advanceTimersByTimeAsync(50) + client.stopScanningUnitIds() await vi.advanceTimersByTimeAsync(5000) await scanPromise - // holding_registers should NOT have been called since scan stopped after discrete_inputs - expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + // Should have scanned far fewer than 100 units + const results = getWindowCalls('scan_unit_id_result') + expect(results.length).toBeLessThan(100) + expect(client.state.scanningUnitIds).toBe(false) }) - it('stops mid-scan when stopScanningUnitIds is called', async () => { + // 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], @@ -1404,16 +2319,70 @@ describe('ModbusClient', () => { timeout: 1000 }) - // Stop after a short time await vi.advanceTimersByTimeAsync(50) client.stopScanningUnitIds() await vi.advanceTimersByTimeAsync(5000) await scanPromise - // Should have scanned far fewer than 100 units - const results = getWindowCalls('scan_unit_id_result') - expect(results.length).toBeLessThan(100) - expect(client.state.scanningUnitIds).toBe(false) + 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) + }) + + // The dialogs read this flag out of `client_state` to decide whether Close + // is live, so the state reporting the disconnect is where it has to be + // false. The loop sets it false on its own way out, one read later. + it('the client_state reporting a disconnect says the unit id scan is over', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + await client.disconnect() + 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 + + const states = getWindowCalls('client_state').map((call) => call[1]) + const firstDisconnected = states.find((state) => state.connectState === 'disconnected') + expect(firstDisconnected?.scanningUnitIds).toBe(false) }) it('scans all four register types', async () => { @@ -1470,7 +2439,7 @@ describe('ModbusClient', () => { expect(progress.at(-1)?.[1]).toBe(100) }) - // ! Coverage-only: exercises FALSE branch of registerTypes.includes('holding_registers') + // The loop visits what was asked for, so a type left out is never read. it('skips holding_registers when not in registerTypes', async () => { await connectClient() mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) @@ -1518,35 +2487,6 @@ describe('ModbusClient', () => { expect(results[0]?.[1].registerTypes).not.toContain('holding_registers') }) - // ! Coverage-only: exercises scan-stop check after holding_registers - it('stops scan after holding_registers check when scanning is cancelled', async () => { - await connectClient() - mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) - mockModbusRTU.readDiscreteInputs.mockResolvedValue({ - data: [true], - buffer: Buffer.from([0x01]) - }) - mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - // Stop scanning right after holding_registers completes - client.stopScanningUnitIds() - return { data: [0], buffer: Buffer.alloc(2) } - }) - mockModbusRTU.readInputRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) - - const scanPromise = client.scanUnitIds({ - range: [1, 5], - address: 0, - length: 1, - registerTypes: ['coils', 'discrete_inputs', 'holding_registers', 'input_registers'], - timeout: 1000 - }) - await vi.advanceTimersByTimeAsync(5000) - await scanPromise - - // input_registers should NOT have been called since scan stopped after holding_registers - expect(mockModbusRTU.readInputRegisters).not.toHaveBeenCalled() - }) - // ! Coverage-only: exercises error path for input_registers in scanUnitIds it('records error for input_registers read failure', async () => { await connectClient() @@ -1573,42 +2513,93 @@ describe('ModbusClient', () => { expect(results[0]?.[1].registerTypes).not.toContain('input_registers') }) - // ! Coverage-only: exercises scan-stop check after input_registers - it('stops scan after input_registers check when scanning is cancelled', async () => { + it('logs the transaction of every probe it made', async () => { await connectClient() - // All register reads succeed but are slow enough to allow cancellation - mockModbusRTU.readCoils.mockResolvedValue({ data: [true], buffer: Buffer.from([0x01]) }) - mockModbusRTU.readDiscreteInputs.mockResolvedValue({ - data: [true], - buffer: Buffer.from([0x01]) + mockModbusRTU.readCoils.mockImplementation(async (address: number, length: number) => { + fileTransaction({ ...createMockTransaction(address, length), nextCode: 1 }) + return { data: [false], buffer: Buffer.alloc(1) } }) - mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { - // Stop scanning right after holding_registers completes - client.stopScanningUnitIds() - return { data: [0], buffer: Buffer.alloc(2) } + setupHoldingRegisterReadMock([0]) + + const scanPromise = client.scanUnitIds({ + range: [5, 6], + address: 3, + length: 1, + registerTypes: ['coils', 'holding_registers'], + timeout: 1000 }) - mockModbusRTU.readInputRegisters.mockResolvedValue({ - data: [0], - buffer: Buffer.alloc(2) + 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: [1, 5], - address: 0, + range: [5, 5], + address: 3, length: 1, - registerTypes: ['coils', 'discrete_inputs', 'holding_registers', 'input_registers'], + registerTypes: ['coils'], timeout: 1000 }) - await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(1000) await scanPromise - // Should have stopped after first unit's holding_registers (before input_registers) - const results = getWindowCalls('scan_unit_id_result') - expect(results.length).toBe(0) + 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', () => { + it('logs the transaction of every read it made', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + + const scanPromise = client.scanRegisters({ + addressRange: [50, 69], + length: 10, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + const transactions = getWindowCalls('transaction') + expect(transactions.map((call) => call[1].address)).toEqual([50, 60]) + expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) + }) + it('sets unit ID before scanning', async () => { await connectClient() setupHoldingRegisterReadMock([100]) @@ -1709,6 +2700,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) + }) + + // The dialogs read this flag out of `client_state` to decide whether Close + // is live, so the state reporting the disconnect is where it has to be + // false. The loop sets it false on its own way out, one read later. + it('the client_state reporting a disconnect says the register scan is over', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + mockModbusRTU.readHoldingRegisters.mockImplementationOnce(async () => { + await client.disconnect() + 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 + + const states = getWindowCalls('client_state').map((call) => call[1]) + const firstDisconnected = states.find((state) => state.connectState === 'disconnected') + expect(firstDisconnected?.scanningRegisters).toBe(false) + }) + it('handles read errors during register scan', async () => { await connectClient() mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { @@ -1770,6 +2806,33 @@ describe('ModbusClient', () => { expect(sentData.every((d: { bit: boolean }) => d.bit === true)).toBe(true) }) + // The chunk size and the grid's read length are two separate fields, and + // the chunk is what goes on the wire. + it('sends every coil a chunk read, not the grid length', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'coils', length: 10 }) + mockModbusRTU.readCoils.mockImplementation(async (address: number, length: number) => { + mockModbusRTU._transactions = { '1': createMockTransaction(address, length) } + return { + data: Array.from({ length }, () => true), + buffer: Buffer.alloc(Math.ceil(length / 8)) + } + }) + + const scanPromise = client.scanRegisters({ + addressRange: [0, 99], + length: 100, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(1000) + await scanPromise + + const sent = getWindowCalls('register_data')[0]?.[1] + expect(sent.map((row: { id: number }) => row.id)).toEqual( + Array.from({ length: 100 }, (_, index) => index) + ) + }) + it('emits scan progress', async () => { await connectClient() setupHoldingRegisterReadMock([100]) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index cde623f1..fa3470be 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { BaseDataType, RegisterParams, UnitIdString, Windows } from '@shared' +import type { BaseDataType, RegisterParams, UnitIdString } from '@shared' +import type { Windows } from '../../windows' +import { defaultSerialPortOptions } from '@shared' import type { IServiceVector } from 'modbus-serial/ServerTCP' // Configurable port availability for net mock @@ -35,22 +37,23 @@ vi.mock('modbus-serial', () => ({ }), ServerSerial: vi.fn().mockImplementation(function () { const handlers: Record void> = {} - // The SerialPort the library opens. `startRtuServer` reaches for it by name - // and registers on it, so a mock without one leaves those listeners out of - // every test. + // The SerialPort the library opens and returns from `getPort()`. + // `startRtuServer` registers on it, so a mock without one leaves those + // listeners out of every test. const pathHandlers: Record void> = {} + const port = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + pathHandlers[event] = handler + }), + _handlers: pathHandlers + } return { on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { handlers[event] = handler }), close: vi.fn((cb: (err: Error | null) => void) => cb(null)), _handlers: handlers, - _serverPath: { - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - pathHandlers[event] = handler - }), - _handlers: pathHandlers - } + getPort: vi.fn(() => port) } }) })) @@ -131,7 +134,7 @@ const lastInstance = (constructor: typeof ServerTCP | typeof ServerSerial) => { /** A serial server as the mock builds it, with the handler records exposed. */ type MockSerialServer = { _handlers: Record void> - _serverPath: { _handlers: Record void> } + getPort: () => { _handlers: Record void> } } /** @@ -145,11 +148,39 @@ const fireSerialPathEvent = ( event: 'error' | 'close', ...args: unknown[] ): void => { - const handler = instance._serverPath._handlers[event] + const handler = instance.getPort()._handlers[event] if (!handler) throw new Error(`the serial port got no '${event}' handler`) handler(...args) } +/** + * Fire the `openCallback` a serial server was constructed with, the last by + * default and an earlier one at a negative index. + * + * A server built without one fails here saying so, where + * `call[1].openCallback?.(err)` would pass quietly. + */ +const fireOpenCallback = (err: Error | null, index: number = -1): void => { + const call = vi.mocked(ServerSerial).mock.calls.at(index) + if (!call) throw new Error('no server was constructed') + const openCallback = call[1].openCallback + if (!openCallback) throw new Error('the server was built with no openCallback') + openCallback(err) +} + +/** + * The third constructor argument of the last serial server. + * + * `ServerSerial`'s typings stop at two arguments and `serverserial.js` reads a + * third, so the call tuple is read through a type that admits one. + */ +const lastSerialPortOptions = (): unknown => { + const calls: readonly (readonly unknown[])[] = vi.mocked(ServerSerial).mock.calls + const call = calls.at(-1) + if (!call) throw new Error('no server was constructed') + return call[2] +} + const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows describe('ModbusServer', () => { @@ -157,6 +188,24 @@ describe('ModbusServer', () => { let windows: Windows const uuid = 'test-server-uuid' const unitId: UnitIdString = '1' + const unitIdNumber = Number(unitId) + + /** A register the unit hosts, which is what makes the server answer for it at all. */ + const hostUnit = (): number[] => + server.addRegister({ + uuid, + unitId, + params: { + address: 100, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1, + min: undefined, + max: undefined, + interval: undefined + } + }) beforeEach(() => { vi.useFakeTimers() @@ -172,15 +221,22 @@ 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) + const clearWindowCalls = (): void => { + ;(windows.send as ReturnType).mockClear() + } + + /** 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', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -201,16 +257,139 @@ describe('ModbusServer', () => { unitId, registerType: 'holding_registers', address: 0, - raw: 1234 - }) + value: 1234 + }), + 'serverView' ) }) + // 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: 'int32', + comment: 'test register', + value: 70000, + min: undefined, + max: undefined, + interval: undefined + } + }) + + 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 an empty register cannot pass by luck. + it('answers the first words a generator wrote', () => { + const answer = server.addRegister({ + uuid, + unitId, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: 'test register', + value: undefined, + min: 100, + max: 200, + interval: 5000 + } + }) + + 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 no words for a register with no data type', () => { + const answer = server.addRegister({ + uuid, + unitId, + params: { + address: 300, + registerType: 'holding_registers', + dataType: 'none', + comment: 'test register', + value: 0, + min: undefined, + max: undefined, + interval: undefined + } + }) + + expect(answer).toEqual([]) + }) + + // `none` reaches a register map only from a config file. It holds its + // address open and stands for no value, and both writers wrote a register + // of zero over whatever the address held. + it('leaves the address alone for a fixed register with no data type', async () => { + await server.createServer({ uuid, port: 5020 }) + const vector = lastVector(ServerTCP) + hostUnit() + await vector.setRegister(0, 1234, unitIdNumber, vi.fn()) + + server.addRegister({ + uuid, + unitId, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'none', + comment: 'placeholder', + value: 5, + min: undefined, + max: undefined, + interval: undefined + } + }) + + const cb = vi.fn() + await vector.getHoldingRegister(0, unitIdNumber, cb) + expect(cb).toHaveBeenCalledWith(null, 1234) + }) + + it('leaves the address alone for a generator with no data type', async () => { + await server.createServer({ uuid, port: 5020 }) + const vector = lastVector(ServerTCP) + hostUnit() + await vector.setRegister(0, 1234, unitIdNumber, vi.fn()) + + server.addRegister({ + uuid, + unitId, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'none', + comment: 'placeholder', + value: undefined, + min: 0, + max: 100, + interval: 1000 + } + }) + vi.advanceTimersByTime(5000) + + const cb = vi.fn() + await vector.getHoldingRegister(0, unitIdNumber, cb) + expect(cb).toHaveBeenCalledWith(null, 1234) + }) + it('writes a 32-bit value across 2 registers', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 10, registerType: 'holding_registers', @@ -228,15 +407,80 @@ describe('ModbusServer', () => { 'register_value', expect.objectContaining({ address: 10, - raw: 1 // high word - }) + value: 1 // high word + }), + 'serverView' ) expect(windows.send).toHaveBeenCalledWith( 'register_value', expect.objectContaining({ address: 11, - raw: 4464 // low word (0x1170) - }) + value: 4464 // low word (0x1170) + }), + 'serverView' + ) + }) + + /** + * 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, value: 4464 }), + 'serverView' + ) + expect(windows.send).toHaveBeenCalledWith( + 'register_value', + expect.objectContaining({ address: 11, value: 1 }), + 'serverView' + ) + }) + + 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, value: 1 }), + 'serverView' ) }) @@ -244,7 +488,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 5, registerType: 'input_registers', @@ -262,8 +505,9 @@ describe('ModbusServer', () => { expect.objectContaining({ registerType: 'input_registers', address: 5, - raw: 999 - }) + value: 999 + }), + 'serverView' ) }) }) @@ -273,7 +517,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -291,8 +534,9 @@ describe('ModbusServer', () => { 'register_value', expect.objectContaining({ address: 0, - raw: 50 - }) + value: 50 + }), + 'serverView' ) }) @@ -301,7 +545,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -321,7 +564,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -339,8 +581,9 @@ describe('ModbusServer', () => { 'register_value', expect.objectContaining({ address: 0, - raw: 99 - }) + value: 99 + }), + 'serverView' ) // Old generator should be disposed (no more ticks from it) @@ -349,7 +592,7 @@ describe('ModbusServer', () => { const allCalls = (windows.send as ReturnType).mock.calls.filter( (call) => call[0] === 'register_value' && call[1].address === 0 ) - const valuesAfterReplace = allCalls.slice(callsAfterFirst).map((call) => call[1].raw) + const valuesAfterReplace = allCalls.slice(callsAfterFirst).map((call) => call[1].value) // All values should be 99 (from new generator), not 10 expect(valuesAfterReplace.every((v: number) => v === 99)).toBe(true) }) @@ -361,7 +604,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 5, registerType: 'holding_registers', @@ -389,7 +631,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -439,7 +680,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -488,7 +728,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -522,7 +761,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -556,13 +794,17 @@ describe('ModbusServer', () => { state: true }) - expect(windows.send).toHaveBeenCalledWith('boolean_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', () => { @@ -574,13 +816,17 @@ describe('ModbusServer', () => { state: false }) - expect(windows.send).toHaveBeenCalledWith('boolean_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 @@ -591,8 +837,9 @@ describe('ModbusServer', () => { server.setBool({ uuid, unitId, registerType: 'coils', address: 1, state: true }) expect(windows.send).toHaveBeenCalledWith( - 'boolean_value', - expect.objectContaining({ address: 1, value: true }) + 'register_value', + expect.objectContaining({ address: 1, value: true }), + 'serverView' ) }) }) @@ -676,7 +923,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -714,7 +960,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -732,7 +977,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId, - littleEndian: false, registerValues: [ { address: 10, @@ -753,8 +997,9 @@ describe('ModbusServer', () => { 'register_value', expect.objectContaining({ address: 10, - raw: 200 - }) + value: 200 + }), + 'serverView' ) }) @@ -763,7 +1008,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'input_registers', @@ -779,7 +1023,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -796,7 +1039,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId, - littleEndian: false, registerValues: [] }) @@ -814,7 +1056,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid: 'fresh-uuid', unitId, - littleEndian: false, registerValues: [ { address: 0, @@ -832,7 +1073,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ address: 0, raw: 42 }) + expect.objectContaining({ address: 0, value: 42 }), + 'serverView' ) }) @@ -842,7 +1084,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -860,7 +1101,6 @@ describe('ModbusServer', () => { server.syncServerRegisters({ uuid, unitId: '2' as UnitIdString, - littleEndian: false, registerValues: [ { address: 0, @@ -878,7 +1118,8 @@ describe('ModbusServer', () => { expect(windows.send).toHaveBeenCalledWith( 'register_value', - expect.objectContaining({ unitId: '2', raw: 99 }) + expect.objectContaining({ unitId: '2', value: 99 }), + 'serverView' ) }) }) @@ -890,7 +1131,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -907,7 +1147,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId: unitId2, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -926,15 +1165,17 @@ describe('ModbusServer', () => { 'register_value', expect.objectContaining({ unitId: '1', - raw: 111 - }) + value: 111 + }), + 'serverView' ) expect(windows.send).toHaveBeenCalledWith( 'register_value', expect.objectContaining({ unitId: '2', - raw: 222 - }) + value: 222 + }), + 'serverView' ) }) }) @@ -1080,7 +1321,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'input_registers', @@ -1096,7 +1336,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1152,7 +1391,7 @@ describe('ModbusServer', () => { it('emits EACCES error and returns current port for privileged port', async () => { // First create a server on a working port await server.createServer({ uuid, port: 5020 }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() portAvailableResults = ['EACCES'] const port = await server.setPort({ uuid, port: 502 }) @@ -1165,7 +1404,7 @@ describe('ModbusServer', () => { it('emits EADDRINUSE error and returns current port when port is taken', async () => { await server.createServer({ uuid, port: 5020 }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() portAvailableResults = ['EADDRINUSE'] const port = await server.setPort({ uuid, port: 5021 }) @@ -1186,7 +1425,7 @@ describe('ModbusServer', () => { it('puts the server back on its old port when the new bind fails', async () => { await server.createServer({ uuid, port: 5020 }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() vi.mocked(ServerTCP).mockClear() // The probe passes and the bind still fails, which is what happens when @@ -1202,7 +1441,7 @@ describe('ModbusServer', () => { it('says so when the old port cannot be taken back either', async () => { await server.createServer({ uuid, port: 5020 }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() bindResults = ['EADDRINUSE', 'EADDRINUSE'] const port = await server.setPort({ uuid, port: 5021 }) @@ -1216,7 +1455,7 @@ describe('ModbusServer', () => { it('refuses port 0 and keeps the server where it is', async () => { await server.createServer({ uuid, port: 5020 }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() const callsBefore = vi.mocked(ServerTCP).mock.calls.length // Listening on 0 succeeds: the kernel hands out a free port, the server @@ -1231,6 +1470,17 @@ describe('ModbusServer', () => { ).toBe(true) }) + // Broadcasting put this in both windows, and in split view the main one is + // on the client view, where a message about a server port means nothing. + it('sends the refusal to the window showing the server', async () => { + await server.createServer({ uuid, port: 5020 }) + clearWindowCalls() + + await server.setPort({ uuid, port: 0 }) + + expect(getAddressees('backend_message')).toEqual(['serverView']) + }) + it('refuses port 0 with no server yet and answers with the default', async () => { const port = await server.setPort({ uuid, port: 0 }) expect(port).toBe(502) @@ -1249,7 +1499,7 @@ describe('ModbusServer', () => { describe('startRtuServer', () => { const serialConfig = { com: '/dev/ttyUSB0', - options: { baudRate: '9600' as const, dataBits: 8, stopBits: 1, parity: 'none' as const } + options: { ...defaultSerialPortOptions } } it('creates a ServerSerial with correct config', async () => { @@ -1260,13 +1510,28 @@ describe('ModbusServer', () => { expect.objectContaining({ path: '/dev/ttyUSB0', baudRate: 9600, - dataBits: 8, - stopBits: 1, parity: 'none' - }) + }), + { dataBits: 8, stopBits: 1 } ) }) + it('gives the port the data bits and stop bits it was configured with', async () => { + // `serverserial.js` builds its own option object and assigns it over the + // third argument, and the two it does not name reach `SerialPort` by no + // other route. In the second argument they typecheck and go nowhere, and + // the binding opens at 8 and 1, which is why this asks for 7 and 2. + await server.startRtuServer({ + uuid, + serialConfig: { + ...serialConfig, + options: { ...serialConfig.options, dataBits: 7, stopBits: 2 } + } + }) + + expect(lastSerialPortOptions()).toEqual({ dataBits: 7, stopBits: 2 }) + }) + it('emits success message and status on initialized event', async () => { await server.startRtuServer({ uuid, serialConfig }) @@ -1280,11 +1545,39 @@ describe('ModbusServer', () => { expect(messageCalls.some((c) => c[1].message.includes('/dev/ttyUSB0'))).toBe(true) }) - it('emits error status on error event', async () => { + // `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']) + }) + + // `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 }) const instance = lastInstance(ServerSerial) - instance._handlers['error'](new Error('port gone')) + // A listener on `error` instead would never fire, and the view would keep + // showing a running server. + instance._handlers['socketError'](new Error('port gone')) const statusCalls = getWindowCalls('rtu_server_status') expect(statusCalls.some((c) => c[1].active === false)).toBe(true) @@ -1293,6 +1586,33 @@ describe('ModbusServer', () => { expect(messageCalls.some((c) => c[1].message.includes('port gone'))).toBe(true) }) + it('reports an open that failed', async () => { + await server.startRtuServer({ uuid, serialConfig }) + clearWindowCalls() + + fireOpenCallback(new Error('cannot open /dev/ttyUSB0')) + + expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ + 'RTU server error: cannot open /dev/ttyUSB0' + ]) + }) + + it('says nothing when the port opens', async () => { + await server.startRtuServer({ uuid, serialConfig }) + clearWindowCalls() + + // The same callback carries the success, with null in place of an error. + fireOpenCallback(null) + const instance = lastInstance(ServerSerial) + instance._handlers['initialized']() + + expect(getWindowCalls('rtu_server_status').map((c) => c[1].active)).toEqual([true]) + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ + 'RTU server started on /dev/ttyUSB0' + ]) + }) + it('skips start when COM port is empty', async () => { await server.startRtuServer({ uuid, @@ -1316,7 +1636,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1342,7 +1661,7 @@ describe('ModbusServer', () => { await server.startRtuServer({ uuid, serialConfig }) const instance = lastInstance(ServerSerial) instance._handlers['initialized']() - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() fireSerialPathEvent(instance, 'close', new Error('Disconnected')) @@ -1358,7 +1677,7 @@ describe('ModbusServer', () => { const instance = lastInstance(ServerSerial) instance._handlers['initialized']() fireSerialPathEvent(instance, 'close', new Error('Disconnected')) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() await server.stopRtuServer() @@ -1373,7 +1692,7 @@ describe('ModbusServer', () => { instance._handlers['initialized']() await server.stopRtuServer() - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() fireSerialPathEvent(instance, 'close', undefined) expect(getWindowCalls('backend_message')).toEqual([]) @@ -1384,7 +1703,7 @@ describe('ModbusServer', () => { await server.startRtuServer({ uuid, serialConfig }) const instance = lastInstance(ServerSerial) instance._handlers['initialized']() - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() // A port unplugged during a write emits both: `_write`'s callback carries // the error into the stream and `_disconnected` closes it. @@ -1395,13 +1714,62 @@ describe('ModbusServer', () => { expect(messageCalls.map((c) => c[1].message)).toEqual(['RTU server error: Disconnected']) }) + it('a replaced server does not report its failed open', async () => { + await server.startRtuServer({ uuid, serialConfig }) + await server.startRtuServer({ uuid, serialConfig }) + const live = lastInstance(ServerSerial) + live._handlers['initialized']() + clearWindowCalls() + + // `stopRtuServer` cannot close a port that never opened, so the first + // server's open is still in flight while the second one is serving. + fireOpenCallback(new Error('cannot open /dev/ttyUSB0'), -2) + + // 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(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ + 'RTU server disconnected from /dev/ttyUSB0' + ]) + }) + + it('a replaced server does not report itself up', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const replaced = lastInstance(ServerSerial) + + await server.startRtuServer({ uuid, serialConfig }) + lastInstance(ServerSerial)._handlers['initialized']() + + // A port that opens late, after the server it belongs to was replaced. + replaced._handlers['initialized']() + + 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]) + }) + + it('a stopped server does not report its failed open', async () => { + await server.startRtuServer({ uuid, serialConfig }) + await server.stopRtuServer() + + fireOpenCallback(new Error('cannot open /dev/ttyUSB0')) + + await server.startRtuServer({ uuid, serialConfig }) + lastInstance(ServerSerial)._handlers['initialized']() + + expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ + 'RTU server started on /dev/ttyUSB0' + ]) + }) + it('leaves the running server alone when an earlier port closes', async () => { await server.startRtuServer({ uuid, serialConfig }) const replaced = lastInstance(ServerSerial) await server.startRtuServer({ uuid, serialConfig }) lastInstance(ServerSerial)._handlers['initialized']() - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() fireSerialPathEvent(replaced, 'close', new Error('Disconnected')) @@ -1413,7 +1781,7 @@ describe('ModbusServer', () => { describe('stopRtuServer', () => { const serialConfig = { com: '/dev/ttyUSB0', - options: { baudRate: '9600' as const, dataBits: 8, stopBits: 1, parity: 'none' as const } + options: { ...defaultSerialPortOptions } } it('does nothing when no RTU server is running', async () => { @@ -1439,7 +1807,7 @@ describe('ModbusServer', () => { // Simulate initialized → wasActive = true instance._handlers['initialized']() - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() await server.stopRtuServer() const messageCalls = getWindowCalls('backend_message') @@ -1449,7 +1817,7 @@ describe('ModbusServer', () => { it('does not emit warning when server never became active', async () => { await server.startRtuServer({ uuid, serialConfig }) // Don't trigger initialized event - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() await server.stopRtuServer() const messageCalls = getWindowCalls('backend_message') @@ -1492,7 +1860,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1620,7 +1987,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 10, registerType: 'input_registers', @@ -1662,7 +2028,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1703,14 +2068,21 @@ describe('ModbusServer', () => { it('sets coil value and emits event', async () => { // First ensure data exists server.setBool({ uuid, unitId, registerType: 'coils', address: 0, state: false }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() const cb = vi.fn() await vector.setCoil(10, true, 1, cb) expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( - 'boolean_value', - expect.objectContaining({ uuid, unitId, registerType: 'coils', address: 10, value: true }) + 'register_value', + expect.objectContaining({ + uuid, + unitId, + registerType: 'coils', + address: 10, + value: true + }), + 'serverView' ) }) @@ -1747,7 +2119,6 @@ describe('ModbusServer', () => { server.addRegister({ uuid, unitId, - littleEndian: false, params: { address: 0, registerType: 'holding_registers', @@ -1760,7 +2131,7 @@ describe('ModbusServer', () => { interval: undefined } }) - ;(windows.send as ReturnType).mockClear() + clearWindowCalls() const cb = vi.fn() await vector.setRegister(20, 12345, 1, cb) @@ -1772,8 +2143,9 @@ describe('ModbusServer', () => { unitId, registerType: 'holding_registers', address: 20, - raw: 12345 - }) + value: 12345 + }), + 'serverView' ) }) @@ -1809,14 +2181,13 @@ describe('ModbusServer', () => { describe('the unit ids a server answers for', () => { const serialConfig = { com: '/dev/ttyUSB0', - options: { baudRate: '9600' as const, dataBits: 8, stopBits: 1, parity: 'none' as const } + options: { ...defaultSerialPortOptions } } - const hostUnit = (id: UnitIdString, address: number, value: number): void => + const hostUnit = (id: UnitIdString, address: number, value: number): number[] => server.addRegister({ uuid, unitId: id, - littleEndian: false, params: { address, registerType: 'holding_registers', @@ -2061,7 +2432,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/__tests__/privilegedPort.test.ts b/src/main/modules/__tests__/privilegedPort.test.ts index 859648e0..fb226149 100644 --- a/src/main/modules/__tests__/privilegedPort.test.ts +++ b/src/main/modules/__tests__/privilegedPort.test.ts @@ -44,7 +44,8 @@ import { detectSandbox, findPkexec, getPrivilegedPortStatus, - readUnprivilegedPortStart + readUnprivilegedPortStart, + UNPRIVILEGED_PORT_START_PATH } from '../privilegedPort' import { privilegedPortCommandArgs, @@ -233,9 +234,45 @@ describe('applyPrivilegedPortFix', () => { expect(execFileCalls[0]?.args).toEqual(privilegedPortCommandArgs('session')) }) - it('runs the persist command when asked to', async () => { - await applyPrivilegedPortFix('persist') + it('runs the persist command when asked to and says the floor will survive a reboot', async () => { + procContent = '1024' + const pending = applyPrivilegedPortFix('persist') + procContent = '502' + const result = await pending + expect(execFileCalls[0]?.args).toEqual(privilegedPortCommandArgs('persist')) + expect(result.ok).toBe(true) + expect(result.message).toContain('after a reboot') + }) + + it('refuses a zero exit that left the floor where it was', async () => { + // pkexec can exit 0 without the sysctl taking, and the green snackbar then + // promised a port the server still cannot bind. + procContent = '1024' + const result = await applyPrivilegedPortFix('session') + + expect(result).toMatchObject({ ok: false, reason: 'failed' }) + expect(result.message).toContain('1024') + expect(result.unprivilegedPortStart).toBe(1024) + }) + + it('refuses a zero exit it cannot read the floor back after', async () => { + procContent = new Error('ENOENT') + const result = await applyPrivilegedPortFix('session') + + expect(result).toMatchObject({ ok: false, reason: 'failed' }) + expect(result.message).toContain(UNPRIVILEGED_PORT_START_PATH) + expect(result.unprivilegedPortStart).toBeUndefined() + }) + + it('accepts a floor below the target as well as one on it', async () => { + procContent = '1024' + const pending = applyPrivilegedPortFix('session') + procContent = '80' + const result = await pending + + expect(result.ok).toBe(true) + expect(result.message).toContain('until the next reboot') }) it('says so when the user dismisses the PolicyKit prompt', async () => { diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index 4c706613..876a55be 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { ValueGenerator } from '../modbusServer/valueGenerator' -import type { ServerData, UnitIdString, Windows } from '@shared' +import type { ServerData, UnitIdString } from '@shared' +import type { Windows } from '../../windows' const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows @@ -48,7 +49,8 @@ describe('ValueGenerator', () => { unitId: '1', registerType: 'holding_registers', address: 0 - }) + }), + 'serverView' ) gen.dispose() diff --git a/src/main/modules/littleBigEndian.md b/src/main/modules/littleBigEndian.md index 424ab81f..f58f1b10 100644 --- a/src/main/modules/littleBigEndian.md +++ b/src/main/modules/littleBigEndian.md @@ -12,7 +12,6 @@ A table showing the association between Modbus registers and the SCL variables ` ### Explanation: - **Big-Endian (BE)**: - - **Bytes** are stored as `12 34 56 78`, with the most significant bytes coming first. - In terms of words: - `W1` (most significant word) = `0x1234`. diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 442e4348..3ba8a08b 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -18,15 +18,21 @@ import { ScanUnitIDParameters, ScanUnitIDResult, Transaction, - Windows, WriteParameters } from '@shared' -import { WriteCoilResult, WriteMultipleResult, WriteRegisterResult } from 'modbus-serial/ModbusRTU' +import { Windows } from '../windows' +import { + ReadCoilResult, + ReadRegisterResult, + WriteCoilResult, + WriteMultipleResult, + WriteRegisterResult +} from 'modbus-serial/ModbusRTU' import round from 'lodash/round' import { DateTime } from 'luxon' import { v4 } from 'uuid' -type TryReadFn = (type: RegisterType, address: number, length: number) => Promise +type ReadRegisters = (address: number, length: number) => Promise type ScanUnitIdFn = ({ id, @@ -35,6 +41,18 @@ type ScanUnitIdFn = ({ registerTypes }: Omit & { id: number }) => Promise +/** + * What a write did with the port. + * + * A write refused before it goes out files no transaction, so refused and + * written are two answers rather than one `undefined`. A write that did go out + * names the transaction it filed, because the table holds the requests of + * everyone else too. + */ +type WriteAttempt = + | { sent: false } + | { sent: true; transactionIdKey: string; errorMessage: string | undefined } + /** * An exception reply rather than silence. * @@ -55,6 +73,30 @@ const toHexString = (bytes: Uint8Array | undefined): string => .map((byte) => Number(byte).toString(16).toUpperCase().padStart(2, '0')) .join(' ') +/** + * The `removeAllListeners` under a `ModbusRTU`. + * + * `index.js` has the class extend `EventEmitter`, and `ModbusRTU.d.ts` declares + * `on` and nothing else of the emitter, so taking listeners off again needs a + * type written here. Nothing inside modbus-serial listens on the client object, + * so what comes off is what this file put on. + */ +interface ModbusRTUEmitter extends ModbusRTU { + removeAllListeners(): void +} + +/** + * The two internals the transaction log reads. + * + * `ModbusRTU.d.ts` declares neither, so both arrive untyped and the shape is + * written here. `_transactions` is the table every request is filed in, and + * `_port._transactionIdWrite` is the key the next one files under. + */ +interface ModbusRTUInternals extends ModbusRTU { + _transactions: Record + _port: { _transactionIdWrite?: number } | undefined +} + export interface ClientParams { appState: AppState windows: Windows @@ -69,10 +111,12 @@ export class ModbusClient { connectState: 'disconnected', polling: false, scanningUnitIds: false, - scanningRegisters: false + scanningRegisters: false, + reading: false } private _pollTimeout: NodeJS.Timeout | undefined + private _pollGeneration = 0 private _totalScans = 1 private _scansDone = 1 @@ -85,8 +129,6 @@ export class ModbusClient { private _reconnectWasPolling = false private _reconnectResumePollingTimeout: NodeJS.Timeout | undefined - private _deliberateDisconnect = false - constructor({ appState, windows }: ClientParams) { this._client = new ModbusRTU() this._appState = appState @@ -131,46 +173,58 @@ export class ModbusClient { this._sendClientState() this._scheduleReconnect() } else { + // Every close that gets here is one the app did not ask for. + // modbus-serial takes its close relay off the port inside `close()`, + // so the close `disconnect` asks for reaches no handler. Measured on + // 8.0.25 over TCP, over a socat pty and on an Arduino's USB serial + // port. The other way out of `disconnect` is the timeout, and that + // one takes the handlers off itself, for the reason written there. this._clientState.connectState = 'disconnected' this._sendClientState() - if (!this._deliberateDisconnect) { - this._emitMessage({ - message: 'Connection closed unexpectedly', - variant: 'error', - error: null - }) - } else { - this._deliberateDisconnect = false - } + this._emitMessage({ + message: 'Connection closed unexpectedly', + variant: 'error', + error: null + }) } }) } // Events + /** + * 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) + 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)) } @@ -183,10 +237,58 @@ export class ModbusClient { } private _setDisconnected = (): void => { this._clientState.connectState = 'disconnected' + // 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 the `client_state` reporting the disconnect + // rather than at the end of the read in flight. They go before + // `stopPolling`, which sends one. + this.stopScanningUnitIds() + this.stopScanningRegisters() this.stopPolling() 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. + * + * One request at a time is what this class can promise, and each of the + * three loops puts one on the wire without asking. Before this, `read` + * refused a poll and not a scan, and nothing refused a write: the write + * action cell and the bitmap panel disable on `polling`, which is a button + * rather than an answer. + */ + private _readLoopOwner = (): string | undefined => { + if (this._clientState.polling) return 'a poll' + if (this._clientState.scanningUnitIds) return 'a unit id scan' + if (this._clientState.scanningRegisters) return 'a register scan' + if (this._clientState.reading) return 'another read' + return undefined + } + // // // Connect @@ -215,10 +317,40 @@ export class ModbusClient { }, this._reconnectDelay) } + /** + * Which connect the port that opens belongs to. + * + * `connect` awaits an open, and `disconnect` runs while it waits: the button + * is a Cancel for as long as the state is 'connecting'. Without this the + * cancelled connect resumes and writes 'connected' over the disconnect, on a + * port the user asked the app to let go of. + */ + private _connectGeneration = 0 + + /** + * Whether an open is on its way back. + * + * One `ModbusRTU` holds one port at a time, and `connect` assigns a new one + * into that slot on every attempt. A second attempt starting while the first + * is still opening would put its port where the first one is about to close, + * so the cancelled attempt would take the live connection down with it. The + * cancel itself is what opens that window: it ends on 'disconnected', which + * is a Connect button, while the port it cancelled is still opening. + */ + private _connectInFlight = false + // --- Override connect/disconnect to manage auto-reconnect --- public connect = async (): Promise => { + if (this._connectInFlight) { + this._emitMessage({ + message: 'Still finishing the connect you cancelled', + variant: 'warning', + error: null + }) + return + } + const generation = ++this._connectGeneration this._shouldAutoReconnect = true - this._deliberateDisconnect = false if (!this._reconnectTriggered) this._consecutiveReconnects = 0 if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout) this._reconnectTimeout = undefined @@ -242,22 +374,19 @@ export class ModbusClient { // Enables storing transaction requests and responses for logging purposes this._client['isDebugEnabled'] = true - // Connect - rtuOptions['autoOpen'] = true - + this._connectInFlight = true try { if (protocol === 'ModbusTcp') { - await this._client.connectTCP(host, tcpOptions) + // A copy, because `connectTCP` writes the client's own timeout into the + // options object it is handed. + await this._client.connectTCP(host, { ...tcpOptions }) } else if (protocol === 'ModbusRtuOverTcp') { // Encapsulated RTU: a full RTU frame (with CRC) sent raw over a TCP // socket, for serial-to-Ethernet gateways in transparent mode. // connectTelnet writes the RTU frame unchanged; connectTcpRTUBuffered // would instead rewrap it as MBAP (i.e. plain Modbus TCP), which is // not RTU over TCP. - await this._client.connectTelnet(host, { - port: tcpOptions.port, - timeout: tcpOptions.timeout - }) + await this._client.connectTelnet(host, { port: tcpOptions.port }) } else { await this._client.connectRTUBuffered(com, { baudRate: Number(rtuOptions.baudRate), @@ -266,6 +395,25 @@ export class ModbusClient { parity: rtuOptions.parity }) } + + if (generation !== this._connectGeneration) { + // A disconnect ran while this one was opening. `close` is what takes + // modbus-serial's relay off the port, so the port that just opened + // goes quiet as well as shut. Awaited, because the next connect opens + // the same path and a serial port that is still closing refuses it: + // `_connectInFlight` is cleared in the `finally` below, so what this + // waits for is what that flag promises. + await new Promise((resolve) => { + const giveUp = setTimeout(resolve, 5000) + this._client.close(() => { + clearTimeout(giveUp) + resolve() + }) + }) + this._reconnectTriggered = false + return + } + if (this._reconnectTriggered) { this._emitMessage({ message: `Reconnected over ${PROTOCOL_LABELS[protocol]}`, @@ -292,6 +440,10 @@ export class ModbusClient { }, 10000) this._setConnected() } catch (error) { + if (generation !== this._connectGeneration) { + this._reconnectTriggered = false + return + } const port = protocol === 'ModbusRtu' ? com : undefined this._emitMessage({ message: humanizeSerialError(error as Error, port), @@ -299,6 +451,8 @@ export class ModbusClient { error }) this._setDisconnected() + } finally { + this._connectInFlight = false } this._reconnectTriggered = false @@ -309,11 +463,9 @@ export class ModbusClient { // Disconnect private _disconnectTimeout: NodeJS.Timeout | undefined public disconnect = async (): Promise => { + const { protocol } = this._appState.connectionConfig this._shouldAutoReconnect = false - // Set before closing: a serial port emits 'close' during close(), which is - // sooner than the callback below, and the handler reads this flag to decide - // whether the close was ours. - this._deliberateDisconnect = true + this._connectGeneration++ this._consecutiveReconnects = 0 if (this._reconnectResetTimeout) clearTimeout(this._reconnectResetTimeout) if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout) @@ -334,8 +486,25 @@ export class ModbusClient { try { await new Promise((resolve) => { this._disconnectTimeout = setTimeout(() => { - this._client.destroy(() => { - const message = 'Disconnect timeout, client destroyed' + const abandoned = this._client as ModbusRTUEmitter + // What `destroy` leaves behind decides what this has to do itself. + // It destroys a socket, and returns a serial port untouched: + // `RTUBufferedPort` declares no `destroy`, so `ModbusRTU.destroy` + // takes the branch that only calls back, keeping the port open and + // its close relay on it. Taking the listeners off is what stops that + // port speaking to the client that replaces it, which it would do as + // a connection lost on a connection that is fine. + abandoned.removeAllListeners() + // modbus-serial's `_onError` emits on the client, and `destroy` + // leaves that relay on a serial port too, so a client with no + // `error` listener left would take the main process down with an + // unhandled `error` event the next time that port faults. + abandoned.on('error', () => {}) + abandoned.destroy(() => { + const message = + protocol === 'ModbusRtu' + ? 'Disconnect timeout, the port may stay open until Modbux closes' + : 'Disconnect timeout, the connection was dropped' this._emitMessage({ message, variant: 'warning', error: null }) resolve() this._client = new ModbusRTU() @@ -357,32 +526,45 @@ export class ModbusClient { } catch (error) { this._emitMessage({ message: (error as Error).message, variant: 'error', error: error }) - // ? Don't know what to do here, I think when there's an error we are not connected anymore + // The promise above takes `resolve` alone, so nothing rejects it and this + // runs only if `ModbusRTU.close` throws where it stands. That leaves a + // client whose close never started, which is a disconnect whatever the + // port went on to do: the state it would otherwise keep is `connected`, + // over a client nothing here can close. this._setDisconnected() } } + /** + * One read, and nothing else on the port until it has answered. + * + * `_read` puts one request per group on the line and waits for each, so two + * of them running at once is two masters on one bus. The read loops own the + * port through `_readLoopOwner`, and this makes a read in flight the fourth + * owner: the state goes out before the first request and comes back after the + * last, and the caller that finds it set is refused the way a caller during a + * poll is. The loops do not go through here, so a poll blocks a read without + * a read ever blocking a poll. + */ public read = async (): Promise => { - if (this._clientState.polling) { - this._emitMessage({ message: 'Already polling', variant: 'warning', error: null }) + const owner = this._readLoopOwner() + if (owner) { + this._emitMessage({ message: `Cannot read during ${owner}`, variant: 'warning', error: null }) return } - await this._read() + this._clientState.reading = true + this._sendClientState() + try { + await this._read() + } finally { + this._clientState.reading = false + this._sendClientState() + } } 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 @@ -393,17 +575,26 @@ export class ModbusClient { const { type, address, length } = this._appState.registerConfig - const configGroups = this._appState.readConfiguration - ? groupAddressInfos(this._appState.registerMapping?.[type]) - : [] + // Read configuration groups by data type, and a bit type carries none: the + // grid mounts the data type column for input and holding registers alone, + // so a comment is all it writes into a coil. A config file is another + // matter, because `RegisterMappingSchema` uses one object schema for all + // four types, and a data type it puts on a coil address is ignored here the + // way the toolbar button refuses it. + const groupable = type === 'input_registers' || type === 'holding_registers' + const configGroups = + this._appState.readConfiguration && groupable + ? groupAddressInfos(this._appState.registerMapping?.[type]) + : [] const groups = configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) for (const [groupIndex, [groupAddress, groupLength]] of groups.entries()) { // Per group: `_logTransaction` below runs whether the group threw or not, // so an errorMessage that outlives its group logs a clean group as failed. let errorMessage: string | undefined + const transactionIdKey = this._nextTransactionIdKey() try { - const rows = await this._tryRead(type, groupAddress, groupLength) + const rows = await this._readers[type](groupAddress, groupLength) rows.forEach((row) => { row.groupIndex = groupIndex }) @@ -445,7 +636,7 @@ export class ModbusClient { }) } } - this._logTransaction(errorMessage) + this._logTransaction(transactionIdKey, errorMessage) if (this._clientState.connectState !== 'connected') break } @@ -477,21 +668,36 @@ export class ModbusClient { // The key is a transaction id on TCP and UDP, and nothing at all on a // serial port: RTU has no transaction ids, so every serial transaction is // filed under the string "undefined". It is a map key, not a number. - private _logTransaction = (errorMessage: string | undefined): void => { - const rawTransactions = Object.entries(this._client['_transactions']) as [ - string, - RawTransaction - ][] - const lastTransaction = rawTransactions.at(-1) - if (!lastTransaction) return + private _internals = (): ModbusRTUInternals => this._client as ModbusRTUInternals - const [transactionIdKey, rawTransaction] = lastTransaction + /** + * The key modbus-serial files the next request under. + * + * A `writeFCx` reads `_port._transactionIdWrite` to file its transaction and + * the port increments it once the buffer is out, so this is the key of the + * request that goes next. Read it immediately before the call: nothing awaits + * in between, so nothing else can file first. + */ + private _nextTransactionIdKey = (): string => String(this._internals()._port?._transactionIdWrite) + + /** + * Log the request filed under `transactionIdKey`, and only that one. + * + * The caller names its own request because the table holds everyone else's. + * A write and a read loop overlap: `write` files its transaction, the poll + * the user then starts files a second, and taking the last entry logged the + * read as the write and took the read's entry with it. + */ + private _logTransaction = (transactionIdKey: string, errorMessage: string | undefined): void => { + const rawTransactions = this._internals()._transactions + const rawTransaction = rawTransactions[transactionIdKey] + if (!rawTransaction) return // Only the entry being logged, so the same one is not logged again on the // next call. Emptying the table takes entries for requests still in flight // with it, and `_onReceive` drops a response whose entry is gone, so the // request times out rather than resolving. - delete this._client['_transactions'][transactionIdKey] + delete rawTransactions[transactionIdKey] const transaction: Transaction = { id: `${transactionIdKey}__${v4()}`, @@ -514,98 +720,87 @@ export class ModbusClient { // // // Polling + /** + * Start a poll chain, unless one is already running. + * + * A chain is a read, a wait, and the next read, so a second chain is a second + * read on one connection. Both callers can arrive while a read is in flight: + * `start_polling` passes on whatever the renderer sends, and the reconnect + * resume fires a second after the connect that scheduled it. + */ public startPolling = (): void => { - clearTimeout(this._pollTimeout) + if (this._clientState.polling) return this._clientState.polling = true this._sendClientState() - this._poll() + this._poll(++this._pollGeneration) } public stopPolling = (): void => { clearTimeout(this._pollTimeout) + this._pollGeneration++ this._clientState.polling = false this._sendClientState() } - private _poll = async (): Promise => { - clearTimeout(this._pollTimeout) - if (!this._clientState.polling) return + /** + * Read, then arm the next read, as long as this chain is still the current one. + * + * `stopPolling` clears the handle a sleeping chain holds, and a chain that is + * awaiting a read holds none, because `_pollTimeout` is assigned after the + * await. It takes the generation instead: the read resolves into a number + * that is no longer current, and the chain ends there rather than arming a + * timer nothing can clear. + */ + private _poll = async (generation: number): Promise => { await this._read() - this._pollTimeout = setTimeout(this._poll, this._appState.registerConfig.pollRate) + if (generation !== this._pollGeneration) return + this._pollTimeout = setTimeout( + () => this._poll(generation), + this._appState.registerConfig.pollRate + ) } // // // Reading - private _tryRead: TryReadFn = async (type, address, length) => { - let data: RegisterData[] = [] - - switch (type) { - case 'coils': - data = await this._readCoils(address, length) - break - case 'discrete_inputs': - data = await this._readDiscreteInputs(address, length) - break - case 'input_registers': - data = await this._readInputRegisters(address, length) - break - case 'holding_registers': - data = await this._readHoldingRegisters(address, length) - break - } - - return data - } - - private _readCoils = async (address: number, length: number): Promise => { - const result = await this._client.readCoils(address, length) - return convertBitData( - result, - address, - this._appState.registerConfig.length, - this._clientState.scanningRegisters - ) - } - - private _readDiscreteInputs = async ( - address: number, - length: number - ): Promise => { - const result = await this._client.readDiscreteInputs(address, length) - return convertBitData( - result, - address, - this._appState.registerConfig.length, - this._clientState.scanningRegisters - ) + /** + * The read each register type asks for, and the rows behind its reply. + * + * `_read` and `_scanRegister` take the rows. The unit id scan takes the same + * four functions and reads only whether the call threw. Each entry reaches + * `this._client` when it runs rather than closing over it, because a + * disconnect that times out replaces the client. + */ + private _readers: Record = { + coils: async (address, length) => + this._toBits(await this._client.readCoils(address, length), address, length), + discrete_inputs: async (address, length) => + this._toBits(await this._client.readDiscreteInputs(address, length), address, length), + input_registers: async (address, length) => + this._toRegisters(await this._client.readInputRegisters(address, length), address), + holding_registers: async (address, length) => + this._toRegisters(await this._client.readHoldingRegisters(address, length), address) } - private _readInputRegisters = async ( - address: number, - length: number - ): Promise => { - const result = await this._client.readInputRegisters(address, length) - return convertRegisterData( - result, - address, - this._appState.registerConfig.littleEndian, - this._clientState.scanningRegisters - ) - } + /** + * One row per bit that was asked for. + * + * The length is the read's own, not `registerConfig.length`. modbus-serial + * answers a bit read with eight booleans per byte, so a read of three comes + * back as eight and the row count has to come from the request. The two are + * one number for a read out of the toolbar, where the only group is + * `[address, registerConfig.length]`. Every other caller passes its own. + */ + private _toBits = (result: ReadCoilResult, address: number, length: number): RegisterData[] => + convertBitData(result, address, length, this._clientState.scanningRegisters) - private _readHoldingRegisters = async ( - address: number, - length: number - ): Promise => { - const result = await this._client.readHoldingRegisters(address, length) - return convertRegisterData( + private _toRegisters = (result: ReadRegisterResult, address: number): RegisterData[] => + convertRegisterData( result, address, this._appState.registerConfig.littleEndian, this._clientState.scanningRegisters ) - } // // @@ -613,48 +808,67 @@ export class ModbusClient { // // Write public write = async (writeParameters: WriteParameters): Promise => { + // `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._requireConnected('write')) return + + const owner = this._readLoopOwner() + if (owner) { + this._emitMessage({ + message: `Cannot write during ${owner}`, + variant: 'warning', + error: null + }) + return + } + const { address, type, value, dataType, single } = writeParameters - let errorMessage: string | undefined + let attempt: WriteAttempt switch (type) { case 'coils': - errorMessage = await this._writeCoil(address, value, single) + attempt = await this._writeCoil(address, value, single) break case 'holding_registers': - errorMessage = await this._writeRegister(address, value, dataType, single) + attempt = await this._writeRegister(address, value, dataType, single) break } - // Log the write transaction. - this._logTransaction(errorMessage) + // Log the write transaction, and only the write's own. + if (attempt.sent) this._logTransaction(attempt.transactionIdKey, attempt.errorMessage) - // When specified, perform a read after writing the register. - if (!this._clientState.polling) this.read() + // Read back what the device now holds, unless a loop started during the + // write and is reading anyway. + if (!this._readLoopOwner()) this.read() } private _writeCoil = async ( address: number, value: boolean[], single: boolean - ): Promise => { + ): Promise => { const { unitId } = this._appState.connectionConfig + // The schema accepts an empty list, and neither function code can carry + // one. FC5 writes the first coil, which would be `undefined` on the wire. + // FC15 writes `array.length` into the frame as the quantity of coils, so + // an empty list asks a device to write none. + const [first] = value + if (first === undefined) { + this._emitMessage({ + message: 'No coil value to write', + variant: 'warning', + error: undefined + }) + return { sent: false } + } + + const transactionIdKey = this._nextTransactionIdKey() + try { if (single) { - // FC5 writes the first coil of the list, and the schema accepts an - // empty one, which would put `undefined` on the wire. - const [first] = value - if (first === undefined) { - this._emitMessage({ - message: 'No coil value to write', - variant: 'warning', - error: undefined - }) - return - } - - // Wrtie single coil await new Promise((resolve, reject) => this._client.writeFC5(unitId, address, first, (err, data) => { if (err) { @@ -664,7 +878,7 @@ export class ModbusClient { resolve(data) }) ) - return + return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple coils await new Promise((resolve, reject) => @@ -678,10 +892,10 @@ export class ModbusClient { ) } catch (error) { this._emitMessage({ message: (error as Error).message, variant: 'error', error }) - return (error as Error).message + return { sent: true, transactionIdKey, errorMessage: (error as Error).message } } - return undefined + return { sent: true, transactionIdKey, errorMessage: undefined } } private _writeRegister = async ( @@ -689,7 +903,7 @@ export class ModbusClient { value: number, dataType: BaseDataType, single: boolean - ): Promise => { + ): Promise => { const { littleEndian } = this._appState.registerConfig if (single && !['int16', 'uint16'].includes(dataType)) { @@ -698,11 +912,24 @@ export class ModbusClient { variant: 'warning', error: undefined }) - return + return { sent: false } + } + + // The dialog offers UTF-8 in the same list as the numbers, and a string is + // written from its characters rather than from the value field. Asking + // `createRegisters` for one wrote a single register of zero over it. + if (dataType === 'utf8' || dataType === 'none') { + this._emitMessage({ + message: `Modbux cannot write a value as ${dataType === 'utf8' ? 'UTF-8' : 'NONE'}`, + variant: 'warning', + error: undefined + }) + return { sent: false } } const { unitId } = this._appState.connectionConfig const registers = createRegisters(dataType, value, littleEndian) + const transactionIdKey = this._nextTransactionIdKey() try { if (single) { @@ -716,7 +943,7 @@ export class ModbusClient { resolve(data) }) ) - return + return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple registers await new Promise((resolve, reject) => @@ -730,25 +957,23 @@ export class ModbusClient { ) } catch (error) { this._emitMessage({ message: (error as Error).message, variant: 'error', error: error }) - return (error as Error).message + return { sent: true, transactionIdKey, errorMessage: (error as Error).message } } - return undefined + return { sent: true, transactionIdKey, errorMessage: undefined } } // // + // 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. // // 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._requireConnected('scan')) return + this.stopPolling() this._client.setTimeout(params.timeout) this._clientState.scanningUnitIds = true @@ -759,7 +984,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() @@ -787,70 +1016,24 @@ export class ModbusClient { } } - if (!this._clientState.scanningUnitIds) { - this._sendClientState() - return - } - - if (registerTypes.includes('coils')) { - // Coils - try { - await this._client.readCoils(address, length) - result.registerTypes.push('coils') - } catch (error) { - result.errorMessage['coils'] = (error as Error).message - if (isModbusException(error)) result.refusedRegisterTypes.push('coils') - } - await this._sendScanProgress() - } - - if (!this._clientState.scanningUnitIds) { - this._sendClientState() - return - } - - // Discrete Inputs - if (registerTypes.includes('discrete_inputs')) { - try { - await this._client.readDiscreteInputs(address, length) - result.registerTypes.push('discrete_inputs') - } catch (error) { - result.errorMessage['discrete_inputs'] = (error as Error).message - if (isModbusException(error)) result.refusedRegisterTypes.push('discrete_inputs') + for (const registerType of registerTypes) { + if (!this._clientState.scanningUnitIds) { + this._sendClientState() + return } - await this._sendScanProgress() - } - if (!this._clientState.scanningUnitIds) { - this._sendClientState() - return - } - // Input Registers - if (registerTypes.includes('holding_registers')) { + let errorMessage: string | undefined + const transactionIdKey = this._nextTransactionIdKey() try { - await this._client.readHoldingRegisters(address, length) - result.registerTypes.push('holding_registers') + await this._readers[registerType](address, length) + result.registerTypes.push(registerType) } catch (error) { - result.errorMessage['holding_registers'] = (error as Error).message - if (isModbusException(error)) result.refusedRegisterTypes.push('holding_registers') + errorMessage = (error as Error).message + result.errorMessage[registerType] = errorMessage + if (isModbusException(error)) result.refusedRegisterTypes.push(registerType) } - await this._sendScanProgress() - } + this._logTransaction(transactionIdKey, errorMessage) - if (!this._clientState.scanningUnitIds) { - this._sendClientState() - return - } - - // Holding Registers - if (registerTypes.includes('input_registers')) { - try { - await this._client.readInputRegisters(address, length) - result.registerTypes.push('input_registers') - } catch (error) { - result.errorMessage['input_registers'] = (error as Error).message - if (isModbusException(error)) result.refusedRegisterTypes.push('input_registers') - } await this._sendScanProgress() } @@ -869,14 +1052,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._requireConnected('scan')) return + this.stopPolling() const { unitId } = this._appState.connectionConfig this._client.setID(unitId) @@ -895,6 +1072,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)) } @@ -909,15 +1087,16 @@ export class ModbusClient { let data: RegisterData[] | undefined let errorMessage: string | undefined + const transactionIdKey = this._nextTransactionIdKey() try { - data = await this._tryRead(type, address, length) + data = await this._readers[type](address, length) } catch (error) { const readError = error as Error errorMessage = readError.message this._emitMessage({ message: errorMessage, variant: 'error', error }) } - this._logTransaction(errorMessage) + this._logTransaction(transactionIdKey, errorMessage) if (!data) return data = data.filter((row) => diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index fa5a2c73..3de84d75 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -13,14 +13,17 @@ import { AddRegisterParams, UnitIdString, UnitIdStringSchema, - BooleanRegisters, - NumberRegisters, - StartRtuServerParams + StartRtuServerParams, + RegisterType, + RegisterValue, + DataBits, + StopBits } from '@shared' import { ServerTCP, ServerSerial } from 'modbus-serial' -import { Windows } from '@shared' +import { ServerEndianness } from '@shared' +import { Windows } from '../windows' import { ValueGenerator } from './modbusServer/valueGenerator' -import type { IServiceVector, FCallbackVal } from 'modbus-serial' +import type { IServiceVector, FCallbackVal, FCallback } from 'modbus-serial' import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' import net from 'net' @@ -41,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 @@ -82,6 +85,51 @@ type ValueGeneratorsUnitMap = Map type ServerDataMap = Map type ValueGeneratorsMap = Map +/** + * One accessor shape per direction, because `IServiceVector`'s four getters and + * two setters differ only in the value they carry. + * + * These are written out rather than derived from `IServiceVector`, so what + * checks them is the assignment in `_getVector`. That catches a signature + * modbus-serial changes incompatibly and not one it widens, because a version + * that adds an optional parameter stays assignable to these. + */ +type IServiceVectorGet = (addr: number, unitID: number, cb: FCallbackVal) => void +type IServiceVectorSet = (addr: number, value: T, unitID: number, cb: FCallback) => void + +/** + * The serial port under a `ServerSerial`, with the events this file listens for. + * + * `serverserial.js` opens a `SerialPort` into `_serverPath` and returns it from + * `getPort()`. `ServerSerial.d.ts` declares neither, so reaching the port needs + * a type written here. + */ +interface RtuSerialPort { + on(event: 'error' | 'close', listener: (err?: Error) => void): void +} + +interface ServerSerialWithPort extends ServerSerial { + getPort(): RtuSerialPort +} + +/** + * `ServerSerial`'s third constructor argument, which its typings leave out. + * + * `serverserial.js` builds its own option object out of `path`, `baudRate`, + * `parity`, `debug`, `unitID` and `binding`, and assigns it over this one. So + * these two reach `SerialPort` by no other route, and the options object the + * typings do declare accepts them and drops them. Without them the binding + * opens at its own defaults, `dataBits: 8` and `stopBits: 1`. + */ +interface ServerSerialPortOptions { + dataBits: DataBits + stopBits: StopBits +} + +type ServerSerialConstructor = new ( + ...args: [...ConstructorParameters, ServerSerialPortOptions] +) => ServerSerial + export interface ServerParams { windows: Windows } @@ -96,10 +144,24 @@ 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 // 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() @@ -117,10 +179,13 @@ export class ModbusServer { * @param uuid - The server UUID * @returns The inner map for the UUID */ - private _ensureInnerMap(outerMap: Map, uuid: string): T { + private _ensureInnerMap( + outerMap: Map>, + uuid: string + ): Map { let inner = outerMap.get(uuid) if (!inner) { - inner = new Map() as T + inner = new Map() outerMap.set(uuid, inner) } return inner @@ -131,12 +196,12 @@ export class ModbusServer { * This vector provides all the Modbus register accessors and mutators. */ private _getVector = (uuid: string, transport: ServerTransport): IServiceVector => ({ - getCoil: this._getCoil(uuid, transport), - getDiscreteInput: this._getDiscreteInput(uuid, transport), - getInputRegister: this._getInputRegister(uuid, transport), - getHoldingRegister: this._getHoldingRegister(uuid, transport), - setCoil: this._setCoil(uuid, transport), - setRegister: this._setHoldingRegister(uuid, transport) + getCoil: this._get('coils', uuid, transport, false), + getDiscreteInput: this._get('discrete_inputs', uuid, transport, false), + getInputRegister: this._get('input_registers', uuid, transport, 0), + getHoldingRegister: this._get('holding_registers', uuid, transport, 0), + setCoil: this._set('coils', uuid, transport), + setRegister: this._set('holding_registers', uuid, transport) }) /** @@ -194,7 +259,7 @@ export class ModbusServer { * Helper to set server data for a unitId in the server data map. */ private _setServerData(uuid: string, unitId: UnitIdString, serverData: ServerData): void { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) + const perUnitMap = this._ensureInnerMap(this._serverData, uuid) perUnitMap.set(unitId, serverData) this._warnBroadcastUnit(uuid) } @@ -213,6 +278,11 @@ export class ModbusServer { /** * Emits a backend message to the frontend via the Windows IPC interface. */ + /** + * A server message goes to the window showing the server, which in split view + * is the popped out one. Broadcasting put "A server needs a port between 1 and + * 65535" in the window on the client view and in the one that asked. + */ private _emitMessage({ message, variant, @@ -222,7 +292,7 @@ export class ModbusServer { variant: 'default' | 'error' | 'success' | 'warning' | 'info' error?: Error }): void { - this._windows.send('backend_message', { message, variant, error }) + this._windows.send('backend_message', { message, variant, error }, 'serverView') } /** @@ -278,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' }), @@ -366,6 +428,7 @@ export class ModbusServer { this._disposeAllGenerators(unitIdGenerators) } this._generatorMap.delete(uuid) + this._littleEndian.delete(uuid) } /** @@ -387,12 +450,32 @@ 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) + } + + private _unitData = (uuid: string, unitId: UnitIdString): ServerData => { + const perUnitMap = this._ensureInnerMap(this._serverData, uuid) + const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() + if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + return serverData + } + /** * 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 => { + * + * 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, registerType, @@ -407,7 +490,7 @@ export class ModbusServer { } = params // Ensure generator map for this server and unitId - const perUnitGeneratorMap = this._ensureInnerMap( + const perUnitGeneratorMap = this._ensureInnerMap( this._generatorMap, uuid ) @@ -423,10 +506,14 @@ export class ModbusServer { generators?.delete(address) // Ensure server data map for this server and unitId - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() + const serverData = this._unitData(uuid, unitId) this._setServerData(uuid, unitId, serverData) + // `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 [] + // If a fixed value is provided, set the register directly const fixedValue = !interval && value !== undefined if (fixedValue) { @@ -437,16 +524,20 @@ 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, - raw: register - }) + this._windows.send( + 'register_value', + { + uuid, + unitId, + registerType, + address: registerAddress, + value: register + }, + 'serverView' + ) }) this._setServerData(uuid, unitId, serverData) - return + return registers } // Otherwise, add a value generator for this register @@ -469,6 +560,11 @@ export class ModbusServer { length }) ) + + // `ValueGenerator` writes its first value from its own constructor, so this + // 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) } /** @@ -483,9 +579,7 @@ export class ModbusServer { dataType, length }: RemoveRegisterParams): void => { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + const serverData = this._unitData(uuid, unitId) // Reset all registers occupied by this data type const registerCount = registerWidth(dataType, length) @@ -493,7 +587,7 @@ export class ModbusServer { serverData[registerType][address + i] = 0 } - const perUnitGeneratorMap = this._ensureInnerMap( + const perUnitGeneratorMap = this._ensureInnerMap( this._generatorMap, uuid ) @@ -512,8 +606,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 +621,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 }) } /** @@ -537,7 +630,7 @@ export class ModbusServer { */ public resetRegisters = ({ uuid, unitId, registerType }: ResetRegistersParams): void => { // Dispose and clear only generators for this unitId and registerType - const perUnitGeneratorMap = this._ensureInnerMap( + const perUnitGeneratorMap = this._ensureInnerMap( this._generatorMap, uuid ) @@ -548,9 +641,7 @@ export class ModbusServer { generators.clear() } - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + const serverData = this._unitData(uuid, unitId) serverData[registerType] = new Array(65536).fill(0) this._setServerData(uuid, unitId, serverData) } @@ -560,21 +651,21 @@ export class ModbusServer { * Updates the server data and emits a value change event. */ public setBool = ({ uuid, unitId, registerType, address, state }: SetBooleanParameters): void => { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + const serverData = this._unitData(uuid, unitId) serverData[registerType][address] = state this._setServerData(uuid, unitId, serverData) - this._windows.send('boolean_value', { uuid, unitId, registerType, address, value: state }) + this._windows.send( + 'register_value', + { uuid, unitId, registerType, address, value: state }, + 'serverView' + ) } /** * Resets all boolean values (coils or discrete inputs) for a given server and unitId. */ public resetBools = ({ uuid, unitId, registerType }: ResetBoolsParams): void => { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + const serverData = this._unitData(uuid, unitId) serverData[registerType] = new Array(65536).fill(false) this._setServerData(uuid, unitId, serverData) } @@ -584,9 +675,7 @@ export class ModbusServer { */ public syncBools = (params: SyncBoolsParameters): void => { const { uuid, unitId } = params - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + const serverData = this._unitData(uuid, unitId) params['coils'].forEach((value, index) => (serverData['coils'][index] = value)) params['discrete_inputs'].forEach((value, index) => { serverData['discrete_inputs'][index] = value @@ -594,6 +683,22 @@ export class ModbusServer { this._setServerData(uuid, unitId, serverData) } + /** + * Reports the RTU server down: the message, and the status the view reads. + * + * A generation that is not the current one belongs to a server `startRtuServer` + * has already replaced, and that server says nothing. An open still in flight + * is what gets here: `stopRtuServer` cannot close a port that never opened, + * because `SerialPortStream.close` takes its `!isOpen` branch and answers + * "Port is not open", so the open outlives the server it was started for. + */ + private _reportRtuDown(generation: number, message: string, error?: Error): void { + if (generation !== this._rtuGeneration) return + this._rtuActive = false + this._emitMessage({ message, variant: 'error', error }) + this._windows.send('rtu_server_status', { active: false }, 'serverView') + } + /** * Starts an RTU server on a serial port for the given UUID. * Closes any existing RTU server first. @@ -602,75 +707,76 @@ export class ModbusServer { if (!serialConfig.com.trim()) return await this.stopRtuServer() this._broadcastWarningSent = false + const generation = ++this._rtuGeneration try { // No unitID on purpose: passing one makes the library answer for that id // alone. Its default of 255 means "listen to all addresses", and the // vector filters, because only the vector knows which ids have data. - this._rtuServer = new ServerSerial(this._getVector(uuid, 'rtu'), { - path: serialConfig.com, - baudRate: Number(serialConfig.options.baudRate), - dataBits: serialConfig.options.dataBits as 8 | 7 | 6 | 5, - stopBits: serialConfig.options.stopBits as 1 | 2, - parity: serialConfig.options.parity ?? 'none' - }) + this._rtuServer = new (ServerSerial as ServerSerialConstructor)( + this._getVector(uuid, 'rtu'), + { + path: serialConfig.com, + baudRate: Number(serialConfig.options.baudRate), + parity: serialConfig.options.parity ?? 'none', + // `@serialport/stream`'s `_error` hands a failed open to this callback + // when one is passed and emits `error` on the port when none is. The + // same callback carries the success, with null in place of an error. + openCallback: (err): void => { + if (err) this._reportRtuDown(generation, `RTU server error: ${err.message}`) + } + }, + { + dataBits: serialConfig.options.dataBits, + stopBits: serialConfig.options.stopBits + } + ) this._rtuUuid = uuid - // The SerialPort under the server. Its `error` listener catches open - // failures, which would otherwise surface as an unhandled rejection. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const serverPath = (this._rtuServer as any)._serverPath - const rtuServer = this._rtuServer - if (serverPath && typeof serverPath.on === 'function') { - serverPath.on('error', (err: Error) => { - this._rtuActive = false - this._emitMessage({ - message: `RTU server error: ${err?.message ?? err}`, - variant: 'error' - }) - this._windows.send('rtu_server_status', { active: false }) - }) + const serverPort = (this._rtuServer as ServerSerialWithPort).getPort() - // `close` is the disconnect event. `@serialport/stream` documents it as - // "in the case of a disconnect it will be called with a Disconnect Error - // object", and its `_disconnected` answers a failed read with - // `close(undefined, new DisconnectedError(...))` while pushing nothing - // into the stream. So an adapter pulled between requests arrives here - // and nowhere else, and without this the view keeps showing a server - // whose port is gone. - serverPath.on('close', (err?: Error) => { - // A close this process caused is already reported. `stopRtuServer` - // clears both fields before it closes the port, and the `error` - // listener above clears `_rtuActive` for the write path, where one - // unplug emits both events. - if (this._rtuServer !== rtuServer || !this._rtuActive) return - this._rtuActive = false - this._emitMessage({ - message: `RTU server disconnected from ${serialConfig.com}`, - variant: 'error', - error: err - }) - this._windows.send('rtu_server_status', { active: false }) - }) - } + // A write to a port that is gone fails in `_write`, which disconnects the + // stream and calls back with the error, and a Writable given an error + // emits it. A failed open arrives in `openCallback` instead. + serverPort.on('error', (err) => { + this._reportRtuDown(generation, `RTU server error: ${err?.message ?? err}`) + }) + + // `close` is the disconnect event. `@serialport/stream` documents it as + // "in the case of a disconnect it will be called with a Disconnect Error + // object", and its `_disconnected` answers a failed read with + // `close(undefined, new DisconnectedError(...))` while pushing nothing + // into the stream. So an adapter pulled between requests arrives here + // and nowhere else, and without this the view keeps showing a server + // whose port is gone. + serverPort.on('close', (err) => { + // A close this process caused is already reported. `stopRtuServer` + // clears `_rtuActive` before it closes the port, and the `error` + // listener above clears it for the write path, where one unplug emits + // both events. A replaced server is refused on its generation instead. + if (!this._rtuActive) return + this._reportRtuDown(generation, `RTU server disconnected from ${serialConfig.com}`, err) + }) this._rtuServer.on('initialized', () => { + // A server that has already been replaced does not get to say it is up. + if (generation !== this._rtuGeneration) return this._rtuActive = true this._emitMessage({ 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) }) - this._rtuServer.on('error', (err) => { - this._rtuActive = false - this._emitMessage({ - message: `RTU server error: ${err?.message ?? err}`, - variant: 'error' - }) - this._windows.send('rtu_server_status', { active: false }) + // `socketError`, not `error`. `serverserial.js` emits `error` only from + // `sockWriter`'s `if (err)`, and the only caller of `sockWriter` is + // `_callbackFactory`, which passes null on both of its branches: it has + // turned the error into an exception frame by then. `socketError` is what + // a failure of the pipe under the server emits. + this._rtuServer.on('socketError', (err) => { + this._reportRtuDown(generation, `RTU server error: ${err?.message ?? err}`) }) } catch (err) { this._emitMessage({ @@ -690,8 +796,9 @@ export class ModbusServer { this._rtuServer = null this._rtuUuid = null 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' }) } @@ -784,155 +891,79 @@ export class ModbusServer { // ------------------------------------------------------------------------- /** - * Returns the value of a coil for a given address and unitId. - * Calls the callback with the value or a Modbus error. - */ - private _getCoil: (uuid: string, transport: ServerTransport) => IServiceVector['getCoil'] = - (uuid, transport) => async (address, unitIdNumber, cb) => { - const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) - if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) - // A broadcast is never acknowledged, so there is nothing to read from one. - if (this._isBroadcast(transport, unitId.data)) return - if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, false) - - const value = this._serverData.get(uuid)?.get(unitId.data)?.coils[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) - - cb(null, value) - } - - /** - * Returns the value of a discrete input for a given address and unitId. + * Returns the value of a register type for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getDiscreteInput: ( + private _get: ( + registerType: K, uuid: string, - transport: ServerTransport - ) => IServiceVector['getDiscreteInput'] = - (uuid, transport) => async (address, unitIdNumber, cb) => { + transport: ServerTransport, + fallback: ServerData[K][number] + ) => IServiceVectorGet = + (registerType, uuid, transport, fallback) => async (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) - if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) + if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, fallback) + // A broadcast is never acknowledged, so there is nothing to read from one. if (this._isBroadcast(transport, unitId.data)) return - if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, false) + if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, fallback) - const value = this._serverData.get(uuid)?.get(unitId.data)?.discrete_inputs[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) + const value = this._serverData.get(uuid)?.get(unitId.data)?.[registerType][address] + if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, fallback) cb(null, value) } /** - * Returns the value of an input register for a given address and unitId. - * Calls the callback with the value or a Modbus error. - */ - private _getInputRegister: ( - uuid: string, - transport: ServerTransport - ) => IServiceVector['getInputRegister'] = (uuid, transport) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) - if (this._isBroadcast(transport, unitIdSafe.data)) return - if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) - - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.input_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) - - cb(null, value) - } - - /** - * Returns the value of a holding register for a given address and unitId. - * Calls the callback with the value or a Modbus error. - */ - private _getHoldingRegister: ( - uuid: string, - transport: ServerTransport - ) => IServiceVector['getHoldingRegister'] = (uuid, transport) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) - if (this._isBroadcast(transport, unitIdSafe.data)) return - if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) - - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.holding_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) - - cb(null, value) - } - - /** - * Writes a coil into a unit this server hosts and tells the view. - */ - private _writeCoil(uuid: string, unitId: UnitIdString, address: number, value: boolean): void { - const serverData = this._serverData.get(uuid)?.get(unitId) - if (!serverData) return - serverData.coils[address] = value - - const registerType: BooleanRegisters = 'coils' - this._windows.send('boolean_value', { uuid, unitId, registerType, address, value }) - } - - /** - * Writes a holding register into a unit this server hosts and tells the view. + * Writes a coil or holding register into a unit this server hosts and tells the view. */ - private _writeHoldingRegister( + private _write( + registerType: K, uuid: string, unitId: UnitIdString, address: number, - raw: number + value: ServerData[K][number] ): void { const serverData = this._serverData.get(uuid)?.get(unitId) if (!serverData) return - serverData.holding_registers[address] = raw + serverData[registerType][address] = value - const registerType: NumberRegisters = 'holding_registers' - this._windows.send('register_value', { uuid, unitId, registerType, address, raw }) + this._windows.send( + 'register_value', + { + uuid, + unitId, + registerType, + address, + value + } as RegisterValue, + 'serverView' + ) } /** - * Sets the value of a coil for a given address and unitId. - * Updates the server data and emits a value change event. - */ - private _setCoil: (uuid: string, transport: ServerTransport) => IServiceVector['setCoil'] = - (uuid, transport) => async (address, value, unitIdNumber, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) - const unitId = unitIdSafe.data - - // A broadcast write reaches every unit on the bus and is never answered. - if (this._isBroadcast(transport, unitId)) { - for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) - this._writeCoil(uuid, hostedUnitId, address, value) - return - } - - if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) - - this._writeCoil(uuid, unitId, address, value) - cb(null) - } - - /** - * Sets the value of a holding register for a given address and unitId. + * Sets the value of a coil or holding register for a given address and unitId. * Updates the server data and emits a value change event. */ - private _setHoldingRegister: ( + private _set: ( + registerType: K, uuid: string, transport: ServerTransport - ) => IServiceVector['setRegister'] = - (uuid, transport) => async (address, raw, unitIdNumber, cb) => { + ) => IServiceVectorSet = + (registerType, uuid, transport) => async (address, value, unitIdNumber, cb) => { const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) const unitId = unitIdSafe.data + // A broadcast write reaches every unit on the bus and is never answered. if (this._isBroadcast(transport, unitId)) { for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) - this._writeHoldingRegister(uuid, hostedUnitId, address, raw) + this._write(registerType, uuid, hostedUnitId, address, value) return } if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) - this._writeHoldingRegister(uuid, unitId, address, raw) + this._write(registerType, uuid, unitId, address, value) cb(null) } diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index a234ecb8..395eea68 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -5,14 +5,14 @@ import { RegisterParamsGeneratorPart, RegisterParamsBasePart, ServerData, - BaseDataType, RegisterParams, RegisterValueGenerator, registerWidth, - UnitIdString + UnitIdString, + ValuedDataType } from '@shared' +import { Windows } from '../../windows' import { round } from 'lodash' -import { Windows } from '@shared' type ValueGeneratorParams = { uuid: string @@ -20,8 +20,11 @@ type ValueGeneratorParams = { windows: Windows serverData: ServerData littleEndian: boolean + // `modbusServer.addRegister` answers `none` before it gets here, because an + // address held open with nothing in it has nothing to generate. + dataType: ValuedDataType } & RegisterParamsGeneratorPart & - RegisterParamsBasePart + Omit /** * ValueGenerator generates and updates Modbus register values at a set interval. @@ -34,7 +37,7 @@ export class ValueGenerator implements RegisterValueGenerator { private _serverData: ServerData private _registerType: NumberRegisters private _address: number - private _dataType: BaseDataType + private _dataType: ValuedDataType private _min: number private _max: number private _littleEndian: boolean @@ -109,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, - raw: 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/modules/privilegedPort.ts b/src/main/modules/privilegedPort.ts index 5ea670c2..fe8a113d 100644 --- a/src/main/modules/privilegedPort.ts +++ b/src/main/modules/privilegedPort.ts @@ -6,7 +6,8 @@ import { PrivilegedPortFixResult, PrivilegedPortSandbox, PrivilegedPortStatus, - privilegedPortCommandArgs + privilegedPortCommandArgs, + UNPRIVILEGED_PORT_START_TARGET } from '@shared' /** @@ -154,8 +155,27 @@ export const applyPrivilegedPortFix = async ( } // Trust the kernel over the exit code: re-read what is actually in effect. + // A pkexec that exits 0 without lowering the floor used to produce a green + // snackbar and a server that still could not bind 502. const unprivilegedPortStart = await readUnprivilegedPortStart() + if (unprivilegedPortStart === undefined) { + return { + ok: false, + reason: 'failed', + message: `The command reported success, but ${UNPRIVILEGED_PORT_START_PATH} could not be read back. Run it in a terminal to see why.` + } + } + + if (unprivilegedPortStart > UNPRIVILEGED_PORT_START_TARGET) { + return { + ok: false, + reason: 'failed', + message: `The command reported success, but the lowest bindable port is still ${unprivilegedPortStart}. Run it in a terminal to see why.`, + unprivilegedPortStart + } + } + return { ok: true, message: diff --git a/src/main/state.ts b/src/main/state.ts index 80279c24..015f7193 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -32,15 +32,11 @@ export const withoutUndefined = (value: T): T => { return kept as T } -export interface State { - connectionConfig: ConnectionConfig - registerConfig: RegisterConfig - registerMapping?: RegisterMapping -} - export class AppState { - private _connectionConfig = defaultConnectionConfig - private _registerConfig = defaultRegisterConfig + // Copies, because a field initialiser that names an export makes main's state + // that export, until the first update replaces the tree. + private _connectionConfig = structuredClone(defaultConnectionConfig) + private _registerConfig = structuredClone(defaultRegisterConfig) private _registerMapping?: RegisterMapping private _readConfiguration = false diff --git a/src/main/windows.ts b/src/main/windows.ts new file mode 100644 index 00000000..51051e56 --- /dev/null +++ b/src/main/windows.ts @@ -0,0 +1,104 @@ +import { IpcEvent, IpcEventPayloadMap } from '@shared' +import { BrowserWindow, WebContents } from 'electron' + +interface WindowsObject { + main: BrowserWindow | null + 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 + + constructor() { + this._windows = { + main: null, + server: null + } + } + + /** + * Send an event to every open window, or to the one `to` names. + * + * 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 + * invoking contents to every handler, so a refused payload reports where it + * came from rather than everywhere. + * + * `'main'` is the window the client work happens in, which never moves. + * `'serverView'` is the window showing the server, which is the popped out + * one while it exists and the main window otherwise, the same question + * `PrivilegedPortModal` answers by where it is mounted. + * + * 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, + payload: IpcEventPayloadMap[E][0], + to?: IpcEventTarget + ): void => { + 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. + */ + } + } + } + + /** 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 [to] + } + + private _liveContents(window: BrowserWindow | null): WebContents[] { + if (!window || window.isDestroyed() || !window.webContents) return [] + return [window.webContents] + } + + // Main window access + get main(): BrowserWindow | null { + return this._windows.main + } + set main(main) { + this._windows.main = main + this._sendUpdate() + } + + // Server window access + get server(): BrowserWindow | null { + return this._windows.server + } + set server(server) { + this._windows.server = server + this._sendUpdate() + } + + private _sendUpdate(): void { + this.send('window_update', { + main: !!this._windows.main, + server: !!this._windows.server + }) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b5558cd0..f26aa729 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,34 +1,16 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import { IPC_CHANNELS, IpcHandlerMap, snakeToCamel } from '@shared' +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') -/** - * Set by the e2e fixture, never in a shipped build. - * - * The DataGrid virtualises both axes, so a column far enough right or a row - * far enough down is not in the DOM and a spec's locator matches nothing. How - * much fits depends on the screen the window landed on, which made the suite - * pass on one machine and fail on another for the same commit. - */ -const isE2e = process.env.MODBUX_E2E === '1' - -export const ipcInvoke = ( +const ipcInvoke = ( channel: C, ...args: IpcHandlerMap[C]['args'] ): Promise => { return ipcRenderer.invoke(channel, ...args) } -type CamelCase = S extends `${infer Head}_${infer Tail}` - ? // If there's an underscore, concatenate Head with Capitalize of the next segment, - // then recursively process the remainder. - `${Head}${Capitalize>}` - : // If no underscore remains, simply return S. - S - /** * AUTOMATIC IPC HANDLER GENERATION * @@ -52,7 +34,7 @@ type CamelCase = S extends `${infer Head}_${infer Tail}` const handlers = Object.fromEntries( (Object.values(IPC_CHANNELS) as Array).map((channelName) => { // channelName is a string like "update_register_config" - const methodName = snakeToCamel(channelName) as CamelCase + const methodName = snakeToCamel(channelName) return [ methodName, ( @@ -79,7 +61,6 @@ type Handlers = typeof handlers */ export type Api = { isServerWindow: boolean - isE2e: boolean } & Handlers /** @@ -89,23 +70,14 @@ export type Api = { */ const api = { isServerWindow, - isE2e, ...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/index.html b/src/renderer/index.html index 7cd9be76..0b644f8d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,19 +1,19 @@ + + + + + + + + - - - - - - - - - - -
- - - - \ No newline at end of file + +
+ + + 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/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 7268b3c6..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 @@ -148,6 +148,4 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen ) }) -export const BITMAP_DETAIL_HEIGHT = 220 // 4 rows × ~52px card + container padding + border - export default BitMapDetailPanel 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.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow.tsx new file mode 100644 index 00000000..ec5d3d41 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow.tsx @@ -0,0 +1,73 @@ +import { GridRow, GridRowProps } from '@mui/x-data-grid/components' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useBitMapZustand } from '@renderer/context/bitmap.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' +import { BITMAP_DATATYPE } from '@shared' +import { useEffect, useRef } from 'react' +import BitMapDetailPanel from './BitMapDetailPanel/BitMapDetailPanel' + +// ───────────────────────────────────────────────────────────────────────────── +// BitMapRow, used as `slots.row` in the DataGrid. +// +// Normal rows render as GridRow unchanged. A bitmap row wraps GridRow and the +// detail panel in one div, and reports what the panel measures so the grid can +// give the row a height that holds it. Before that, the grid's row positions +// were multiples of its fixed rowHeight and the panel's height reached none of +// them: with virtualisation on, the rows below the expanded one sat under the +// ones above them, and the last of them was out of scroll reach by exactly the +// panel's height. +// ───────────────────────────────────────────────────────────────────────────── + +const BitMapRow = meme((props: GridRowProps): JSX.Element => { + const address = props.rowId as number + + const expandedAddress = useBitMapZustand((z) => z.expandedAddress) + const isExpanded = expandedAddress === address + + const isBitmap = + useClientZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === + BITMAP_DATATYPE + + const panelRef = useRef(null) + + // The panel is four rows of bit cards above 560px of container width and + // eight below, so its height is a measurement rather than a constant. The + // observer keeps it true across a window resize as well as a first render. + useEffect(() => { + const panel = panelRef.current + if (!panel) return + + const observer = new ResizeObserver(([entry]) => { + if (!entry) return + useBitMapZustand.getState().setDetailHeight(entry.contentRect.height) + }) + observer.observe(panel) + + return (): void => observer.disconnect() + }, [isBitmap, isExpanded]) + + if (!isBitmap) { + // Fast path: render as a normal row, no overhead. + return + } + + // No style on the wrapper: `renderRow` in @mui/x-virtualizer hands the row + // slot none, so `props.style` is undefined and reading it here only suggests + // the wrapper carries the scroller's geometry. + return ( +
+ {/* The row is held at its own height by the expanded-row rule in + RegisterGrid's sx: MUI writes min-height, max-height and --height + from what getRowHeight answered, over anything passed in style. */} + + + {isExpanded && ( +
+ +
+ )} +
+ ) +}) + +export default BitMapRow diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx deleted file mode 100644 index efc468f0..00000000 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { GridRow, GridRowProps } from '@mui/x-data-grid/components' -import { meme } from '@renderer/components/shared/inputs/meme' -import { useBitMapZustand } from '@renderer/context/bitmap.zustand' -import { useClientZustand } from '@renderer/context/client.zustand' -import { BITMAP_DATATYPE } from '@shared' -import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' - -// ───────────────────────────────────────────────────────────────────────────── -// BitMapRow – used as `slots.row` in the DataGrid. -// Normal rows render as GridRow unchanged. -// Bitmap rows wrap GridRow + BitMapDetailPanel in an outer div that owns -// the virtual-scroller height slot when expanded. -// ───────────────────────────────────────────────────────────────────────────── - -const BitMapRow = meme((props: GridRowProps): JSX.Element => { - const address = props.rowId as number - - const expandedAddress = useBitMapZustand((z) => z.expandedAddress) - const isExpanded = expandedAddress === address - - const isBitmap = - useClientZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === - BITMAP_DATATYPE - - if (!isBitmap) { - // Fast path: render as normal row, no overhead - return - } - - // For bitmap rows the outer div owns the height slot from the virtual scroller. - // We strip the position style from GridRow so it renders naturally (at rowHeight) - // inside the outer div instead of repositioning itself absolutely. - const { style, ...rowProps } = props - - return ( -
- {/* Override --height CSS var + min/max-height so MUI class rules don't stretch the row */} - - - {/* Detail panel in the space below the row cells */} - {isBitmap && isExpanded && ( -
- -
- )} -
- ) -}) - -export default BitMapRow diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index 74807e32..c5ec2341 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -10,11 +10,18 @@ import RegisterGridToolbar from './RegisterGridToolbar/RegisterGridToolbar' import { useGridApiRef } from '@mui/x-data-grid' import { DataGrid } from '@mui/x-data-grid/DataGrid' import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' -import { GridFilterModel, GridLogicOperator } from '@mui/x-data-grid/models' -import { DataType, RegisterData } from '@shared' +import { + GridFilterModel, + GridLogicOperator, + GridRowHeightParams, + GridRowHeightReturnValue +} from '@mui/x-data-grid/models' +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' // // // @@ -55,6 +62,34 @@ const RegisterGridContent = meme((): JSX.Element => { // a cell put into edit mode or a column menu opened over data that is still // arriving is a fight nobody wins. Scrolling and paging stay. const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + + // An expanded bitmap row is taller by whatever its detail panel measures, and + // the grid places every row below it from this answer. + // + // An address stops being a bitmap while its panel is open: the type cell is + // editable, the register type switches, a config loads over it. `BitMapRow` + // takes its fast path then and draws no panel, so the height has to go with + // it or an empty gap is left where no control remains to close it. + const expandedAddress = useBitMapZustand((z) => z.expandedAddress) + const detailHeight = useBitMapZustand((z) => z.detailHeight) + const expandedBitmap = + expandedAddress !== null && registerMapping[expandedAddress]?.dataType === BITMAP_DATATYPE + ? expandedAddress + : null + + // `null` is the grid's own word for "use rowHeight". + const getRowHeight = useCallback( + ({ id, densityFactor }: GridRowHeightParams): GridRowHeightReturnValue => + id === expandedBitmap ? ROW_HEIGHT * densityFactor + detailHeight : null, + [expandedBitmap, detailHeight] + ) + + // The grid caches what `getRowHeight` answered, and a new function alone does + // not tell it to ask again. + useEffect(() => { + apiRef.current?.resetRowHeights() + }, [apiRef, expandedBitmap, detailHeight]) + const prevReadConfigRef = useRef(readConfiguration) useEffect(() => { const filterModel: GridFilterModel = { @@ -106,12 +141,13 @@ const RegisterGridContent = meme((): JSX.Element => { return ( { disableColumnFilter={readConfiguration} autoHeight={false} density="compact" - rowHeight={40} + rowHeight={ROW_HEIGHT} + getRowHeight={getRowHeight} columnHeaderHeight={48} hideFooterPagination - getRowClassName={(params) => ((params.row as RegisterData).error ? 'register-error-row' : '')} + getRowClassName={(params) => + [ + (params.row as RegisterData).error ? 'register-error-row' : '', + params.id === expandedBitmap ? 'bitmap-expanded-row' : '' + ] + .filter(Boolean) + .join(' ') + } editMode="cell" isCellEditable={({ colDef: { field }, row: { id } }) => { if (scanning) return false if (field === 'comment') return true - const scalingEnabledDataTypes: DataType[] = [ - 'double', - 'float', - 'int16', - 'int32', - 'int64', - 'uint16', - 'uint32', - 'uint64' - ] const dataType = registerMapping[id]?.dataType ?? 'none' - if (field === 'scalingFactor' && !scalingEnabledDataTypes.includes(dataType)) { + if (field === 'scalingFactor' && !scalableDataTypes.includes(dataType)) { return false } @@ -162,6 +196,16 @@ const RegisterGridContent = meme((): JSX.Element => { height: 36, overflow: 'hidden' }, + // `getRowHeight` answers for the row and its panel together, and MUI + // writes that height onto the row element itself, over anything passed + // in its style. The panel is a sibling of the row inside the slot, so + // without this it is counted twice and the slot comes out that much + // too tall. + '& .bitmap-expanded-row': { + minHeight: `${COMPACT_ROW_HEIGHT}px !important`, + maxHeight: `${COMPACT_ROW_HEIGHT}px !important`, + '--height': `${COMPACT_ROW_HEIGHT}px !important` + }, '& .register-error-row': { backgroundColor: alpha(theme.palette.error.main, 0.08), '&:hover': { 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 94% 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 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.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/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 98% 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 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.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/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx similarity index 94% 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 58e481ef..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 { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' +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 245653e9..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,16 +1,21 @@ -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' +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, { SetAnchorProps } from './ScanRegistersButton/ScanRegistersButton' -import { ScanUnitIdsButton } from './ScanUnitIds/ScanUnitIds' +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' import Popover from '@mui/material/Popover' +/** What a menu entry needs to close the menu it was pressed in. */ +export interface SetAnchorProps { + setAnchor: (anchor: HTMLDivElement | null) => void +} + const MenuContent = meme(({ setAnchor }: SetAnchorProps) => { return ( 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 83% 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 035ad77d..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 @@ -1,12 +1,9 @@ import Button from '@mui/material/Button' -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' +import { useScanRegistersZustand } from '@renderer/components/client/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' - -export interface SetAnchorProps { - setAnchor: (anchor: HTMLDivElement | null) => void -} +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.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx new file mode 100644 index 00000000..64ad3237 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx @@ -0,0 +1,32 @@ +import Button from '@mui/material/Button' +import { useScanUnitIdZustand } from '@renderer/components/client/ScanUnitIds/scanUnitIds.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useCallback } from 'react' +import type { SetAnchorProps } from './MenuButton' + +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 ( + + ) +}) + +export default ScanUnitIdsButton 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..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 @@ -13,10 +13,10 @@ 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' +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 @@ -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/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 50% 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 index 3de969d5..2491022a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx @@ -1,27 +1,22 @@ import Button, { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' -import { useCallback, useRef, useState } from 'react' +import { useCallback } from 'react' const ReadButton = meme((): JSX.Element => { - const disabled = useClientZustand( - (z) => z.clientState.connectState !== 'connected' || z.clientState.polling - ) + const connected = useClientZustand((z) => z.clientState.connectState === 'connected') + const polling = useClientZustand((z) => z.clientState.polling) - const [reading, setReading] = useState(false) - const readingRef = useRef(false) + // Main says a read is running, and refuses a second one while it is. This + // used to be a ref here, which held for this button and for nothing else. + const reading = useClientZustand((z) => z.clientState.reading) - // Read registers, prevent sending the command until the read is done - const handleRead = useCallback(async () => { - if (readingRef.current) return - readingRef.current = true - setReading(true) - await window.api.read() - readingRef.current = false - setReading(false) + const handleRead = useCallback(() => { + window.api.read() }, []) const color: ButtonProps['color'] = reading ? 'warning' : 'primary' + const disabled = !connected || polling || reading 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 94% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/_columns.tsx rename to src/renderer/src/components/client/ScanUnitIds/_columns.tsx index 5f23022b..205e7ab7 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/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/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/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index a2fc6173..364be315 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -6,9 +6,10 @@ import DialogContent from '@mui/material/DialogContent' import DialogTitle from '@mui/material/DialogTitle' import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' +import { blockedReason, reportFixResult } from '@renderer/components/shared/linuxFix' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' -import { SerialGroupStatus, serialGroupCommandDisplay } from '@shared' +import { serialGroupCommandDisplay } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' import { useSerialGroupZustand } from './serialGroupModal.zustand' @@ -35,18 +36,8 @@ import { useSerialGroupZustand } from './serialGroupModal.zustand' * at install time. */ -/** Why Modbux cannot run the command itself, or null when it can. */ -const blockedReason = (status: SerialGroupStatus | null): string | null => { - if (!status) return null - if (status.sandbox) { - const name = status.sandbox === 'flatpak' ? 'Flatpak' : 'Snap' - return `Modbux is running inside ${name}, so it cannot change your groups itself.` - } - if (!status.canElevate) { - return 'pkexec is not installed, so Modbux cannot ask for permission itself.' - } - return null -} +/** What the two modals differ in, filling "so it cannot change ... itself". */ +const WHAT_IT_CHANGES = 'your groups' /** A no, kept for as long as the window lives. Not a hook: nothing subscribes. */ const decline = (): void => { @@ -100,7 +91,7 @@ const Explanation = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) const username = useSerialGroupZustand((z) => z.status?.username) // A string or null, so it compares by value like any other primitive. - const blocked = useSerialGroupZustand((z) => blockedReason(z.status)) + const blocked = useSerialGroupZustand((z) => blockedReason(z.status, WHAT_IT_CHANGES)) return ( <> @@ -146,7 +137,7 @@ const NotNowButton = meme((): JSX.Element => { const RunCommandButton = meme((): JSX.Element | null => { const busy = useSerialGroupZustand((z) => z.busy) - const blocked = useSerialGroupZustand((z) => blockedReason(z.status)) + const blocked = useSerialGroupZustand((z) => blockedReason(z.status, WHAT_IT_CHANGES)) const { enqueueSnackbar } = useSnackbar() const apply = useCallback(async (): Promise => { @@ -154,8 +145,7 @@ const RunCommandButton = meme((): JSX.Element | null => { setBusy(true) try { const result = await window.api.applySerialGroupFix() - enqueueSnackbar({ message: result.message, variant: result.ok ? 'success' : 'warning' }) - if (result.ok) setDone(true) + if (reportFixResult(result, enqueueSnackbar)) setDone(true) } catch { enqueueSnackbar({ message: 'Could not change your groups', variant: 'error' }) } finally { @@ -255,13 +245,9 @@ const SerialGroupModal = meme(({ active }: SerialGroupModalProps): JSX.Element | if (!active) return let cancelled = false - const ask = async (): Promise => { - const opened = await useSerialGroupZustand.getState().check() - // Switching back to TCP while the answer was in flight should not land - // a serial question on the TCP tab. - if (opened && cancelled) useSerialGroupZustand.getState().setOpen(false) - } - ask() + // Switching back to TCP while the answer was in flight should not land a + // serial question on the TCP tab. + useSerialGroupZustand.getState().check({ isStale: () => cancelled }) return (): void => { cancelled = true diff --git a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx index c5aa85a2..fb74494f 100644 --- a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx @@ -254,7 +254,7 @@ describe('SerialGroupModal', () => { await waitFor(() => expect(screen.queryByTestId('serial-group-modal')).not.toBeInTheDocument()) // What the Connect button does before it tries the port. - const opened = await useSerialGroupZustand.getState().check(true) + const opened = await useSerialGroupZustand.getState().check({ force: true }) expect(opened).toBe(true) expect(await screen.findByTestId('serial-group-modal')).toBeInTheDocument() diff --git a/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts b/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts index 7ef1a114..255c497d 100644 --- a/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts +++ b/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts @@ -25,8 +25,14 @@ interface SerialGroupZustand { * Resolves true when it opened, so Connect can hold off rather than fail on * a permission error. `force` is for that press: a no said to an unprompted * question should not silence the answer to something you just asked for. + * + * `isStale` is asked once, after the answer is in and before anything is + * written. A caller whose reason for asking went away while the round trip + * was out says so there: opening and then closing again is a flash on + * screen, and an older call closing what a newer one opened would leave the + * question unasked for the rest of the session. */ - check: (force?: boolean) => Promise + check: (options?: { force?: boolean; isStale?: () => boolean }) => Promise } export const useSerialGroupZustand = create( @@ -56,11 +62,12 @@ export const useSerialGroupZustand = create { state.declined = declined }), - check: async (force = false) => { + check: async ({ force = false, isStale } = {}) => { const { declined, setStatus, setDone, setOpen } = get() if (declined && !force) return false try { const result = await window.api.getSerialGroupStatus() + if (isStale?.()) return false if (!result.needsMembership && !result.pendingLogin) { // Close rather than return: the store outlives a remount, so a stale // open would otherwise keep an answered question on screen. diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear.tsx similarity index 79% rename from src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx rename to src/renderer/src/components/server/OpenSaveClear.tsx index 7b043c73..26f7817d 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear.tsx @@ -3,11 +3,17 @@ import FileOpen from '@mui/icons-material/FileOpen' import Save from '@mui/icons-material/Save' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' +import { downloadJson } from '@renderer/components/shared/downloadJson' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, migrateServerConfig } from '@shared' -import { ServerConfig, ServerRegistersPerUnit, UnitIdStringSchema } from '@shared' +import { + CURRENT_SERVER_CONFIG_VERSION, + ServerConfig, + ServerRegistersPerUnit, + UnitIdStringSchema +} from '@shared' import { snakeCase } from 'lodash' import { useSnackbar } from 'notistack' import { useRef, useState, useCallback } from 'react' @@ -36,15 +42,10 @@ const useOpen: UseOpenHook = () => { const serverZustand = useServerZustand.getState() - // Reset the server before opening a new configuration - // This way we can ensure that the server is in a clean state - // When unitId's are configured which are not present in the file - // there would be remaining registers in the server because - // they are now overwritten - await window.api.resetServer(serverZustand.selectedUuid) - // Also clean the zustand state - // to ensure that the state is in a clean state - serverZustand.clean(serverZustand.selectedUuid) + // A unit the file does not name is not written over on the way in, so + // whatever the previous config left on it would answer a master after + // this one is loaded. Reset takes both sides down to nothing first. + await serverZustand.resetServer(serverZustand.selectedUuid) const content = await file.text() @@ -64,7 +65,6 @@ const useOpen: UseOpenHook = () => { const hasConfig = checkHasConfig(serverRegisters) if (!hasConfig) continue serverZustand.replaceServerRegisters(unitId, serverRegisters) - await new Promise((r) => setTimeout(r, 1)) } // Show success notification @@ -144,24 +144,13 @@ const useSave: UseSaveHook = () => { const modbuxVersion = useLayoutZustand.getState().version const config: ServerConfig = { - version: 2, + version: CURRENT_SERVER_CONFIG_VERSION, modbuxVersion, name, littleEndian: littleEndian[selectedUuid] ?? false, serverRegistersPerUnit } - const configJson = JSON.stringify(config, null, 2) - - const element = document.createElement('a') - element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(configJson)) - - const filename = `modbux_server_${snakeCase(name)}.json` - - element.setAttribute('download', filename) - element.style.display = 'none' - document.body.appendChild(element) - element.click() - document.body.removeChild(element) + downloadJson(`modbux_server_${snakeCase(name)}.json`, JSON.stringify(config, null, 2)) }, []) return { save } @@ -177,15 +166,7 @@ const OpenSaveClear = meme(() => { const clear = useCallback(async () => { const serverZustand = useServerZustand.getState() serverZustand.setName('') - // Reset the server before opening a new configuration - // This way we can ensure that the server is in a clean state - // When unitId's are configured which are not present in the file - // there would be remaining registers in the server because - // they are now overwritten - await window.api.resetServer(serverZustand.selectedUuid) - // Also clean the zustand state - // to ensure that the state is in a clean state - serverZustand.clean(serverZustand.selectedUuid) + await serverZustand.resetServer(serverZustand.selectedUuid) }, []) return ( diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index 9c9296e5..d40b61b7 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -10,11 +10,11 @@ import ToggleButton from '@mui/material/ToggleButton' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' +import { blockedReason, reportFixResult } from '@renderer/components/shared/linuxFix' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' import { PrivilegedPortFixMode, - PrivilegedPortStatus, privilegedPortCommandDisplay, UNPRIVILEGED_PORT_START_TARGET } from '@shared' @@ -36,28 +36,8 @@ import { usePrivilegedPortZustand } from './privilegedPortModal.zustand' * boundary. That is the user's call to make, not ours to make quietly. */ -/** Remembered across restarts — a user who says no once should not be nagged. */ -const DISMISS_KEY = 'privilegedPortPromptDismissed' - -/** Why Modbux cannot run the command itself, or null when it can. */ -const blockedReason = (status: PrivilegedPortStatus | null): string | null => { - if (!status) return null - if (status.sandbox) { - const name = status.sandbox === 'flatpak' ? 'Flatpak' : 'Snap' - return `Modbux is running inside ${name}, so it cannot change system settings itself.` - } - if (!status.canElevate) { - return 'pkexec is not installed, so Modbux cannot ask for permission itself.' - } - return null -} - -/** Closes, remembering the answer when asked to. Not a hook: nothing subscribes. */ -const close = (): void => { - const { dontAsk, setOpen } = usePrivilegedPortZustand.getState() - if (dontAsk) localStorage.setItem(DISMISS_KEY, 'true') - setOpen(false) -} +/** What the two modals differ in, filling "so it cannot change ... itself". */ +const WHAT_IT_CHANGES = 'system settings' // // @@ -125,7 +105,7 @@ const ModeToggle = meme((): JSX.Element => { // The command, which follows the toggle so the two cannot drift apart const Command = meme((): JSX.Element => { const mode = usePrivilegedPortZustand((z) => z.mode) - const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) + const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status, WHAT_IT_CHANGES)) // With no button to press, the terminal instructions are the lasting fix. return ( @@ -167,7 +147,7 @@ const DontAskCheckbox = meme((): JSX.Element => { // // Body const Body = meme((): JSX.Element => { - const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) + const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status, WHAT_IT_CHANGES)) return ( <> @@ -201,6 +181,7 @@ const Body = meme((): JSX.Element => { // Buttons const CancelButton = meme((): JSX.Element => { const busy = usePrivilegedPortZustand((z) => z.busy) + const close = usePrivilegedPortZustand.getState().close return (