diff --git a/binary-versions.json b/binary-versions.json index 53d60cdb5..d43af3c10 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -1,6 +1,6 @@ { "strucpp": { - "version": "v0.6.1", + "version": "v0.6.2", "repository": "Autonomy-Logic/STruCpp" } } diff --git a/configs/webpack/webpack.config.renderer.dev.ts b/configs/webpack/webpack.config.renderer.dev.ts index 7da2db258..82d5857bb 100644 --- a/configs/webpack/webpack.config.renderer.dev.ts +++ b/configs/webpack/webpack.config.renderer.dev.ts @@ -173,6 +173,11 @@ const configuration: webpack.Configuration = { // `npm run dev` to point at staging or localhost: // `VPP_CATALOG_URL=http://localhost:3333 npm run dev` VPP_CATALOG_URL: '', + // Same mechanism for the Edge WEB app (the `/buy` license page), which is + // a DIFFERENT origin from the API above. Falls back to the production + // host hardcoded in `system-adapter.ts` when unset: + // `OPENPLC_EDGE_WEB_URL=http://localhost:5173 npm run dev` + OPENPLC_EDGE_WEB_URL: '', }), new webpack.DefinePlugin({ diff --git a/configs/webpack/webpack.config.renderer.prod.ts b/configs/webpack/webpack.config.renderer.prod.ts index 4cbf7f713..736450039 100644 --- a/configs/webpack/webpack.config.renderer.prod.ts +++ b/configs/webpack/webpack.config.renderer.prod.ts @@ -141,6 +141,10 @@ const configuration: webpack.Configuration = { // (`https://api.autonomylogic.com`) wins. Local/staging // builds can prepend `VPP_CATALOG_URL=...` to override. VPP_CATALOG_URL: '', + // Same for the Edge WEB app host (the `/buy` license page) — a different + // origin from the API above. Unset in release builds → `system-adapter.ts` + // falls back to https://edge.autonomylogic.com. + OPENPLC_EDGE_WEB_URL: '', }), new MiniCssExtractPlugin({ diff --git a/docs/version-compatibility-strategy.md b/docs/version-compatibility-strategy.md new file mode 100644 index 000000000..99304a838 --- /dev/null +++ b/docs/version-compatibility-strategy.md @@ -0,0 +1,473 @@ +# Editor + Runtime + VPP Version Compatibility — Strategy + +> Status: **agreed design, implemented** (Marcone + Thiago Alves, 2026-08-05) +> Jira: [DOPE-448](https://autonomylogic.atlassian.net/browse/DOPE-448) (epic DOPE-317 — VPP in the Editor) +> Scope: `openplc-editor` + `openplc-web` (shared surface — byte-identical), +> `openplc-runtime`, `openplc-packages` +> Shipped in: openplc-editor#993 · openplc-runtime#163 · openplc-packages#28 · +> openplc-web#652 — see §8 for what each one covers. + +**Reading this later:** §1 is the design and stays true. **§2 is a snapshot of +the state _before_ this work, dated 2026-08-05** — it describes gaps the PRs +above have since closed, and is kept because the reasoning only makes sense +against what it was fixing. §8 marks what landed. Do not read §2 as a to-do +list. + +## 1. The decision + +Three declarations, one comparer. + +| Component | Declares | Where it lives | +| ----------- | ---------------------------------------- | ---------------------------------------------- | +| **VPP** | `minEditorVersion` + `minRuntimeVersion` | `manifest.json` → `package` (openplc-packages) | +| **Runtime** | `minEditorVersion` | new `GET /api/capabilities` (openplc-runtime) | +| **Editor** | `minRuntimeVersion` | global constant in the shared surface | + +**The editor is the only component that compares.** The runtime publishes what it +requires and never enforces it; there is no editor→runtime advertising handshake +and nothing new travels inside the upload bundle. + +Two consequences of that choice, both good: + +- **No release-ordering constraint between phases.** A runtime that starts + publishing `minEditorVersion` changes nothing for editors that don't read it + yet. Every phase below is independently shippable in any order, and none can + break a device already in the field. +- **One place to debug.** When an upload is refused, the decision was made in the + editor, with both version strings in hand. + +The accepted trade-off: the runtime _advertises_ rather than _enforces_, so a +client that skips the check can still upload. This protects the real case (our +editor against our runtime) and is not a security boundary — see §7. + +## 2. What was missing, per declaration — baseline as of 2026-08-05 + +_Historical. Every gap below is closed by the PRs listed at the top; §8 says +which. Kept verbatim because the design decisions in §1 and §3 only make sense +against the state they were correcting._ + +### 2.1 VPP — the field exists, nothing checks it + +`schema/manifest.schema.json` already makes `package.minEditorVersion` +**required**, and `openplc-packages/docs/package-format.md:69` already promises: + +> `minEditorVersion`: Editor will refuse to install packages requiring a newer version. + +**That promise is not implemented.** The only consumer is the remote catalog UI — +`catalog-browser.tsx:272` uses `isCompatibleEditorVersion(v.minEditorVersion, APP_VERSION)` +to render an "Editor outdated" button state. + +`package-manager-module.ts::install` — the single trust boundary that _both_ the +remote install and the local "Add from file…" flow pass through, which already +validates the manifest schema, verifies the package signature and hardens the +path — **never looks at `minEditorVersion`**. So today: + +- installing a `.vpp` from disk ignores it entirely; +- a package installed before an editor downgrade keeps loading; +- compiling against an incompatible package is never blocked. + +This matters more than a missing field would, because the agreed design _relies_ +on this mechanism: a VPP that needs a UI engine only present from editor 4.2.1 +onward is expected to be unable to install on 4.2.0. Making that true is the +first piece of work. + +`minRuntimeVersion` does not exist in the schema at all. It is needed because a +`runtime-v4-plugin` HAL is code that runs **inside the runtime process** — +`apply_vpp_plugin_conf()` installs its conf on every upload — so a package built +against a newer runtime plugin API currently installs cleanly and fails at load +or scan time. + +### 2.2 Runtime — publishes a version, requires nothing + +`webserver/version.py` resolves `RUNTIME_VERSION` (baked in at image build time +via `ARG RUNTIME_VERSION`), exposed at `GET /api/version` and on the +`X-OpenPLC-Runtime-Version` header (`webserver/restapi.py:46`). + +At the time of writing there was no endpoint where the runtime stated what it +needs from an editor, and `handle_upload_file` accepted any ZIP that passed +`analyze_zip` (path traversal, size, ZIP-bomb ratio, denylisted executable +extensions). `GET /api/capabilities` (§3.1) closes this in openplc-runtime#163; +the upload path itself is deliberately left untouched — see §7. + +### 2.3 Editor — the constant exists under a narrower name + +`src/backend/shared/firmware/runtime-version-gate.ts` (shared surface) already +holds the editor's floor: + +```ts +export const MIN_STRUCPP_RUNTIME_VERSION = '4.1.0' +``` + +This _is_ the global `minRuntimeVersion` the design calls for — it blocks upload +to any runtime below it. It only needs a name that says so. + +**It must not absorb the per-feature gates.** The same file also holds: + +```ts +export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9' +``` + +That one hides a UI screen; it does not block upload. Collapsing the two into one +number forces a bad choice: declare `4.1.0` and the User Management screen breaks +on a 4.1.7 runtime; declare `4.1.9` and upload is blocked on a 4.1.7 runtime that +handles upload perfectly. They stay separate. + +### 2.4 Two semver parsers with divergent semantics + +The whole design rests on comparing version strings, and the codebase currently +answers "is X at least Y" two different ways: + +| | `frontend/utils/semver.ts` | `firmware/runtime-version-gate.ts` | +| ----------------------- | -------------------------- | ---------------------------------- | +| Consumers | VPP catalog | runtime gates | +| `"v4"` | `4.0.0` | `null` (rejected) | +| `"4.1"` | `4.1.0` | `null` (rejected) | +| `"garbage"` | `0.0.0` (lowest) | `null` (rejected) | +| `4.1.0-rc.3` vs `4.1.0` | equal (suffix stripped) | equal, **deliberately** | +| Failure mode | degrade to lowest | fail closed | + +Both are individually well-reasoned. Together they mean the three comparisons in +§1 could disagree depending on which helper a call site happened to import — +exactly on the malformed and pre-release inputs that show up in the field. +Unifying them is cheap and is a precondition for everything else. + +## 3. How it works + +### 3.1 Runtime → Editor + +Scenario: editor **4.2.10**, Raspberry Pi at **192.168.1.50** running runtime **4.2.0**. + +``` +GET http://192.168.1.50/api/capabilities +``` + +```json +{ + "runtimeVersion": "v4.2.0", + "minEditorVersion": "4.2.1" +} +``` + +The editor compares, both directions, locally: + +``` +runtime 4.2.0 >= editor's MIN_RUNTIME_VERSION (4.1.0)? yes → ok +editor 4.2.10 >= runtime's minEditorVersion (4.2.1)? yes → ok +→ upload proceeds +``` + +Reverse case — editor **4.2.0** against the same runtime: + +``` +editor 4.2.0 >= runtime's minEditorVersion (4.2.1)? NO → blocked +``` + +Nothing is sent. This is the "and vice-versa" direction from the card — the one +that had no enforcement at all before this work. + +**Legacy runtime** — `GET /api/capabilities` fails. In practice with **401**, +not 404: the runtime's `restapi.py` ends in a +`@restapi_bp.route("/")` catch-all behind `@jwt_required()`, so an +unknown path under `/api/` falls into it and comes back "Missing Authorization +Header". Verified against a real pre-change container. The probe treats any +unreadable answer the same way, so both shapes fall back identically: + +``` +GET /api/capabilities → 401 (or 404) +GET /api/version → {"version": "v4.1.7"} + +runtime declares no floor → nothing to check in that direction +runtime 4.1.7 >= MIN_RUNTIME_VERSION (4.1.0)? yes → upload proceeds +user-management needs 4.1.9? no → screen hidden +``` + +Identical to the previous behaviour. The fallback is deliberately **silent** — +that 401 is the normal answer from every runtime already deployed, so warning on +it would nag on every upload. + +### 3.2 VPP → Editor, at install time + +```json +{ + "package": { + "id": "com.automationdirect.p1am", + "version": "2.0.0", + "minEditorVersion": "4.2.1", + "minRuntimeVersion": "4.1.9" + } +} +``` + +`package-manager-module.ts::install`, right after the signature check: + +``` +manifest schema valid? ok +signature verified? ok +package.id safe as a path component? ok +APP_VERSION (4.2.0) >= minEditorVersion (4.2.1)? NO → install rejected +``` + +Covers both entry paths — remote catalog install and local "Add from file…" — +because both converge here. `catalog-browser.tsx` keeps its "Editor outdated" +state but derives it from the shared helper rather than owning the decision. + +This is the mechanism that answers "what about a VPP that needs a UI engine the +editor may not have": the engine landed in some editor release, the package +declares that release as its floor, and an older editor cannot install it. + +### 3.3 VPP → Runtime, at compile time + +`minRuntimeVersion` cannot be checked at install — the target runtime is unknown +until you connect to a device. So it is checked in the compile pipeline, when the +selected board comes from a VPP whose target type is `runtime-v4`: + +``` +VPP requires runtime >= 4.1.9 +connected runtime reports v4.1.7 +→ compile blocked +``` + +## 4. Where each check lives + +| Check | Gate | Failure surface | +| ---------------------------------- | ----------------------------------------------------------------- | ------------------------------------- | +| runtime new enough for this editor | `probe-runtime-version.ts` + `MIN_RUNTIME_VERSION` | upload blocked pre-compile | +| editor new enough for this runtime | `probe-runtime-version.ts` + `minEditorVersion` from the endpoint | upload blocked pre-compile | +| editor new enough for this VPP | `package-manager-module.ts::install` | install rejected, both versions named | +| runtime new enough for this VPP | compile pipeline, `runtime-v4` targets | compile blocked | +| per-feature runtime capability | existing predicates in `runtime-version-gate.ts` | UI surface hidden | + +Every row is decided in the editor. The runtime and the VPP only declare. + +**Not covered: an already-installed package after an editor downgrade.** The +gate runs at install, so a package installed under 4.3 keeps loading on 4.2. +Re-checking on load would close it — see §10 for why it is still open. + +## 5. Error messages + +A gate that fires is a support ticket unless the message is complete. Following +the existing `describeIncompatibleRuntime`, every rejection names **what** was +refused, **which two versions** disagree, **which side** is stale, and **the one +action** that fixes it. + +``` +Runtime v4.2.0 requires OpenPLC Editor 4.2.1 or newer. +This editor is 4.2.0. +Update the editor, or connect to a runtime that accepts 4.2.0. +``` + +``` +Package "AutomationDirect P1AM" 2.0.0 requires OpenPLC Editor 4.2.1 or newer. +This editor is 4.2.0. +Update the editor, or install package version 1.4.2. +``` + +``` +Package "AutomationDirect P1AM" 2.0.0 requires runtime v4.1.9 or newer. +The runtime at 192.168.1.50 reports v4.1.7. +Upgrade the runtime on that device. +``` + +Rules: always name the device when one is involved; always state the direction — +never "incompatible versions". + +## 6. Peers that declare nothing + +Everything already in the field predates this work, so absence must be a +supported state rather than an error. + +| Peer state | Behaviour as implemented | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Runtime without `/api/capabilities` | no editor floor to check; `MIN_RUNTIME_VERSION` gate still applies; **silent** — see below | +| Runtime version unreadable (`"dev"`, junk) | fail closed — previous behaviour, unchanged | +| Runtime declares a partial floor (`"4.3"`, `"4"`) | enforced as `4.3.0` / `4.0.0` — a missing component is zero everywhere | +| Runtime declares an _unreadable_ floor (junk) | floor ignored, upload proceeds, **warning logged** to the compile console | +| VPP without `minRuntimeVersion` on a `runtime-v4` target | install allowed, runtime match unverifiable, no warning; blocked at package build time instead | +| VPP with an _unreadable_ floor | manifest rejected at install — the schema refuses a floor it cannot parse | + +The first row is silent **on purpose**: a runtime with no `/api/capabilities` is +every device currently deployed, so warning there would fire on every upload from +every editor. The fourth row is different — a floor that is present but malformed +is a mistake someone made, and swallowing it means a constraint the runtime +believes it is enforcing silently is not. That one now warns. + +Note the third row: `"4.3"` is **not** an unreadable floor. A missing component +is zero throughout the codebase, so a hand-written shorthand is enforced exactly +as its zero-filled equivalent. Only genuine junk reaches the fourth row, which is +why refusing it in a manifest costs nothing. + +Because the runtime never enforces, no closing window is needed on the upload +path: an old runtime simply contributes no constraint. Making `minRuntimeVersion` +mandatory for `runtime-v4` packages already ships, enforced at **package build +time** (`scripts/validate.ts`), so it never breaks an installed package. The +remaining gap is install-time enforcement for sideloaded packages, which never +pass through that validator — see §10. + +## 7. Accepted limitation + +The runtime advertises its requirement; it does not enforce it. A client that +does not implement the check — an older editor, or any other tool speaking the +upload API — can still push a program. + +This was decided deliberately: it keeps the upload path untouched, requires no +new metadata inside the bundle, and removes any possibility of a runtime release +locking out editors already installed. It is not a security control, and should +not be described as one. If enforcement is ever needed, the natural follow-up is +for the editor to send its version on upload and for the runtime to compare — +additive, and not required by anything in this plan. + +## 8. Delivery — what landed where + +**All six phases are implemented.** There was deliberately no ordering +constraint between them: the runtime never blocks, so a phase shipping early +cannot break a peer. + +| # | Phase | Repos | Landed in | +| --- | ----------------------------------- | ----------------- | ---------------------------------------- | +| 1 | One semver parser | editor + web | openplc-editor#993 · openplc-web#652 | +| 2 | VPP install gate | editor + web | openplc-editor#993 · openplc-web#652 | +| 3 | `minRuntimeVersion` in the manifest | packages + editor | openplc-packages#28 · openplc-editor#993 | +| 4 | `GET /api/capabilities` | runtime | openplc-runtime#163 | +| 5 | Editor consumes the endpoint | editor + web | openplc-editor#993 · openplc-web#652 | +| 6 | Tests | editor + web | openplc-editor#993 · openplc-web#652 | + +**Phase 1 — one semver parser.** `frontend/utils/semver.ts` owns the parse and +the ordering; `parseRuntimeVersion` and `compareSemver` are thin wrappers over +it. It lives in `frontend/utils/` rather than `backend/shared/` because the layer +rules allow `backend-shared → utils` and not the reverse. + +The first cut of this kept _two_ parsers — a strict one and a lenient one, chosen +by name at the call site — which review showed was still one too many: the same +string meant different things depending on which a caller reached for, and that +is the defect the phase existed to remove (see §10 items 3 and 6). There is now +exactly one `parseVersion`, applying one rule: a `v` prefix is decoration, a +missing component is zero, anything else is UNKNOWN (`null`). Unknown never +becomes `0.0.0` behind a caller's back, so a gate can still distinguish "I cannot +read this" from "this is old" and fail closed on the first. The one place a total +order is genuinely required — sorting catalog rows — coerces unknown to `0.0.0` +explicitly, inside `compareSemver`, where nothing is gated on the result. + +Consequence worth recording: the legacy `"v4"` header now parses as `4.0.0` +instead of being rejected as unreadable. No gate's answer changes — `4.0.0` is +below `MIN_RUNTIME_VERSION` either way — but it is refused because it is old, +not because the string looked odd. + +**Phase 2 — VPP install gate.** `package-manager-module.ts::install` compares +`minEditorVersion` against `APP_VERSION`, next to the signature verification, so +one gate covers both the catalog and the "Add from file…" path. +`openplc-packages/docs/package-format.md:69` is now true. Reading an installed +package's manifest back off disk goes through `parseInstalledPackageManifest` +instead, which tolerates a floor it cannot compare — see §10 item 4. + +**Phase 3 — `minRuntimeVersion` in the manifest.** Field added to the schema, +conditionally required when any device targets `runtime-v4` and rejected +otherwise, enforced in `scripts/validate.ts`; compared in the compile pipeline +against the connected runtime. + +**Phase 4 — `GET /api/capabilities`.** Unauthenticated endpoint returning +`runtimeVersion` + `minEditorVersion`, constant beside `RUNTIME_VERSION` in +`webserver/version.py`. + +**Phase 5 — editor consumes it.** `probe-runtime-version.ts` prefers the +endpoint and falls back to `/api/version`. Note the correction to the original +plan: a runtime predating the endpoint answers **401**, not 404 — its +`restapi.py` ends in a `@restapi_bp.route("/")` catch-all behind +`@jwt_required()`, so an unknown path lands there. Verified against a real +pre-change container. The probe therefore keys off "can I read a version out of +this answer" rather than off a status code, and covers both shapes. +`MIN_STRUCPP_RUNTIME_VERSION` renamed to `MIN_RUNTIME_VERSION`, old name kept as +an alias. + +**Phase 6 — tests.** Automated coverage of all four comparisons plus the +declares-nothing and unparseable rows. Also verified manually end-to-end with +negative controls, including against a real Raspberry Pi reporting `v4.1.9`. + +## 9. Explicitly out of scope + +Considered and dropped, so nobody re-derives them: + +- **Monotonic integer contracts** (`PROGRAM_CONTRACT_VERSION` and friends). + Would decouple the release trains and isolate a debug-protocol change from the + upload path, at the cost of a second versioning concept to maintain. Human + semver is the agreed key. +- **`bundle-manifest.json` inside the upload ZIP.** Not needed once the runtime + publishes its floor and the editor compares locally. +- **Editor→runtime advertising handshake** (`/api/adv` or similar). Decided + against: the editor validates. +- **`maxEditorVersion` / `maxRuntimeVersion`.** An upper bound pointing at + releases that do not exist yet is unknowable — any value either blocks + compatible future peers or does nothing. + +## 10. Open questions and known gaps + +**Decided** + +1. ~~**What value does the runtime publish as `minEditorVersion`?**~~ → + **`4.1.0`**, set in `webserver/version.py`. That is where the STruC++ + pipeline landed, so it locks out nobody who works today; 4.0.x editors + emitted MatIEC artefacts the runtime cannot build at all. The rule for + raising it is stated on the constant itself: only when an older editor + genuinely produces a bundle this runtime would mis-compile. It is not a build + counter. +2. ~~**Warning surface for a runtime that declares nothing?**~~ → **silent**, for + the reason in §6: that is every deployed device. + +3. ~~**An unreadable floor is discarded with no signal.**~~ → **fixed** + (openplc-editor#993 · openplc-web#652). Two halves. The one that mattered in + practice was that `"4.2"` was not "unreadable" in one gate and was in another: + the install gate honoured it as 4.2.0 while `isVersionAtLeast` dropped it. A + missing component is now zero everywhere, so a partial floor is enforced + exactly as its zero-filled equivalent, and `isCompatibleEditorVersion` + delegates to `isVersionAtLeast` so the two gates cannot disagree at all. What + remains genuinely unreadable is junk, and `probe-runtime-version.ts` logs a + warning when a runtime publishes it. The upload still proceeds — refusing a + device over a typo in its metadata would be worse — but a constraint the + runtime believes it is enforcing can no longer go missing in silence. +4. ~~**A malformed floor in a VPP manifest becomes "no floor".**~~ → **fixed** + (openplc-editor#993 · openplc-web#652). `package-manifest-schema.ts` refuses a + `minEditorVersion` / `minRuntimeVersion` it cannot parse, naming the field in + the error. As predicted, the cost is nil: `"4.3"`, `"4"`, `"v5"` and + pre-release suffixes all pass, so only genuine junk changes behaviour. This is + the only boundary a sideloaded `.vpp` crosses, which is the entry path the + install gate exists for. + + Second-order effect, raised in re-review and fixed with it: the same schema is + also used to read the `manifest.json` of an **already-installed** package, so + the new format rule would have made a package installed before this change — + carrying a floor only genuine junk could produce — stop resolving on load, and + its boards disappear from the board lookup with no message, on an upgrade + where the user did nothing. The rule is therefore strict where the artefact + **enters** and tolerant where we are only **reading** what is already on disk: + `parseInstalledPackageManifest` drops an uncomparable floor and logs it, the + install path still refuses it. Dropping costs nothing that was not already + lost — an unreadable floor never gated anything — and refusing stays where + refusing belongs. + +5. ~~**Hand-rolled comparators remain**~~ → **fixed** (openplc-editor#993 · + openplc-web#652). `isStrucppCompatibleRuntime` and + `isUserManagementCapableRuntime` are now `isVersionAtLeast(raw, )`. + (The item said "three" and named two — there were two.) The equivalence was + verified exhaustively before the swap, but the reason to do it turned out to + be sharper than duplication: the hand-rolled bodies hardcoded the _shape_ of + the constant beside them. `return v.minor >= 1` is correct only because the + floor ends in `.0`; raise `MIN_RUNTIME_VERSION` to `4.1.5` and it keeps + admitting 4.1.0 while every behavioural test still passes, because those tests + were written against the old floor too. Guard tests now derive their + expectations from the constants, so a re-inlined comparison fails immediately. + +**Still open** + +5. **An installed-but-incompatible VPP keeps loading after an editor downgrade** + (§4). Re-checking `minEditorVersion` on load would close it. Open sub-question + if we do: hide the package, or show it as unusable? Hiding is cleaner; showing + explains why a board disappeared. +6. **`minRuntimeVersion` is not enforced at install time for sideloaded + packages.** `scripts/validate.ts` requires it for `runtime-v4` packages at + authoring time, and the compile pipeline compares it against the connected + runtime — but a `.vpp` added from disk declaring no floor installs and only + fails later, at compile. Lower priority than 5: the compile-time gate catches + the mismatch before anything reaches a device. + +Items 3–6 were raised in review of openplc-editor#993; 3, 4 and 6 were fixed in +the same PR (mirrored in openplc-web#652, both on shared-surface files). Item 7 +was split out of CodeRabbit's read of §6. Items 5 and 7 need their own tickets. diff --git a/package.json b/package.json index 8a90807d6..d3a908a27 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-plc-editor", "description": "OpenPLC Editor - IDE capable of creating programs for the OpenPLC Runtime", - "version": "4.2.10", + "version": "4.2.11", "license": "GPL-3.0", "author": { "name": "Autonomy Logic" diff --git a/resources/sources/Baremetal/ARCHITECTURE.md b/resources/sources/Baremetal/ARCHITECTURE.md new file mode 100644 index 000000000..a9dcdf184 --- /dev/null +++ b/resources/sources/Baremetal/ARCHITECTURE.md @@ -0,0 +1,126 @@ +# Modbus slave — module architecture + +The `ModbusSlave` layer is split into 10 cohesive `modbus_*` translation units, +each owning one concern and its own build gate. Dependencies point **inward +only** (transport → protocol → handlers → data), and everything is glued by a +single shared buffer, `mb_frame`. + +`Baremetal.ino` is unchanged: it `#include "ModbusSlave.h"` (the umbrella) and +calls `mbtask()` once per scan cycle. + +## Layers + +``` + Baremetal.ino + │ (#include "ModbusSlave.h"; calls mbtask()) + ┌─────────▼──────────┐ + │ ModbusSlave.* │ umbrella header + mbtask() facade + └───┬────────────┬───┘ + ┌─────────────▼──┐ ┌──▼──────────────┐ + TRANSPORT │ modbus_serial │ │ modbus_tcp │ own the "wire" + │ (RTU single/dual)│ │ (Eth/WiFi/ETH) │ + └──────┬──────────┘ └────────┬───────┘ + │ fill mb_frame, │ + │ ask for frame shape, │ + └───────────┬──────────────┘ + ┌────────▼─────────┐ + PROTOCOL │ modbus_pdu │ dispatch + per-FC frame shape + └───┬──────────┬───┘ + ┌─────────────▼─┐ ┌──▼──────────────┐ + HANDLERS │ modbus_registers│ │ modbus_debug │ + │ (store + op FCs)│ │ (0x41-0x48 + …) │ + └──────┬─────────┘ └─────────────────┘ + │ + ┌─────────▼───────────────────────────────────────────────┐ + BASE │ modbus_frame (seam) · modbus_crc · modbus_types · modbus_config │ + └─────────────────────────────────────────────────────────┘ +``` + +## Modules + +| Module | Responsibility | Build gate | Depends on | +|--------|----------------|------------|------------| +| **`modbus_config.h`** | Build configuration. Pulls in the generated `defines.h` (which has **no include guard**) and derives the composite gates (`MB_SERIAL_ACTIVE`, `DEBUG_*` defaults). The single guarded path through which `defines.h` reaches every TU. | — | `defines.h` | +| **`modbus_types.h`** | Shared contracts: FC / exception enums, `struct MBinfo`, `MAX_MB_FRAME`, `MBAP_SIZE`, `MB_DEBUG_*` status codes, bit helpers. Pure declarations, no storage. | — | `modbus_config` | +| **`modbus_frame.*`** | The **seam**: the global `mb_frame` / `mb_frame_len` buffer, the `modbus` instance (slave id + register banks) and `exceptionResponse()`. Every transport fills it, every handler writes into it. | — | `types` | +| **`modbus_crc.*`** | Modbus RTU CRC-16 (`calcCrc`) + the two lookup tables, defined **once** in the `.cpp` (they used to live in a header → one flash copy per TU). | — (RTU) | `frame` | +| **`modbus_registers.*`** | Register store + the standard **operation** FCs (`0x01`–`0x10`): `init_mbregs`, `get/write_discrete`, `read*`/`write*`. Compiled out of debug-only builds. | `MODBUS_ENABLED` | `frame` | +| **`modbus_debug.*`** | The always-on **debugger** FCs (`0x41`–`0x48`): info / set / get / md5 / status / version / board-id. Growth home for future custom FCs (e.g. the `0x49+` licensing set). | — | `frame`, `arduino_runtime_glue`, `ArduinoUniqueID` | +| **`modbus_pdu.*`** | The **protocol** layer: `process_mbpacket()` dispatches each FC to its handler, and it owns the **per-FC frame shape** — `mb_pdu_request_len()` (RTU length by FC) and `mb_pdu_skips_crc()` (which FCs bypass CRC). Single source of truth for "the set of function codes". | — | `registers`, `debug` | +| **`modbus_serial.*`** | The **RTU** transport (single- and dual-serial). Declared-length framing (robust over USB-CDC), one-byte resync, RS485 tx-enable timing, per-port RX assembly buffers. | `MB_SERIAL_ACTIVE` | `pdu`, `crc`, `frame` | +| **`modbus_tcp.*`** | The **TCP** transport (Ethernet / WiFi / ESP ETH). Brings the network stack up, accepts up to `MAX_SRV_CLIENTS`, services MBAP-framed requests. | `MBTCP` | `pdu`, `frame` | +| **`ModbusSlave.*`** | **Umbrella** header (re-includes every `modbus_*.h`, so `Baremetal.ino` is untouched) + the `mbtask()` facade that fans out to `handle_tcp()` / `handle_serial()`. | — | all | + +## Build gates + +Which TUs actually compile is driven by `defines.h` (generated per build) and the +composite gates in `modbus_config.h`: + +- `MODBUS_ENABLED` — full Modbus operations. A debug-only build compiles + `modbus_registers.cpp` to an empty TU (the debugger reads IEC variables + directly through the strucpp debug table, needing no operation buffers). +- `MB_SERIAL_ACTIVE` = `MBSERIAL || DEBUGGER_ENABLED` — the serial transport. + Always on for baremetal (the debugger is always on). +- `MBTCP` (+ `MBTCP_ETHERNET` / `MBTCP_WIFI`) — the TCP transport. +- `MBSERIAL_ON_SECONDARY` — dual-serial: Modbus RTU on a distinct UART while the + debugger keeps the default serial (each with its own RX buffer). Otherwise + single-serial (`MBSERIAL_SHARES_DEBUG_SERIAL`), where RTU/debugger share the + default serial and `mb_frame` doubles as the RX-assembly buffer. + +> **Rule:** any gated TU must see `defines.h`. Because `defines.h` has no include +> guard, it reaches a TU through exactly one guarded path: `modbus_config.h` +> (via `modbus_types.h`). Every `modbus_*` header includes that chain. + +## Request lifecycle + +**RTU (single-serial):** +1. `mbtask()` → `handle_serial()` → `handle_serial_port(mb_serialport, …, mb_frame, …)`. +2. Drain available bytes into `mb_frame`; **ask `modbus_pdu`** via + `mb_pdu_request_len()` how many bytes the frame should be (derived per FC). +3. Unless the FC is a debug FC (`mb_pdu_skips_crc()`), validate the CRC with + `modbus_crc::calcCrc()`. +4. `process_mbpacket()` dispatches: operation FC → `modbus_registers`; debug FC → + `modbus_debug`. The response is built back into `mb_frame`. +5. `handle_serial_port` appends the CRC and writes to the serial port. + +**TCP:** same from step 4 onward, but `handle_tcp` reads/writes with an MBAP +header (no CRC) instead of RTU framing. + +**Dual-serial:** `handle_serial()` services two ports with dedicated RX buffers +(`mb_rx_dbg` / `mb_rx_rtu`); `mb_frame` is only transient process/TX scratch. + +## Invariants + +1. **Transports do not know the function-code set.** They ask `modbus_pdu` + (`mb_pdu_request_len` + `mb_pdu_skips_crc`). Adding a function code touches + only `modbus_debug` (the handler) and `modbus_pdu` (dispatch + shape) — never + the transports. +2. **`mb_frame` is the one seam.** Every transport fills it, calls + `process_mbpacket()`, and reads the response back out. Single-threaded + cooperative scheduling means the transports time-slice within a scan; there + are no data races, but persistent partial state in `mb_frame` is a hazard — + see the note below. + +## Adding a function code (e.g. custom `0x49+`) + +1. Add the handler in **`modbus_debug.cpp`** (+ prototype in `modbus_debug.h`). +2. In **`modbus_pdu.cpp`**: + - add a `case` in `process_mbpacket()` that calls the handler; + - add the FC's request length to `mb_pdu_request_len()`; + - if the FC should bypass CRC on RTU, add it to `mb_pdu_skips_crc()`. +3. Add the FC constant to the enum in **`modbus_types.h`**. + +That is the whole surface. `modbus_serial.*` and `modbus_tcp.*` are untouched. + +## Known constraint — single-serial + TCP + +`mb_frame` is shared between `handle_tcp()` and the single-serial assembly path. +In **single-serial** builds `mb_frame` doubles as the RX-assembly buffer and +holds a partial RTU/debug frame **across scan cycles**; since `mbtask()` runs +`handle_tcp()` first, an incoming TCP request can clobber that partial frame. +The framing logic resyncs, but the in-flight transaction is lost → intermittent +glitches under concurrent TCP load. Dual-serial + TCP is safe (dedicated RX +buffers; `mb_frame` only transient). The original design assumed a single Modbus +operation transport per board; the editor allowing RTU + TCP together violates +that. Fix is planned separately (dedicated single-serial RX buffer scoped to +`MBSERIAL && MBTCP`). diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 126e6478e..c94141608 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -29,8 +29,10 @@ #include "openplc.h" #include "defines.h" #include "arduino_runtime_glue.h" +#include "license_gate.h" +#include "license_store.h" // license_store_read + LIC_BLOB_SIZE (via license_blob.h) -#ifdef MODBUS_ENABLED +#if defined(MODBUS_ENABLED) || defined(DEBUGGER_ENABLED) #include "ModbusSlave.h" #endif @@ -128,8 +130,55 @@ void setup() // Initialize hardware (HAL -- unchanged) hardwareInit(); + // Establish the run/stop state. Must follow hardwareInit() so the HAL has + // already configured its mode-switch pin: a board powered up with the + // switch in STOP must never execute a scan. Boards with no mode switch + // read RUN and start immediately, as they always have. + runtime_init_plc_state(); + + // ----------------------------------------------------------------------- + // License gate. Hand the stored license blob to the license-core so it can + // verify it and arm its demo timer. + // + // With no license-core linked, `license_gate_init()` is the weak default in + // license_gate_weak.cpp and this whole block is a harmless no-op: actuation + // then stays unconditionally allowed, i.e. a board that never had licensing + // behaves exactly as before. `millis()` gives the core the same time base the + // runtime uses, with no esp_timer dependency. + // + // THE HARDWARE ANCHOR IS NOT PASSED IN. An earlier design read `UniqueID` + // here and handed the bytes over, which made the board's IDENTITY a claim + // made by the OPEN firmware: licensing hardware you do not own cost one edit + // to this file, substituting the target's anchor. The license-core reads the + // silicon itself, inside the closed artifact, so there is nothing here to + // substitute. (FC 0x48 still REPORTS the anchor to the editor, so a purchase + // can be bound to this board — reporting an identity and asserting one are + // different things.) + // ----------------------------------------------------------------------- + { + // Zero-initialised: on a failed read the core is handed length 0, and a + // buffer of indeterminate bytes behind a zero length is the kind of detail + // that turns into a hard-to-place bug the first time someone reads past it. + uint8_t lic_blob[LIC_BLOB_SIZE] = {0}; + size_t lic_len = 0; + if (license_store_read(lic_blob, sizeof(lic_blob), &lic_len) != LIC_STORE_OK) + { + // EMPTY / CORRUPT / UNSUPPORTED / any error: nothing usable was read, + // so present a zero-length blob. The core's verify rejects it and + // starts the demo window; with no core the weak gate ignores the args. + lic_len = 0; + } + + license_gate_init(lic_blob, lic_len, (uint32_t)millis()); + } + #ifdef MODBUS_ENABLED #ifdef MBSERIAL + #ifdef MBSERIAL_ON_SECONDARY + // Dual-serial: Modbus RTU runs on a secondary UART (below) while + // the always-on debugger keeps the default serial — bring it up. + DEBUG_IFACE.begin(DEBUG_BAUD); + #endif #ifdef MBSERIAL_TXPIN // Disable TX pin from OpenPLC hardware layer for (int i = 0; i < NUM_DISCRETE_INPUT; i++) @@ -155,6 +204,18 @@ void setup() mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, -1); #endif modbus.slaveid = MBSERIAL_SLAVE; + // NOTE (single-serial model): the debugger and Modbus RTU share one + // mb_serialport. When MBSERIAL_SHARES_DEBUG_SERIAL is defined the RTU + // port IS the debugger's default serial, so this single begin() also + // brings up the debugger. Running the debugger on the default USB + // serial while RTU uses a *different* UART simultaneously would need + // a second serial handler — a documented follow-up. + #elif defined(DEBUGGER_ENABLED) + // Modbus TCP-only build: no MBSERIAL, but the always-on debugger + // still needs the default serial up on mb_serialport to respond. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif #ifdef MBTCP @@ -178,6 +239,15 @@ void setup() init_mbregs(MAX_ANALOG_OUTPUT + MAX_MEMORY_WORD, MAX_MEMORY_DWORD, MAX_MEMORY_LWORD, MAX_DIGITAL_OUTPUT, MAX_ANALOG_INPUT, MAX_DIGITAL_INPUT); mapEmptyBuffers(); + #elif defined(DEBUGGER_ENABLED) + // Always-on debugger without full Modbus: bring up the serial port and + // the Modbus RTU framing/slave id ONLY. The debugger reads/writes IEC + // variables directly through the strucpp debug table (openplc_debug_*), + // so it needs NO operation buffers — init_mbregs()/mapEmptyBuffers() are + // deliberately not called here, saving SRAM on small boards. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif setupCycleDelay(base_tick_ns); @@ -369,8 +439,12 @@ void scheduler() sketch_loop(); #endif - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) modbusTask(); + #elif defined(DEBUGGER_ENABLED) + // Debug-only: poll the serial transport for debugger requests. No buffer + // sync (modbusTask's mirror loops) because there are no operation buffers. + mbtask(); #endif if (!first_cycle) @@ -392,12 +466,18 @@ void loop() last_run += scan_cycle; } - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) // Only run Modbus task again if we have at least 10ms gap until the next cycle if ((micros() - last_run) >= 10000) { modbusTask(); } + #elif defined(DEBUGGER_ENABLED) + // Debug-only: give the debugger extra serial-poll time between cycles too. + if ((micros() - last_run) >= 10000) + { + mbtask(); + } #endif #ifdef SIMULATOR_MODE diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 80d113e9d..807399e50 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -4,1460 +4,40 @@ Copyright (C) 2022 OpenPLC - Thiago Alves */ #include "ModbusSlave.h" -// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h -// (openplc_debug_*) so this TU stays free of strucpp template-heavy headers -// and compiles cleanly in arduino-cli's path with the core's default C++ -// standard (gnu++14 on mbed and others). The shims forward to -// strucpp::debug::handle_* inside arduino_runtime_glue.cpp, which is part -// of the precompiled OpenPLCUserLib archive built with -std=gnu++17. -#include "arduino_runtime_glue.h" +// The debugger handlers (and their arduino_runtime_glue.h / ArduinoUniqueID +// dependencies) moved to modbus_debug.cpp. -//Global Modbus vars -struct MBinfo modbus; -uint8_t mb_frame[MAX_MB_FRAME]; -uint16_t mb_frame_len; -Stream* mb_serialport; -int8_t mb_txpin; -uint16_t mb_t15; // inter character time out -uint16_t mb_t35; // frame delay +// Global Modbus vars — modbus / mb_frame / mb_frame_len moved to modbus_frame.cpp; +// the serial port/timing globals to modbus_serial.cpp; the TCP server state +// (mb_server / mb_serverClients / mb_mbap) to modbus_tcp.cpp. +// init_mbregs / get_discrete / write_discrete moved to modbus_registers.cpp. +// mbconfig_serial_iface() and the serial transport moved to modbus_serial.cpp. +// mbconfig_ethernet_iface() and handle_tcp() moved to modbus_tcp.cpp. -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - WiFiServer mb_server(502); - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#else - EthernetServer mb_server(502); -#endif - uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - WiFiServer mb_server(502); - uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) -{ - //Save sizes - modbus.holding_size = size_holding; - modbus.dint_memory_size = size_dint_memory; - modbus.lint_memory_size = size_lint_memory; - modbus.coils_size = size_coils; - modbus.input_regs_size = size_inputregs; - modbus.input_status_size = size_inputstatus; - - //round discrete regs sizes - if (size_coils % 8 > 0) - size_coils = (size_coils / 8) + 1; - else - size_coils = size_coils / 8; - if (size_inputstatus % 8 > 0) - size_inputstatus = (size_inputstatus / 8) + 1; - else - size_inputstatus = (size_inputstatus / 8); - - modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); - if (modbus.coils == NULL) return false; - memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); - - modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); - if (modbus.holding == NULL) return false; - memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); - - if (size_dint_memory > 0) - { - modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); - if (modbus.dint_memory == NULL) return false; - memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); - } - - if (size_lint_memory > 0) - { - modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); - if (modbus.lint_memory == NULL) return false; - memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); - } - - modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); - if (modbus.input_status == NULL) return false; - memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); - - modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); - if (modbus.input_regs == NULL) return false; - memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); - - return true; -} - -bool get_discrete(uint16_t addr, bool regtype) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - return bitRead(modbus.coils[byte_addr], bit_addr); - else - return bitRead(modbus.input_status[byte_addr], bit_addr); -} - -void write_discrete(uint16_t addr, bool regtype, bool value) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - bitWrite(modbus.coils[byte_addr], bit_addr, value); - else - bitWrite(modbus.input_status[byte_addr], bit_addr, value); -} - -void mbconfig_serial_iface(Stream* port, long baud, int txPin) -{ - mb_serialport = port; - mb_txpin = txPin; - //(*port).begin(baud); //Initialization already happened on main .ino file - - //RS-485 control - if (txPin >= 0) - { - pinMode(txPin, OUTPUT); - digitalWrite(txPin, LOW); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) - Controllino_RS485Init(); - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { - pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); - pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - // Modbus states that a baud rate higher than 19200 must use a fixed 750 us - // for inter character time out. For baud rates below 19200 the timing - // is more critical and has to be calculated. - // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second - // In milliseconds this will be 872 characters per 1000ms. So for 1 character - // 1000ms/872 characters is 1.14583ms per character. Finally modbus states - // an inter-character must be 1.5T or 1.5 times longer than a character. Thus - // 1.5T = 1.14583ms * 1.5 = 1.71875ms. - // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud - // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant - - if (baud > 19200) - mb_t15 = 750; - else - mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 - - /* The modbus definition of a frame delay is a waiting period of 3.5 character times - between packets.*/ - - mb_t35 = mb_t15 * 3.5; -} - - -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - - ETH.begin(); - - if (ip != NULL && subnet != NULL && gateway != NULL) - (ETH.config(ip, gateway, subnet, dns)); - - #else - if (ip == NULL) - Ethernet.begin(mac); - else if (dns == NULL) - Ethernet.begin(mac, IPAddress(ip)); - else if (gateway == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - #endif - -// int num_tries = 0; -// while (!ETH.linkUp()) -// { -// delay(500); -// num_tries++; -// if (num_tries == 20) break; -// } - - #endif - #ifdef MBTCP_WIFI - #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) - if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) - { - uint8_t secondaryDNS[] = {8, 8, 8, 8}; - WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); - } - mb_server.setNoDelay(true); - #elif defined(BOARD_PORTENTA) - if (ip != NULL && subnet != NULL && gateway != NULL) - { - WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); - } - #else - if (ip != NULL) - { - if (dns == NULL) - WiFi.config(IPAddress(ip)); - else if (gateway == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - } - #endif - WiFi.begin(MBTCP_SSID, MBTCP_PWD); - int num_tries = 0; - while (WiFi.status() != WL_CONNECTED) - { - delay(500); - num_tries++; - if (num_tries == 10) break; - } - #endif - - mb_server.begin(); - -} -#endif void mbtask() { #ifdef MBTCP handle_tcp(); #endif - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE handle_serial(); #endif } -#ifdef MBTCP -void handle_tcp() -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - WiFiClient client = mb_server.available(); - #else - EthernetClient client = mb_server.available(); - #endif - #endif - - #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) - WiFiClient client = mb_server.available(); - #endif - - //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific - //code lies below - #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) - - - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) - if (client) - #else - if (mb_server.hasClient()) - #endif - { - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() - { - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) - mb_serverClients[i] = client; - #else - mb_serverClients[i] = mb_server.available(); - #endif - break; - } - } - } - - //search all clients for data - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - int j = 0; - - - if (mb_serverClients[i].connected() && mb_serverClients[i].available()) - - { - //Read packet - - - while (mb_serverClients[i].available()) - { - mb_mbap[j] = mb_serverClients[i].read(); - j++; - if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - j = 0; - while (mb_serverClients[i].available()) - { - mb_frame[j] = mb_serverClients[i].read(); - j++; - if (j==mb_frame_len) break; - } - - //Safety check - discard packages that lie about their size - if (j != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (j = 0 ; j < MBAP_SIZE ; j++) - sendbuffer[j] = mb_mbap[j]; - - //PDU Frame - for (j = 0 ; j < mb_frame_len ; j++) - sendbuffer[j+MBAP_SIZE] = mb_frame[j]; - - //Write back - mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - - //If this is not an ESP board or Portenta board, then here is the default code - #else - if (client) - { - if (client.connected()) - { - int i = 0; - while (client.available()) - { - mb_mbap[i] = client.read(); - i++; - if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - i = 0; - while (client.available()) - { - mb_frame[i] = client.read(); - i++; - if (i==mb_frame_len || i==MAX_MB_FRAME) break; - } - - //Safety check - discard packages that lie about their size - if (i != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (i = 0 ; i < MBAP_SIZE ; i++) - sendbuffer[i] = mb_mbap[i]; - - //PDU Frame - for (i = 0 ; i < mb_frame_len ; i++) - sendbuffer[i+MBAP_SIZE] = mb_frame[i]; - - //Write back - client.write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - #endif -} -#endif - -#ifdef MBSERIAL -// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose -// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a -// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at -// 115200) and the byte cadence delimits frames. That assumption is INVALID on -// USB-CDC (and any store-and-forward link): a single request is split into -// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, -// so cadence framing tears requests apart (the bug that made the P1AM-100 / -// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length -// (derived from the function code) and fall back to this idle only to drop a -// truncated partial. It must exceed any intra-frame USB gap yet stay well below -// a master's request timeout. -#define MB_RTU_FRAME_GAP_MS 8 - -// Persistent RX-assembly state. handle_serial() is called every scan cycle and -// never blocks; a request whose bytes straddle several calls is carried across -// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which -// is safe because an OpenPLC board is configured for a single Modbus transport; -// the two are not driven mid-frame at the same time.) -static uint16_t mb_rx_len = 0; -static uint32_t mb_rx_last_ms = 0; - -// Total on-wire length (slave id + PDU + 2 CRC bytes) of the request whose -// first `n` bytes are in `f`. Returns >0 for a known length, 0 when more header -// bytes are needed to size it, and -1 for a function code we do not serve (so -// the byte cannot be a frame head). Length is implicit in Modbus RTU — derived -// per function code, exactly as `process_mbpacket()` later parses the fields. -static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) -{ - if (n < 2) return 0; // need at least id + FC - switch (f[1]) - { - case MB_FC_READ_COILS: - case MB_FC_READ_INPUT_STAT: - case MB_FC_READ_REGS: - case MB_FC_READ_INPUT_REGS: - case MB_FC_WRITE_COIL: - case MB_FC_WRITE_REG: - return 8; // [id][fc][a:2][b:2][crc:2] - case MB_FC_WRITE_COILS: - case MB_FC_WRITE_REGS: - if (n < 7) return 0; // byte count lives at f[6] - return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] - case MB_FC_DEBUG_INFO: - return 4; // [id][fc][crc:2] - case MB_FC_DEBUG_GET: - return 9; // [id][fc][arr:1][s:2][e:2][crc:2] - case MB_FC_DEBUG_GET_LIST: - if (n < 4) return 0; // count lives at f[2..3] - return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); - case MB_FC_DEBUG_SET: - if (n < 8) return 0; // value len lives at f[6..7] - return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); - case MB_FC_DEBUG_GET_MD5: - return 8; // [id][fc][endian:2][00:2][crc:2] - default: - return -1; // not one of our function codes - } -} - -// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used -// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — -// so a genuine frame head sitting further into the buffer always survives and -// is eventually found (guarantees resync convergence; no "discard every frame" -// loop). Slides only run on the error path, so the O(n) cost is irrelevant. -static void mb_rtu_drop_front(uint16_t k) -{ - if (k >= mb_rx_len) { mb_rx_len = 0; return; } - for (uint16_t i = k; i < mb_rx_len; i++) - mb_frame[i - k] = mb_frame[i]; - mb_rx_len = (uint16_t)(mb_rx_len - k); -} - -void handle_serial() -{ - uint16_t packet_crc; - - // 1) Drain the RX buffer without blocking. One frame's bytes may arrive - // across several calls; the scan cycle is never stalled waiting on them. - while ((*mb_serialport).available() > 0) - { - if (mb_rx_len >= MAX_MB_FRAME) break; // full — let the parser drain it - mb_frame[mb_rx_len++] = (uint8_t)(*mb_serialport).read(); - mb_rx_last_ms = millis(); - } - - // 2) Extract every complete frame in the buffer. Each iteration either - // consumes/realigns by >=1 byte or returns to await more data, so the - // loop always terminates. - for (;;) - { - if (mb_rx_len == 0) - return; - - // Header byte-alignment: the first byte must be OUR slave id. This is - // the cheap framing check, and it is the ONLY validation applied to - // debugger frames (CRC is deliberately skipped on debug FCs for - // performance — those function codes are private and well-formed). - if (mb_frame[0] != modbus.slaveid) - { - mb_rtu_drop_front(1); // foreign/garbage head — slide - continue; - } - - int32_t expected = mb_rtu_frame_len(mb_frame, mb_rx_len); - - if (expected < 0 || expected > MAX_MB_FRAME) - { - mb_rtu_drop_front(1); // illegal FC / impossible length - continue; - } - if (expected == 0 || mb_rx_len < (uint16_t)expected) - { - // Header incomplete, or the frame's tail has not arrived yet. Wait - // for it; abandon the partial only if its remainder never comes. - if ((uint32_t)(millis() - mb_rx_last_ms) > MB_RTU_FRAME_GAP_MS) - mb_rx_len = 0; - return; - } - - // 3) A full candidate frame occupies mb_frame[0 .. expected). - // Standard FCs are validated by CRC (the arbiter that makes resync - // trustworthy); a mismatch means corruption or misalignment, so we - // slide one byte and retry instead of discarding the whole buffer. - if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5) - { - mb_frame_len = (uint16_t)expected; - packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); - if (packet_crc != calcCrc()) - { - mb_rtu_drop_front(1); - continue; - } - } - - // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, - // which builds the response back into mb_frame. - mb_frame_len = (uint16_t)expected - 2; - process_mbpacket(); - - //Add CRC - //Check if response message is too big for this device - if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); - mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC - packet_crc = calcCrc(); //calculate CRC of the new packet - mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); - mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); - - if (mb_txpin >= 0) - { - digitalWrite(mb_txpin, HIGH); - delayMicroseconds(mb_t35); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port - Controllino_RS485TxEnable(); // Enable RS485 chip to transmit - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - (*mb_serialport).write(mb_frame, mb_frame_len); - (*mb_serialport).flush(); - delayMicroseconds(mb_t35); - - if (mb_txpin >= 0) - digitalWrite(mb_txpin, LOW); - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port - Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); - } - #endif - - // 5) The request — and the response built over it — consumed the whole - // assembly buffer. Modbus RTU is turn-taking: the master waits for - // this reply before sending its next request, so no following frame - // can already be buffered. Reset for the next request. A - // non-conformant pipelining master simply retransmits after its - // timeout, and the gap/realignment logic above recovers cleanly. - mb_rx_len = 0; - return; - } -} -#endif - - -void process_mbpacket() -{ - uint8_t fcode = mb_frame[1]; - // Standard Modbus fields — preserved for the non-debug FCs. - uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; - void *endianness_check = &mb_frame[2]; - - switch (fcode) - { - case MB_FC_WRITE_REG: - //field1 = reg, field2 = value - writeSingleRegister(field1, field2); - break; - - case MB_FC_READ_REGS: - //field1 = startreg, field2 = numregs - readRegisters(field1, field2); - break; - - case MB_FC_WRITE_REGS: - //field1 = startreg, field2 = status - writeMultipleRegisters(field1, field2, mb_frame[6]); - break; - case MB_FC_READ_COILS: - //field1 = startreg, field2 = numregs - readCoils(field1, field2); - break; +// Serial transport (mbconfig_serial_iface, handle_serial/handle_serial_port, +// mb_rtu_drop_front, RX-assembly buffers, RS485 timing) moved to modbus_serial.cpp. - case MB_FC_READ_INPUT_STAT: - //field1 = startreg, field2 = numregs - readInputStatus(field1, field2); - break; - case MB_FC_READ_INPUT_REGS: - //field1 = startreg, field2 = numregs - readInputRegisters(field1, field2); - break; +// process_mbpacket() + mb_pdu_request_len() + mb_pdu_skips_crc() moved to modbus_pdu.cpp. - case MB_FC_WRITE_COIL: - //field1 = reg, field2 = status - writeSingleCoil(field1, field2); - break; - case MB_FC_WRITE_COILS: - //field1 = startreg, field2 = numoutputs - writeMultipleCoils(field1, field2, mb_frame[6]); - break; +// Register store + operation FCs (readRegisters..writeMultipleCoils) moved to modbus_registers.cpp. - case MB_FC_DEBUG_INFO: - debugInfo(); - break; +// Debugger FCs (debugInfo/debugSetTrace/debugGetTrace/debugGetTraceList/debugGetMd5/ +// debugGetStatus/debugGetVersion/debugGetBoardId) moved to modbus_debug.cpp. - case MB_FC_DEBUG_GET: - { - // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] - uint8_t arr = mb_frame[2]; - uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; - debugGetTrace(arr, startIdx, endIdx); - } - break; - - case MB_FC_DEBUG_GET_LIST: - { - // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] - uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - debugGetTraceList(numIndexes, &mb_frame[4]); - } - break; - - case MB_FC_DEBUG_SET: - { - // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] - uint8_t arr = mb_frame[2]; - uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint8_t flag = mb_frame[5]; - uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; - void *value = &mb_frame[8]; - debugSetTrace(arr, elem, flag, len, value); - } - break; - - case MB_FC_DEBUG_GET_MD5: - debugGetMd5(endianness_check); - break; - - default: - exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); - } -} - - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_REGS; - mb_frame[2] = mb_frame_len - 3; //byte count - - uint16_t val; - uint16_t i = 0; - uint8_t pos = 0; - while(numregs--) - { - if ((startreg + i) < modbus.holding_size) - { - //retrieve the value from the register bank for the current register - val = modbus.holding[startreg + i]; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - val = (uint16_t)(modbus.dint_memory[pos] >> 16); - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); - } - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - val = (uint16_t)(modbus.lint_memory[pos] >> 48); - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); - } - } - - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleRegister(uint16_t reg, uint16_t value) -{ - if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); - return; - } - - uint8_t pos = 0; - - if (reg < modbus.holding_size) - { - modbus.holding[reg] = value; - } - else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if (reg % 2 == 0) //first word - { - pos = (reg - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = (reg - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if (reg % 4 == 0) //first word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if (reg % 4 == 1) //second word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if (reg % 4 == 2) //third word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } -} - -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) -{ - //Check value - if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_REGS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - uint16_t value; - uint16_t i = 0; - uint8_t pos = 0; - while(numoutputs--) - { - value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; - - if ((startreg + i) < modbus.holding_size) - { - modbus.holding[(startreg + i)] = value; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } - - i++; - } -} - -void exceptionResponse(uint16_t fcode, uint16_t excode) -{ - //Clean frame buffer (leave only SlaveID) - mb_frame_len = 3; - for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; - mb_frame[0] = modbus.slaveid; - mb_frame[1] = fcode + 0x80; - mb_frame[2] = excode; -} - -void readCoils(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (startreg + numregs > modbus.coils_size) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = slaveid + function type + byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_COILS; - mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) - - uint8_t bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete((uint8_t)startreg, COILS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputStatus(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_status_size) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = function type, byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_STAT; - mb_frame[2] = mb_frame_len - 3; - - byte bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete(startreg, INPUTSTATUS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_regs_size) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - //for each register queried add 2 bytes - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_REGS; - mb_frame[2] = mb_frame_len - 3; - - uint16_t val; - uint16_t i = 0; - while(numregs--) - { - //retrieve the value from the register bank for the current register - val = modbus.input_regs[startreg + i]; - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleCoil(uint16_t reg, uint16_t status) -{ - //Check value (status) - if (status != 0xFF00 && status != 0x0000) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (reg > (modbus.coils_size - 1)) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Execute - write_discrete(reg, COILS, status == 0xFF00 ? true : false); -} - -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) -{ - //Check value - uint8_t bytecount_calc = numoutputs / 8; - if (numoutputs%8) bytecount_calc++; - if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) > modbus.coils_size) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_COILS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - //Execute - uint8_t bitn = 0; - uint16_t totoutputs = numoutputs; - uint16_t i; - while (numoutputs) - { - i = (totoutputs - numoutputs--) / 8; - write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_INFO function code. - * - * This function constructs a Modbus response frame for the DEBUG_INFO function code. - * The response frame includes the number of variables defined in the PLC program. - * - * Modbus Response Frame (DEBUG_INFO): - * +-----+-------+-------+ - * | MB | Count | Count | - * | FC | | | - * +-----+-------+-------+ - * |0x41 | High | Low | - * | | Byte | Byte | - * | | | | - * +-----+-------+-------+ - * - * @return void - */ -// Phase 4 PDU: -// +-----+-------+------+-----------+-----------+-----------+ -// | FC | arrs | stat | count_0 | count_1 | ... | -// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | -// +-----+-------+------+-----------+-----------+-----------+ -// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] -void debugInfo() -{ - uint8_t arrCount = openplc_debug_array_count(); - - // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. - // Realistic projects have <=10 arrays, so this is never a real limit. - uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; - if (arrCount > maxArrs) arrCount = maxArrs; - - mb_frame[1] = MB_FC_DEBUG_INFO; - mb_frame[2] = arrCount; - mb_frame[3] = MB_DEBUG_SUCCESS; - uint16_t pos = 4; - for (uint8_t i = 0; i < arrCount; i++) - { - uint16_t c = openplc_debug_elem_count(i); - mb_frame[pos++] = (uint8_t)(c >> 8); - mb_frame[pos++] = (uint8_t)(c & 0xFF); - } - mb_frame_len = pos; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_SET function code. - * - * This function constructs a Modbus response frame for the DEBUG_SET function code. - * The response frame indicates whether the set trace command was successful or if - * there was an error, such as an out-of-bounds index. - * - * Modbus Response Frame (DEBUG_SET): - * +-----+------+ - * | MB | Resp.| - * | FC | Code | - * +-----+------+ - * |0x42 | Code | - * +-----+------+ - * - * @param varidx The index of the variable to set trace for. - * @param flag The trace flag. - * @param len The length of the trace data. - * @param value Pointer to the trace data. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] -// Response: [FC, STATUS] -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value) -{ - if (len > (MAX_MB_FRAME - 8)) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint8_t status = openplc_debug_set( - arr, elem, (uint8_t)flag, (const uint8_t *)value, len); - - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = status; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET function code. - * The response frame includes the trace data for variables within the specified index range. - * - * Modbus Response Frame (DEBUG_GET): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param startidx The start index of the variables to get trace for. - * @param endidx The end index of the variables to get trace for. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] -// Response: [FC, STATUS, last_elem_hi, last_elem_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) -{ - uint16_t arrCount = openplc_debug_elem_count(arr); - if (arrCount == 0 || startidx >= arrCount || - endidx >= arrCount || startidx > endidx) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint16_t lastElemIdx = startidx; - uint16_t responseSize = 0; - uint8_t *responsePtr = &(mb_frame[11]); - - for (uint16_t elem = startidx; elem <= endidx; elem++) - { - uint16_t varSize = openplc_debug_size(arr, elem); - // Bounds check — stop packing if this one won't fit. - if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; - if (varSize == 0) { - // Entry has no readable bytes (string stub / out-of-bounds) - // — skip gracefully to keep the scan progressing. - lastElemIdx = elem; - continue; - } - uint16_t n = openplc_debug_read(arr, elem, responsePtr); - if (n == 0) { - lastElemIdx = elem; - continue; - } - responsePtr += n; - responseSize += n; - lastElemIdx = elem; - } - - mb_frame_len = 11 + responseSize; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastElemIdx >> 8); - mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. - * The response frame includes the trace data for variables specified in the provided index list. - * - * Modbus Response Frame (DEBUG_GET_LIST): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param numIndexes The number of indexes requested. - * @param indexArray Pointer to the array containing variable indexes. - * - * @return void - */ -// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] -// Response: [FC, STATUS, last_idx_hi, last_idx_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -// last_idx is the index *into the request list* that was last successfully -// included — the editor uses it to retry from the next item on overflow. -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) -{ - uint16_t response_idx = 11; - uint16_t responseSize = 0; - uint16_t lastReqIdx = 0; - - #ifdef MBSERIAL - #define VARIDX_SIZE 20 - #else - #define VARIDX_SIZE 60 - #endif - - if (numIndexes > VARIDX_SIZE) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; - return; - } - - // The request indexArray (at mb_frame[4..]) and the response buffer - // (mb_frame[11..]) overlap. Once handle_read writes the first response - // byte, later index entries inside mb_frame are clobbered. Snapshot the - // request first. - uint8_t localIndex[VARIDX_SIZE * 3]; - for (uint16_t i = 0; i < numIndexes * 3; i++) { - localIndex[i] = indexArray[i]; - } - - // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] - for (uint16_t i = 0; i < numIndexes; i++) - { - uint8_t arr = localIndex[i * 3]; - uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | - (uint16_t)localIndex[i * 3 + 2]; - - uint16_t varSize = openplc_debug_size(arr, elem); - if (varSize == 0) - { - // Out-of-bounds or string stub — skip gracefully. - lastReqIdx = i; - continue; - } - if ((response_idx + varSize) > MAX_MB_FRAME) break; - - uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); - if (n == 0) - { - lastReqIdx = i; - continue; - } - response_idx += n; - responseSize += n; - lastReqIdx = i; - } - - mb_frame_len = response_idx; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastReqIdx >> 8); - mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -// PDU request: [FC, endian_check_hi, endian_check_lo] -// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] -// -// The target always writes variable data in native byte order — STruC++ does -// no server-side byte-order adaptation, force/read is pure memcpy. To let -// the editor detect what "native" means here, the MD5 response trailer -// writes the literal value 0xDEAD via a native `uint16_t*` store. The -// bytes that land in the response are therefore in the target's native -// byte order: -// -// LE target → trailer bytes = [0xAD, 0xDE] -// BE target → trailer bytes = [0xDE, 0xAD] -// -// The editor inspects those two bytes after MD5 verification and decides -// whether subsequent force/read traffic needs byte-swapping at its end. -// -// The probe bytes the editor sends are intentionally ignored — the trailer -// is a runtime-driven sentinel, not an echo. The argument stays in the -// signature for ABI compatibility with the dispatcher. -void debugGetMd5(void * /*endianness*/) -{ - mb_frame[1] = MB_FC_DEBUG_GET_MD5; - mb_frame[2] = MB_DEBUG_SUCCESS; - - const char md5[] = PROGRAM_MD5; - int md5_len = 0; - for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) - { - mb_frame[md5_len + 3] = md5[md5_len]; - } - - // Native-order store of the endianness sentinel. Written byte-wise - // (not via `*reinterpret_cast`) because `md5_len + 3` is an - // odd offset for a 32-char MD5, and a typed 16-bit store there is an - // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / - // P1AM-100) — hanging the device on the first debugger request. Copying - // the two bytes of a native-order uint16_t preserves the target's byte - // ordering (the signal the editor uses to choose its swap behaviour) - // while keeping every access byte-aligned. - const uint16_t endian_sentinel = 0xDEAD; - const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); - mb_frame[md5_len + 3] = sentinel_bytes[0]; - mb_frame[md5_len + 4] = sentinel_bytes[1]; - mb_frame_len = md5_len + 5; -} - -uint16_t calcCrc() -{ - uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; - - int i = 0; - Index = CRCHi ^ mb_frame[i]; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - i++; - - while (i < (mb_frame_len - 2)) - { - Index = CRCHi ^ mb_frame[i]; - i++; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - } - - return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; -} +// calcCrc() and the CRC lookup tables moved to modbus_crc.cpp. diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 47236126d..d7f3b5c80 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -7,61 +7,25 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MODBUSSLAVE_H #include -#include "defines.h" - -#ifndef bitRead - #define bitRead(value, bit) (((value) >> (bit)) & 0x01) -#endif -//#define bitSet(value, bit) ((value) |= (1UL << (bit))) -//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) -#ifndef bitWrite - #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) -#endif -#define COILS 0 -#define INPUTSTATUS 1 -#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) - #define MAX_MB_FRAME 128 -#else - #define MAX_MB_FRAME 256 -#endif -#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time -#define MBAP_SIZE 6 - -//Platform specific defines and includes -#ifdef MBTCP_ETHERNET -#include -#ifdef BOARD_ESP32 - // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT - // Type of the Ethernet PHY (LAN8720 or TLK110) - #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) - #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C clock signal for the Ethernet PHY - #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C IO signal for the Ethernet PHY - #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT - // External clock from crystal oscillator - #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT - #include - #include -#else - #include -#endif -#endif - -#ifdef MBTCP_WIFI -#if defined(BOARD_ESP8266) -#include -#elif defined(BOARD_ESP32) -#include -#elif defined(BOARD_WIFININA) -#include -#else -#include -#include -#endif -#endif +#include "openplc_version.h" +// modbus_types.h pulls modbus_config.h, which brings defines.h and the composite +// build gates (MB_SERIAL_ACTIVE, DEBUG_* defaults). defines.h has no include +// guard, so it is deliberately NOT included directly here — only via that path. +#include "modbus_types.h" +#include "modbus_frame.h" +#include "modbus_crc.h" +#include "modbus_registers.h" +#include "modbus_debug.h" +#include "modbus_pdu.h" +#include "modbus_serial.h" +#include "modbus_tcp.h" + +// Shared type/constant declarations (enums, MBinfo, MAX_MB_FRAME, MBAP_SIZE, +// COILS/INPUTSTATUS, bit helpers, MB_DEBUG_* status codes) live in modbus_types.h; +// the build gates above come from modbus_config.h — both included above. + +// The TCP platform includes (SPI/Ethernet/WiFi/ETH + ESP32 PHY defines) and the +// TCP server state now live in modbus_tcp.h (included above). #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) #include "Controllino.h" @@ -72,162 +36,23 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // file deliberately does NOT redeclare it (a second declaration would // conflict with the C-linkage one and break the build). -// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept -// as macros here so the Modbus layer doesn't have to include the C++ -// runtime header when the rest of the protocol is C-style). -#define MB_DEBUG_SUCCESS 0x7E -#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 -#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 - -//Modbus registers struct -struct MBinfo { - uint8_t slaveid; - uint16_t *holding; - uint8_t holding_size; - uint32_t *dint_memory; - uint8_t dint_memory_size; - uint64_t *lint_memory; - uint8_t lint_memory_size; - uint8_t *coils; - uint8_t coils_size; - uint16_t *input_regs; - uint8_t input_regs_size; - uint8_t *input_status; - uint8_t input_status_size; -}; +// MBinfo, the MB_FC_* / MB_EX_* enums and the MB_DEBUG_* status codes now live +// in modbus_types.h (included above). The shared frame seam (mb_frame, +// mb_frame_len, the `modbus` instance and exceptionResponse) lives in +// modbus_frame.h (included above). -//Function Codes -enum { - MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx - MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx - MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx - MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx - MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx - MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx - MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx - MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx - MB_FC_DEBUG_INFO = 0x41, // Request debug variables count - MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) - MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) - MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) - MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 -}; +// The serial port/timing globals (mb_serialport/mb_txpin/mb_t15/mb_t35) live in +// modbus_serial.h; the TCP server state (mb_server/mb_serverClients/mb_mbap) and +// mbconfig_ethernet_iface()/handle_tcp() in modbus_tcp.h — both included above. -//Exception Codes -enum { - MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported - MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists - MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range - MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request -}; - -//Global Modbus vars -extern struct MBinfo modbus; -extern uint8_t mb_frame[MAX_MB_FRAME]; -extern uint16_t mb_frame_len; -extern Stream* mb_serialport; -extern int8_t mb_txpin; -extern uint16_t mb_t15; // inter character time out -extern uint16_t mb_t35; // frame delay - -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - extern WiFiServer mb_server; -#else - extern EthernetServer mb_server; -#endif - extern uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - extern WiFiServer mb_server; - extern uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); -bool get_discrete(uint16_t addr, bool regtype); -void write_discrete(uint16_t addr, bool regtype, bool value); -void mbconfig_serial_iface(Stream* port, long baud, int txPin); -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); -#endif void mbtask(); -#ifdef MBTCP -void handle_tcp(); -#endif -#ifdef MBSERIAL -void handle_serial(); -#endif -void process_mbpacket(); -uint16_t calcCrc(); - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleRegister(uint16_t reg, uint16_t value); -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); -void exceptionResponse(uint16_t fcode, uint16_t excode); -void readCoils(uint16_t startreg, uint16_t numregs); -void readInputStatus(uint16_t startreg, uint16_t numregs); -void readInputRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleCoil(uint16_t reg, uint16_t status); -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); -// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era -// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. -void debugInfo(void); -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value); -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); -void debugGetMd5(void *endianness); - - -/* Table of CRC values for high-order byte */ -const byte _auchCRCHi[] = { - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40}; - -/* Table of CRC values for low-order byte */ -const byte _auchCRCLo[] = { - 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, - 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, - 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, - 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, - 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, - 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, - 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, - 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, - 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, - 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, - 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, - 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, - 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, - 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, - 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, - 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, - 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, - 0x40}; +// mbconfig_serial_iface() and handle_serial() live in modbus_serial.h; +// process_mbpacket() and the per-FC frame-shape helpers (mb_pdu_request_len, +// mb_pdu_skips_crc) live in modbus_pdu.h; the register store and operation FCs +// (init_mbregs, get/write_discrete, readRegisters..writeMultipleCoils) in +// modbus_registers.h; the debugger FCs (debugInfo..debugGetBoardId) in +// modbus_debug.h; calcCrc() and the CRC tables in modbus_crc.{h,cpp} — all +// included above. #endif diff --git a/resources/sources/Baremetal/license_blob.h b/resources/sources/Baremetal/license_blob.h new file mode 100644 index 000000000..7c7c1694c --- /dev/null +++ b/resources/sources/Baremetal/license_blob.h @@ -0,0 +1,93 @@ +/* +license_blob.h - On-device license blob binary layout + storage CRC +Copyright (C) 2022 OpenPLC - Thiago Alves + +The C side of the license blob contract. Cross-pinned to the TypeScript +serializer (src/backend/shared/debug/license-blob.ts) by the golden vector in +its __tests__/fixtures/license-golden.json -- a layout change on either side +must fail a test rather than produce a blob the other end silently rejects. +Shared by every storage backend (AVR EEPROM, ESP32 NVS) and by the Modbus +license handlers. Layout is PACKED and LITTLE-ENDIAN for every multi-byte field +of the struct (magic, crc32). This is INDEPENDENT of the Modbus wire, which +carries the transfer `len` in BIG-ENDIAN (see modbus_pdu.cpp / modbus_debug.cpp). + + ENDIANNESS DUALITY: blob CONTENT is LITTLE-ENDIAN; the Modbus wire + `len` field is BIG-ENDIAN. Do not confuse the two. +*/ + +#ifndef LICENSE_BLOB_H +#define LICENSE_BLOB_H + +#include +#include + +// Byte layout (packed, contiguous — no padding): +// off field type size notes +// 0 magic uint32_t LE 4 'OPLC' -> bytes 4F 50 4C 43 (LE u32 = 0x434C504F) +// 4 fmt_version uint8_t 1 +// 5 key_id uint8_t 1 signing-key id (rotation) +// 6 device_id uint8_t[16] 16 +// 22 product_id uint8_t[8] 8 vpp id +// 30 (end of signed payload — payload = 30 bytes) +// 30 signature uint8_t[64] 64 ECDSA P-256 r||s raw (not DER) +// 94 crc32 uint32_t LE 4 over [payload||signature] (offsets 0..93) +// 98 (end of blob — sizeof(lic_blob_t) == 98) + +#pragma pack(push, 1) +typedef struct { + uint32_t magic; // 'OPLC' -> bytes 4F 50 4C 43 (LE u32 = 0x434C504F) + uint8_t fmt_version; + uint8_t key_id; // signing-key id (rotation) + uint8_t device_id[16]; + uint8_t product_id[8]; // vpp id +} lic_payload_t; // 30 bytes + +typedef struct __attribute__((packed)) { + lic_payload_t payload; // offsets 0..29 + uint8_t signature[64]; // offsets 30..93 (ECDSA P-256 r||s raw) + uint32_t crc32; // offset 94 (covers [payload||signature], 0..93) +} lic_blob_t; // 98 bytes +#pragma pack(pop) + +// Belt-and-suspenders: both #pragma pack and __attribute__((packed)) so AVR-GCC +// and xtensa-GCC (which treat the two differently) both drop the padding. + +#define LIC_MAGIC_LE 0x434C504Fu /* bytes 4F 50 4C 43 */ +#define LIC_BLOB_SIZE 98u +#define LIC_PAYLOAD_SIZE 30u + +// Portable compile-time assert. Every Baremetal .cpp includes this header, so it +// is compiled as C++, where static_assert is a keyword. _Static_assert is C-only +// (C11) and the C++ frontend (xtensa/avr gcc) rejects it. Keep the C form for the +// host golden test, which compiles this header as C11. +#if defined(__cplusplus) + #define LIC_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#else + #define LIC_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#endif + +LIC_STATIC_ASSERT(sizeof(lic_payload_t) == 30, "lic_payload_t must be 30 bytes"); +LIC_STATIC_ASSERT(sizeof(lic_blob_t) == 98, "lic_blob_t must be 98 bytes"); + +// CRC-32/ISO-HDLC (a.k.a. CRC-32, zlib/PKZIP). +// poly 0xEDB88320 (reflected) · init 0xFFFFFFFF · refin/refout true · xorout 0xFFFFFFFF +// test vector: crc32_iso_hdlc("123456789", 9) == 0xCBF43926 +// Bitwise loop (no 256-entry table) to save flash on AVR — the blob is only 94 +// bytes, so the ~8x/byte cost is negligible. Must match the TS implementation +// (license-blob.ts crc32IsoHdlc) byte-for-byte. +static inline uint32_t crc32_iso_hdlc(const uint8_t *data, size_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) + { + crc ^= (uint32_t)data[i]; + for (uint8_t b = 0; b < 8; b++) + { + uint32_t mask = -(int32_t)(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return crc ^ 0xFFFFFFFFu; +} + +#endif diff --git a/resources/sources/Baremetal/license_gate.h b/resources/sources/Baremetal/license_gate.h new file mode 100644 index 000000000..e89846b5d --- /dev/null +++ b/resources/sources/Baremetal/license_gate.h @@ -0,0 +1,80 @@ +/* +license_gate.h - License enforcement gate (verify + demo window). +The closed license-core (prebuilt) provides the STRONG implementation (verify + +15-minute demo timer). The open firmware ships a weak default that reports +UNSUPPORTED and allows actuation, so boards without a license-core behave as +before. The clock is injected (now_ms) so the core stays host-testable. +*/ +#ifndef LICENSE_GATE_H +#define LICENSE_GATE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + LIC_GATE_FULL = 0, /* valid license -> full operation */ + LIC_GATE_DEMO = 1, /* no/invalid license -> demo window running */ + LIC_GATE_DEMO_EXPIRED = 2, /* demo window elapsed -> actuation must stop */ + LIC_GATE_UNSUPPORTED = 3, /* no license-core linked (weak default) -> unenforced */ +} lic_gate_state_t; + +/* 15 minutes. Overridable at build (-DLIC_GATE_DEMO_MS=...) for bench tests + * that must watch the demo expire in seconds; production keeps the default. */ +#ifndef LIC_GATE_DEMO_MS +#define LIC_GATE_DEMO_MS 900000u +#endif + +/* + * Verify the stored blob once and arm the gate. + * blob / blob_len - the 98 bytes read out of on-device storage. + * now_ms - injected clock, so the core stays host-testable. + * + * The device anchor is NOT a parameter: license_core reads it from the silicon + * inside the closed artifact. It used to be passed in from the sketch, + * which made the identity a claim the open firmware could rewrite. + */ +void license_gate_init(const uint8_t *blob, size_t blob_len, uint32_t now_ms); +lic_gate_state_t license_gate_state(uint32_t now_ms); +int license_gate_actuation_allowed(uint32_t now_ms); + +/* + * May outputs be driven RIGHT NOW? Takes no clock on purpose. + * + * This is the question a signed HAL asks at the top of its own raw output write, + * so that calling `hal_write_outputs` directly -- which open code can do, the + * symbol is declared in openplc.h -- cannot skip the gate the way it did when the + * only check lived inside updateOutputBuffers(). + * + * No now_ms parameter because the caller must not choose the time: an entry point + * that accepts a timestamp accepts the boot timestamp forever. The gate reads a + * monotonic clock inside the closed artifact instead. + * + * Returns 1 when actuation is allowed (FULL, or a demo window still running) and + * 0 once the demo has expired. Fail-open before init, like + * license_gate_actuation_allowed: the firmware always inits in setup. + */ +int license_gate_outputs_permitted(void); + +#ifndef ARDUINO +/* + * HOST-TEST SEAM, absent from every device build by construction -- `ARDUINO` is + * defined for the prebuilt `.a`, so this symbol is not in the artifact a VPP + * ships. It has to be absent: `license_gate_init` refuses a second call + * specifically so open code cannot re-arm the demo window, and an exported + * "forget you were initialised" would hand that back with a nicer name. + * + * The host tests need it because they exercise FULL, expiry and millis-wrap as + * separate scenarios against one set of file-scope statics. + */ +void license_gate_reset_for_test(void); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* LICENSE_GATE_H */ diff --git a/resources/sources/Baremetal/license_gate_weak.cpp b/resources/sources/Baremetal/license_gate_weak.cpp new file mode 100644 index 000000000..d91edc938 --- /dev/null +++ b/resources/sources/Baremetal/license_gate_weak.cpp @@ -0,0 +1,41 @@ +/* +license_gate_weak.cpp - Weak fallback for the license enforcement gate. +Copyright (C) 2022 OpenPLC - Thiago Alves + +Guarantees the firmware always links even when no platform VPP provides a real +license-core. The VPP's prebuilt license-core (.a) defines the STRONG symbols +(ECDSA verify + 15-minute demo timer), which override these weak defaults at +link time. When absent, these run: the gate reports LIC_GATE_UNSUPPORTED and +actuation stays unconditionally allowed, so a board without a license-core +behaves exactly as it did before licensing existed (no enforcement). +*/ +#include "license_gate.h" + +__attribute__((weak)) void license_gate_init(const uint8_t *blob, size_t blob_len, + uint32_t now_ms) +{ + (void)blob; + (void)blob_len; + (void)now_ms; +} + +__attribute__((weak)) lic_gate_state_t license_gate_state(uint32_t now_ms) +{ + (void)now_ms; + return LIC_GATE_UNSUPPORTED; +} + +__attribute__((weak)) int license_gate_actuation_allowed(uint32_t now_ms) +{ + (void)now_ms; + return 1; +} + +// Same unenforced answer, for the same reason: a board with no license-core is +// not a board running an expired demo. A licensable VPP overrides this with the +// strong version from its closed .a, and its HAL asks THAT one before driving a +// pin — so this default is only ever reached where there is nothing to enforce. +__attribute__((weak)) int license_gate_outputs_permitted(void) +{ + return 1; +} diff --git a/resources/sources/Baremetal/license_store.h b/resources/sources/Baremetal/license_store.h new file mode 100644 index 000000000..72e716129 --- /dev/null +++ b/resources/sources/Baremetal/license_store.h @@ -0,0 +1,64 @@ +/* +license_store.h - Single storage interface for the on-device license blob +Copyright (C) 2022 OpenPLC - Thiago Alves + +The one point of contact for persisting the license blob. The closed license-core +CONSUMES this interface; the open-source firmware IMPLEMENTS it (per-arch backend, +each self-gated on its ARDUINO_ARCH_* macro: ESP32 NVS / ESP8266 emulated-EEPROM / +AVR EEPROM). + +license_store_read validates magic + crc32 internally, so it returns semantic +status (EMPTY / CORRUPT). ECDSA verify lives ABOVE this layer (out of scope here). +*/ + +#ifndef LICENSE_STORE_H +#define LICENSE_STORE_H + +#include +#include + +#include "license_blob.h" +#include "modbus_types.h" // MB_DEBUG_* status codes for lic_status_to_mb() + +typedef enum { + LIC_STORE_OK = 0, // operation done; for read: valid blob (magic+crc match) + LIC_STORE_EMPTY = 1, // virgin storage: magic absent OR key/region never written + LIC_STORE_CORRUPT = 2, // magic present but crc32 mismatch + LIC_STORE_IO_ERROR = 3, // backend failure (NVS begin/commit; EEPROM unavailable) + LIC_STORE_TOO_LARGE = 4, // len > backend capacity (write) or > caller buffer (read) + LIC_STORE_UNSUPPORTED = 5,// no backend on this board (weak default): licensing FCs degrade gracefully +} lic_store_status_t; + +// Write `len` raw bytes. Validates len <= capacity (else TOO_LARGE). +// Does NOT validate magic/crc/signature (integrity is checked on read / at verify). +lic_store_status_t license_store_write(const uint8_t *blob, size_t len); + +// Read the blob into `out` (capacity `cap`). Writes the read size into *out_len. +// EMPTY if virgin, CORRUPT if magic ok but crc fails, TOO_LARGE if cap < stored size. +lic_store_status_t license_store_read(uint8_t *out, size_t cap, size_t *out_len); + +// Erase the license (NVS remove / EEPROM zero the length). Idempotent. +lic_store_status_t license_store_erase(void); + +// Map a storage status to the Modbus debug status byte returned on the wire. +// OK -> MB_DEBUG_SUCCESS (0x7E) +// TOO_LARGE -> MB_DEBUG_ERROR_OUT_OF_BOUNDS (0x81, reused) +// IO_ERROR -> MB_DEBUG_ERROR_OUT_OF_MEMORY (0x82, reused) +// EMPTY -> MB_DEBUG_LIC_EMPTY (0x83) +// CORRUPT -> MB_DEBUG_LIC_CORRUPT (0x84) +// UNSUPPORTED -> MB_DEBUG_LIC_UNSUPPORTED (0x85) +static inline uint8_t lic_status_to_mb(lic_store_status_t st) +{ + switch (st) + { + case LIC_STORE_OK: return MB_DEBUG_SUCCESS; + case LIC_STORE_TOO_LARGE: return MB_DEBUG_ERROR_OUT_OF_BOUNDS; + case LIC_STORE_IO_ERROR: return MB_DEBUG_ERROR_OUT_OF_MEMORY; + case LIC_STORE_EMPTY: return MB_DEBUG_LIC_EMPTY; + case LIC_STORE_CORRUPT: return MB_DEBUG_LIC_CORRUPT; + case LIC_STORE_UNSUPPORTED: return MB_DEBUG_LIC_UNSUPPORTED; + default: return MB_DEBUG_ERROR_OUT_OF_MEMORY; + } +} + +#endif diff --git a/resources/sources/Baremetal/license_store_weak.cpp b/resources/sources/Baremetal/license_store_weak.cpp new file mode 100644 index 000000000..e01aa5649 --- /dev/null +++ b/resources/sources/Baremetal/license_store_weak.cpp @@ -0,0 +1,19 @@ +/* +license_store_weak.cpp - Weak fallback backend for the license store. +Copyright (C) 2022 OpenPLC - Thiago Alves + +Guarantees the firmware always links even when no platform VPP provides a real +backend. A VPP that ships license_store_.cpp defines the STRONG symbols, +which override these weak defaults at link time. When absent, these run and +report LIC_STORE_UNSUPPORTED, so the licensing FCs degrade gracefully. +*/ +#include "license_store.h" + +__attribute__((weak)) lic_store_status_t license_store_write(const uint8_t *, size_t) +{ return LIC_STORE_UNSUPPORTED; } + +__attribute__((weak)) lic_store_status_t license_store_read(uint8_t *, size_t, size_t *out_len) +{ if (out_len) *out_len = 0; return LIC_STORE_UNSUPPORTED; } + +__attribute__((weak)) lic_store_status_t license_store_erase(void) +{ return LIC_STORE_UNSUPPORTED; } diff --git a/resources/sources/Baremetal/modbus_config.h b/resources/sources/Baremetal/modbus_config.h new file mode 100644 index 000000000..642bd5b9f --- /dev/null +++ b/resources/sources/Baremetal/modbus_config.h @@ -0,0 +1,50 @@ +/* +modbus_config.h - Build configuration for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +The single place every Modbus translation unit picks up its build gates from. +It pulls in the generated defines.h (MODBUS_ENABLED / MBSERIAL / DEBUGGER_ENABLED +/ MBTCP / MBSERIAL_ON_SECONDARY / ...) and derives the composite gates on top of +it. Because the modularized TUs are gated (e.g. modbus_registers.cpp is wrapped +in #ifdef MODBUS_ENABLED), EACH of them must see these macros — so every modbus_* +header includes this one (via modbus_types.h). defines.h has no include guard, so +it must reach a TU through exactly one path: this header. +*/ + +#ifndef MODBUS_CONFIG_H +#define MODBUS_CONFIG_H + +#include +#include "defines.h" + +// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the +// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest +// of Modbus. This gate guards the serial RX/framing code so the debugger works +// over serial even when no Modbus operation buffers (coils/holding/etc.) are +// allocated. +#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) + #define MB_SERIAL_ACTIVE +#endif + +// Default serial config for the always-on debugger. `defines.h` normally emits +// DEBUG_IFACE / DEBUG_BAUD / DEBUG_SLAVE explicitly (from the Serial and Modbus +// RTU screens); these `#ifndef` defaults cover anything it left unset — they are +// a fallback for a hand-written defines.h, NOT the expected path. When they do +// apply, they must agree with what the editor dials, so they mirror +// `generate-defines.ts` (Serial @ 115200, slave id 1). +// Defined whenever the debugger is on — +// including alongside full Modbus RTU, since the dual-serial path (RTU on a +// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. +#ifdef DEBUGGER_ENABLED + #ifndef DEBUG_IFACE + #define DEBUG_IFACE Serial + #endif + #ifndef DEBUG_BAUD + #define DEBUG_BAUD 115200 + #endif + #ifndef DEBUG_SLAVE + #define DEBUG_SLAVE 1 + #endif +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_crc.cpp b/resources/sources/Baremetal/modbus_crc.cpp new file mode 100644 index 000000000..909836620 --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.cpp @@ -0,0 +1,72 @@ +/* +modbus_crc.cpp - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_crc.h" +// mb_frame / mb_frame_len come from the shared frame TU. Until T3 of the +// modularization they are declared in ModbusSlave.h; afterwards in modbus_frame.h. +#include "ModbusSlave.h" + +/* Table of CRC values for high-order byte */ +static const byte _auchCRCHi[] = { + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40}; + +/* Table of CRC values for low-order byte */ +static const byte _auchCRCLo[] = { + 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, + 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, + 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, + 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, + 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, + 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, + 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, + 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, + 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, + 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, + 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, + 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, + 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, + 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, + 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, + 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, + 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, + 0x40}; + +uint16_t calcCrc() +{ + uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; + + int i = 0; + Index = CRCHi ^ mb_frame[i]; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + i++; + + while (i < (mb_frame_len - 2)) + { + Index = CRCHi ^ mb_frame[i]; + i++; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + } + + return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; +} diff --git a/resources/sources/Baremetal/modbus_crc.h b/resources/sources/Baremetal/modbus_crc.h new file mode 100644 index 000000000..0655295da --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.h @@ -0,0 +1,18 @@ +/* +modbus_crc.h - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_CRC_H +#define MODBUS_CRC_H + +#include "modbus_types.h" + +// CRC-16 (Modbus) over mb_frame[0 .. mb_frame_len-2] — i.e. the whole frame +// except the trailing two CRC bytes. Reads the shared frame globals +// (mb_frame / mb_frame_len, declared in modbus_frame.h). The lookup tables are +// defined once in modbus_crc.cpp (they used to sit in the header, which risked +// one flash copy per translation unit). +uint16_t calcCrc(); + +#endif diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp new file mode 100644 index 000000000..4c8bd8ee3 --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -0,0 +1,519 @@ +/* +modbus_debug.cpp - OpenPLC always-on debugger function codes (0x41-0x4B) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_debug.h" +#include "license_store.h" // license_store_read/write + lic_status_to_mb +// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h +// (openplc_debug_*, scan_counter) so this TU stays free of strucpp's +// template-heavy headers and compiles cleanly under arduino-cli's default C++ +// standard. The shims forward to strucpp::debug::handle_* inside +// arduino_runtime_glue.cpp (part of the precompiled OpenPLCUserLib archive). +#include "arduino_runtime_glue.h" +// PLC_STATE_* / PLC_SWITCH_* and runtime_get_plc_state(), for the run/stop +// reporting in debugGetStatus() and plcSetState(). +#include "openplc.h" +#include "openplc_version.h" + +// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. +// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without +// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), +// the board-id handler returns id_len = 0 instead of failing to compile. +#ifndef OPENPLC_NO_UNIQUE_ID + #include + #define OPENPLC_HAS_UNIQUE_ID +#endif + +/** + * @brief Sends a Modbus response frame for the DEBUG_INFO function code. + * + * This function constructs a Modbus response frame for the DEBUG_INFO function code. + * The response frame includes the number of variables defined in the PLC program. + * + * Modbus Response Frame (DEBUG_INFO): + * +-----+-------+-------+ + * | MB | Count | Count | + * | FC | | | + * +-----+-------+-------+ + * |0x41 | High | Low | + * | | Byte | Byte | + * | | | | + * +-----+-------+-------+ + * + * @return void + */ +// Phase 4 PDU: +// +-----+-------+------+-----------+-----------+-----------+ +// | FC | arrs | stat | count_0 | count_1 | ... | +// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | +// +-----+-------+------+-----------+-----------+-----------+ +// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] +void debugInfo() +{ + uint8_t arrCount = openplc_debug_array_count(); + + // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. + // Realistic projects have <=10 arrays, so this is never a real limit. + uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; + if (arrCount > maxArrs) arrCount = maxArrs; + + mb_frame[1] = MB_FC_DEBUG_INFO; + mb_frame[2] = arrCount; + mb_frame[3] = MB_DEBUG_SUCCESS; + uint16_t pos = 4; + for (uint8_t i = 0; i < arrCount; i++) + { + uint16_t c = openplc_debug_elem_count(i); + mb_frame[pos++] = (uint8_t)(c >> 8); + mb_frame[pos++] = (uint8_t)(c & 0xFF); + } + mb_frame_len = pos; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_SET function code. + * + * This function constructs a Modbus response frame for the DEBUG_SET function code. + * The response frame indicates whether the set trace command was successful or if + * there was an error, such as an out-of-bounds index. + * + * Modbus Response Frame (DEBUG_SET): + * +-----+------+ + * | MB | Resp.| + * | FC | Code | + * +-----+------+ + * |0x42 | Code | + * +-----+------+ + * + * @param varidx The index of the variable to set trace for. + * @param flag The trace flag. + * @param len The length of the trace data. + * @param value Pointer to the trace data. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] +// Response: [FC, STATUS] +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value) +{ + if (len > (MAX_MB_FRAME - 8)) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint8_t status = openplc_debug_set( + arr, elem, (uint8_t)flag, (const uint8_t *)value, len); + + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = status; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET function code. + * The response frame includes the trace data for variables within the specified index range. + * + * Modbus Response Frame (DEBUG_GET): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param startidx The start index of the variables to get trace for. + * @param endidx The end index of the variables to get trace for. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] +// Response: [FC, STATUS, last_elem_hi, last_elem_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) +{ + uint16_t arrCount = openplc_debug_elem_count(arr); + if (arrCount == 0 || startidx >= arrCount || + endidx >= arrCount || startidx > endidx) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint16_t lastElemIdx = startidx; + uint16_t responseSize = 0; + uint8_t *responsePtr = &(mb_frame[11]); + + for (uint16_t elem = startidx; elem <= endidx; elem++) + { + uint16_t varSize = openplc_debug_size(arr, elem); + // Bounds check — stop packing if this one won't fit. + if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; + if (varSize == 0) { + // Entry has no readable bytes (string stub / out-of-bounds) + // — skip gracefully to keep the scan progressing. + lastElemIdx = elem; + continue; + } + uint16_t n = openplc_debug_read(arr, elem, responsePtr); + if (n == 0) { + lastElemIdx = elem; + continue; + } + responsePtr += n; + responseSize += n; + lastElemIdx = elem; + } + + mb_frame_len = 11 + responseSize; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastElemIdx >> 8); + mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. + * The response frame includes the trace data for variables specified in the provided index list. + * + * Modbus Response Frame (DEBUG_GET_LIST): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param numIndexes The number of indexes requested. + * @param indexArray Pointer to the array containing variable indexes. + * + * @return void + */ +// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] +// Response: [FC, STATUS, last_idx_hi, last_idx_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +// last_idx is the index *into the request list* that was last successfully +// included — the editor uses it to retry from the next item on overflow. +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) +{ + uint16_t response_idx = 11; + uint16_t responseSize = 0; + uint16_t lastReqIdx = 0; + + #ifdef MB_SERIAL_ACTIVE + #define VARIDX_SIZE 20 + #else + #define VARIDX_SIZE 60 + #endif + + if (numIndexes > VARIDX_SIZE) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; + return; + } + + // The request indexArray (at mb_frame[4..]) and the response buffer + // (mb_frame[11..]) overlap. Once handle_read writes the first response + // byte, later index entries inside mb_frame are clobbered. Snapshot the + // request first. + uint8_t localIndex[VARIDX_SIZE * 3]; + for (uint16_t i = 0; i < numIndexes * 3; i++) { + localIndex[i] = indexArray[i]; + } + + // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] + for (uint16_t i = 0; i < numIndexes; i++) + { + uint8_t arr = localIndex[i * 3]; + uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | + (uint16_t)localIndex[i * 3 + 2]; + + uint16_t varSize = openplc_debug_size(arr, elem); + if (varSize == 0) + { + // Out-of-bounds or string stub — skip gracefully. + lastReqIdx = i; + continue; + } + if ((response_idx + varSize) > MAX_MB_FRAME) break; + + uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); + if (n == 0) + { + lastReqIdx = i; + continue; + } + response_idx += n; + responseSize += n; + lastReqIdx = i; + } + + mb_frame_len = response_idx; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastReqIdx >> 8); + mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +// PDU request: [FC, endian_check_hi, endian_check_lo] +// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] +// +// The target always writes variable data in native byte order — STruC++ does +// no server-side byte-order adaptation, force/read is pure memcpy. To let +// the editor detect what "native" means here, the MD5 response trailer +// writes the literal value 0xDEAD via a native `uint16_t*` store. The +// bytes that land in the response are therefore in the target's native +// byte order: +// +// LE target → trailer bytes = [0xAD, 0xDE] +// BE target → trailer bytes = [0xDE, 0xAD] +// +// The editor inspects those two bytes after MD5 verification and decides +// whether subsequent force/read traffic needs byte-swapping at its end. +// +// The probe bytes the editor sends are intentionally ignored — the trailer +// is a runtime-driven sentinel, not an echo. The argument stays in the +// signature for ABI compatibility with the dispatcher. +void debugGetMd5(void * /*endianness*/) +{ + mb_frame[1] = MB_FC_DEBUG_GET_MD5; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char md5[] = PROGRAM_MD5; + int md5_len = 0; + for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) + { + mb_frame[md5_len + 3] = md5[md5_len]; + } + + // Native-order store of the endianness sentinel. Written byte-wise + // (not via `*reinterpret_cast`) because `md5_len + 3` is an + // odd offset for a 32-char MD5, and a typed 16-bit store there is an + // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / + // P1AM-100) — hanging the device on the first debugger request. Copying + // the two bytes of a native-order uint16_t preserves the target's byte + // ordering (the signal the editor uses to choose its swap behaviour) + // while keeping every access byte-aligned. + const uint16_t endian_sentinel = 0xDEAD; + const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); + mb_frame[md5_len + 3] = sentinel_bytes[0]; + mb_frame[md5_len + 4] = sentinel_bytes[1]; + mb_frame_len = md5_len + 5; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] +// +// Lightweight liveness/diagnostic probe that does not require a full debug +// session. `running` is always 1 on baremetal (the PLC scan is unconditional); +// `tick` is the scan counter (same value the read FCs report), so a client can +// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` +// is millis() since boot. +void debugGetStatus() +{ + uint32_t uptime = (uint32_t)millis(); + + mb_frame[1] = MB_FC_DEBUG_GET_STATUS; + mb_frame[2] = MB_DEBUG_SUCCESS; + // The real run/stop state, not a constant: the baremetal runtime has a + // state machine now (see arduino_runtime_glue.h). This byte being the + // state is why there is no separate query function code -- the editor's + // status poll already carries it. + mb_frame[3] = runtime_get_plc_state(); + mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[7] = (uint8_t)(scan_counter & 0xFF); + mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); + mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); + mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); + mb_frame[11] = (uint8_t)(uptime & 0xFF); + // Mode-switch position, appended so the editor can gate a start locally. + // Boards with no physical switch report RUN, so a caller needs no "absent" + // case; an older editor that stops reading at byte 11 simply ignores it. + mb_frame[12] = runtime_get_switch_position(); + mb_frame_len = 13; +} + +// PDU request: [FC][state:u8] (0 = STOP, 1 = RUN) +// PDU response: [FC][status][plc_state:u8][switch_position:u8] +// +// Command only -- reading the state is debugGetStatus() (FC 0x46) above, which +// already reports it. A RUN request while the mode switch reads STOP is +// REFUSED, not queued, so the editor tells the user to flip the switch instead +// of leaving a start pending. Stop requests are always honoured. +// +// The reported state is read back after the request is applied, but the runtime +// derives it inside runtime_plc_cycle() -- so on a change the value here is the +// state as of the last cycle and the caller sees the new one on its next status +// poll (at most one scan period later). +void plcSetState(uint8_t desired) +{ + uint8_t status = MB_DEBUG_SUCCESS; + + const uint8_t target = (desired == 0x01) ? PLC_STATE_RUNNING : PLC_STATE_STOPPED; + if (runtime_request_plc_state(target) == PLC_CTRL_REFUSED_SWITCH_STOP) + status = MB_PLC_CTRL_REFUSED_SWITCH; + + mb_frame[1] = MB_FC_PLC_SET_STATE; + mb_frame[2] = status; + mb_frame[3] = runtime_get_plc_state(); + mb_frame[4] = runtime_get_switch_position(); + mb_frame_len = 5; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) +// +// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor +// reads the ASCII bytes up to the end of the frame. +void debugGetVersion() +{ + mb_frame[1] = MB_FC_DEBUG_GET_VERSION; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char ver[] = OPENPLC_RUNTIME_VERSION; + uint16_t i = 0; + for (i = 0; ver[i] != '\0'; i++) + { + if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame + mb_frame[3 + i] = (uint8_t)ver[i]; + } + mb_frame_len = 3 + i; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, id_len:u8, id_bytes...] +// +// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize +// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 +// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. +void debugGetBoardId() +{ + mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; + mb_frame[2] = MB_DEBUG_SUCCESS; + +#ifdef OPENPLC_HAS_UNIQUE_ID + uint8_t idLen = (uint8_t)UniqueIDsize; + // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. + if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); + mb_frame[3] = idLen; + for (uint8_t i = 0; i < idLen; i++) + mb_frame[4 + i] = UniqueID[i]; + mb_frame_len = 4 + idLen; +#else + mb_frame[3] = 0; // no unique-id support on this core + mb_frame_len = 4; +#endif +} + +// --------------------------------------------------------------------------- +// On-device license storage (FC 0x49 write / 0x4A read) +// +// These two only MOVE BYTES. They do not verify a signature and do not decide +// anything about execution: that is the closed license-core's job, via +// license_gate.h. Keeping them dumb is what lets the open firmware carry them. +// --------------------------------------------------------------------------- + +// PDU request: [FC][len:u16 BE][blob...] (dispatcher passes `len` already unpacked) +// PDU response: [FC][STATUS] +// +// NOTE on endianness: `len` on the wire is BIG-ENDIAN (matches every other debug +// FC, e.g. GET_LIST/SET). The blob CONTENT it carries is little-endian — the two +// are independent. `blob` points at mb_frame[4], and the response is only written +// after license_store_write has consumed it, so there is no overlap hazard. +void debugWriteLicense(uint16_t len, const uint8_t *blob) +{ + // The license blob is a FIXED 98 bytes; anything else is not one, so refuse + // before the store ever sees it. Two separate problems close here. + // + // 1. CONTRACT. The two targets disagreed about this exact field: the Linux + // runtime answers LIC_CORRUPT for len != 98, while bare metal used to pass + // `len` straight through — so [0x49][len=0x0004][4 bytes] came back + // SUCCESS. One command, two contracts, and the editor believing whichever + // target it happened to be talking to. + // + // 2. OVERREAD. `len` is read from mb_frame[2..3] and can be up to 0xFFFF, + // while `blob` points into `mb_frame`, a static buffer of MAX_MB_FRAME + // (128 on 328P, 256 elsewhere). Over RTU this was already unreachable: + // mb_pdu_request_len() computes 6 + len, modbus_serial.cpp drops any frame + // whose expected length exceeds MAX_MB_FRAME, and it waits for the bytes to + // actually arrive. Over TCP nothing checked it: modbus_tcp.cpp bounds only + // the MBAP length (mb_frame_len) and never cross-checks it against this + // field, so a 6-byte packet declaring len = 0xFFFF would make + // license_store_write read ~64 KB past the frame. Testing `len` here is the + // one check that covers both transports at once. + if ((size_t)len != (size_t)LIC_BLOB_SIZE) + { + mb_frame[1] = MB_FC_DEBUG_WRITE_LICENSE; + mb_frame[2] = lic_status_to_mb(LIC_STORE_CORRUPT); + mb_frame_len = 3; + return; + } + + lic_store_status_t st = license_store_write(blob, (size_t)len); + mb_frame[1] = MB_FC_DEBUG_WRITE_LICENSE; + mb_frame[2] = lic_status_to_mb(st); + mb_frame_len = 3; +} + +// PDU request: [FC] +// PDU response (OK): [FC][STATUS][len:u16 BE][blob...] +// PDU response (EMPTY/CORRUPT/error): [FC][STATUS] (no len, no blob) +// +// Absolute mb_frame indices (index 0 is the slave id, the PDU starts at 1, +// exactly like debugGetBoardId): FC@1, STATUS@2, len@3..4 (BIG-ENDIAN), blob@5. +// +// The store reads straight into &mb_frame[5]: the frame IS the static buffer, so +// there is no malloc on AVR. READ carries no request payload, so writing at [5] +// cannot clobber an input. `out_len` is unknown until after the read, and the len +// field lives at [3..4] — BEFORE the blob — so filling it afterwards never +// overlaps the blob bytes. A 98-byte blob fits MAX_MB_FRAME comfortably. +void debugReadLicense(void) +{ + size_t out_len = 0; + lic_store_status_t st = + license_store_read(&mb_frame[5], MAX_MB_FRAME - 5, &out_len); + + mb_frame[1] = MB_FC_DEBUG_READ_LICENSE; + mb_frame[2] = lic_status_to_mb(st); + if (st == LIC_STORE_OK) + { + // len BIG-ENDIAN at [3..4] (blob content stays little-endian). + mb_frame[3] = (uint8_t)((out_len >> 8) & 0xFF); + mb_frame[4] = (uint8_t)(out_len & 0xFF); + mb_frame_len = 5 + out_len; + } + else + { + mb_frame_len = 3; // [FC][STATUS] only + } +} diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h new file mode 100644 index 000000000..347ce9904 --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.h @@ -0,0 +1,35 @@ +/* +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x4B) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the +dispatch in modbus_pdu references them unconditionally (the always-on debugger is +present on every baremetal build). This is the growth home for future custom FCs. +*/ + +#ifndef MODBUS_DEBUG_H +#define MODBUS_DEBUG_H + +#include "modbus_frame.h" + +// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era +// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. +void debugInfo(void); +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value); +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); +void debugGetMd5(void *endianness); +// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). +void debugGetStatus(void); +void debugGetVersion(void); +void debugGetBoardId(void); +// On-device license storage (0x49/0x4A). `len` is the BIG-ENDIAN wire length +// (already unpacked by the dispatcher); the blob CONTENT is little-endian. +void debugWriteLicense(uint16_t len, const uint8_t *blob); // 0x49 +void debugReadLicense(void); // 0x4A +// FC 0x4B -- set the runtime run/stop state. Command only; the state is read +// back through debugGetStatus (FC 0x46), which reports it. +void plcSetState(uint8_t desired); + +#endif diff --git a/resources/sources/Baremetal/modbus_frame.cpp b/resources/sources/Baremetal/modbus_frame.cpp new file mode 100644 index 000000000..ae4055108 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.cpp @@ -0,0 +1,21 @@ +/* +modbus_frame.cpp - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_frame.h" + +//Global Modbus vars — the shared frame buffer and the slave/register struct. +struct MBinfo modbus; +uint8_t mb_frame[MAX_MB_FRAME]; +uint16_t mb_frame_len; + +void exceptionResponse(uint16_t fcode, uint16_t excode) +{ + //Clean frame buffer (leave only SlaveID) + mb_frame_len = 3; + for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; + mb_frame[0] = modbus.slaveid; + mb_frame[1] = fcode + 0x80; + mb_frame[2] = excode; +} diff --git a/resources/sources/Baremetal/modbus_frame.h b/resources/sources/Baremetal/modbus_frame.h new file mode 100644 index 000000000..f751eabd8 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.h @@ -0,0 +1,23 @@ +/* +modbus_frame.h - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_FRAME_H +#define MODBUS_FRAME_H + +#include "modbus_types.h" + +// The one buffer every layer shares. A transport fills mb_frame[0..mb_frame_len), +// calls process_mbpacket() (which builds the response back into mb_frame), then +// writes it out. `modbus` carries the slave id — used in EVERY build, including +// debug-only — plus the operation register banks, which are allocated only when +// full Modbus is enabled (see modbus_registers.cpp under MODBUS_ENABLED). +extern struct MBinfo modbus; +extern uint8_t mb_frame[MAX_MB_FRAME]; +extern uint16_t mb_frame_len; + +// Build a Modbus exception response into mb_frame: [slaveid][fcode|0x80][excode]. +void exceptionResponse(uint16_t fcode, uint16_t excode); + +#endif diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp new file mode 100644 index 000000000..995ced8ab --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -0,0 +1,212 @@ +/* +modbus_pdu.cpp - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_pdu.h" +#include "modbus_registers.h" +#include "modbus_debug.h" + +// Derived per function code, exactly as process_mbpacket() below parses the +// fields — the single source of truth for the RTU frame shape. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) +{ + if (n < 2) return 0; // need at least id + FC + switch (f[1]) + { + case MB_FC_READ_COILS: + case MB_FC_READ_INPUT_STAT: + case MB_FC_READ_REGS: + case MB_FC_READ_INPUT_REGS: + case MB_FC_WRITE_COIL: + case MB_FC_WRITE_REG: + return 8; // [id][fc][a:2][b:2][crc:2] + case MB_FC_WRITE_COILS: + case MB_FC_WRITE_REGS: + if (n < 7) return 0; // byte count lives at f[6] + return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] + case MB_FC_DEBUG_INFO: + return 4; // [id][fc][crc:2] + case MB_FC_DEBUG_GET: + return 9; // [id][fc][arr:1][s:2][e:2][crc:2] + case MB_FC_DEBUG_GET_LIST: + if (n < 4) return 0; // count lives at f[2..3] + return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); + case MB_FC_DEBUG_SET: + if (n < 8) return 0; // value len lives at f[6..7] + return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); + case MB_FC_DEBUG_GET_MD5: + return 8; // [id][fc][endian:2][00:2][crc:2] + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + case MB_FC_DEBUG_READ_LICENSE: + return 4; // [id][fc][crc:2] + case MB_FC_DEBUG_WRITE_LICENSE: + if (n < 4) return 0; // len (BE) lives at f[2..3] + // [id][fc][len:2][blob:len][crc:2] -> overhead 6 + blob len. + // NOTE: len is BIG-ENDIAN on the wire (blob content is little-endian). + return 6 + (int32_t)(((uint16_t)f[2] << 8) | f[3]); + case MB_FC_PLC_SET_STATE: + return 5; // [id][fc][state:1][crc:2] + default: + return -1; // not one of our function codes + } +} + +// The debug FCs are private, well-formed and performance-sensitive, so their RTU +// frames skip CRC. Keep this list in lockstep with the DEBUG cases below and in +// mb_pdu_request_len above. +bool mb_pdu_skips_crc(uint8_t fc) +{ + switch (fc) + { + case MB_FC_DEBUG_INFO: + case MB_FC_DEBUG_SET: + case MB_FC_DEBUG_GET: + case MB_FC_DEBUG_GET_LIST: + case MB_FC_DEBUG_GET_MD5: + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + case MB_FC_DEBUG_WRITE_LICENSE: + case MB_FC_DEBUG_READ_LICENSE: + return true; + default: + return false; + } +} + +void process_mbpacket() +{ + uint8_t fcode = mb_frame[1]; +#ifdef MODBUS_ENABLED + // Standard Modbus fields — only used by the operation FCs, which are + // compiled out in debug-only builds (so guard to avoid unused-var warnings). + uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; +#endif + void *endianness_check = &mb_frame[2]; + + switch (fcode) + { +#ifdef MODBUS_ENABLED + // Standard Modbus operation FCs read/write the coil/register buffers, + // which only exist when full Modbus is enabled. In debug-only builds + // these cases are compiled out, so operation requests fall through to + // the default and get an ILLEGAL_FUNCTION exception. + case MB_FC_WRITE_REG: + //field1 = reg, field2 = value + writeSingleRegister(field1, field2); + break; + + case MB_FC_READ_REGS: + //field1 = startreg, field2 = numregs + readRegisters(field1, field2); + break; + + case MB_FC_WRITE_REGS: + //field1 = startreg, field2 = status + writeMultipleRegisters(field1, field2, mb_frame[6]); + break; + + case MB_FC_READ_COILS: + //field1 = startreg, field2 = numregs + readCoils(field1, field2); + break; + + case MB_FC_READ_INPUT_STAT: + //field1 = startreg, field2 = numregs + readInputStatus(field1, field2); + break; + + case MB_FC_READ_INPUT_REGS: + //field1 = startreg, field2 = numregs + readInputRegisters(field1, field2); + break; + + case MB_FC_WRITE_COIL: + //field1 = reg, field2 = status + writeSingleCoil(field1, field2); + break; + + case MB_FC_WRITE_COILS: + //field1 = startreg, field2 = numoutputs + writeMultipleCoils(field1, field2, mb_frame[6]); + break; +#endif // MODBUS_ENABLED + + case MB_FC_DEBUG_INFO: + debugInfo(); + break; + + case MB_FC_DEBUG_GET: + { + // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] + uint8_t arr = mb_frame[2]; + uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; + debugGetTrace(arr, startIdx, endIdx); + } + break; + + case MB_FC_DEBUG_GET_LIST: + { + // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] + uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + debugGetTraceList(numIndexes, &mb_frame[4]); + } + break; + + case MB_FC_DEBUG_SET: + { + // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] + uint8_t arr = mb_frame[2]; + uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint8_t flag = mb_frame[5]; + uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; + void *value = &mb_frame[8]; + debugSetTrace(arr, elem, flag, len, value); + } + break; + + case MB_FC_DEBUG_GET_MD5: + debugGetMd5(endianness_check); + break; + + case MB_FC_DEBUG_GET_STATUS: + debugGetStatus(); + break; + + case MB_FC_DEBUG_GET_VERSION: + debugGetVersion(); + break; + + case MB_FC_DEBUG_GET_BOARD_ID: + debugGetBoardId(); + break; + + case MB_FC_DEBUG_WRITE_LICENSE: + { + // PDU: [FC:1][len:u16 BE][blob...] + // len is BIG-ENDIAN (same convention as GET_LIST/SET); the blob + // CONTENT it carries is little-endian. + uint16_t lic_len = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + debugWriteLicense(lic_len, &mb_frame[4]); + } + break; + + case MB_FC_DEBUG_READ_LICENSE: + debugReadLicense(); + break; + + case MB_FC_PLC_SET_STATE: + // PDU: [FC:1][state:u8] (0 = STOP, 1 = RUN) + plcSetState(mb_frame[2]); + break; + + + default: + exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); + } +} diff --git a/resources/sources/Baremetal/modbus_pdu.h b/resources/sources/Baremetal/modbus_pdu.h new file mode 100644 index 000000000..e0ff077bf --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.h @@ -0,0 +1,34 @@ +/* +modbus_pdu.h - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves + +The protocol layer: it owns the set of function codes and their shapes. A +transport fills mb_frame with a request, calls process_mbpacket() to dispatch it +(operation FC -> modbus_registers, debug FC -> modbus_debug) and read the +response back out. The transport does NOT know the FC set — it asks this layer +via mb_pdu_request_len() / mb_pdu_skips_crc(), so adding a function code touches +only this file plus its handler, never the transports. +*/ + +#ifndef MODBUS_PDU_H +#define MODBUS_PDU_H + +#include "modbus_frame.h" + +// Dispatch the PDU in mb_frame[0..mb_frame_len) to its handler and build the +// response back into mb_frame. +void process_mbpacket(); + +// Total on-wire length (slave id + PDU + 2 CRC bytes) of the RTU request whose +// first `n` bytes are in `f`: >0 for a known length, 0 when more header bytes are +// needed to size it, -1 for a function code we do not serve (so the byte cannot +// be a frame head). Length is implicit in Modbus RTU — derived per FC, exactly as +// process_mbpacket() later parses the fields. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n); + +// True for the private debugger FCs, whose RTU frames deliberately skip CRC +// validation (they are well-formed and performance-sensitive). Lets the serial +// transport decide CRC handling without hardcoding the debug FC list. +bool mb_pdu_skips_crc(uint8_t fc); + +#endif diff --git a/resources/sources/Baremetal/modbus_registers.cpp b/resources/sources/Baremetal/modbus_registers.cpp new file mode 100644 index 000000000..a1be65c24 --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.cpp @@ -0,0 +1,525 @@ +/* +modbus_registers.cpp - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_registers.h" + +// The register banks and operation FCs only exist when full Modbus is enabled. +// In a debug-only build this whole TU compiles to nothing, saving flash/SRAM. +#ifdef MODBUS_ENABLED + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) +{ + //Save sizes + modbus.holding_size = size_holding; + modbus.dint_memory_size = size_dint_memory; + modbus.lint_memory_size = size_lint_memory; + modbus.coils_size = size_coils; + modbus.input_regs_size = size_inputregs; + modbus.input_status_size = size_inputstatus; + + //round discrete regs sizes + if (size_coils % 8 > 0) + size_coils = (size_coils / 8) + 1; + else + size_coils = size_coils / 8; + if (size_inputstatus % 8 > 0) + size_inputstatus = (size_inputstatus / 8) + 1; + else + size_inputstatus = (size_inputstatus / 8); + + modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); + if (modbus.coils == NULL) return false; + memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); + + modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); + if (modbus.holding == NULL) return false; + memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); + + if (size_dint_memory > 0) + { + modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); + if (modbus.dint_memory == NULL) return false; + memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); + } + + if (size_lint_memory > 0) + { + modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); + if (modbus.lint_memory == NULL) return false; + memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); + } + + modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); + if (modbus.input_status == NULL) return false; + memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); + + modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); + if (modbus.input_regs == NULL) return false; + memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); + + return true; +} + +bool get_discrete(uint16_t addr, bool regtype) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + return bitRead(modbus.coils[byte_addr], bit_addr); + else + return bitRead(modbus.input_status[byte_addr], bit_addr); +} + +void write_discrete(uint16_t addr, bool regtype, bool value) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + bitWrite(modbus.coils[byte_addr], bit_addr, value); + else + bitWrite(modbus.input_status[byte_addr], bit_addr, value); +} + +//Modbus handling functions +void readRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_REGS; + mb_frame[2] = mb_frame_len - 3; //byte count + + uint16_t val; + uint16_t i = 0; + uint8_t pos = 0; + while(numregs--) + { + if ((startreg + i) < modbus.holding_size) + { + //retrieve the value from the register bank for the current register + val = modbus.holding[startreg + i]; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + val = (uint16_t)(modbus.dint_memory[pos] >> 16); + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); + } + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + val = (uint16_t)(modbus.lint_memory[pos] >> 48); + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); + } + } + + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleRegister(uint16_t reg, uint16_t value) +{ + if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); + return; + } + + uint8_t pos = 0; + + if (reg < modbus.holding_size) + { + modbus.holding[reg] = value; + } + else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if (reg % 2 == 0) //first word + { + pos = (reg - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = (reg - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if (reg % 4 == 0) //first word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if (reg % 4 == 1) //second word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if (reg % 4 == 2) //third word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } +} + +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) +{ + //Check value + if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_REGS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + uint16_t value; + uint16_t i = 0; + uint8_t pos = 0; + while(numoutputs--) + { + value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; + + if ((startreg + i) < modbus.holding_size) + { + modbus.holding[(startreg + i)] = value; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } + + i++; + } +} + +void readCoils(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (startreg + numregs > modbus.coils_size) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = slaveid + function type + byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_COILS; + mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) + + uint8_t bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete((uint8_t)startreg, COILS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputStatus(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_status_size) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = function type, byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_STAT; + mb_frame[2] = mb_frame_len - 3; + + byte bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete(startreg, INPUTSTATUS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_regs_size) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + //for each register queried add 2 bytes + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_REGS; + mb_frame[2] = mb_frame_len - 3; + + uint16_t val; + uint16_t i = 0; + while(numregs--) + { + //retrieve the value from the register bank for the current register + val = modbus.input_regs[startreg + i]; + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleCoil(uint16_t reg, uint16_t status) +{ + //Check value (status) + if (status != 0xFF00 && status != 0x0000) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (reg > (modbus.coils_size - 1)) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Execute + write_discrete(reg, COILS, status == 0xFF00 ? true : false); +} + +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) +{ + //Check value + uint8_t bytecount_calc = numoutputs / 8; + if (numoutputs%8) bytecount_calc++; + if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) > modbus.coils_size) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_COILS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + //Execute + uint8_t bitn = 0; + uint16_t totoutputs = numoutputs; + uint16_t i; + while (numoutputs) + { + i = (totoutputs - numoutputs--) / 8; + write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +#endif // MODBUS_ENABLED diff --git a/resources/sources/Baremetal/modbus_registers.h b/resources/sources/Baremetal/modbus_registers.h new file mode 100644 index 000000000..27258273b --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.h @@ -0,0 +1,31 @@ +/* +modbus_registers.h - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves + +The coil/holding/input register banks and the standard Modbus operation FCs +(0x01-0x10). Compiled only under MODBUS_ENABLED — a debug-only build never +references these symbols (the debugger reads IEC variables directly through the +strucpp debug table, needing no operation buffers). The `modbus` instance itself +lives in modbus_frame.* because its slave id is shared by every build. +*/ + +#ifndef MODBUS_REGISTERS_H +#define MODBUS_REGISTERS_H + +#include "modbus_frame.h" + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); +bool get_discrete(uint16_t addr, bool regtype); +void write_discrete(uint16_t addr, bool regtype, bool value); + +//Modbus operation function-code handlers +void readRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleRegister(uint16_t reg, uint16_t value); +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); +void readCoils(uint16_t startreg, uint16_t numregs); +void readInputStatus(uint16_t startreg, uint16_t numregs); +void readInputRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleCoil(uint16_t reg, uint16_t status); +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); + +#endif diff --git a/resources/sources/Baremetal/modbus_serial.cpp b/resources/sources/Baremetal/modbus_serial.cpp new file mode 100644 index 000000000..c68f36a5c --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.cpp @@ -0,0 +1,269 @@ +/* +modbus_serial.cpp - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_serial.h" +#include "modbus_pdu.h" // process_mbpacket, mb_pdu_request_len, mb_pdu_skips_crc +#include "modbus_crc.h" // calcCrc + +#if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) +#include "Controllino.h" +#endif + +//Serial timing/port state. +Stream* mb_serialport; +int8_t mb_txpin; +uint16_t mb_t15; // inter character time out +uint16_t mb_t35; // frame delay + +void mbconfig_serial_iface(Stream* port, long baud, int txPin) +{ + mb_serialport = port; + mb_txpin = txPin; + //(*port).begin(baud); //Initialization already happened on main .ino file + + //RS-485 control + if (txPin >= 0) + { + pinMode(txPin, OUTPUT); + digitalWrite(txPin, LOW); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (mb_serialport == &Serial3) + Controllino_RS485Init(); + #elif defined(CONTROLLINO_MICRO) + if (mb_serialport == &Serial2) { + pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); + pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + // Modbus states that a baud rate higher than 19200 must use a fixed 750 us + // for inter character time out. For baud rates below 19200 the timing + // is more critical and has to be calculated. + // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second + // In milliseconds this will be 872 characters per 1000ms. So for 1 character + // 1000ms/872 characters is 1.14583ms per character. Finally modbus states + // an inter-character must be 1.5T or 1.5 times longer than a character. Thus + // 1.5T = 1.14583ms * 1.5 = 1.71875ms. + // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud + // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant + + if (baud > 19200) + mb_t15 = 750; + else + mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 + + /* The modbus definition of a frame delay is a waiting period of 3.5 character times + between packets.*/ + + mb_t35 = mb_t15 * 3.5; +} + +#ifdef MB_SERIAL_ACTIVE +// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose +// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a +// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at +// 115200) and the byte cadence delimits frames. That assumption is INVALID on +// USB-CDC (and any store-and-forward link): a single request is split into +// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, +// so cadence framing tears requests apart (the bug that made the P1AM-100 / +// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length +// (derived from the function code) and fall back to this idle only to drop a +// truncated partial. It must exceed any intra-frame USB gap yet stay well below +// a master's request timeout. +#define MB_RTU_FRAME_GAP_MS 8 + +// Persistent RX-assembly state. handle_serial() is called every scan cycle and +// never blocks; a request whose bytes straddle several calls is carried across +// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which +// is safe because an OpenPLC board is configured for a single Modbus transport; +// the two are not driven mid-frame at the same time.) +static uint16_t mb_rx_len = 0; +static uint32_t mb_rx_last_ms = 0; + +#ifdef MBSERIAL_ON_SECONDARY +// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a +// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on +// one port must survive while the other is serviced. `mb_frame` becomes a +// transient process/TX buffer, borrowed for one complete transaction at a time +// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled +// sequentially). These extra buffers are compiled ONLY for boards that use a +// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the +// original single-buffer footprint. +static uint8_t mb_rx_dbg[MAX_MB_FRAME]; +static uint16_t mb_rx_dbg_len = 0; +static uint32_t mb_rx_dbg_last_ms = 0; +static uint8_t mb_rx_rtu[MAX_MB_FRAME]; +static uint16_t mb_rx_rtu_len = 0; +static uint32_t mb_rx_rtu_last_ms = 0; +#endif + +// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used +// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — +// so a genuine frame head sitting further into the buffer always survives and +// is eventually found (guarantees resync convergence; no "discard every frame" +// loop). Slides only run on the error path, so the O(n) cost is irrelevant. +static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) +{ + if (k >= *plen) { *plen = 0; return; } + for (uint16_t i = k; i < *plen; i++) + buf[i - k] = buf[i]; + *plen = (uint16_t)(*plen - k); +} + +// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly +// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 +// when none). A complete frame is copied into the shared `mb_frame`, processed, +// and the response written back to `port`. In the single-serial build `buf` IS +// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a +// distinct buffer and `mb_frame` is the transient process/TX scratch. +static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, + uint8_t *buf, uint16_t *plen, uint32_t *plast) +{ + uint16_t packet_crc; + + // 1) Drain the RX buffer without blocking. One frame's bytes may arrive + // across several calls; the scan cycle is never stalled waiting on them. + while (port->available() > 0) + { + if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it + buf[(*plen)++] = (uint8_t)port->read(); + *plast = millis(); + } + + // 2) Extract every complete frame in the buffer. Each iteration either + // consumes/realigns by >=1 byte or returns to await more data, so the + // loop always terminates. + for (;;) + { + if (*plen == 0) + return; + + // Header byte-alignment: the first byte must be THIS port's slave id. + // This is the cheap framing check, and it is the ONLY validation applied + // to debugger frames (CRC is deliberately skipped on debug FCs for + // performance — those function codes are private and well-formed). + if (buf[0] != slaveid) + { + mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide + continue; + } + + int32_t expected = mb_pdu_request_len(buf, *plen); + + if (expected < 0 || expected > MAX_MB_FRAME) + { + mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length + continue; + } + if (expected == 0 || *plen < (uint16_t)expected) + { + // Header incomplete, or the frame's tail has not arrived yet. Wait + // for it; abandon the partial only if its remainder never comes. + if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) + *plen = 0; + return; + } + + // 3) A full candidate frame occupies buf[0 .. expected). Move it into the + // shared process buffer (a no-op self-copy on the single-serial path, + // where buf already IS mb_frame). + if (buf != mb_frame) + { + for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; + } + + // Standard FCs are validated by CRC (the arbiter that makes resync + // trustworthy); a mismatch means corruption or misalignment, so we + // slide one byte and retry instead of discarding the whole buffer. + if (!mb_pdu_skips_crc(mb_frame[1])) + { + mb_frame_len = (uint16_t)expected; + packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); + if (packet_crc != calcCrc()) + { + mb_rtu_drop_front(buf, plen, 1); + continue; + } + } + + // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, + // which builds the response back into mb_frame. + mb_frame_len = (uint16_t)expected - 2; + process_mbpacket(); + + //Add CRC + //Check if response message is too big for this device + if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); + mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC + packet_crc = calcCrc(); //calculate CRC of the new packet + mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); + mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); + + if (txpin >= 0) + { + digitalWrite(txpin, HIGH); + delayMicroseconds(mb_t35); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485TxEnable(); // Enable RS485 chip to transmit + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + port->write(mb_frame, mb_frame_len); + port->flush(); + delayMicroseconds(mb_t35); + + if (txpin >= 0) + digitalWrite(txpin, LOW); + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); + } + #endif + + // 5) The request — and the response built over it — consumed the whole + // assembly buffer. Modbus RTU is turn-taking: the master waits for + // this reply before sending its next request, so no following frame + // can already be buffered. Reset for the next request. A + // non-conformant pipelining master simply retransmits after its + // timeout, and the gap/realignment logic above recovers cleanly. + *plen = 0; + return; + } +} + +// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus +// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial +// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus +// RTU runs on a distinct UART — each with its own RX buffer. +void handle_serial() +{ +#ifdef MBSERIAL_ON_SECONDARY + handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); + #ifdef MBSERIAL_TXPIN + handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #else + handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #endif +#else + handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); +#endif +} +#endif // MB_SERIAL_ACTIVE diff --git a/resources/sources/Baremetal/modbus_serial.h b/resources/sources/Baremetal/modbus_serial.h new file mode 100644 index 000000000..eeca143db --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.h @@ -0,0 +1,32 @@ +/* +modbus_serial.h - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves + +The serial wire: RTU framing (declared-length, not byte-cadence), RS485 tx-enable +timing, and single- or dual-serial polling. It fills mb_frame with a request, +asks modbus_pdu for the frame shape / CRC policy, calls process_mbpacket() and +writes the response back — it holds NO knowledge of the function-code set. +*/ + +#ifndef MODBUS_SERIAL_H +#define MODBUS_SERIAL_H + +#include "modbus_frame.h" + +// Serial timing/port state, configured once by mbconfig_serial_iface(). +extern Stream* mb_serialport; +extern int8_t mb_txpin; +extern uint16_t mb_t15; // inter character time out +extern uint16_t mb_t35; // frame delay + +// Bind + configure the serial interface (RS485 driver-enable pin, T1.5/T3.5 +// timing derived from baud). Serial.begin() itself happens in the .ino sketch. +void mbconfig_serial_iface(Stream* port, long baud, int txPin); + +#ifdef MB_SERIAL_ACTIVE +// Poll the serial port(s) for a complete RTU/debugger frame and answer it. +// Non-blocking; called every scan cycle. +void handle_serial(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.cpp b/resources/sources/Baremetal/modbus_tcp.cpp new file mode 100644 index 000000000..ec9e88afd --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -0,0 +1,294 @@ +/* +modbus_tcp.cpp - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_tcp.h" +#include "modbus_pdu.h" // process_mbpacket + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + WiFiServer mb_server(502); + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#else + EthernetServer mb_server(502); +#endif + uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + WiFiServer mb_server(502); + uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + + ETH.begin(); + + if (ip != NULL && subnet != NULL && gateway != NULL) + (ETH.config(ip, gateway, subnet, dns)); + + #else + if (ip == NULL) + Ethernet.begin(mac); + else if (dns == NULL) + Ethernet.begin(mac, IPAddress(ip)); + else if (gateway == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + #endif + +// int num_tries = 0; +// while (!ETH.linkUp()) +// { +// delay(500); +// num_tries++; +// if (num_tries == 20) break; +// } + + #endif + #ifdef MBTCP_WIFI + #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) + if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) + { + uint8_t secondaryDNS[] = {8, 8, 8, 8}; + WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); + } + mb_server.setNoDelay(true); + #elif defined(BOARD_PORTENTA) + if (ip != NULL && subnet != NULL && gateway != NULL) + { + WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); + } + #else + if (ip != NULL) + { + if (dns == NULL) + WiFi.config(IPAddress(ip)); + else if (gateway == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + } + #endif + WiFi.begin(MBTCP_SSID, MBTCP_PWD); + int num_tries = 0; + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + num_tries++; + if (num_tries == 10) break; + } + #endif + + mb_server.begin(); + +} + +void handle_tcp() +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + WiFiClient client = mb_server.available(); + #else + EthernetClient client = mb_server.available(); + #endif + #endif + + #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) + WiFiClient client = mb_server.available(); + #endif + + //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific + //code lies below + #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) + + + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) + if (client) + #else + if (mb_server.hasClient()) + #endif + { + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() + { + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) + mb_serverClients[i] = client; + #else + mb_serverClients[i] = mb_server.available(); + #endif + break; + } + } + } + + //search all clients for data + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + int j = 0; + + + if (mb_serverClients[i].connected() && mb_serverClients[i].available()) + + { + //Read packet + + + while (mb_serverClients[i].available()) + { + mb_mbap[j] = mb_serverClients[i].read(); + j++; + if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + j = 0; + while (mb_serverClients[i].available()) + { + mb_frame[j] = mb_serverClients[i].read(); + j++; + if (j==mb_frame_len) break; + } + + //Safety check - discard packages that lie about their size + if (j != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (j = 0 ; j < MBAP_SIZE ; j++) + sendbuffer[j] = mb_mbap[j]; + + //PDU Frame + for (j = 0 ; j < mb_frame_len ; j++) + sendbuffer[j+MBAP_SIZE] = mb_frame[j]; + + //Write back + mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + + //If this is not an ESP board or Portenta board, then here is the default code + #else + if (client) + { + if (client.connected()) + { + int i = 0; + while (client.available()) + { + mb_mbap[i] = client.read(); + i++; + if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + i = 0; + while (client.available()) + { + mb_frame[i] = client.read(); + i++; + if (i==mb_frame_len || i==MAX_MB_FRAME) break; + } + + //Safety check - discard packages that lie about their size + if (i != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (i = 0 ; i < MBAP_SIZE ; i++) + sendbuffer[i] = mb_mbap[i]; + + //PDU Frame + for (i = 0 ; i < mb_frame_len ; i++) + sendbuffer[i+MBAP_SIZE] = mb_frame[i]; + + //Write back + client.write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + #endif +} +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.h b/resources/sources/Baremetal/modbus_tcp.h new file mode 100644 index 000000000..dcfec3c38 --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.h @@ -0,0 +1,77 @@ +/* +modbus_tcp.h - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The TCP wire: brings the platform networking stack up, accepts up to +MAX_SRV_CLIENTS connections and services MBAP-framed requests. Like the serial +transport it fills mb_frame, calls process_mbpacket() and writes the response +back — no knowledge of the function-code set. +*/ + +#ifndef MODBUS_TCP_H +#define MODBUS_TCP_H + +#include "modbus_frame.h" + +//Platform specific defines and includes +#ifdef MBTCP_ETHERNET +#include +#ifdef BOARD_ESP32 + // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) + #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT + // Type of the Ethernet PHY (LAN8720 or TLK110) + #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) + #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C clock signal for the Ethernet PHY + #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C IO signal for the Ethernet PHY + #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT + // External clock from crystal oscillator + #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT + #include + #include +#else + #include +#endif +#endif + +#ifdef MBTCP_WIFI +#if defined(BOARD_ESP8266) +#include +#elif defined(BOARD_ESP32) +#include +#elif defined(BOARD_WIFININA) +#include +#else +#include +#include +#endif +#endif + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + extern WiFiServer mb_server; +#else + extern EthernetServer mb_server; +#endif + extern uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + extern WiFiServer mb_server; + extern uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); +void handle_tcp(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h new file mode 100644 index 000000000..be8fb1d5a --- /dev/null +++ b/resources/sources/Baremetal/modbus_types.h @@ -0,0 +1,107 @@ +/* +modbus_types.h - Shared type/constant declarations for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +Pure declarations only (enums, MBinfo, frame-size constants, status codes, +bit helpers). No storage, no functions — every Modbus TU includes this so the +protocol, transport, register and debug layers agree on the same contracts. +*/ + +#ifndef MODBUS_TYPES_H +#define MODBUS_TYPES_H + +// Brings , the generated defines.h and the composite build gates +// (MB_SERIAL_ACTIVE, DEBUG_* defaults). Every modbus_* TU reaches defines.h +// through this single path — defines.h itself has no include guard. +#include "modbus_config.h" + +#ifndef bitRead + #define bitRead(value, bit) (((value) >> (bit)) & 0x01) +#endif +//#define bitSet(value, bit) ((value) |= (1UL << (bit))) +//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) +#ifndef bitWrite + #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) +#endif + +#define COILS 0 +#define INPUTSTATUS 1 + +#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) + #define MAX_MB_FRAME 128 +#else + #define MAX_MB_FRAME 256 +#endif +#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time +#define MBAP_SIZE 6 + +// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept +// as macros here so the Modbus layer doesn't have to include the C++ +// runtime header when the rest of the protocol is C-style). +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +// License storage semantic states. The editor distinguishes all three: EMPTY and +// CORRUPT both mean "recover from the backend", while UNSUPPORTED means the board +// cannot hold a license at all and buying one would not help. They don't collide +// with Modbus exceptions (0x01-0x04) nor 0x7E/0x81/0x82. +#define MB_DEBUG_LIC_EMPTY 0x83 +#define MB_DEBUG_LIC_CORRUPT 0x84 +// No license-store backend on this board (weak default): the licensing FCs +// degrade gracefully instead of failing as a transport error. +#define MB_DEBUG_LIC_UNSUPPORTED 0x85 +// MB_FC_PLC_SET_STATE only: a RUN request was refused because the hardware mode +// switch reads STOP. The editor turns this into a "flip the switch to RUN" +// warning rather than a generic failure. It doesn't collide with Modbus +// exceptions (0x01-0x04) nor 0x7E/0x81/0x82. +#define MB_PLC_CTRL_REFUSED_SWITCH 0x86 + +//Modbus registers struct +struct MBinfo { + uint8_t slaveid; + uint16_t *holding; + uint8_t holding_size; + uint32_t *dint_memory; + uint8_t dint_memory_size; + uint64_t *lint_memory; + uint8_t lint_memory_size; + uint8_t *coils; + uint8_t coils_size; + uint16_t *input_regs; + uint8_t input_regs_size; + uint8_t *input_status; + uint8_t input_status_size; +}; + +//Function Codes +enum { + MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx + MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx + MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx + MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx + MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx + MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx + MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx + MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx + MB_FC_DEBUG_INFO = 0x41, // Request debug variables count + MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) + MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) + MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) + MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 + MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) + MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version + MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID + MB_FC_DEBUG_WRITE_LICENSE = 0x49, // Debug write license blob to on-device storage + MB_FC_DEBUG_READ_LICENSE = 0x4A, // Debug read license blob from on-device storage + MB_FC_PLC_SET_STATE = 0x4B, // Set the runtime run/stop state +}; + +//Exception Codes +enum { + MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported + MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists + MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range + MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request +}; + +#endif diff --git a/resources/sources/Baremetal/openplc_version.h b/resources/sources/Baremetal/openplc_version.h new file mode 100644 index 000000000..8023a9080 --- /dev/null +++ b/resources/sources/Baremetal/openplc_version.h @@ -0,0 +1,19 @@ +/* +openplc_version.h - OpenPLC runtime/firmware version +Copyright (C) 2022 OpenPLC - Thiago Alves + +Single source of truth for the firmware version reported by the always-on +debugger (Modbus FC 0x47, DEBUG_GET_VERSION). This is a property of the +firmware source tree, NOT the editor application version, so it is defined +here rather than injected by the editor at compile time. Bump manually when +the firmware runtime evolves. +*/ + +#ifndef OPENPLC_VERSION_H +#define OPENPLC_VERSION_H + +#ifndef OPENPLC_RUNTIME_VERSION + #define OPENPLC_RUNTIME_VERSION "4.2.7" +#endif + +#endif // OPENPLC_VERSION_H diff --git a/resources/sources/arduino/arduino_runtime_glue.cpp b/resources/sources/arduino/arduino_runtime_glue.cpp index 37ed22dae..2967f9d45 100644 --- a/resources/sources/arduino/arduino_runtime_glue.cpp +++ b/resources/sources/arduino/arduino_runtime_glue.cpp @@ -19,6 +19,15 @@ #include "generated.hpp" #include "debug_dispatch.hpp" +// Placement new, used by runtime_reinit_program() to re-run the program's +// initializers over storage that already exists. Available on every target the +// editor builds for, AVR included (the bundled avr-libstdcpp ships , and +// the strucpp headers above already pull it in transitively via ). +// Note this is the PLACEMENT form only -- it allocates nothing. +#include +// std::is_trivially_destructible, for the diagnostic static_assert below. +#include + // --------------------------------------------------------------------------- // Runtime fault hook // --------------------------------------------------------------------------- @@ -46,6 +55,53 @@ static size_t total_programs = 0; unsigned long long base_tick_ns = 20000000ULL; uint32_t scan_counter = 0; +// --------------------------------------------------------------------------- +// Run/stop state. See the contract comment in arduino_runtime_glue.h. +// +// `software_stop` is the latch set by runtime_request_plc_state(); `plc_state` +// is derived from it plus the switch every cycle, so it is never written from +// anywhere but runtime_plc_cycle() / runtime_init_plc_state(). +// --------------------------------------------------------------------------- +static uint8_t plc_state = PLC_STATE_RUNNING; +static uint8_t switch_position = PLC_SWITCH_RUN; +static uint8_t last_switch = PLC_SWITCH_RUN; +static bool software_stop = false; + +// Weak default: boards with no physical mode switch always read RUN, so the +// gate collapses to "software request only" and the boot state is RUNNING -- +// identical to the behaviour before this interface existed. A VPP HAL +// provides a strong extern "C" override. +extern "C" __attribute__((weak)) uint8_t hardwareStateSwitch(void) +{ + return PLC_SWITCH_RUN; +} + +extern "C" uint8_t runtime_get_plc_state(void) +{ + return plc_state; +} + +extern "C" uint8_t runtime_get_switch_position(void) +{ + return switch_position; +} + +extern "C" uint8_t runtime_request_plc_state(uint8_t desired_state) +{ + if (desired_state == PLC_STATE_RUNNING) { + // Hardware is authoritative: refuse rather than queue, so the caller + // can tell the user to flip the switch instead of silently waiting. + if (hardwareStateSwitch() == PLC_SWITCH_STOP) return PLC_CTRL_REFUSED_SWITCH_STOP; + software_stop = false; + return PLC_CTRL_OK; + } + if (desired_state == PLC_STATE_STOPPED) { + software_stop = true; + return PLC_CTRL_OK; + } + return PLC_CTRL_INVALID; +} + // --------------------------------------------------------------------------- // GCD utility — used by discoverTasks for the base-tick computation // --------------------------------------------------------------------------- @@ -235,25 +291,147 @@ void runtime_apply_located_forces() } // --------------------------------------------------------------------------- -// One scan cycle: copy inputs → run scheduled programs → copy outputs → -// advance IEC TIME() so TON/TOF/TP can progress. +// De-energise the output image. +// +// Called every cycle while stopped, immediately before updateOutputBuffers() +// pushes the image to hardware. Two consequences worth keeping in mind: +// +// - A Modbus client writing coils between cycles cannot energise a physical +// output while stopped: its write lands in the image and is zeroed here +// before the HAL ever sees it. +// - Memory areas (int_memory / dint_memory / lint_memory) are deliberately +// NOT cleared. They are not physical outputs. +// +// The image slots alias the located variables' IECVar storage, so this also +// zeroes the program's own %QX / %QW / %QD variables. That is intended: a +// stopped PLC holds no output state. +// --------------------------------------------------------------------------- +static void runtime_zero_output_image() +{ + for (int i = 0; i < MAX_DIGITAL_OUTPUT; ++i) { + if (bool_output[i / 8][i % 8]) *bool_output[i / 8][i % 8] = 0; + } + for (int i = 0; i < MAX_ANALOG_OUTPUT; ++i) { + if (int_output[i]) *int_output[i] = 0; + } +#if !defined(__AVR_ATmega328P__) && !defined(__AVR_ATmega168__) && !defined(__AVR_ATmega32U4__) && !defined(__AVR_ATmega16U4__) + for (int i = 0; i < MAX_REAL_OUTPUT; ++i) { + if (real_output[i]) *real_output[i] = 0.0f; + } +#endif +} + +// --------------------------------------------------------------------------- +// Cold-stop the program: re-run every IEC initial value so the next start +// begins at cycle 1 rather than resuming mid-flight. +// +// NO DYNAMIC ALLOCATION. g_config is a file-scope object with static storage +// duration (.bss/.data), and placement new constructs into that existing +// storage — it calls neither malloc nor operator new(size_t). Everything the +// generated Configuration holds is by value and fixed size, and nothing in +// the strucpp runtime allocates (IECVar is three value members; IEC_STRING is +// a fixed char array). +// +// Every pointer into g_config survives, because placement new reuses the same +// storage with the same layout: locatedVars[i].pointer, the image-table slots, +// the ProgramBase* entries cached in all_programs[] and in the configuration's +// own task_programs_storage[], and the flash-resident Entry tables in +// generated_debug.cpp that hold raw void* into g_config members. +// +// runtime_discover_tasks() is deliberately NOT re-run: it new[]-allocates +// all_programs / task_divisors, so calling it twice would leak. The tables it +// built stay correct. +// +// Two documented consequences: debugger forces are cleared (force state lives +// inside each IECVar), and a program using the explicit IEC NEW operator must +// DELETE before stopping or it leaks across restarts — nothing frees those +// allocations automatically, at re-init or otherwise. +// --------------------------------------------------------------------------- +static void runtime_reinit_program() +{ + // Destroy then re-construct in place. The destructor call matters: + // Configuration_CONFIG0 derives from strucpp::ConfigurationInstance, which + // declares `virtual ~ConfigurationInstance() = default` (iec_std_lib.hpp), + // so the type is NOT trivially destructible even though it owns nothing. + // Pairing the destructor with the placement new is correct either way -- + // for a defaulted virtual destructor it compiles to nothing, and if a + // future strucpp change adds a genuinely owning member it runs that + // member's cleanup instead of leaking it. Neither call allocates. + g_config.~Configuration_CONFIG0(); + new (&g_config) strucpp::Configuration_CONFIG0(); + + runtime_zero_output_image(); + runtime_bind_located_vars(); // idempotent, allocation-free + scan_counter = 0; +} + +// --------------------------------------------------------------------------- +// Establish the initial state. Called once from setup(), after hardwareInit() +// so the HAL's switch pin is already configured. +// --------------------------------------------------------------------------- +void runtime_init_plc_state() +{ + switch_position = hardwareStateSwitch(); + last_switch = switch_position; + software_stop = false; + plc_state = (switch_position == PLC_SWITCH_STOP) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; +} + +// --------------------------------------------------------------------------- +// One scan cycle: resolve run/stop → copy inputs → run scheduled programs → +// copy outputs → advance IEC TIME() so TON/TOF/TP can progress. +// +// While stopped the loop keeps cycling: inputs are still refreshed (so the +// debugger and Modbus clients see live field data during commissioning), +// outputs stay de-energised, updateOutputBuffers() is still called (so a HAL +// driving a status LED from it stays correct), and IEC time is frozen. // --------------------------------------------------------------------------- void runtime_plc_cycle() { + // 1. Resolve the state from the mode switch and the software latch. + const uint8_t sw = hardwareStateSwitch(); + // A physical flip to RUN always puts the PLC in RUN — clearing a software + // stop, so the switch is never overridden by a stale editor command. + if (sw == PLC_SWITCH_RUN && last_switch == PLC_SWITCH_STOP) software_stop = false; + last_switch = sw; + switch_position = sw; + + const uint8_t new_state = + (sw == PLC_SWITCH_STOP || software_stop) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; + + // Entering STOP is a cold stop: zero the outputs and re-initialise the + // program exactly once, on the transition. + if (new_state == PLC_STATE_STOPPED && plc_state != PLC_STATE_STOPPED) { + runtime_reinit_program(); + } + plc_state = new_state; + + // 2. Inputs, in both states. updateInputBuffers(); // HAL just wrote raw input storage directly — re-impose any forced input. runtime_apply_located_forces(); - for (size_t i = 0; i < total_programs; ++i) { - if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { - all_programs[i]->run(); + if (plc_state == PLC_STATE_RUNNING) { + for (size_t i = 0; i < total_programs; ++i) { + if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { + all_programs[i]->run(); + } } + ++scan_counter; + } else { + // Re-zero every stopped cycle, not just on the transition: a Modbus + // client may have written coils into the image since the last cycle. + runtime_zero_output_image(); } - ++scan_counter; + // 3. Outputs, in both states — zeros while stopped. updateOutputBuffers(); - strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + // 4. IEC time advances only while running, so TON/TOF/TP resume where + // they left off instead of jumping by the stop duration. + if (plc_state == PLC_STATE_RUNNING) { + strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + } } // --------------------------------------------------------------------------- diff --git a/resources/sources/arduino/arduino_runtime_glue.h b/resources/sources/arduino/arduino_runtime_glue.h index 0ffe2ca42..1aa1b59e4 100644 --- a/resources/sources/arduino/arduino_runtime_glue.h +++ b/resources/sources/arduino/arduino_runtime_glue.h @@ -39,9 +39,50 @@ extern uint32_t scan_counter; void runtime_bind_located_vars(); void runtime_discover_tasks(); +// Establish the initial run/stop state. Call once from setup() AFTER +// hardwareInit(), so the HAL has already configured its switch pin. Reads +// the mode switch: a board powered up with the switch in STOP never +// executes a scan. +void runtime_init_plc_state(); + // Per-cycle helpers (call once per scan cycle from scheduler()/loop()). void runtime_plc_cycle(); +// --------------------------------------------------------------------------- +// Run/stop control surface. +// +// State is derived every cycle from the mode switch (hardwareStateSwitch(), +// PLC_SWITCH_RUN when no HAL implements it) and a software-request latch set +// through runtime_request_plc_state(): +// +// switch software request state +// ------ ---------------- ----- +// RUN run (default) RUNNING <- every board with no switch +// RUN stop STOPPED +// STOP (ignored) STOPPED <- hardware is authoritative +// +// A STOP -> RUN edge on the switch resets the software request to `run`, so +// a physical flip to RUN always puts the PLC in RUN -- otherwise a +// software-stopped device would sit dead in the RUN position with no local +// way to recover. +// +// runtime_get_plc_state() is declared in openplc.h because HALs call it to +// drive a status LED. +// --------------------------------------------------------------------------- + +// Result codes for runtime_request_plc_state(). +#define PLC_CTRL_OK 0 +#define PLC_CTRL_REFUSED_SWITCH_STOP 1 +#define PLC_CTRL_INVALID 2 + +// Last value read from hardwareStateSwitch() (PLC_SWITCH_*). +uint8_t runtime_get_switch_position(void); + +// Ask for PLC_STATE_RUNNING or PLC_STATE_STOPPED. A request to run while the +// mode switch reads STOP is REFUSED, not queued -- the caller reports that +// to the user rather than retrying. Returns PLC_CTRL_*. +uint8_t runtime_request_plc_state(uint8_t desired_state); + // Re-impose forced located variables' values onto their raw storage. Call // after any code path that writes the image pointers directly (HAL input // refresh, Modbus reverse-copy) so a debugger force is not clobbered. Cheap diff --git a/resources/sources/arduino/openplc.h b/resources/sources/arduino/openplc.h index a9cde6270..ddfc8d7e4 100644 --- a/resources/sources/arduino/openplc.h +++ b/resources/sources/arduino/openplc.h @@ -74,6 +74,20 @@ extern IEC_ULINT *lint_memory[MAX_MEMORY_LWORD]; #endif +/*********************/ +/* Run/stop state */ +/*********************/ + +// Mode-switch positions reported by hardwareStateSwitch(). +#define PLC_SWITCH_STOP 0 +#define PLC_SWITCH_RUN 1 + +// Externally visible runtime states, as reported by runtime_get_plc_state() +// and over Modbus FC 0x49. +#define PLC_STATE_STOPPED 0 +#define PLC_STATE_RUNNING 1 +#define PLC_STATE_ERROR 2 + //Hardware Layer (implemented in arduino.cpp HAL file, compiled as extern "C") #ifdef __cplusplus extern "C" { @@ -81,6 +95,58 @@ extern "C" { void hardwareInit(); void updateInputBuffers(); void updateOutputBuffers(); + +/* ---- Optional: physical mode switch ------------------------------------ + * Weak default in arduino_runtime_glue.cpp returns PLC_SWITCH_RUN, so a HAL + * that does not define this behaves exactly as before this interface + * existed: the runtime boots into RUNNING and the editor has full software + * control. + * + * Override with a strong extern "C" definition in the HAL .cpp -- the same + * mechanism the P1AM HAL already uses for strucpp::iec_runtime_fault. + * + * Called once per scan cycle, in every state, from the scan path. MUST + * return quickly and MUST NOT block. HOW it does so is the HAL's decision: + * a GPIO is cheap enough to read synchronously, while a switch behind a + * slow bus (I2C expander, fieldbus backplane) should be sampled elsewhere + * and returned here from a cached value. The runtime never polls on the + * HAL's behalf and never imposes a sampling period. + * ---------------------------------------------------------------------- */ +uint8_t hardwareStateSwitch(void); + +/* ---- Optional: state indication ---------------------------------------- + * There is no indication callback. The runtime holds the state; a HAL with + * a status LED reads it inside updateOutputBuffers() (which the runtime + * calls every cycle in every state, so the LED is correct from the first + * cycle even on a board that boots into STOP) and drives its own pin. A + * HAL with no LED reads nothing and the runtime never knows the difference. + * ---------------------------------------------------------------------- */ +uint8_t runtime_get_plc_state(void); + +/* ---- Raw I/O ops, for LICENSABLE VPP targets only ---------------------- + * Two mutually exclusive shapes exist, and there is deliberately NO weak + * fallback for the gated wrappers above: + * + * - Licensable VPP HAL: defines ONLY these raw ops. The gated + * updateInputBuffers / updateOutputBuffers come from inside the closed + * license-core .a, which asks license_gate_actuation_allowed() and then + * calls hal_read_inputs / hal_write_outputs — or hal_disable_all_outputs + * once the demo window has expired. + * + * - Every other HAL (the bundled resources/sources/hal/*.cpp, and any + * unlicensed VPP): defines updateInput/OutputBuffers itself and leaves + * these raw ops undeclared-but-unused. No gate is involved. + * + * THE CONSEQUENCE IS THE POINT: dropping the license-core .a from a licensable + * VPP does not silently degrade to unenforced I/O — the firmware fails to LINK, + * with undefined updateInputBuffers / updateOutputBuffers. The only weak + * defaults shipped are license_gate_weak.cpp (gate query -> UNSUPPORTED, + * actuation allowed) and license_store_weak.cpp (blob store -> UNSUPPORTED), + * which keep NON-licensable boards linking; neither provides these wrappers. + * ---------------------------------------------------------------------- */ +void hal_read_inputs(void); +void hal_write_outputs(void); +void hal_disable_all_outputs(void); #ifdef __cplusplus } #endif diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 8070dc4b8..333812800 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -10,6 +10,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' // --------------------------------------------------------------------------- // Layer definitions @@ -118,7 +119,12 @@ const LAYER_RULES: Record = { // Helpers // --------------------------------------------------------------------------- -const SRC_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..') +// `new URL(...).pathname` yields a URL-encoded path that, on win32, carries a +// leading slash before the drive letter (/C:/Users/...). Passing that into +// resolve() prepends the current drive, producing a doubled C:\C:\Users\... +// prefix — which made validate:arch fail to even scan the tree. fileURLToPath +// does the file:// -> filesystem conversion correctly on every platform. +const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') function collectFiles(dir: string, ext: string[]): string[] { const results: string[] = [] @@ -180,36 +186,25 @@ function getLayer(filePath: string): LayerName | null { return null } -/** Extract import/export-from paths from a TypeScript source string */ +/** + * Every `import ... from '...'` / `export ... from '...'` / bare `import '...'`, + * with the line it starts on. + * + * Scans the whole source rather than line by line: a MULTI-LINE import — the + * default once a statement names more than a couple of symbols — puts the + * `import` keyword and the module path on different lines, so a per-line regex + * silently sees neither. That blind spot hid real violations of these very rules, + * which is worse than having no gate, because the gate reported success. + */ function extractImports(source: string): { path: string; line: number }[] { const results: { path: string; line: number }[] = [] - const lines = source.split('\n') - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Static imports: import ... from '...' - // Re-exports: export ... from '...' - const staticMatch = line.match(/(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/) - if (staticMatch) { - results.push({ path: staticMatch[1], line: i + 1 }) - continue - } - - // Side-effect imports: import '...' - const sideEffectMatch = line.match(/^\s*import\s+['"]([^'"]+)['"]/) - if (sideEffectMatch) { - results.push({ path: sideEffectMatch[1], line: i + 1 }) - continue - } - - // Dynamic imports: import('...') - const dynamicMatch = line.match(/import\(\s*['"]([^'"]+)['"]\s*\)/) - if (dynamicMatch) { - results.push({ path: dynamicMatch[1], line: i + 1 }) - } + const pattern = /(?:^|\n)\s*(?:import|export)\b[\s\S]*?from\s+['"]([^'"]+)['"]|(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g + let match: RegExpExecArray | null + while ((match = pattern.exec(source)) !== null) { + const path = match[1] ?? match[2] + if (!path) continue + results.push({ path, line: source.slice(0, match.index).split('\n').length }) } - return results } @@ -279,6 +274,31 @@ const KNOWN_EXCEPTIONS: Record = { 'frontend/store/slices/ladder/utils/index.ts': ['components'], // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation 'frontend/store/slices/ladder/slice.ts': ['components'], + // Device CONNECT flow (D72) — resolves RTU params from the board debug spec + // via the shared `resolveDebugConnection` resolver, same as the activity bar's + // debugger/post-flash paths. + 'frontend/hooks/use-device-connect.ts': ['backend-shared'], + // Baremetal run/stop mirror — maps the PROTOCOL's run/stop and switch wire + // values (`PlcRuntimeState` / `PlcSwitchPosition`, defined next to the RTU + // client that reads them) onto the store's `PlcStatus` union. Same D72 device + // link as the sibling entry above. The alternative is either duplicating the + // numeric constants in the frontend or hoisting the two enums into + // ports/types.ts; both were judged worse than one documented import. + 'frontend/hooks/use-device-plc-state.ts': ['backend-shared'], + // Run/stop control port — `PlcControlResult` is the FC 0x4b acknowledgement + // shape, defined with the protocol types it is built from (`PlcRuntimeState`). + // Type-only import; hoisting it into ports/types.ts would drag the wire enums + // along with it, so the contract stays where the protocol is described. + 'middleware/shared/ports/debugger-port.ts': ['backend-shared'], + // Device connect/debug resolution — interprets the board's declarative `debug` + // spec (backend/shared/hardware/debug-spec.ts), which is the ONE place that spec + // is read. The alternative is a second interpreter in the frontend, which is how + // Connect and the debugger came to disagree about what a spec meant. + 'frontend/services/device-link-resolution.ts': ['backend-shared'], + // Activity bar — resolves the same spec for the post-upload reconnect and the + // debug session. Pre-existing; it was invisible until `extractImports` learned + // to read multi-line imports. + 'frontend/components/_organisms/workspace-activity-bar/default.tsx': ['backend-shared'], // PLCopen export — needs the shared XmlGenerator composing function // (backend/shared/utils/PLC/xml-generator.ts) to turn the converted // project data into XML before handing it to the platform port. No diff --git a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts index 1de587f46..66b2edcda 100644 --- a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts +++ b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts @@ -277,7 +277,29 @@ describe('createEditorCompilerPlatformPort', () => { const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext()) const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) expect(handleVendorPluginPackaging).toHaveBeenCalledTimes(1) - expect(result).toEqual({ files: {} }) + // No `getVppRuntimeFloor` in the default context, so no floor is known — + // which the pipeline reads as "no constraint". + expect(result).toEqual({ files: {}, minRuntimeVersion: null }) + }) + + it('packageVppPlugin surfaces the VPP runtime floor when the context can resolve one', async () => { + const getVppRuntimeFloor = jest.fn(() => '4.1.9') + const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor })) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(getVppRuntimeFloor).toHaveBeenCalledWith('SLM-RP4') + expect(result.minRuntimeVersion).toBe('4.1.9') + }) + + it('packageVppPlugin reports no floor when the resolver throws', async () => { + // A gate that failed the build because it could not read its own metadata + // would be worse than the mismatch it exists to catch. + const getVppRuntimeFloor = jest.fn(() => { + throw new Error('registry unreadable') + }) + const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor })) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(result.minRuntimeVersion).toBeNull() + expect(result.errors).toBeUndefined() }) it('packageVppPlugin returns an errors[] when the handler throws', async () => { @@ -327,7 +349,48 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, () => undefined, ) - expect(result).toEqual({ ok: true, version: '4.1.2' }) + // This stub answers every endpoint with a `/api/version` body, so + // `/api/capabilities` yields no usable `runtimeVersion` and the probe + // falls back — the exact shape of a runtime predating the endpoint. + expect(result).toEqual({ ok: true, version: '4.1.2', minEditorVersion: null }) + }) + + it('checkRuntimeVersion reads the editor floor from /api/capabilities when the device serves it', async () => { + const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => { + if (endpoint === '/api/capabilities') { + return { success: true as const, data: { runtimeVersion: 'v4.2.0', minEditorVersion: '4.2.1' } } + } + return { success: true as const, data: { version: 'SHOULD-NOT-BE-USED' } } + }) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + () => undefined, + ) + expect(result).toEqual({ ok: true, version: 'v4.2.0', minEditorVersion: '4.2.1' }) + }) + + it('checkRuntimeVersion falls back to /api/version when capabilities 404s', async () => { + const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => { + if (endpoint === '/api/capabilities') return { success: false as const, error: '404 Not Found' } + return { success: true as const, data: { version: 'v4.1.7' } } + }) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const log = jest.fn() + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + log, + ) + expect(result).toEqual({ ok: true, version: 'v4.1.7', minEditorVersion: null }) + // The 404 is the normal answer from every deployed runtime — it must not + // nag the user on every upload. + expect(log).not.toHaveBeenCalled() }) it('checkRuntimeVersion returns version=null and logs a warning on probe failure', async () => { @@ -344,7 +407,7 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, log, ) - expect(result).toEqual({ ok: true, version: null }) + expect(result).toEqual({ ok: true, version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime'), 'warning') }) @@ -361,7 +424,7 @@ describe('createEditorCompilerPlatformPort', () => { { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, log, ) - expect(result).toEqual({ ok: true, version: null }) + expect(result).toEqual({ ok: true, version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('probe blew up'), 'warning') }) }) diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts index 393bd63d6..36415b989 100644 --- a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -126,3 +126,72 @@ describe('handleCoreInstallation (prebuilt core pin = exact manifest version)', expect(message).toMatch(/already installed/) }) }) + +/** + * Vendor board-manager index support. + * + * Regression cover for "Platform 'industrialshields:esp32' not found": a VPP + * declaring `target.boardManagerUrl` must reach arduino-cli as + * `--additional-urls`, on BOTH `core update-index` and `core install`. The + * index refresh is what makes the install resolvable — `core install` alone + * matches against the cached index and still fails. + */ +describe('handleCoreInstallation (vendor board manager URL)', () => { + const VENDOR_URL = 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + let compilerModule: CompilerModule + + beforeEach(() => { + compilerModule = new CompilerModule() + jest.mocked(spawn).mockReset() + jest.mocked(spawn).mockImplementation(() => fakeChild(0) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + }) + + it('refreshes the index against the vendor URL BEFORE installing', async () => { + await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL) + + expect(spawn).toHaveBeenCalledTimes(2) + const [, updateArgv] = jest.mocked(spawn).mock.calls[0] + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(updateArgv).toEqual(expect.arrayContaining(['core', 'update-index', '--additional-urls', VENDOR_URL])) + expect(installArgv).toEqual( + expect.arrayContaining(['core', 'install', 'industrialshields:esp32', '--additional-urls', VENDOR_URL]), + ) + }) + + it('passes --additional-urls alongside a pinned core version', async () => { + await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), '2.7.1', VENDOR_URL) + + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(installArgv).toEqual( + expect.arrayContaining(['core', 'install', 'industrialshields:esp32@2.7.1', '--additional-urls', VENDOR_URL]), + ) + }) + + it('omits --additional-urls entirely when the board declares no vendor index', async () => { + await compilerModule.handleCoreInstallation('arduino:avr', jest.fn(), '1.8.6') + + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).not.toContain('--additional-urls') + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'arduino:avr@1.8.6'])) + }) + + it('still attempts the install when the index refresh fails', async () => { + // First spawn (update-index) fails, second (install) succeeds. A flaky + // network on the refresh must not mask the install's own error. + let call = 0 + jest.mocked(spawn).mockImplementation(() => { + call += 1 + return fakeChild(call === 1 ? 1 : 0) as unknown as ReturnType + }) + + await expect( + compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL), + ).resolves.not.toThrow() + + expect(spawn).toHaveBeenCalledTimes(2) + const [, installArgv] = jest.mocked(spawn).mock.calls[1] + expect(installArgv).toEqual(expect.arrayContaining(['core', 'install', 'industrialshields:esp32'])) + }) +}) diff --git a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts index 458a83c8c..6f29d2fb8 100644 --- a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts @@ -129,6 +129,34 @@ describe('handlePrecompileUserLib include-path injection', () => { expect(compileCmd).toContain('-I/fake/renesas/variants/UNOWIFIR4') }) + // esp8266 decides flash-vs-IRAM by matching the OBJECT NAME in its linker + // script (`*.cpp.o(.literal* .text*)` -> flash). Named `foo.o`, every TU of + // libOpenPLCUserLib.a missed that match and fell into `.text1`, a catch-all + // mapped into the 32 KB `iram1_0_seg` shared with the WiFi/SDK core — + // measured at 7387 bytes for a small project, which overflowed the segment + // and failed the link with "section `.text1' will not fit in region + // `iram1_0_seg'", naming neither this archive nor the cause. + it('names objects `.cpp.o` so esp8266 links them into flash, not IRAM', async () => { + fs.writeFileSync(join(srcDir, 'arduino_runtime_glue.cpp'), 'void glue() {}\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('arduino_runtime_glue.cpp')) ?? '' + expect(compileCmd).toContain('arduino_runtime_glue.cpp.o') + // The bare `.o` form is what the esp8266 flash matcher misses. + expect(compileCmd).not.toMatch(/[/\\]arduino_runtime_glue\.o\b/) + }) + it('omits the variant -I when build.variant.path is unset (runtime-only / minimalist cores)', async () => { extractSpy.mockResolvedValue({ ...baseProps, diff --git a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts index 209eb06f7..9f78de947 100644 --- a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts @@ -28,13 +28,37 @@ jest.mock('electron', () => ({ MessageChannelMain: class {}, })) +type FindVppDevice = typeof import('../../../shared/hardware/find-vpp-device') + const listInstalled = jest.fn() const getInstalledPackageManifest = jest.fn() +// The build-time integrity gate (DOPE-539). Defaults to "intact" so the +// provisioning tests below exercise the packaging behaviour they are about; the +// refusal case overrides it explicitly. +const verifyBoardPackageIntegrity = jest.fn<{ ok: boolean; packageId?: string; reason?: string }, [string]>(() => ({ + ok: true, +})) jest.mock('../../package-manager', () => ({ - PackageManagerModule: jest.fn().mockImplementation(() => ({ - listInstalled, - getInstalledPackageManifest, - })), + formatPackageIntegrityError: (boardName: string, failure: { packageId: string; reason: string }) => + `Board "${boardName}" is provided by the VPP package "${failure.packageId}", which no longer matches its signature: ${failure.reason}.`, + PackageManagerModule: jest.fn().mockImplementation(() => { + const port = { listInstalled, getInstalledPackageManifest } + return { + ...port, + verifyBoardPackageIntegrity, + // Board lookup runs through the shared `findVppDeviceByBoardName`, and + // the mock runs the real one over these two stubs rather than + // re-implementing the search — a stub that resolved boards its own way + // would let the production lookup change without a test noticing. + // `require` (not a top-level import) because jest.mock factories are + // hoisted above the import block. + findDeviceByBoardName: (boardName: string) => + (jest.requireActual('../../../shared/hardware/find-vpp-device') as FindVppDevice).findVppDeviceByBoardName( + port, + boardName, + ), + } + }), })) // eslint-disable-next-line import/first @@ -144,6 +168,32 @@ describe('handleVendorPluginPackaging — provisioning branch', () => { expect(logs.some((l) => /source file\(s\)/.test(l.message))).toBe(true) }) + it('refuses to copy the payload when the package no longer matches its signature', async () => { + // DOPE-539: this step is re-gated because it runs minutes after the + // compile-entry check, and what it copies is compiled on the live PLC. + // A throw is the contract — `packageVppPlugin` in the platform port turns + // it into the `errors[]` the pipeline bails on, whereas a logged error + // would let the build upload a bundle with no vendor I/O. + verifyBoardPackageIntegrity.mockReturnValueOnce({ + ok: false, + packageId: 'com.openplc.rpi', + reason: 'Tampered file detected: hal/runtime-v4/plugin/rpi_plugin.o', + }) + + await expect( + runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }), + ).rejects.toThrow(/com\.openplc\.rpi/) + + expect(existsSync(join(targetDir, 'vpp_plugin'))).toBe(false) + expect(logs.some((l) => l.level === 'error' && /rpi_plugin\.o/.test(l.message))).toBe(true) + }) + it('copies the payload byte-for-byte (prebuilt object content preserved)', async () => { await runFor({ type: 'runtime-v4-plugin', diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 7cea25be6..005146485 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -540,9 +540,12 @@ describe('CompilerModule', () => { handleOutputData: noopLog, }) - const aPos = arCmd.indexOf('a_first.o') - const mPos = arCmd.indexOf('m_middle.o') - const zPos = arCmd.indexOf('z_last.o') + // `foo.cpp.o`, not `foo.o` — the `.cpp` is KEPT so esp8266's linker script + // matches `*.cpp.o` and sends the code to flash instead of the 32 KB IRAM + // catch-all. See the objectFiles comment in handlePrecompileUserLib. + const aPos = arCmd.indexOf('a_first.cpp.o') + const mPos = arCmd.indexOf('m_middle.cpp.o') + const zPos = arCmd.indexOf('z_last.cpp.o') expect(aPos).toBeGreaterThan(-1) expect(mPos).toBeGreaterThan(aPos) expect(zPos).toBeGreaterThan(mPos) @@ -658,7 +661,7 @@ describe('CompilerModule', () => { // The TU set discovered from the stash matches the original two // strucpp sources — order is deterministic (sorted basenames). - expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.o', 'pou_MAIN.o']) + expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.cpp.o', 'pou_MAIN.cpp.o']) }) it('injects -I{build.core.path} and -I{build.variant.path} into every TU compile (so Arduino.h resolves)', async () => { diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6a3ae3cdb..41208a004 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -112,6 +112,7 @@ import { buildModuleConfigEntries, generateVendorPluginConfig, } from '@root/backend/shared/utils/vpp/generate-vendor-plugin-config' +import { APP_VERSION } from '@root/frontend/data/constants/app-version' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { app as electronApp, dialog, MessageChannelMain } from 'electron' import type { MessagePortMain } from 'electron/main' @@ -119,8 +120,7 @@ import JSZip from 'jszip' import type { PlatformOption } from '../../../middleware/shared/ports/types' import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver' -import type { PackageManifest } from '../package-manager' -import { PackageManagerModule } from '../package-manager' +import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import { createDesktopLibraryBuildPort } from './desktop-library-build-port' import { createEditorCompilerPlatformPort } from './editor-compiler-platform-port' @@ -195,6 +195,10 @@ class CompilerModule { 'ArduinoJson', 'Arduino_MachineControl', 'ArduinoMqttClient', + // Backs the always-on debugger's DEBUG_GET_BOARD_ID (FC 0x48). ModbusSlave.cpp + // includes unconditionally (not behind a USE_*_BLOCK gate), + // so the lib must be installed for every Arduino build. + 'ArduinoUniqueID', 'AVR_PWM', 'CAN', 'CONTROLLINO', @@ -934,9 +938,18 @@ class CompilerModule { // and the Arduino sketch walks them dynamically for I/O binding. // The debugger will be redesigned in Phase 4. - // TODO: This method is used to update the index of the Arduino core. - // We should validate if this is necessary and if it works correctly. - async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback) { + /** + * `arduino-cli core update-index` — refetch the platform indexes. + * + * Required before installing a core that lives in a third-party index: + * passing `--additional-urls` to `core install` alone is not enough, + * because the CLI resolves the platform against its *cached* index and + * reports "Platform not found" until that cache has seen the vendor URL. + * + * `additionalUrls` is forwarded so the refresh covers the vendor index + * as well as the ones configured in `arduino-cli.yaml`. + */ + async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback, additionalUrls?: string) { return new Promise>((resolve, reject) => { let binaryPath = this.arduinoCliBinaryPath const [flag, configFilePath] = this.arduinoCliBaseParameters @@ -945,7 +958,13 @@ class CompilerModule { // INFO: On Windows, we need to add the .exe extension to the binary path. binaryPath += '.exe' } - const executeCommand = spawn(binaryPath, ['core', 'update-index', flag, configFilePath]) + const executeCommand = spawn(binaryPath, [ + 'core', + 'update-index', + ...(additionalUrls ? ['--additional-urls', additionalUrls] : []), + flag, + configFilePath, + ]) let stderrData = '' @@ -967,10 +986,24 @@ class CompilerModule { }) } + /** + * Install the Arduino core a board needs, pulling it from a vendor + * board-manager index when the board declares one. + * + * `boardManagerUrl` comes from the VPP manifest (`target.boardManagerUrl`) + * or hals.json (`board_manager_url`). Cores outside arduino-cli's + * built-in index — `industrialshields:esp32`, for example — are + * unresolvable without it, and the install dies with + * "Platform '' not found" (exit 7). When one is supplied we refresh + * the index against that URL first, then install with the same + * `--additional-urls`; both steps are needed, since `core install` + * resolves against the cached index. + */ async handleCoreInstallation( boardCore: string | null, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, coreVersion?: string, + boardManagerUrl?: string, ) { if (boardCore === null) return @@ -990,6 +1023,25 @@ class CompilerModule { handleOutputData(`Installing pinned core ${coreRef} (required by a prebuilt library)...`, 'info') } + // Refresh the platform index against the vendor URL before installing. + // Non-fatal: a transient network failure here should not mask the far + // more useful error that `core install` produces a moment later. + if (boardManagerUrl) { + handleOutputData(`Using vendor board index: ${boardManagerUrl}`, 'info') + try { + // `handleCoreUpdateIndex` logs at the wider 'info' | 'warning' | + // 'error' level set; this callback only accepts 'info' | 'error', + // so fold 'warning' down to 'info'. + await this.handleCoreUpdateIndex( + (chunk, level) => handleOutputData(chunk, level === 'error' ? 'error' : 'info'), + boardManagerUrl, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + handleOutputData(`Warning: could not refresh the board index (${message}). Continuing.`, 'info') + } + } + let binaryPath = this.arduinoCliBinaryPath if (CompilerModule.HOST_PLATFORM === 'win32') { @@ -997,7 +1049,13 @@ class CompilerModule { binaryPath += '.exe' } return new Promise>((resolve, reject) => { - const executeCommand = spawn(binaryPath, ['core', 'install', coreRef, ...this.arduinoCliBaseParameters]) + const executeCommand = spawn(binaryPath, [ + 'core', + 'install', + coreRef, + ...(boardManagerUrl ? ['--additional-urls', boardManagerUrl] : []), + ...this.arduinoCliBaseParameters, + ]) let stderrData = '' @@ -1459,7 +1517,25 @@ class CompilerModule { // Build the .o path list synchronously up-front so the archive members // land in source-file order regardless of the concurrent compile result. - const objectFiles = sources.map((sourcePath) => join(objDir, path.basename(sourcePath).replace(/\.cpp$/, '.o'))) + // + // The `.cpp` is KEPT in the object name (`foo.cpp.o`, not `foo.o`) — the same + // convention arduino-cli uses for sketch objects, and on ESP8266 it decides + // whether the code runs from flash or from IRAM. + // + // esp8266's linker script sends code to flash by matching the OBJECT NAME: + // + // .irom0.text : { *.c.o(.literal* .text*) + // *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*) + // *.cc.o(.literal* .text*) … } + // + // Anything it does not match falls through to `.text1`, a catch-all mapped + // into `iram1_0_seg` — 32 KB shared with the WiFi/SDK core. Named `foo.o`, + // every translation unit of libOpenPLCUserLib.a landed there: measured at + // 7387 bytes of IRAM for a small project (glue 3781 + configuration 3149 + + // pou_MAIN 457), which overflowed the segment and failed the link with + // "section `.text1' will not fit in region `iram1_0_seg'" — a message that + // names neither this archive nor the reason. + const objectFiles = sources.map((sourcePath) => join(objDir, `${path.basename(sourcePath)}.o`)) // Cap concurrent toolchain spawns at the host's logical core count. // An unbounded `sources.map(async …)` was dispatching one g++ per TU @@ -1988,29 +2064,35 @@ class CompilerModule { sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, ): Promise { + // Second gate, deliberately re-run here rather than trusted from + // `compileProgram` (DOPE-539). This step is what copies vendor C into the + // bundle the runtime compiles ON the PLC, and it runs late — transpile, + // strucpp and the v4 bundle compose happen in between, which on a large + // project is minutes of wall clock during which the package directory is + // still writable. Checking again costs one directory hash. + // + // This sits OUTSIDE the catch-all below on purpose. Every other failure in + // this method degrades the build and reports it; this one has to stop it, + // and `packageVppPlugin` in the platform port turns a throw into the + // `errors[]` the pipeline bails on. + const integrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!integrity.ok) { + const message = formatPackageIntegrityError(boardTarget, integrity) + handleOutputData(message, 'error') + throw new Error(message) + } + try { - const packageManager = new PackageManagerModule() - const installed = packageManager.listInstalled() - - let matchingPackagePath: string | null = null - let matchingDevice: PackageManifest['devices'][number] | null = null - - for (const pkg of installed) { - const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardTarget) - if (device) { - matchingPackagePath = pkg.path - matchingDevice = device - break - } - } + const match = new PackageManagerModule().findDeviceByBoardName(boardTarget) - if (!matchingDevice || !matchingPackagePath) { + if (!match) { handleOutputData(`Board "${boardTarget}" is not from a VPP package, skipping VPP packaging`, 'info') return } + const matchingPackagePath = match.pkg.path + const matchingDevice = match.device + if (matchingDevice.target.type !== 'runtime-v4') { handleOutputData( `VPP board "${boardTarget}" is not runtime-v4 (target=${matchingDevice.target.type}), skipping VPP packaging`, @@ -2267,26 +2349,12 @@ class CompilerModule { vendorScreenData: Record, ): Promise> { try { - const packageManager = new PackageManagerModule() - const installed = packageManager.listInstalled() - - let matchingPackagePath: string | null = null - let matchingDevice: PackageManifest['devices'][number] | null = null - for (const pkg of installed) { - const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardTarget) - if (device) { - matchingPackagePath = pkg.path - matchingDevice = device - break - } - } + const match = new PackageManagerModule().findDeviceByBoardName(boardTarget) - const rawModules = matchingDevice?.moduleSystem?.modules - if (!matchingDevice || !matchingPackagePath || !rawModules || rawModules.length === 0) return [] + const rawModules = match?.device.moduleSystem?.modules + if (!match || !rawModules || rawModules.length === 0) return [] - const pkgPath = matchingPackagePath + const pkgPath = match.pkg.path const modules = await Promise.all( rawModules.map(async (m) => { let configScreenDefinition: unknown @@ -2374,6 +2442,22 @@ class CompilerModule { Record | undefined, ] + // VPP integrity gate (DOPE-539). FIRST, before the manifest is read for + // anything else: from here on this method trusts the package directory for + // the HAL it links, the licence-store backend it injects and every + // capability it branches on. The import check and the project-open sweep + // are both behind us and neither says anything about the package as it + // exists right now. + const boardPackageIntegrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!boardPackageIntegrity.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${formatPackageIntegrityError(boardTarget, boardPackageIntegrity)}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + // Resolve board info uniformly across hals.json + installed VPP // packages via the shared `resolveBoardSelection` helper — the // same code path runs on web (no VPP packages installed → falls @@ -2506,6 +2590,33 @@ class CompilerModule { }) } } + // An arduino-cli target CANNOT work without its HAL: `hardwareInit` / + // `updateInputBuffers` / `updateOutputBuffers` have no other definition, + // so the build either dies at link with an undefined-reference wall or — + // if anything ever weak-defines them — silently produces firmware that + // drives no I/O at all. Both were observed as "the program runs but the + // outputs never move", with the real cause (the board resolved without a + // HAL, e.g. a VPP whose manifest didn't load) reported only as a warning + // several hundred log lines earlier. + // + // Fail here instead, naming the board and the path, so the message says + // what is wrong rather than what it broke. + // Runtime v3 legitimately has no HAL (its on-device MatIEC compiles the + // ST itself and it never links Arduino firmware), so this only applies to + // targets that actually build a sketch. + if (!boardHalContent && !isRuntimeV3) { + const where = boardInfo.halSourceFile + ? `its HAL source could not be read from ${boardInfo.halSourceFile}` + : 'it did not resolve a HAL source file (a VPP package may have failed to load — try reinstalling it)' + _mainProcessPort.postMessage({ + logLevel: 'error', + message: + `Board "${boardTarget}" cannot be compiled: ${where}. ` + + 'Without a HAL the firmware has no hardware I/O layer.\nStopping compilation process.', + }) + _mainProcessPort.close() + return + } // Re-key strucpp runtime headers from // `strucpp_runtime/include/X` into `src/X` so arduino-cli's // `--library src` pass finds them; also drop the board HAL @@ -2517,6 +2628,60 @@ class CompilerModule { strucppRuntimeHeaders: v4Layout, boardHalContent, }) + + // VPP-provided license-store backend source(s). Mirrors the HAL read + // above: each path is an absolute one `BoardInfoResolver` produced from + // `device.hal.licenseStore` through the same traversal-guarded + // `resolvePackageRelativePath`. + // + // Unlike the HAL (which lands at the canonical `src/arduino.cpp`), these + // keep their distinctive basenames and land in the SKETCH directory next + // to `license_store.h` / `license_blob.h`, so their + // `#include "license_store.h"` resolves and they define the STRONG + // `license_store_*` symbols that override the skeleton's + // `license_store_weak.cpp`. Boards without a VPP backend inject nothing + // and link the weak default, which answers LIC_UNSUPPORTED. + // + // A read failure is a WARNING, not a hard stop, and the asymmetry with the + // HAL check above is deliberate: a missing HAL means no I/O at all, while a + // missing store backend means the board answers "cannot store a licence" — + // degraded, correctly reported to the editor, and still a working PLC. + for (const licenseStoreFile of boardInfo.licenseStoreFiles ?? []) { + try { + const content = await readFile(licenseStoreFile, 'utf-8') + firmwareSkeleton[`examples/Baremetal/${path.basename(licenseStoreFile)}`] = content + // Logged on SUCCESS, not only on failure. Without this line the build + // output is identical whether the backend went in or not, and the only + // symptom of it missing appears much later and somewhere else: the + // board answers LIC_UNSUPPORTED and the editor says "this device + // cannot store a licence" — which reads as a hardware limitation + // rather than a firmware that was built without the backend. + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `License store backend included: ${path.basename(licenseStoreFile)}`, + }) + } catch (lsErr) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: + `Could not read license-store backend at ${licenseStoreFile}: ${getErrorMessage(lsErr)}. ` + + 'This board will report that it cannot store a licence.', + }) + } + } + // A licensable board that resolves NO backend is a manifest fault: every + // licensable VPP targets hardware that persists a licence, so the storage + // source is not optional for one. Saying it here is far cheaper than + // deducing it from a badge three steps later. + if (boardInfo.capabilities?.isLicensable === true && (boardInfo.licenseStoreFiles ?? []).length === 0) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: + `Board "${boardTarget}" belongs to a licensed VPP but its manifest declares no ` + + '`hal.licenseStore`. Every licensed VPP needs one, so this is a packaging fault: the ' + + 'firmware will link the weak default and report that its licence storage is missing.', + }) + } } try { // `devices/pin-mapping.json` ships in one of two shapes (the @@ -2585,6 +2750,11 @@ class CompilerModule { cleanBuild: cleanBuild ?? false, mainProcessBridge, compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath), + // VPP runtime floor (DOPE-448). Constructed per call rather than + // held on the class because the registry is read off disk and may + // have changed since the last compile (a package installed or + // removed mid-session). + getVppRuntimeFloor: (board: string) => new PackageManagerModule().getRuntimeFloorForBoard(board), pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, @@ -2614,6 +2784,8 @@ class CompilerModule { const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) const vendorScreenData = deviceConfig.vendorScreenData ?? {} vppModbusState = { + serial: vendorScreenData['serial'] as VppModbusScreenState['serial'], + network: vendorScreenData['network'] as VppModbusScreenState['network'], modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], } @@ -2665,6 +2837,12 @@ class CompilerModule { communicationPort: communicationPort ?? undefined, ...(vppModbusState ? { vppModbusState } : {}), vendorScreenData: effectiveVendorScreenData, + // Compared against the `minEditorVersion` a runtime publishes at + // `/api/capabilities` (DOPE-448). Injected because the pipeline + // lives in `backend/shared/`, which the layer rules keep out of + // `frontend/data/` — which build is running is a fact about the + // host app, not about the compile. + editorVersion: APP_VERSION, }, platformPort, (event) => { @@ -2739,6 +2917,21 @@ class CompilerModule { const [projectPath, boardTarget, projectData] = args as [string, string, PLCProjectData] + // Same gate as `compileProgram` (DOPE-539). The debug build is a smaller + // consumer of the package — it resolves the board and its debug spec — but + // it is still a build the user runs against a live device, and letting it + // through on a package the normal compile just refused would only teach + // that the check is avoidable. + const debugPackageIntegrity = new PackageManagerModule().verifyBoardPackageIntegrity(boardTarget) + if (!debugPackageIntegrity.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${formatPackageIntegrityError(boardTarget, debugPackageIntegrity)}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + const debugResolver = await this.#createBoardInfoResolver() const { boardRuntime } = debugResolver.resolve(boardTarget) const normalizedProjectPath = projectPath.replace('project.json', '') diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 8b689d18d..c85622d40 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -24,7 +24,7 @@ * `middleware/adapters/web/`. */ -import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' +import { deployReachedDevice, deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' import { fromSchemaShape, @@ -117,6 +117,22 @@ export interface EditorCompilerPlatformPortContext { * in the `archiver`-dependent compressSourceFolder method (which * has its own private state on CompilerModule). */ compressSourceFolder: (folderPath: string) => Promise + /** + * `package.minRuntimeVersion` of the VPP providing a given board, or + * null when the board isn't from a VPP / declares no floor + * (DOPE-448). The pipeline compares it against the connected + * runtime after the version probe. + * + * Injected rather than resolved here for the same reason as + * `compressSourceFolder`: importing `PackageManagerModule` directly + * pulls in the Electron-dependent logger at module load, which + * breaks anything importing this adapter outside a real Electron + * process (its own unit test included). + * + * Optional so callers predating this stay valid; absent means "no + * floor known", which the pipeline treats as no constraint. + */ + getVppRuntimeFloor?: (boardTarget: string) => string | null /** Timeout for the post-upload compile-status poll. */ pollTimeoutMs: number /** Interval for the post-upload compile-status poll. */ @@ -193,6 +209,12 @@ export function createEditorCompilerPlatformPort( * `handleCoreInstallation` already takes a core id and a log * callback — direct passthrough modulo the log-shape * translation. + * + * `args.boardManagerUrl` (the VPP's `target.boardManagerUrl`) is + * forwarded so vendor cores outside arduino-cli's built-in index + * install automatically rather than failing with "Platform not + * found". `handleCoreInstallation` refreshes the index against + * that URL before installing. */ async installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise { try { @@ -203,6 +225,7 @@ export function createEditorCompilerPlatformPort( log(message, level ?? 'info') }, args.coreVersion, + args.boardManagerUrl, ) return { ok: true } } catch (error) { @@ -387,7 +410,11 @@ export function createEditorCompilerPlatformPort( startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v4 upload failed: ${message}`, 'error') @@ -486,7 +513,11 @@ export function createEditorCompilerPlatformPort( startTimeoutMs: context.startTimeoutMs, startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v3 upload failed: ${message}`, 'error') @@ -495,8 +526,14 @@ export function createEditorCompilerPlatformPort( }, /** - * Probe the device's `/api/version` (unauthenticated) so the - * pipeline can short-circuit uploads to pre-4.1.0 runtimes. + * Probe the device (unauthenticated) so the pipeline can + * short-circuit uploads in both directions: to a runtime too old + * for this editor, and from an editor too old for this runtime. + * + * Tries `/api/capabilities` first — it carries the runtime version + * AND the runtime's `minEditorVersion` in one round-trip — and + * falls back to `/api/version` for runtimes that predate it + * (DOPE-448). * * Transport: Electron's HTTPS bridge → device IP. * Response parsing + null-fallback live in the shared @@ -505,19 +542,21 @@ export function createEditorCompilerPlatformPort( */ async checkRuntimeVersion(args: CheckRuntimeVersionArgs, log: PlatformLog): Promise { const deviceContext = assertEditorHttpsContext(args.context) - const { version } = await probeRuntimeVersion({ - fetchVersion: async () => { - const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>( - deviceContext.ip, - '/api/version', - (data: string) => JSON.parse(data) as { version: string }, - ) - if (!result.success) return { success: false, error: result.error } - return { success: true, body: result.data } - }, + const getJson = async (endpoint: string) => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest( + deviceContext.ip, + endpoint, + (data: string) => JSON.parse(data) as unknown, + ) + if (!result.success) return { success: false as const, error: result.error } + return { success: true as const, body: result.data } + } + const { version, minEditorVersion } = await probeRuntimeVersion({ + fetchCapabilities: () => getJson('/api/capabilities'), + fetchVersion: () => getJson('/api/version'), log, }) - return { ok: true, version } + return { ok: true, version, minEditorVersion } }, /** @@ -560,7 +599,12 @@ export function createEditorCompilerPlatformPort( log(message, logLevel ?? 'info') }, ) - return { files: {} } + // Surface the package's runtime floor so the pipeline can compare + // it against the connected runtime after the version probe + // (DOPE-448). Read here rather than inside the handler because + // the handler returns void and writes straight to disk; the + // registry lookup is cheap next to the packaging work that ran. + return { files: {}, minRuntimeVersion: readVppRuntimeFloor(context, args.boardTarget) } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { @@ -590,6 +634,24 @@ export function assertEditorHttpsContext( return context } +/** + * `package.minRuntimeVersion` of the VPP providing `boardTarget`, or + * null when no resolver was injected, the board is not from a VPP, is + * not a `runtime-v4` target, or the package declares no floor. + * + * Never throws: a missing or unreadable registry means "no declared + * floor", which the pipeline treats as no constraint. A version gate + * that failed the build because it could not read its own metadata + * would be worse than the mismatch it exists to catch. + */ +function readVppRuntimeFloor(context: EditorCompilerPlatformPortContext, boardTarget: string): string | null { + try { + return context.getVppRuntimeFloor?.(boardTarget) ?? null + } catch { + return null + } +} + /** * Find the arduino-cli-produced `Baremetal.ino.hex` under the build * directory. arduino-cli writes it to a board-FQBN-specific diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index 7d2533449..cc5109af6 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -1,16 +1,5 @@ import { z } from 'zod/v4' -const ArduinoCliConfigSchema = z.object({ - board_manager: z.object({ - additional_urls: z.array(z.string()), - }), - output: z.object({ - no_color: z.boolean(), - }), -}) - -type ArduinoCliConfig = z.infer - const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer @@ -38,6 +27,6 @@ type ToolchainProperties = { export type { BoardInfo, HalsFile } from '../hardware/types' export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' -export { ArduinoCliConfigSchema, ArduinoCoreControlSchema } +export { ArduinoCoreControlSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, ToolchainProperties } +export type { ArduinoCoreControl, ToolchainProperties } diff --git a/src/backend/editor/hardware/__tests__/device-link-policy.test.ts b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts new file mode 100644 index 000000000..643f01df9 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts @@ -0,0 +1,129 @@ +/** + * The held link's counting rules — what a user feels as "it recovered by itself" + * vs "it gave up too early", and the only part of the connection manager that can + * be checked without a cable to pull. + */ +import { DeviceLinkPolicy } from '../device-link-policy' + +/** Production shape: 2 silent polls enter recovery, 2 failed reopens give up. */ +const newPolicy = () => new DeviceLinkPolicy(2, 2) + +const enterRecovery = () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + policy.onProbeResult('unresponsive') + return policy +} + +describe('DeviceLinkPolicy', () => { + describe('a vanished endpoint fails immediately', () => { + it('does not spend the failure budget first', () => { + // A pulled USB cable is not a slow device. There is nothing to retry + // against, so the user hears about it on the very first tick. + const policy = newPolicy() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('fails immediately even mid-recovery', () => { + // Recovering from noise, and then the port disappears outright: stop + // retrying and say so. + const policy = enterRecovery() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('leaves the policy reusable for the next connect', () => { + const policy = newPolicy() + policy.onProbeResult('gone') + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('alive')).toBe('continue') + }) + }) + + describe('while healthy', () => { + it('stays healthy as long as the device answers', () => { + const policy = newPolicy() + for (let i = 0; i < 50; i++) expect(policy.onProbeResult('alive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('tolerates a single silent poll', () => { + // Reopening a serial port resets an AVR board, so one dropped frame must + // not restart the user's program. + const policy = newPolicy() + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('enters recovery on the configured number of consecutive failures', () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.recovering).toBe(true) + }) + + it('requires the failures to be CONSECUTIVE', () => { + // Alternating silence and answers is a noisy link, not a dead one. + const policy = newPolicy() + for (let i = 0; i < 10; i++) { + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.onProbeResult('alive')).toBe('continue') + } + expect(policy.recovering).toBe(false) + }) + }) + + describe('while recovering', () => { + it('gives up quickly rather than retrying for half a minute', () => { + const policy = enterRecovery() + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + expect(policy.recovering).toBe(false) + }) + + it('recovers at any point in the window and returns to healthy', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + + expect(policy.onReopenResult(true)).toBe('recovered') + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + // Full budget again, not one poll away from another recovery. + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + + it('gives a full window to a second outage', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + policy.onReopenResult(true) + + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + }) + + it('counts an attempt that could not even be made', () => { + // No candidate could be built: if that did not count, recovery would spin + // forever and the user would never be told. + const policy = enterRecovery() + policy.onReopenResult(false) + expect(policy.onReopenResult(false)).toBe('give-up') + }) + }) + + describe('reset', () => { + it('returns a recovering policy to healthy', () => { + // A fresh Connect supersedes whatever the previous link was doing. + const policy = enterRecovery() + policy.onReopenResult(false) + + policy.reset() + + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/device-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts new file mode 100644 index 000000000..55024374e --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -0,0 +1,132 @@ +import type { DebugBoardIdResult } from '@root/backend/shared/debug/types' + +import { classifyDeviceLink, FALLBACK_BAUD_RATES, planBaudAttempts, readBoardIdWithRetries } from '../device-probe' + +/** + * A channel that answers the board-id read (FC 0x48) according to a script, so + * the classification can be tested without a board. + */ +function fakeChannel(script: DebugBoardIdResult[]) { + const calls: number[] = [] + let index = 0 + return { + calls, + getBoardId: (): Promise => { + calls.push(index) + const answer = script[Math.min(index, script.length - 1)] + index += 1 + return Promise.resolve(answer) + }, + } +} + +const ANSWERED: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([1, 2, 3, 4]) } +/** Opened, but nothing spoke the debug protocol — a blank board, or a wrong baud. */ +const SILENT: DebugBoardIdResult = { success: false } +/** Answered the frame but reported no unique id (a core without ArduinoUniqueID). */ +const EMPTY_ID: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([]) } + +describe('planBaudAttempts', () => { + it('leads with the configured rate, then sweeps the rest', () => { + const plan = planBaudAttempts(9600) + + expect(plan[0]).toEqual({ baudRate: 9600, speculative: false }) + expect(plan.slice(1).every((attempt) => attempt.speculative)).toBe(true) + // The configured rate is never repeated as a guess. + expect(plan.filter((attempt) => attempt.baudRate === 9600)).toHaveLength(1) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length) + }) + + it('covers every fallback rate exactly once', () => { + const rates = planBaudAttempts(115200).map((attempt) => attempt.baudRate) + + expect(new Set(rates).size).toBe(rates.length) + for (const rate of FALLBACK_BAUD_RATES) expect(rates).toContain(rate) + }) + + it('does not sweep an endpoint with no baud rate (TCP / WebSocket)', () => { + expect(planBaudAttempts(undefined)).toEqual([{ baudRate: undefined, speculative: false }]) + }) + + it('does not sweep when the caller opts out', () => { + // The debug channel of an established session: the rate is already settled, + // so re-opening the port at other rates would be wrong, not merely wasteful. + expect(planBaudAttempts(9600, { sweep: false })).toEqual([{ baudRate: 9600, speculative: false }]) + }) + + it('keeps a rate that is not in the fallback list as the leading attempt', () => { + const plan = planBaudAttempts(4800) + + expect(plan[0]).toEqual({ baudRate: 4800, speculative: false }) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length + 1) + }) +}) + +describe('readBoardIdWithRetries', () => { + it('stops at the first answer', async () => { + const channel = fakeChannel([ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 6, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(1) + }) + + it('retries a silent port up to the budget — a board can still be booting', async () => { + const channel = fakeChannel([SILENT, SILENT, ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 3, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(3) + }) + + it('spends no more than the budget allows', async () => { + const channel = fakeChannel([SILENT]) + const result = await readBoardIdWithRetries(channel, { attempts: 2, backoffMs: 0 }) + + expect(result.success).toBe(false) + expect(channel.calls).toHaveLength(2) + }) +}) + +describe('classifyDeviceLink', () => { + it('keeps a channel a firmware answered on', async () => { + const result = await classifyDeviceLink(fakeChannel([ANSWERED]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + // This is what a WRONG BAUD looks like from here: the port opened, so the + // transport is fine, and nothing decoded. Reporting it as `no-firmware` is what + // lets the caller fall through to the next rate instead of keeping a dead link. + it('reports no-firmware when the channel opens but nothing answers', async () => { + const result = await classifyDeviceLink(fakeChannel([SILENT]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'no-firmware' }) + }) + + // Cores without ArduinoUniqueID, and boards opting out via + // OPENPLC_NO_UNIQUE_ID, answer FC 0x48 with `id_len = 0` on purpose rather than + // failing to compile. That is a firmware replying, not a blank board — treating + // the empty id as "no firmware" told those users to reflash a working device. + it('keeps a firmware that answers with no unique id at all', async () => { + const result = await classifyDeviceLink(fakeChannel([EMPTY_ID]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + it('does not burn retries once a firmware has answered, empty id or not', async () => { + const channel = fakeChannel([EMPTY_ID]) + await classifyDeviceLink(channel, { boardIdProbe: { attempts: 6, backoffMs: 0 } }) + + expect(channel.calls).toHaveLength(1) + }) + + it('never throws — a transport that blows up resolves to an error status', async () => { + const result = await classifyDeviceLink( + { getBoardId: () => Promise.reject(new Error('port disappeared')) }, + { boardIdProbe: { attempts: 1, backoffMs: 0 } }, + ) + + expect(result).toEqual({ status: 'error', error: 'port disappeared' }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts new file mode 100644 index 000000000..c321698c0 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts @@ -0,0 +1,175 @@ +/** + * Who may close the debug channel, and when. + * + * Two rules that pull against each other, which is why they are pinned here: + * + * - A session whose debug medium is its OWN (Runtime v3's second Modbus TCP + * connection, v4's WebSocket) must close that channel when the debug session + * ends. Leaving it open holds an authenticated channel to the user's PLC for + * no reason, and contradicts the whole point of opening it lazily. + * - A BAREMETAL session must NOT close anything: control and debug are the same + * connection, so closing on debug-stop would disconnect the device and take + * run/stop and the status poll down with it. + * + * The bug these cover: per-command callers (`read variables` on every poll tick, + * `write variable`, `verify md5`) were registered as lifetime holders, so the + * holder set was never empty and the v3/v4 channel never closed. + */ +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../../shared/debug/types' +import { type DeviceLinkHooks, DeviceSessionManager } from '../device-session-manager' + +/** A debug channel that records whether it was closed. */ +function fakeDebugChannel() { + const channel = { + connect: () => Promise.resolve(), + disconnect: () => { + channel.disconnects += 1 + }, + disconnects: 0, + } + return channel +} + +/** A Modbus client standing in for a held baremetal link. */ +function fakeModbusClient() { + const client = { + connect: () => Promise.resolve(), + disconnect: () => { + client.disconnects += 1 + }, + disconnects: 0, + } + return client +} + +function managerWith(overrides: Partial = {}) { + return new DeviceSessionManager({ + verify: () => Promise.resolve(true), + probe: () => Promise.resolve(true), + serialPortPresent: () => Promise.resolve(true), + emit: () => undefined, + log: () => undefined, + ...overrides, + }) +} + +describe('debug channel lifetime — a session with its own debug medium (v3 / v4)', () => { + it('closes the channel once the debug session releases, even after per-command use', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + // The real order main.ts uses: connect (the lifetime holder), then commands. + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('verify md5') + manager.releaseDebugChannel('verify md5') + for (let tick = 0; tick < 3; tick += 1) { + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + } + expect(channel.disconnects).toBe(0) // still debugging — nothing may close it + + manager.releaseDebugChannel('debug session') + + expect(channel.disconnects).toBe(1) + expect(manager.getDebugClient()).toBeNull() + }) + + it('opens exactly one channel across the whole session', async () => { + let created = 0 + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => { + created += 1 + return channel as unknown as DeviceDebugChannel + }, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + + expect(created).toBe(1) + }) + + it('keeps the channel while a second holder still needs it', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('licensing') + manager.releaseDebugChannel('debug session') + + // Ref-counting still holds: one release does not close a channel another holds. + expect(channel.disconnects).toBe(0) + manager.releaseDebugChannel('licensing') + expect(channel.disconnects).toBe(1) + }) +}) + +describe('debug channel lifetime — a baremetal session (one shared channel)', () => { + it('never closes the device connection when a debug caller releases', async () => { + const client = fakeModbusClient() + const manager = managerWith() + const opened = await manager.open([ + { + transport: 'rtu', + descriptor: '/dev/ttyACM0', + baudRate: 115200, + create: () => client as unknown as DeviceModbusTransport, + }, + ]) + expect(opened.ok).toBe(true) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + manager.releaseDebugChannel('debug session') + + // The control channel IS the debug channel here. Stopping the debugger must + // leave run/stop and the status poll with a live connection. + expect(client.disconnects).toBe(0) + expect(manager.isConnected()).toBe(true) + expect(manager.getDebugClient()).not.toBeNull() + manager.close() + }) +}) + +describe('open() always reports a settled state', () => { + it('emits disconnected when no candidate could be built', async () => { + // Otherwise the renderer, which set 'connecting' the moment the user clicked, + // is left there forever with its Connect button disabled. + const emitted: string[] = [] + const manager = managerWith({ + emit: (status) => { + emitted.push(status.status) + }, + }) + + const result = await manager.open([]) + + expect(result.ok).toBe(false) + expect(emitted).toContain('disconnected') + }) +}) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts new file mode 100644 index 000000000..e04ede5a7 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -0,0 +1,763 @@ +/** + * The single held connection: candidate fallback, one owner for every command, + * and what happens when the endpoint goes away. + * + * `tick()` is driven directly rather than through the poll timer, so the + * sequences here are the real ones a cable pull produces, without the waiting. + */ +import { + DeviceSessionManager, + type DeviceLinkCandidate, + type DeviceLinkHooks, + type DeviceLinkStatus, +} from '../device-session-manager' +import type { DeviceModbusTransport } from '../../../shared/debug/types' + +/** A client that records open/close and can be made to answer or not. */ +class FakeClient { + connected = false + disconnectCount = 0 + connectCount = 0 + constructor( + private readonly behaviour: { + connectFails?: boolean + answers?: boolean + } = {}, + ) {} + + connect = async (): Promise => { + this.connectCount += 1 + if (this.behaviour.connectFails) throw new Error('cannot open') + this.connected = true + } + + disconnect = (): void => { + this.disconnectCount += 1 + this.connected = false + } + + answers(): boolean { + return this.behaviour.answers !== false + } +} + +const asTransport = (client: FakeClient): DeviceModbusTransport => client as unknown as DeviceModbusTransport + +interface Harness { + manager: DeviceSessionManager + statuses: DeviceLinkStatus[] + /** Serial ports the OS currently reports. Mutate to pull or replug a cable. */ + ports: Set + clients: FakeClient[] + /** Overridable per test. */ + verifyResult: { value: boolean } +} + +function harness(overrides: Partial = {}): Harness { + const statuses: DeviceLinkStatus[] = [] + const ports = new Set(['/dev/ttyUSB0']) + const clients: FakeClient[] = [] + const verifyResult = { value: true } + + const hooks: DeviceLinkHooks = { + verify: async () => verifyResult.value, + probe: async (client) => (client as unknown as FakeClient).answers(), + serialPortPresent: async (port) => ports.has(port), + emit: (status) => statuses.push(status), + ...overrides, + } + + const manager = new DeviceSessionManager(hooks, { + pollIntervalMs: 10_000, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, + }) + return { manager, statuses, ports, clients, verifyResult } +} + +/** Candidate factory that hands out the clients a test prepared, in order. */ +function candidate( + transport: 'rtu' | 'tcp', + descriptor: string, + queue: FakeClient[], + registry: FakeClient[], + extra: Partial = {}, +): DeviceLinkCandidate { + return { + transport, + descriptor, + create: () => { + const client = queue.shift() ?? new FakeClient() + registry.push(client) + return asTransport(client) + }, + ...extra, + } +} + +afterEach(() => { + jest.useRealTimers() +}) + +describe('DeviceSessionManager', () => { + describe('opening', () => { + it('takes the first candidate that works', async () => { + const h = harness() + const tcp = new FakeClient() + const serial = new FakeClient() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('tcp') + // The serial fallback must not have been touched at all. + expect(serial.connectCount).toBe(0) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + + // The baud sweep sends several candidates down the SAME port, differing only + // by `baudRate`. `descriptor` is matched against the OS port list, so it has + // to stay the bare port name: when the rate was folded into it ("COM5 @ 9600 + // baud") every swept candidate matched no port and was skipped in 1ms — the + // sweep silently did nothing at all. + it('tries every baud rate on one port instead of skipping them as absent ports', async () => { + const h = harness() + const wrongBaud = new FakeClient() + const rightBaud = new FakeClient() + // Only the real port name is enumerated, exactly as the OS reports it. + h.ports.clear() + h.ports.add('COM5') + // First candidate opens but answers nothing (what a wrong baud looks like). + let verified = 0 + const h2 = harness({ + serialPortPresent: async (port) => h.ports.has(port), + verify: async () => { + verified += 1 + return verified > 1 + }, + }) + + const result = await h2.manager.open([ + candidate('rtu', 'COM5', [wrongBaud], h2.clients, { baudRate: 9600, patient: true }), + candidate('rtu', 'COM5', [rightBaud], h2.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(true) + // Both were actually opened — the second was not dismissed as a missing port. + expect(wrongBaud.connectCount).toBe(1) + expect(rightBaud.connectCount).toBe(1) + if (result.ok) expect(result.descriptor).toBe('COM5') + h2.manager.close() + }) + + it('names the baud rate when reporting what it tried', async () => { + const h = harness({ verify: async () => false }) + h.ports.clear() + h.ports.add('COM5') + + const result = await h.manager.open([ + candidate('rtu', 'COM5', [], h.clients, { baudRate: 9600 }), + candidate('rtu', 'COM5', [], h.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + // Two entries for one port are only meaningful if each says its rate. + expect(result.attempts.map((attempt) => attempt.baudRate)).toEqual([9600, 115200]) + } + }) + + // A debug session polls variables continuously, and every request queues on + // the ONE serial link. On a slow wire the liveness read waits behind that + // traffic and times out; two such timeouts entered recovery and tore down a + // debug session whose own reads were succeeding — measured at roughly every + // ten seconds on a 9600-baud ESP8266. Traffic IS liveness evidence. + it('treats recent successful traffic as liveness instead of polling the busy link', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return false + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + const afterOpen = probes + + // The debugger just read something successfully. + h.manager.noteTraffic() + await h.manager.tick() + + // No probe was sent, and a probe that WOULD have failed did not count. + expect(probes).toBe(afterOpen) + expect(h.statuses.map((s) => s.status)).not.toContain('error') + expect(h.manager.getClient()).toBe(asTransport(client)) + h.manager.close() + }) + + it('polls normally on a fresh connection, before any traffic', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + await h.manager.tick() + + // Nothing else is on the link yet, so the poll does its own read. + expect(probes).toBe(1) + h.manager.close() + }) + + it('resumes polling once the traffic evidence has aged out', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + h.manager.noteTraffic() + await h.manager.tick() + expect(probes).toBe(0) + + // A whole interval with nothing on the link: the poll must ask again. + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + await h.manager.tick() + expect(probes).toBe(1) + + jest.restoreAllMocks() + h.manager.close() + }) + + it('falls back to serial when Modbus TCP cannot connect', async () => { + const h = harness() + const tcp = new FakeClient({ connectFails: true }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + h.manager.close() + }) + + it('falls back when Modbus TCP opens but nothing answers', async () => { + // A socket that connects proves a host, not a PLC. This is the case that + // makes "prefer TCP" safe: an IP that belongs to something else, or a stale + // DHCP address, must not strand the user on a dead link. + const h = harness({ verify: async (client) => (client as unknown as FakeClient).answers() }) + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + // The rejected candidate is closed, not leaked. + expect(tcp.disconnectCount).toBeGreaterThan(0) + h.manager.close() + }) + + it('tells verify whether alternatives remain, so patience is spent last', async () => { + // Measured on a real board: ruling out one Modbus TCP address took 32.5s, + // because the id read is retried for a device that might still be booting. + // That patience belongs to the LAST candidate — with alternatives waiting, a + // stale address must not delay the cable that would have worked. + const seen: Array<{ descriptor: string; isLastCandidate: boolean }> = [] + const h = harness({ + verify: async (_client, candidate, context) => { + seen.push({ descriptor: candidate.descriptor, isLastCandidate: context.isLastCandidate }) + return candidate.transport === 'rtu' + }, + }) + + await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients), + ]) + + expect(seen).toEqual([ + { descriptor: '192.168.0.50', isLastCandidate: false }, + { descriptor: '/dev/ttyUSB0', isLastCandidate: true }, + ]) + h.manager.close() + }) + + it('treats a sole candidate as the last one', async () => { + const seen: boolean[] = [] + const h = harness({ + verify: async (_client, _candidate, context) => { + seen.push(context.isLastCandidate) + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(seen).toEqual([true]) + h.manager.close() + }) + + it('fails when no candidate works, reporting each attempt', async () => { + const h = harness() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient({ connectFails: true })], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient({ connectFails: true })], h.clients), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.attempts).toHaveLength(2) + expect(result.attempts[0]).toMatchObject({ transport: 'tcp', descriptor: '192.168.0.50' }) + expect(result.attempts[1]).toMatchObject({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + } + // Nothing is held, and the renderer is told — claiming "connected" without + // a connection is what made later requests time out mysteriously. + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('skips a serial candidate whose port is not enumerated', async () => { + const h = harness() + h.ports.clear() + const serial = new FakeClient() + + const result = await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(result.ok).toBe(false) + // Not even opened: no connect timeout was waited out. + expect(serial.connectCount).toBe(0) + if (!result.ok) expect(result.attempts[0].error).toContain('not available') + }) + + it('supersedes a previously held link', async () => { + const h = harness() + const first = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [first], h.clients)]) + + const second = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [second], h.clients)]) + + expect(first.disconnectCount).toBe(1) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + }) + + describe('one owner for every command', () => { + it('hands the same client to every caller', async () => { + // The whole point: the debugger, run/stop and the poll must share this, not + // open their own. A second socket to an Arduino Modbus TCP server is never + // answered, which is how a stop command died with a bare timeout. + const h = harness() + const tcp = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [tcp], h.clients)]) + + expect(h.manager.getClient()).toBe(asTransport(tcp)) + expect(h.manager.getClient()).toBe(h.manager.getClient()) + expect(tcp.connectCount).toBe(1) + h.manager.close() + }) + + it('reports no client while recovering, instead of a dead one', async () => { + const h = harness() + const live = new FakeClient({ answers: false }) + await h.manager.open([candidate('tcp', '192.168.0.50', [live], h.clients)]) + + await h.manager.tick() + await h.manager.tick() + + expect(h.manager.isRecovering()).toBe(true) + expect(h.manager.getClient()).toBeNull() + h.manager.close() + }) + }) + + describe('a pulled serial cable', () => { + it('fails immediately on the first tick, without retrying', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + h.statuses.length = 0 + + h.ports.clear() // cable pulled + await h.manager.tick() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.isRecovering()).toBe(false) + expect(h.statuses).toEqual([{ status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }]) + }) + }) + + describe('a device that stops answering', () => { + it('recovers on its own when it comes back', async () => { + const h = harness() + const dying = new FakeClient({ answers: false }) + const revived = new FakeClient() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: jest + .fn() + .mockImplementationOnce(() => asTransport(dying)) + .mockImplementation(() => asTransport(revived)), + } + + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() // one silent poll: tolerated + expect(h.manager.isRecovering()).toBe(false) + await h.manager.tick() // second: enter recovery + expect(h.statuses).toEqual([{ status: 'connecting', transport: 'tcp', descriptor: '192.168.0.50' }]) + expect(dying.disconnectCount).toBe(1) + + await h.manager.tick() // reopen attempt succeeds + expect(h.manager.isRecovering()).toBe(false) + expect(h.manager.getClient()).toBe(asTransport(revived)) + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + transport: 'tcp', + // Shared session: one medium serves both roles, so both are reported as it. + debugTransport: 'tcp', + descriptor: '192.168.0.50', + }) + h.manager.close() + }) + + it('gives up after the retry budget and reports the link lost', async () => { + const h = harness() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: () => asTransport(new FakeClient({ answers: false })), + } + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // attempt 1 + expect(h.manager.isRecovering()).toBe(true) + await h.manager.tick() // attempt 2 -> give up + + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ + status: 'error', + transport: 'tcp', + descriptor: '192.168.0.50', + reason: 'lost', + }) + }) + + it('can come back on the OTHER transport', async () => { + // Seamless across transports: the link was opened from a candidate list, so + // recovery tries the whole list. An ethernet link that drops while the USB + // cable is plugged in comes back over serial. + const h = harness() + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + const candidates = [ + { transport: 'tcp' as const, descriptor: '192.168.0.50', create: () => asTransport(tcp) }, + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ] + + await h.manager.open(candidates) + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // reopen: tcp still silent, serial answers + + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + + it('treats a throwing probe as unresponsive rather than crashing the tick', async () => { + const h = harness({ + probe: async () => { + throw new Error('read timeout') + }, + }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + await expect(h.manager.tick()).resolves.toBeUndefined() + await h.manager.tick() + expect(h.manager.isRecovering()).toBe(true) + h.manager.close() + }) + }) + + describe('a REST-controlled session (Runtime v3/v4)', () => { + it('counts as connected without holding anything open', () => { + // REST is connectionless: there is no socket to hold, poll or recover, so the + // session records the address and routes control operations to it. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, + }) + + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getRestAddress()).toBe('10.0.0.5') + expect(h.manager.getClient()).toBeNull() // nothing Modbus to hold + expect(h.statuses.at(-1)).toMatchObject({ status: 'connected', descriptor: '10.0.0.5' }) + h.manager.close() + }) + + it('publishes the DEBUG medium, which is not the control one', async () => { + // The debug poll sizes its batches to the frame budget: a WebSocket takes 500 + // variables per round trip, Modbus TCP 60, RTU 19. A v4 session is controlled + // over REST — no medium there at all — so publishing only the control medium + // left the poller with nothing and it silently used TCP-sized batches. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, + }) + + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + debugTransport: 'websocket', + descriptor: '10.0.0.5', + }) + h.manager.close() + }) + + it('leaves its debug channel shut until something asks', async () => { + // Logging in to read logs or start the PLC must not open a debug channel — + // for v3 that would be a second Modbus connection to the same box. + const h = harness() + const debug = new FakeClient() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'tcp', descriptor: 'modbus-tcp 10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(debug.connectCount).toBe(0) + + await h.manager.acquireDebugChannel('debug session') + expect(debug.connectCount).toBe(1) + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + h.manager.close() + }) + + it('forgets the session on close', () => { + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + h.manager.close() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.getRestAddress()).toBeNull() + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('is superseded by a device connection', async () => { + // One target at a time: connecting a device replaces a runtime session rather + // than leaving two sessions claiming to be current. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(h.manager.getRestAddress()).toBeNull() + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + }) + + describe('the debug channel', () => { + it('IS the control channel when one medium serves both', async () => { + // A baremetal board answers control and debug over one connection. Opening a + // second client to it is what an Arduino Modbus TCP server never answers, and + // what the OS refuses on a serial port. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + + expect(h.manager.isDebugShared()).toBe(true) + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired && acquired.client).toBe(asTransport(only)) + expect(only.connectCount).toBe(1) + h.manager.close() + }) + + it('releasing a shared channel never closes the connection', async () => { + // Stopping the debugger must not take the connection run/stop and the status + // poll are using. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + await h.manager.acquireDebugChannel('debug session') + + h.manager.releaseDebugChannel('debug session') + + expect(only.disconnectCount).toBe(0) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getDebugClient()).toBe(asTransport(only)) + h.manager.close() + }) + + it('opens a channel of its own when the debug medium differs', async () => { + // A Runtime v3/v4 shape: control is elsewhere, debug is its own channel, and + // it stays shut until something asks for it. + const h = harness() + const control = new FakeClient() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [control], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(h.manager.getDebugClient()).toBeNull() + expect(debug.connectCount).toBe(0) + + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired).toBe(true) + expect(debug.connectCount).toBe(1) + h.manager.close() + }) + + it('closes its own channel only when the last holder lets go', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + await h.manager.acquireDebugChannel('debug session') + await h.manager.acquireDebugChannel('license check') + expect(debug.connectCount).toBe(1) // reused, not reopened + + // A license check finishing must not close the channel a live debug session + // is still reading through. + h.manager.releaseDebugChannel('license check') + expect(debug.disconnectCount).toBe(0) + expect(h.manager.getDebugClient()).not.toBeNull() + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + h.manager.close() + }) + + it('leaves control connected when its own channel will not open', async () => { + // Independent channels: port 502 firewalled is a debugging problem, not a + // reason to drop a working control connection. + const h = harness() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: candidate('tcp', '10.0.0.5:502', [new FakeClient({ connectFails: true })], h.clients), + }) + + const acquired = await h.manager.acquireDebugChannel('debug session') + + expect('error' in acquired).toBe(true) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getClient()).not.toBeNull() + h.manager.close() + }) + + it('closes its own channel when the session ends', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + await h.manager.acquireDebugChannel('debug session') + + h.manager.close() + + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + }) + + it('refuses to acquire when nothing is connected', async () => { + const h = harness() + expect(await h.manager.acquireDebugChannel('debug session')).toEqual({ error: 'Not connected' }) + }) + }) + + describe('upload handoff', () => { + it('releases a serial link that holds the port being flashed', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(true) + expect(h.manager.isConnected()).toBe(false) + expect(serial.disconnectCount).toBe(1) + }) + + it('keeps a TCP link across an upload', async () => { + // Flashing over USB does not disturb an ethernet link, so debugging and + // run/stop keep working through an upload. + const h = harness() + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + + it('leaves a serial link on a different port alone', async () => { + const h = harness() + h.ports.add('/dev/ttyUSB1') + await h.manager.open([candidate('rtu', '/dev/ttyUSB1', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + }) + + describe('polling', () => { + it('stops polling once the link is closed', async () => { + jest.useFakeTimers() + const probe = jest.fn().mockResolvedValue(true) + const h = harness({ probe }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + jest.advanceTimersByTime(30_000) + const callsWhileOpen = probe.mock.calls.length + expect(callsWhileOpen).toBeGreaterThan(0) + + h.manager.close() + jest.advanceTimersByTime(30_000) + expect(probe.mock.calls.length).toBe(callsWhileOpen) + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index be8c99782..5c28e34a6 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -22,28 +22,24 @@ describe('toCalloutPath', () => { }) describe('mergeSerialPortList', () => { - it('labels a port with the arduino-cli board name when identified', () => { + it('reports both descriptors when both scans knew one', () => { + // The merge no longer picks a winner: it reports what each scan found and + // lets `serialPortDisplay` apply the precedence. That split is why the + // renderer can no longer mistake one shape for another. const boards = boardMap([['/dev/cu.usbmodem1', 'Arduino Uno']]) const manufacturers = boardMap([['/dev/cu.usbmodem1', 'Arduino LLC']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem1 (Arduino Uno)', address: '/dev/cu.usbmodem1' }, + { address: '/dev/cu.usbmodem1', boardName: 'Arduino Uno', manufacturer: 'Arduino LLC' }, ]) }) - it('prefers the board name over the manufacturer when both are present', () => { - const boards = boardMap([['COM1', 'Opta']]) - const manufacturers = boardMap([['COM1', 'Arduino']]) - - expect(mergeSerialPortList(boards, manufacturers)[0].name).toBe('COM1 (Opta)') - }) - it('falls back to the manufacturer when the board is detected but not identified', () => { const boards = boardMap([['COM6', undefined]]) const manufacturers = boardMap([['COM6', 'com0com - serial port emulator']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM6 (com0com - serial port emulator)', address: 'COM6' }, + { address: 'COM6', manufacturer: 'com0com - serial port emulator' }, ]) }) @@ -51,14 +47,14 @@ describe('mergeSerialPortList', () => { const boards = boardMap([]) const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: '/dev/ttyUSB0' }]) }) it('treats an empty-string descriptor as absent', () => { const boards = boardMap([['COM1', '']]) const manufacturers = boardMap([['COM1', '']]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: 'COM1', address: 'COM1' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: 'COM1' }]) }) it('unions both scans, keeps serialport ordering, and dedupes by path', () => { @@ -73,9 +69,9 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM3 (FTDI)', address: 'COM3' }, - { name: 'COM4 (Arduino Mega)', address: 'COM4' }, - { name: 'COM9 (Arduino Nano)', address: 'COM9' }, + { address: 'COM3', manufacturer: 'FTDI' }, + { address: 'COM4', boardName: 'Arduino Mega' }, + { address: 'COM9', boardName: 'Arduino Nano' }, ]) }) @@ -89,7 +85,7 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -97,7 +93,7 @@ describe('mergeSerialPortList', () => { const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ - { name: '/dev/cu.usbserial-99 (FTDI)', address: '/dev/cu.usbserial-99' }, + { address: '/dev/cu.usbserial-99', manufacturer: 'FTDI' }, ]) }) @@ -117,10 +113,10 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.debug-console', address: '/dev/cu.debug-console' }, - { name: '/dev/cu.Bluetooth-Incoming-Port', address: '/dev/cu.Bluetooth-Incoming-Port' }, - { name: '/dev/cu.usbserial-1140 (Prolific Technology Inc.)', address: '/dev/cu.usbserial-1140' }, - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.debug-console' }, + { address: '/dev/cu.Bluetooth-Incoming-Port' }, + { address: '/dev/cu.usbserial-1140', manufacturer: 'Prolific Technology Inc.' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -132,8 +128,8 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/ttyACM0', 'Arduino Uno']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/ttyUSB0 (FTDI)', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0 (Arduino Uno)', address: '/dev/ttyACM0' }, + { address: '/dev/ttyUSB0', manufacturer: 'FTDI' }, + { address: '/dev/ttyACM0', boardName: 'Arduino Uno' }, ]) }) }) diff --git a/src/backend/editor/hardware/device-link-policy.ts b/src/backend/editor/hardware/device-link-policy.ts new file mode 100644 index 000000000..d3cde5d7a --- /dev/null +++ b/src/backend/editor/hardware/device-link-policy.ts @@ -0,0 +1,125 @@ +/** + * When is a held device link down, coming back, or gone for good? + * + * The I/O around the link (open a port or socket, read the status frame, close a + * dead handle) lives in `DeviceSessionManager`. What lives HERE is only the + * counting, because that is where the off-by-ones hide and it is the one part + * that can be tested without a cable to pull. + * + * Two states: + * + * healthy - polling a live client. Consecutive silent polls accumulate; + * `failuresBeforeRecovery` of them enter recovery. Any answer + * resets the count, so one dropped frame is not a dropped link. + * recovering - the link is down and reopens are attempted, one per tick. A + * reopen that answers restores the link; `maxRecoveryAttempts` + * failures declare it lost. + * + * Two things set the pace, and they pull in opposite directions: + * + * - Failing fast is good. A link that is definitely gone should say so at once, + * not after half a minute of pointless retries. + * - Reopening is NOT free. Opening a serial port asserts DTR, which resets an + * AVR board — so a trigger-happy reconnect would restart the user's PLC + * program over a single dropped frame. (Native-USB parts like the SAMD in a + * P1AM do not reset, but the policy cannot know which board it is talking to.) + * + * Hence: a `gone` verdict — the serial port is no longer enumerated, so there is + * nothing to reset and nothing to wait for — fails IMMEDIATELY, bypassing the + * budget entirely. An `unresponsive` verdict, which may be noise, spends the + * budget first and only then reopens. + */ + +/** What a single probe of the held link concluded. */ +export type LinkProbeVerdict = + /** Answered. */ + | 'alive' + /** Open but silent: timed out, bad reply, or an unexplained error. */ + | 'unresponsive' + /** The endpoint itself is no longer there (serial port vanished from the OS). */ + | 'gone' + +/** What the caller should do after reporting a probe. */ +export type ProbeDecision = + /** Healthy, or not yet past the failure budget — keep polling. */ + | 'continue' + /** Link is down: drop the dead client, keep the link, start reopening. */ + | 'enter-recovery' + /** Endpoint is gone: tear down and tell the user now. No retries. */ + | 'fail-now' + +/** What the caller should do after reporting a reopen attempt. */ +export type ReopenDecision = + /** Not back yet, attempts remain — try again next tick. */ + | 'retry' + /** Back: adopt the fresh client and report connected. */ + | 'recovered' + /** Out of attempts: tear down and tell the user. */ + | 'give-up' + +export class DeviceLinkPolicy { + private consecutiveFailures = 0 + private recoveryAttempts = 0 + private inRecovery = false + + constructor( + private readonly failuresBeforeRecovery: number, + private readonly maxRecoveryAttempts: number, + ) {} + + /** True while reopens are being attempted rather than the client polled. */ + get recovering(): boolean { + return this.inRecovery + } + + /** Attempts made in the current recovery window (0 when healthy). */ + get attempts(): number { + return this.recoveryAttempts + } + + /** Back to a freshly connected, healthy link. */ + reset(): void { + this.consecutiveFailures = 0 + this.recoveryAttempts = 0 + this.inRecovery = false + } + + /** Report what this tick's probe of the held client concluded. */ + onProbeResult(verdict: LinkProbeVerdict): ProbeDecision { + if (verdict === 'alive') { + this.consecutiveFailures = 0 + return 'continue' + } + if (verdict === 'gone') { + // Nothing to retry against and nothing to reset: the endpoint is not there. + this.reset() + return 'fail-now' + } + this.consecutiveFailures += 1 + if (this.consecutiveFailures < this.failuresBeforeRecovery) return 'continue' + + this.inRecovery = true + this.recoveryAttempts = 0 + this.consecutiveFailures = 0 + return 'enter-recovery' + } + + /** + * Report whether this tick's reopen produced a link that answers. Counts the + * attempt, so a caller that could not even build a client (no candidates left, + * port gone) must still report `false` — otherwise recovery would retry forever + * and the user would never be told. + */ + onReopenResult(recovered: boolean): ReopenDecision { + this.recoveryAttempts += 1 + if (recovered) { + this.reset() + return 'recovered' + } + if (this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.reset() + return 'give-up' + } + return 'retry' + } +} diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts new file mode 100644 index 000000000..99babf7d4 --- /dev/null +++ b/src/backend/editor/hardware/device-probe.ts @@ -0,0 +1,180 @@ +/** + * Connect-time classification of a device link (D72), over an ALREADY-CONNECTED + * `DeviceChannelTransport`: it neither connects nor disconnects — the caller + * holds the client open for the live link, so classification happens over a + * SINGLE port open. + * + * Pure orchestration over the transport, so it is unit-testable with mocks. + * Never throws — failures resolve to a status. + */ +import { getErrorMessage } from '../../../frontend/utils/get-error-message' +import type { DebugBoardIdResult } from '../../shared/debug/types' + +/** Just enough of a channel to open it. */ +type Connectable = { connect(): Promise } + +/** + * Just enough of a channel to classify it. Narrower than + * `DeviceChannelTransport`, where `getBoardId` is optional: a channel that + * cannot answer the board-id read is not one this module can classify. + */ +type BoardIdReadable = { getBoardId(): Promise } + +/** Retry budget for a bounded connect/probe loop. */ +export interface ProbeBudget { + attempts: number + backoffMs: number +} + +/** + * How patiently to wait for a board to answer the id read (0x48). + * + * The generous default exists for ONE situation: a board that has just been + * flashed and is still coming up. Six attempts at a 5s request timeout is ~32s of + * patience, which is right when this is the only endpoint there is and the device + * is expected to appear. + * + * It is wrong when the caller is CHOOSING between endpoints: 32s spent ruling out + * a Modbus TCP address the user is not even using delays the serial connection + * that would have worked. Such callers pass a short budget and move on. + */ +export const PATIENT_BOARD_ID_PROBE: ProbeBudget = { attempts: 6, backoffMs: 500 } +export const QUICK_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 300 } +/** + * For a SPECULATIVE candidate — an alternative baud rate nobody configured. + * + * Two attempts rather than one, and not out of optimism: opening the port asserts + * DTR, which resets an AVR or ESP8266, so the first read after the open can land + * while the board is still booting. One attempt would reject a correct rate for a + * reason that has nothing to do with the rate. Two is the floor that makes the + * sweep trustworthy; more would multiply across every rate tried. + */ +export const SPECULATIVE_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 400 } + +/** + * Baud rates tried, in this order, when the configured one does not answer. + * + * A board whose baud nobody remembers is otherwise unreachable, and it fails in + * the most misleading way available: the port opens (so it is not "no response"), + * nothing decodes (so it reads as "no firmware"), and the user is told to reflash + * a device that is running perfectly well. In the field that reflash is the + * expensive part — it is why this sweep exists. + * + * Ordered by how often they occur in practice, not numerically. Deliberately + * short: every wrong rate costs a port open, and on AVR/ESP8266 opening the port + * asserts DTR and RESETS the board, so a wide sweep is not free — it restarts the + * user's program once per guess. + */ +export const FALLBACK_BAUD_RATES = [115200, 9600, 57600, 19200, 38400] as const + +/** One baud rate to try, and whether trying it is a guess. */ +export interface BaudAttempt { + baudRate: number | undefined + /** True for a rate nobody configured — verification keeps these cheap. */ + speculative: boolean +} + +/** + * The order to try baud rates in for one serial endpoint: the configured rate + * first, then every fallback that isn't it. + * + * The configured rate leads because it is nearly always right, and a correct + * first try costs one port open. The guesses follow in `FALLBACK_BAUD_RATES` + * order. + * + * Returns a single non-speculative attempt when there is no rate to sweep — a + * TCP or WebSocket endpoint (`undefined`), or a caller that opted out. + */ +export function planBaudAttempts(declaredBaud: number | undefined, options: { sweep?: boolean } = {}): BaudAttempt[] { + const declared: BaudAttempt = { baudRate: declaredBaud, speculative: false } + if (options.sweep === false || typeof declaredBaud !== 'number') return [declared] + + return [ + declared, + ...FALLBACK_BAUD_RATES.filter((baud) => baud !== declaredBaud).map((baud) => ({ + baudRate: baud, + speculative: true, + })), + ] +} + +/** + * Connect with a bounded retry/backoff loop. A device flashed over arduino-cli + * serial reboots as the programmer releases the port, so the first connect right + * after an upload frequently races the reboot; retry, rethrowing the last error + * only once every attempt is exhausted. + */ +export async function connectWithRetries(client: Connectable, { attempts, backoffMs }: ProbeBudget): Promise { + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + await client.connect() + return + } catch (error) { + lastError = error + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + } + throw lastError +} + +/** + * Read the board id (FC 0x48) with a bounded retry/backoff loop -- a readiness + * probe for the firmware itself (the serial open auto-resets ESP8266/AVR boards). + * + * A SUCCESSFUL REPLY is the signal, not a non-empty id. `success` already means + * the frame came back with the right function code and a SUCCESS status, which + * only an OpenPLC firmware sends. The id itself is allowed to be empty: cores + * without ArduinoUniqueID support, and boards that opt out with + * `OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to + * compile (see `debugGetBoardId` in modbus_debug.cpp). Requiring bytes here + * reported those boards as having no firmware at all. + */ +export async function readBoardIdWithRetries( + client: BoardIdReadable, + { attempts, backoffMs }: ProbeBudget, +): Promise<{ success: boolean; boardId?: Uint8Array }> { + let last: { success: boolean; boardId?: Uint8Array } = { success: false } + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await client.getBoardId() + last = { success: result.success, boardId: result.boardId } + if (last.success) return last + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + return last +} + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +export interface DeviceProbeOutcome { + status: DeviceProbeStatus + error?: string +} + +/** + * Classify an already-connected candidate: did an OpenPLC firmware answer the + * debug protocol on it? + * + * Only the board-id read decides. A channel that opens but answers nothing — + * a blank board, or an IP that belongs to something else entirely — classifies + * as `no-firmware`, so the caller can fall through to the next candidate rather + * than keeping a link that cannot serve a single command. + */ +export async function classifyDeviceLink( + client: BoardIdReadable, + opts: { boardIdProbe?: ProbeBudget } = {}, +): Promise { + try { + const probe = await readBoardIdWithRetries(client, opts.boardIdProbe ?? PATIENT_BOARD_ID_PROBE) + if (!probe.success) { + // Channel opened but nothing spoke the debug protocol -> blank board, a + // non-OpenPLC device, or the wrong baud rate. Whether the reply carried a + // unique id is NOT part of this question — see `readBoardIdWithRetries`. + return { status: 'no-firmware' } + } + return { status: 'connected-with-firmware' } + } catch (error) { + return { status: 'error', error: getErrorMessage(error) } + } +} diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts new file mode 100644 index 000000000..eb2662ad5 --- /dev/null +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -0,0 +1,718 @@ +/** + * THE session with a device: what it is reached through, and by whom. + * + * A session has two channel slots — CONTROL (run/stop, status) and DEBUG + * (variables, md5, licensing) — because that is the shape real targets have: + * + * - a baremetal board answers both over ONE Modbus connection, serial or TCP; + * - a Runtime v3/v4 is controlled over REST but debugged over something else + * entirely (Modbus TCP / a WebSocket); + * - the simulator answers both over its in-process virtual serial port. + * + * When both roles share a medium the two slots hold the SAME channel, so nothing + * opens twice and releasing the debug role cannot close the connection out from + * under run/stop. When they differ the debug channel is opened on request and + * closed when the last requester lets go — an independent channel, whose failure + * leaves control untouched. + * + * Whatever the shape, every caller shares what the session holds: the debugger, + * run/stop, the status poll, licensing. + * That single-ownership rule is the point of this module. Before it, three + * places opened their own client for the same device (the debug session, the two + * lazy-reconnect paths, and a transient one per run/stop command), and each had + * its own idea of which transport counted as reusable. A run/stop command with a + * live Modbus TCP session therefore opened a SECOND socket to the board — which + * an Arduino Modbus TCP server, serving one client at a time, never answered, so + * the command failed with a bare timeout while a perfectly good connection sat + * idle. + * + * Transport is a detail here, not a branch. Both Modbus clients implement + * `DeviceModbusTransport`, so this module never asks which one it holds except to + * describe it to the user and to know whether a vanished serial port applies. + * + * What the manager does NOT decide: + * - which candidates to try, or in what order -> the caller resolves those + * from the board's debug spec (Modbus TCP first when the project enables it, + * serial otherwise), so this works for every baremetal target rather than + * any particular board; + * - what "this is really the device" means -> `hooks.verify`, which the + * main process implements as its existing classify + license recover; + * - the counting for down / back / lost -> `DeviceLinkPolicy`. + */ +import type { DebugMedium, DeviceLinkTransport } from '../../../middleware/shared/ports/types' +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' +import { DeviceLinkPolicy } from './device-link-policy' + +// Re-exported so callers in this layer keep one import site; the definition is +// shared with the renderer, which mirrors these media into the store. +export type { DebugMedium, DeviceLinkTransport } + +/** + * How to open a DEBUG channel that is not the control channel. Simpler than a + * control candidate: there is nothing to choose between and nothing to classify — + * the control side already established what this target is. + */ +export interface DeviceDebugCandidate { + /** + * Medium this channel rides. Published with the session status because the debug + * poll sizes its batches to the frame budget (a WebSocket swallows 500 variables + * per round trip, Modbus TCP 60, RTU 19), and a poller that has to GUESS the + * medium either wastes round trips or overruns a frame. + */ + transport: DebugMedium + descriptor: string + create: () => DeviceDebugChannel +} + +/** One way to reach the device, ready to be tried. */ +export interface DeviceLinkCandidate { + transport: DeviceLinkTransport + /** + * What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". + * + * This is an IDENTIFIER, not a caption: it is matched against the OS port list + * (`serialPortPresent`) and against the port an upload asks to borrow. Anything + * decorative belongs in `baudRate` or the trace, never here — a descriptor that + * read "COM5 @ 9600 baud" matched no port and no upload. + */ + descriptor: string + /** + * Wire speed for a serial candidate, carried so the trace can name it. Two + * candidates on one port differ only by this, and a log that repeated the port + * five times said nothing about what was actually tried. + */ + baudRate?: number + /** Build an unconnected client for this candidate. */ + create: () => DeviceModbusTransport + /** + * A guess rather than something the project declared (an alternative baud + * rate). Verification spends a short budget on these — there may be several, + * and being wrong about one must not delay the next. + */ + speculative?: boolean + /** + * Worth waiting for. Set on the last candidate the project actually declared, + * so it keeps the patient probe budget even when speculative candidates queue + * up behind it — a board that was just flashed is still booting, and that wait + * belongs to the configured endpoint, not to a guess. + */ + patient?: boolean +} + +/** Live link state, as pushed to the renderer. */ +export interface DeviceLinkStatus { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * The CONTROL channel's medium. Absent for a REST-controlled session (v3/v4): + * REST holds no connection, so there is no medium to report or lose. + */ + transport?: DeviceLinkTransport + /** + * The DEBUG channel's medium — the same as `transport` when one channel serves + * both roles, and `websocket` (v4) or `tcp` (v3) when it does not. Reported + * separately because these are genuinely two facts: the control medium decides + * what "the connection dropped" means, the debug medium decides the poll's frame + * budget. + */ + debugTransport?: DebugMedium + descriptor?: string + /** + * Set only when a link that WAS up died and could not be recovered. The one + * status the user must be told about; every other 'error' came straight out of + * something they just clicked and already has its own dialog. + */ + reason?: 'lost' +} + +export interface DeviceLinkOpenSuccess { + ok: true + transport: DeviceLinkTransport + descriptor: string + client: DeviceModbusTransport +} + +export interface DeviceLinkOpenFailure { + ok: false + /** Every candidate that was tried, with why it did not work. */ + attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; baudRate?: number; error: string }> +} + +export type DeviceLinkOpenResult = DeviceLinkOpenSuccess | DeviceLinkOpenFailure + +/** + * How a candidate reads in the trace and in a failure message: the endpoint plus + * its wire speed, when it has one. + * + * Derived here rather than baked into `descriptor`, because that field is matched + * against OS port names and upload requests — see the note on it. + */ +export function describeLinkCandidate(candidate: { + transport: DeviceLinkTransport + descriptor: string + baudRate?: number +}): string { + const speed = candidate.baudRate === undefined ? '' : ` @ ${candidate.baudRate} baud` + return `${candidate.transport} ${candidate.descriptor}${speed}` +} + +export interface DeviceLinkHooks { + /** + * Is this freshly opened client really a device we can work with? Decides + * whether to keep a candidate or move on to the next one, so a Modbus TCP + * socket that opens but answers nothing correctly falls back to serial. + * + * The main process implements this as its classify + license recover, which is + * why it runs on open only — see `probe` for the per-tick check. + */ + verify: ( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ) => Promise + /** + * Cheap liveness read on the held client, also used to confirm a reopen. Kept + * separate from `verify` so recovery does not re-run licensing every couple of + * seconds for as long as a cable is out. + */ + probe: (client: DeviceModbusTransport) => Promise + /** Is this serial port still enumerated by the OS? */ + serialPortPresent: (port: string) => Promise + /** Report a link state change to the renderer. */ + emit: (status: DeviceLinkStatus) => void + /** + * Diagnostic trace of every decision this manager makes: which candidate was + * tried, how long its connect took, why it was kept or rejected, what each poll + * concluded. Optional, but in practice always supplied — a connection flow that + * spans two transports and a remote board cannot be diagnosed by watching the UI. + */ + log?: (message: string) => void +} + +export interface DeviceLinkTimings { + pollIntervalMs: number + failuresBeforeRecovery: number + maxRecoveryAttempts: number +} + +/** Fail fast, but not so fast that noise reopens a port. See DeviceLinkPolicy. */ +export const DEFAULT_DEVICE_LINK_TIMINGS: DeviceLinkTimings = { + pollIntervalMs: 2500, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, +} + +export class DeviceSessionManager { + /** The control channel's client, and the debug channel's too when shared. */ + private client: DeviceModbusTransport | null = null + private current: DeviceLinkCandidate | null = null + /** + * Debug channel, when it is NOT the control channel: its client (null until + * something asks for it) and how to open it. + */ + private debugClientHeld: DeviceDebugChannel | null = null + private debugCandidate: DeviceDebugCandidate | null = null + /** + * Who currently wants the debug channel, by reason. A set rather than a counter + * so the trace can say who is holding it, and so a double release from one + * caller cannot close a channel another still needs. + */ + private readonly debugHolders = new Set() + /** + * Runtime targets (v3/v4) are CONTROLLED over REST, which is connectionless: + * there is no socket to hold, poll or recover, so the session records the address + * and routes control operations to the HTTP client instead of holding a channel. + * That is why this slot is an address rather than a client — and why polling and + * recovery below apply only to a Modbus control channel. + */ + private restControl: { address: string } | null = null + /** The full list the link was opened from, so recovery can try them all again. */ + private candidates: DeviceLinkCandidate[] = [] + private readonly policy: DeviceLinkPolicy + private timer: ReturnType | null = null + private tickInFlight = false + /** When a command last succeeded over the link. 0 = nothing since it opened. */ + private lastTrafficAt = 0 + + constructor( + private readonly hooks: DeviceLinkHooks, + private readonly timings: DeviceLinkTimings = DEFAULT_DEVICE_LINK_TIMINGS, + ) { + this.policy = new DeviceLinkPolicy(timings.failuresBeforeRecovery, timings.maxRecoveryAttempts) + } + + private trace(message: string): void { + this.hooks.log?.(message) + } + + /** + * A command just succeeded over the held link. + * + * This is liveness evidence, and better evidence than the poll's own read: it + * already happened, and it cost nothing extra. The poll uses it to skip its + * round trip entirely (see `probeVerdict`). + * + * Why that matters, not merely as an optimisation: a debug session polls + * variables continuously, and every request queues on the ONE serial link. + * On a slow wire the status read waits behind that traffic, times out, and two + * such timeouts enter recovery — tearing down a debug session over a link that + * was demonstrably working, which is what the traffic proves. Measured on a + * 9600-baud ESP8266: the debugger died roughly every ten seconds while its own + * reads kept succeeding. + */ + noteTraffic(): void { + this.lastTrafficAt = Date.now() + } + + /** + * The CONTROL channel's client, or null when nothing is connected (including + * mid-recovery). Run/stop and the status poll go here. + */ + getClient(): DeviceModbusTransport | null { + return this.client + } + + /** + * The DEBUG channel's client, or null when it is not open. + * + * For a shared session this IS the control client, so it needs no acquiring and + * cannot be closed independently. For a session whose debug medium differs, it + * is null until someone calls `acquireDebugChannel`. + */ + getDebugClient(): DeviceDebugChannel | null { + return this.debugCandidate ? this.debugClientHeld : this.client + } + + /** True when one medium serves both roles, so the slots hold the same channel. */ + isDebugShared(): boolean { + return this.debugCandidate === null + } + + /** + * Open the debug channel if it isn't already, and record `reason` as a holder. + * + * Independent of control on purpose: a debug channel that will not open is + * reported to whoever asked, and the control connection carries on. For a shared + * session there is nothing to open — the answer is the control channel, and the + * session having been established is the only precondition. + */ + async acquireDebugChannel(reason: string): Promise<{ client: DeviceDebugChannel } | { error: string }> { + if (this.debugCandidate === null) { + if (!this.client) return { error: 'Not connected' } + // (A REST session always has a debug candidate, so it never lands here.) + this.debugHolders.add(reason) + return { client: this.client } + } + + if (this.debugClientHeld) { + this.debugHolders.add(reason) + return { client: this.debugClientHeld } + } + + this.trace(`debug channel: opening ${this.debugCandidate.descriptor} for ${reason}`) + let client: DeviceDebugChannel + try { + client = this.debugCandidate.create() + await client.connect() + } catch (error) { + this.trace(`debug channel: could not open — ${describeError(error)} (control connection unaffected)`) + return { error: describeError(error) } + } + this.debugClientHeld = client + this.debugHolders.add(reason) + return { client } + } + + /** + * Let go of the debug channel. It closes only once nothing holds it AND it is a + * channel of its own — releasing a shared one must never take the connection + * that run/stop and the status poll are using. + */ + releaseDebugChannel(reason: string): void { + this.debugHolders.delete(reason) + if (this.debugHolders.size > 0) return + if (this.debugCandidate === null || !this.debugClientHeld) return + this.trace(`debug channel: closing (last holder ${reason} released)`) + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + + /** Transport + endpoint of the held link, for messages and handoff decisions. */ + getLink(): { transport: DeviceLinkTransport; descriptor: string } | null { + if (!this.current) return null + return { transport: this.current.transport, descriptor: this.current.descriptor } + } + + /** True while the link is down and reopens are being attempted. */ + isRecovering(): boolean { + return this.policy.recovering + } + + isConnected(): boolean { + return this.client !== null || this.restControl !== null + } + + /** + * Open the first candidate that works and hold it. + * + * Candidates are tried IN ORDER and the first one to both connect and verify + * wins; a candidate that connects but fails verification is closed before the + * next is tried, so no stray handles are left behind. If none work the attempt + * fails, reporting what was tried — an editor that claimed "connected" without + * a working connection is what made every later request time out mysteriously. + * + * A fresh open supersedes any held link (reconnect, transport change). + */ + async open( + candidates: DeviceLinkCandidate[], + options: { + /** + * How to reach this target for DEBUG when that is a different medium from + * control (Runtime v3/v4). Omit when one medium serves both — the slots then + * share a channel, which is what keeps a debug session from opening a second + * connection to a device that only answers one. + */ + debugChannel?: DeviceDebugCandidate + } = {}, + ): Promise { + this.close({ silent: true }) + this.debugCandidate = options.debugChannel ?? null + + if (candidates.length === 0) { + this.trace('open: refused, no usable candidate was resolved') + // Still report a settled state. The `close({ silent: true })` above suppressed + // its own notification on the assumption that this open would publish one, so + // returning quietly here leaves the renderer showing 'connecting' forever — + // and its Connect button disabled with no way back. + this.hooks.emit({ status: 'disconnected' }) + return { ok: false, attempts: [] } + } + + this.candidates = candidates + const attempts: DeviceLinkOpenFailure['attempts'] = [] + + for (const [index, candidate] of candidates.entries()) { + // The plan is only worth stating once something went wrong. On the ordinary + // connection the first candidate answers, and listing four baud rates nobody + // will dial just buries the two lines that matter. The moment a fallback IS + // in play, the order becomes the thing you need to read. + if (index === 1) { + this.trace( + `open: falling back — ${candidates.length} candidate(s): ${candidates.map(describeLinkCandidate).join(', ')}`, + ) + } + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + + const startedAt = Date.now() + const outcome = await this.tryCandidate(candidate, { isLastCandidate: index === candidates.length - 1 }) + // Only a rejection is traced. Acceptance is already announced by the + // `connected` status that follows, with the same descriptor. + if (!outcome.ok) { + this.trace( + `open: ${describeLinkCandidate(candidate)} rejected in ${Date.now() - startedAt}ms — ${outcome.error}`, + ) + } + if (outcome.ok) { + this.client = outcome.client + this.current = candidate + this.policy.reset() + // Deliberately NOT seeding `lastTrafficAt` from the verify that just + // passed: nothing else is on the link yet, so letting the first tick do + // its own read costs nothing and keeps the poll's behaviour on a fresh + // connection exactly as it was. + this.startPolling() + this.hooks.emit({ + status: 'connected', + transport: candidate.transport, + // Shared unless the caller supplied a separate debug channel. + debugTransport: this.debugCandidate?.transport ?? candidate.transport, + descriptor: candidate.descriptor, + }) + return { ok: true, transport: candidate.transport, descriptor: candidate.descriptor, client: outcome.client } + } + attempts.push({ + transport: candidate.transport, + descriptor: candidate.descriptor, + baudRate: candidate.baudRate, + error: outcome.error, + }) + } + + this.candidates = [] + this.trace('open: FAILED, no candidate answered') + this.hooks.emit({ status: 'disconnected' }) + return { ok: false, attempts } + } + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4). + * + * Nothing is opened here. REST needs no connection, and the debug channel is + * deliberately left shut until something asks for it — a user who logs in to look + * at logs or start the PLC should not be made to hold a debug channel open, and + * for v3 that channel is a second Modbus connection to the same box. + */ + openRestSession(options: { address: string; debugChannel: DeviceDebugCandidate }): void { + this.close({ silent: true }) + this.restControl = { address: options.address } + this.debugCandidate = options.debugChannel + this.trace(`session: control over REST at ${options.address}, debug via ${options.debugChannel.descriptor}`) + this.hooks.emit({ + status: 'connected', + // No control transport: REST holds nothing. The debug medium is what the + // debugger will actually ride, and what its poll must be sized for. + debugTransport: options.debugChannel.transport, + descriptor: options.address, + }) + } + + /** The REST address when control runs over REST, else null. */ + getRestAddress(): string | null { + return this.restControl?.address ?? null + } + + /** Open + verify a single candidate, leaving nothing open on failure. */ + private async tryCandidate( + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise<{ ok: true; client: DeviceModbusTransport } | { ok: false; error: string }> { + // A serial candidate whose port is not even enumerated cannot be opened: + // say so instead of waiting out a connect timeout. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + this.trace(` ${describeLinkCandidate(candidate)}: serial port is not enumerated, skipping`) + return { ok: false, error: `${candidate.descriptor} is not available` } + } + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch (error) { + return { ok: false, error: describeError(error) } + } + + const connectStartedAt = Date.now() + try { + // Nothing traced on success: opening is a step towards the answer, not the + // answer. Only failing to open is news. + await client.connect() + } catch (error) { + client.disconnect() + this.trace( + ` ${describeLinkCandidate(candidate)}: transport would not open after ${Date.now() - connectStartedAt}ms`, + ) + return { ok: false, error: describeError(error) } + } + + // Opening proves an endpoint, not a PLC. A Modbus TCP socket to something + // that is not an OpenPLC target connects instantly and then answers nothing, + // so this is the step that decides whether to keep the candidate. + const verifyStartedAt = Date.now() + try { + if (await this.hooks.verify(client, candidate, context)) return { ok: true, client } + client.disconnect() + this.trace( + ` ${describeLinkCandidate(candidate)}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, + ) + return { ok: false, error: 'No OpenPLC firmware answered' } + } catch (error) { + client.disconnect() + this.trace(` ${describeLinkCandidate(candidate)}: verification threw after ${Date.now() - verifyStartedAt}ms`) + return { ok: false, error: describeError(error) } + } + } + + /** + * Close the held link. `silent` skips the renderer notification, for the case + * where a new open is about to report its own state. + */ + close(options: { silent?: boolean } = {}): void { + this.stopPolling() + this.policy.reset() + this.debugHolders.clear() + if (this.debugClientHeld) { + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + this.debugCandidate = null + const hadRest = this.restControl !== null + this.restControl = null + const had = this.client !== null || hadRest + if (had) this.trace(`close: dropping ${this.current?.transport ?? (hadRest ? 'rest' : '?')} session`) + this.dropClient() + this.current = null + this.candidates = [] + if (had && !options.silent) this.hooks.emit({ status: 'disconnected' }) + } + + /** + * Give up the link if it holds `port` — the handoff before an upload takes the + * same serial port. Returns whether anything was released, so the caller knows + * whether to reconnect afterwards. + * + * A link running over Modbus TCP is untouched: flashing over USB does not + * disturb it, so debugging and run/stop keep working across an upload. + */ + releaseSerialPort(port: string | null | undefined): boolean { + if (!this.current || this.current.transport !== 'rtu') { + this.trace(`release ${String(port)}: nothing to release (held: ${this.current?.transport ?? 'none'})`) + return false + } + if (port !== undefined && port !== null && this.current.descriptor !== String(port)) { + this.trace(`release ${String(port)}: held connection is on ${this.current.descriptor}, leaving it alone`) + return false + } + this.trace(`release ${this.current.descriptor}: handing the port over for an upload`) + this.close() + return true + } + + private dropClient(): void { + this.client?.disconnect() + this.client = null + // Traffic over a client we just dropped proves nothing about the next one. + this.lastTrafficAt = 0 + } + + private startPolling(): void { + this.stopPolling() + this.timer = setInterval(() => { + if (this.tickInFlight) return + this.tickInFlight = true + void this.tick().finally(() => { + this.tickInFlight = false + }) + }, this.timings.pollIntervalMs) + } + + private stopPolling(): void { + if (!this.timer) return + clearInterval(this.timer) + this.timer = null + } + + /** + * One step of the link's lifecycle: probe the held client, or make a single + * reopen attempt while recovering. Public so it can be driven directly in + * tests instead of waiting on a timer. + */ + async tick(): Promise { + if (this.policy.recovering) return this.attemptRecovery() + + const client = this.client + const candidate = this.current + if (!client || !candidate) return + + const verdict = await this.probeVerdict(client, candidate) + const decision = this.policy.onProbeResult(verdict) + if (verdict !== 'alive') { + this.trace(`poll: ${describeLinkCandidate(candidate)} ${verdict} -> ${decision}`) + } + switch (decision) { + case 'enter-recovery': + // Drop the dead handle but KEEP the link: a stale open fd is what makes + // the reopen fail with "cannot lock port", while the candidate list is + // what lets the next ticks bring it back with nothing for the user to do. + this.dropClient() + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + return + case 'fail-now': + return this.declareLost(candidate) + default: + return + } + } + + /** Classify one probe of the held client. */ + private async probeVerdict( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + ): Promise<'alive' | 'unresponsive' | 'gone'> { + // Check the endpoint first: a pulled USB cable is not a slow device, and + // treating it as one would spend the whole failure budget waiting for + // timeouts on a port that no longer exists. Deliberately BEFORE the traffic + // shortcut below: the port list is local and instant, so a yanked cable is + // still caught on the very next tick. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + return 'gone' + } + // Something already answered within this interval, so the link is up and + // asking again would only add traffic to a wire that is evidently busy — + // and on a slow one, queue behind it and time out. See `noteTraffic`. + if (this.lastTrafficAt > 0 && Date.now() - this.lastTrafficAt < this.timings.pollIntervalMs) { + return 'alive' + } + try { + return (await this.hooks.probe(client)) ? 'alive' : 'unresponsive' + } catch { + return 'unresponsive' + } + } + + /** + * One reopen attempt while recovering. Tries the SAME candidate list the link + * was opened from, so a device that comes back on either transport is picked + * up — and a serial port that has not reappeared is skipped without cost. + * + * Verification here is the cheap `probe`, not `verify`: the classification and + * license recover from the original open still stand, and re-running them every + * couple of seconds while a cable is out would hammer the licensing backend. + */ + private async attemptRecovery(): Promise { + const previous = this.current + if (!previous) return + + const reopened = await this.reopen() + this.trace( + `recovery: attempt ${this.policy.attempts + 1} ${reopened ? `restored over ${reopened.candidate.transport}` : 'failed'}`, + ) + + switch (this.policy.onReopenResult(reopened !== null)) { + case 'recovered': + this.client = reopened!.client + this.current = reopened!.candidate + this.hooks.emit({ + status: 'connected', + transport: reopened!.candidate.transport, + debugTransport: this.debugCandidate?.transport ?? reopened!.candidate.transport, + descriptor: reopened!.candidate.descriptor, + }) + return + case 'give-up': + return this.declareLost(previous) + default: + return + } + } + + /** Try every candidate once; return the first that opens and answers. */ + private async reopen(): Promise<{ client: DeviceModbusTransport; candidate: DeviceLinkCandidate } | null> { + for (const candidate of this.candidates) { + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) continue + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch { + continue + } + try { + await client.connect() + if (await this.hooks.probe(client)) return { client, candidate } + } catch { + // Still out, or open but silent — fall through and close it. + } + client.disconnect() + } + return null + } + + private declareLost(candidate: DeviceLinkCandidate): void { + const { transport, descriptor } = candidate + this.trace(`LOST: ${transport} ${descriptor} could not be recovered`) + this.close({ silent: true }) + this.hooks.emit({ status: 'error', transport, descriptor, reason: 'lost' }) + } +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/backend/editor/hardware/device-transport-factory.ts b/src/backend/editor/hardware/device-transport-factory.ts new file mode 100644 index 000000000..56b6ba3e5 --- /dev/null +++ b/src/backend/editor/hardware/device-transport-factory.ts @@ -0,0 +1,107 @@ +/** + * THE place a Modbus client is built from connection params. + * + * There used to be nine: the debug session, two lazy-reconnect paths, a transient + * one per run/stop command, the md5 verify, the license probe, the connect probe. + * Each repeated the same option literals and, worse, each decided on its own which + * transport it would accept — which is how a run/stop command over Modbus TCP came + * to open a second socket instead of using the connection already open. + * + * Building a client is now the only transport-specific step in the whole flow; + * everything downstream talks to `DeviceModbusTransport`. + */ +import type { DeviceModbusTransport } from '../../shared/debug/types' +import { ModbusTcpClient } from '../modbus/modbus-client' +import { ModbusRtuClient } from '../modbus/modbus-rtu-client' + +/** Transports that speak Modbus to a device. `websocket` (runtime v4) does not. */ +export type DeviceModbusTransportKind = 'rtu' | 'tcp' | 'simulator' + +export interface DeviceTransportParams { + connectionType?: string + /** RTU: serial port path. TCP: optional numeric port override. */ + port?: string | number + baudRate?: number + slaveId?: number + /** TCP host. `ipAddress` is accepted as an alias, as the debug specs emit that. */ + host?: string + ipAddress?: string +} + +export interface DeviceTransportOptions { + /** + * Request timeout. The default suits interactive debug traffic; the license + * probe passes a shorter one because it retries while a board is still booting. + */ + timeoutMs?: number + /** + * In-process serial port for the simulator target. Required for + * `connectionType: 'simulator'`, meaningless otherwise. + */ + virtualSerialPort?: ConstructorParameters[0]['serialPort'] +} + +/** Standard Modbus TCP port. */ +const MODBUS_TCP_PORT = 502 +const DEFAULT_TIMEOUT_MS = 5000 + +/** Which Modbus transport do these params describe, if any? */ +export function modbusTransportKind(connectionType: string | undefined): DeviceModbusTransportKind | null { + if (connectionType === 'tcp' || connectionType === 'rtu' || connectionType === 'simulator') return connectionType + // An absent type means serial, matching the license factory's long-standing default. + return connectionType === undefined ? 'rtu' : null +} + +/** + * Build an unconnected Modbus client. Returns `{ error }` rather than throwing + * when the params for the chosen transport are incomplete, so every caller + * surfaces the same message instead of inventing its own. + */ +export function buildDeviceModbusTransport( + params: DeviceTransportParams, + options: DeviceTransportOptions = {}, +): { client: DeviceModbusTransport } | { error: string } { + const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const kind = modbusTransportKind(params.connectionType) + + if (kind === 'simulator') { + if (!options.virtualSerialPort) return { error: 'The simulator transport needs an in-process serial port' } + return { + client: new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout, + serialPort: options.virtualSerialPort, + }), + } + } + + if (kind === 'tcp') { + const host = params.host ?? params.ipAddress + if (!host) return { error: 'IP address is required for a Modbus TCP connection' } + return { + client: new ModbusTcpClient({ + host, + port: typeof params.port === 'number' ? params.port : MODBUS_TCP_PORT, + timeout, + }), + } + } + + if (kind === 'rtu') { + if (!params.port || typeof params.port !== 'string') { + return { error: 'A serial port is required for a Modbus RTU connection' } + } + return { + client: new ModbusRtuClient({ + port: params.port, + baudRate: params.baudRate ?? 115200, + slaveId: params.slaveId ?? 1, + timeout, + }), + } + } + + return { error: `Unsupported Modbus transport: ${String(params.connectionType)}` } +} diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index ea312170a..882030a38 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -14,7 +14,7 @@ import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import { orderBoardsByVppGroup } from './order-boards-by-vpp-group' -import { mergeSerialPortList } from './serial-port-list' +import { mergeSerialPortList, toCalloutPath } from './serial-port-list' import type { AvailableBoards, HalsFile, SerialPort } from './types' const execFileAsync = promisify(execFile) @@ -113,6 +113,30 @@ class HardwareModule { return mergeSerialPortList(boardNamesByPath, manufacturersByPath) } + /** + * Is this serial port still attached? + * + * The `serialport` scan only — deliberately NOT `getAvailableSerialPorts()`, + * which also shells out to arduino-cli. This is called on every tick of the + * device link poll to tell a pulled USB cable (fail now, there is nothing to + * retry against) from a device that is merely slow to answer (retry), so it has + * to be instant. + * + * Fails SAFE: if enumeration itself breaks, the port is reported present. A + * false "gone" would tear down a working connection, which is worse than + * waiting out one timeout. + */ + async isSerialPortPresent(address: string): Promise { + try { + const ports = await NodeSerialPort.list() + const wanted = toCalloutPath(address) + return ports.some((port) => toCalloutPath(port.path) === wanted) + } catch (error: unknown) { + logger.error(`Failed to check serial port presence: ${String(error)}`) + return true + } + } + /** * `serialport` enumeration → `path → manufacturer`. This is the reliable, * instant, cross-platform source for the *set* of ports; arduino-cli only @@ -358,6 +382,8 @@ class HardwareModule { } : null, }, + ...(device.serialPorts ? { serialPorts: device.serialPorts } : {}), + ...(device.defaultSerial ? { defaultSerial: device.defaultSerial } : {}), ...(device.debug ? { debug: device.debug } : {}), }) } diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts index 3a925a310..b752d865d 100644 --- a/src/backend/editor/hardware/serial-port-list.ts +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -34,31 +34,26 @@ function toCalloutMap(byPath: Map): Map arduino-cli board name (`undefined` when * the port was detected but no board matched) - * @param manufacturersByPath path → `serialport` manufacturer/vendor string + * @param manufacturersByPath path -> `serialport` manufacturer/vendor string */ export function mergeSerialPortList( boardNamesByPath: Map, @@ -72,11 +67,14 @@ export function mergeSerialPortList( const addresses = new Set([...manufacturers.keys(), ...boardNames.keys()]) return [...addresses].map((address) => { - // Board name (more specific) wins; `||` so an empty descriptor falls through. - const descriptor = boardNames.get(address) || manufacturers.get(address) + // Empty strings are normalised away so the renderer only has to check for + // absence, not for blank-but-present descriptors. + const boardName = boardNames.get(address)?.trim() || undefined + const manufacturer = manufacturers.get(address)?.trim() || undefined return { - name: descriptor ? `${address} (${descriptor})` : address, address, + ...(boardName ? { boardName } : {}), + ...(manufacturer ? { manufacturer } : {}), } }) } diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index ba9d0f7b3..d42dd4fbb 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -4,8 +4,9 @@ import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-type import type { PlatformOption, TargetCapabilities } from '../../../middleware/shared/ports/types' const SerialPortSchema = z.object({ - name: z.string(), address: z.string(), + boardName: z.string().optional(), + manufacturer: z.string().optional(), }) type SerialPort = z.infer diff --git a/src/backend/editor/library-manager/desktop-catalog-transport.ts b/src/backend/editor/library-manager/desktop-catalog-transport.ts index 2b66ce6f7..9b941ac14 100644 --- a/src/backend/editor/library-manager/desktop-catalog-transport.ts +++ b/src/backend/editor/library-manager/desktop-catalog-transport.ts @@ -20,6 +20,7 @@ import https from 'https' import type { CatalogQueryParams, CatalogTransportPort } from '../../../middleware/shared/ports/catalog-transport-port' +import { defaultPortFor, httpModuleFor } from '../utils/http-module' /** Default base URL when no env override is set. */ const DEFAULT_EDGE_API_URL = 'https://api.autonomylogic.com' @@ -77,7 +78,7 @@ function requestText(url: string, signal?: AbortSignal): Promise { const parsed = new URL(url) const reqOptions: https.RequestOptions = { hostname: parsed.hostname, - port: parsed.port || undefined, + port: parsed.port || defaultPortFor(parsed), path: parsed.pathname + parsed.search, method: 'GET', headers: { @@ -86,7 +87,8 @@ function requestText(url: string, signal?: AbortSignal): Promise { }, } - const req = https.request(reqOptions, (res) => { + // Scheme-driven, so OPENPLC_EDGE_API_URL can point at a local http backend. + const req = httpModuleFor(parsed).request(reqOptions, (res) => { // Accumulate as a single utf-8 string instead of buffering raw // chunks — sidesteps the Buffer[] vs Uint8Array[] friction in // recent @types/node and matches the existing httpRequest in diff --git a/src/backend/editor/license/__tests__/device-identity.test.ts b/src/backend/editor/license/__tests__/device-identity.test.ts new file mode 100644 index 000000000..085cdb3b5 --- /dev/null +++ b/src/backend/editor/license/__tests__/device-identity.test.ts @@ -0,0 +1,34 @@ +import { deriveDeviceId, deriveVppId } from '../device-identity' + +describe('deriveDeviceId', () => { + it('derives the golden 16-byte device id for the real NodeMCU anchor', () => { + // Anchor 00 b1 8c ed is the real NodeMCU hardware anchor. The golden + // value below is the deterministic sha256("openplc-dev-v1|"||anchor)[:16]. + const anchor = Uint8Array.from([0, 177, 140, 237]) + expect(deriveDeviceId(anchor)).toBe('659a3520540f803625ddc34081e893d3') + }) + + it('returns 32 lowercase hex chars (16 bytes)', () => { + const id = deriveDeviceId(Uint8Array.from([1, 2, 3])) + expect(id).toMatch(/^[0-9a-f]{32}$/) + }) + + it('is deterministic and domain-separated by the prefix', () => { + const anchor = Uint8Array.from([0, 177, 140, 237]) + // Same anchor -> same id. + expect(deriveDeviceId(anchor)).toBe(deriveDeviceId(Uint8Array.from([0, 177, 140, 237]))) + // A different anchor -> different id. + expect(deriveDeviceId(anchor)).not.toBe(deriveDeviceId(Uint8Array.from([0, 177, 140, 238]))) + }) +}) + +describe('deriveVppId', () => { + it('derives the golden 8-byte vpp id for the espressif package', () => { + // Golden value = sha256("com.openplc.espressif")[:8], hex. + expect(deriveVppId('com.openplc.espressif')).toBe('29a17c7c2486d355') + }) + + it('returns 16 lowercase hex chars (8 bytes)', () => { + expect(deriveVppId('com.openplc.espressif')).toMatch(/^[0-9a-f]{16}$/) + }) +}) diff --git a/src/backend/editor/license/__tests__/license-activation-client.test.ts b/src/backend/editor/license/__tests__/license-activation-client.test.ts new file mode 100644 index 000000000..77c4087f3 --- /dev/null +++ b/src/backend/editor/license/__tests__/license-activation-client.test.ts @@ -0,0 +1,262 @@ +import { EventEmitter } from 'events' +import http from 'http' +import https from 'https' + +import { checkDeviceActivation } from '../license-activation-client' + +/** + * Fake `request`: invokes the response callback synchronously and emits the given + * status/body when `req.end()` is called, so `postJson`'s promise resolves without + * a real socket. Returns the `req` mock so tests can assert on the body written. + */ +function mockResponse(statusCode: number, jsonBody: unknown, mod: typeof https | typeof http = https) { + const req = Object.assign(new EventEmitter(), { write: jest.fn(), end: jest.fn(), setTimeout: jest.fn() }) + jest.spyOn(mod, 'request').mockImplementation(((_options: unknown, callback: (res: unknown) => void) => { + const res = Object.assign(new EventEmitter(), { statusCode, statusMessage: 'OK', setEncoding: jest.fn() }) + callback(res) + req.end.mockImplementation(() => { + res.emit('data', JSON.stringify(jsonBody)) + res.emit('end') + }) + return req as unknown as ReturnType + }) as typeof https.request) + return req +} + +const INPUT = { + deviceId: '659a3520540f803625ddc34081e893d3', + packageId: 'com.openplc.espressif-licensed', +} + +/** The golden 98-byte blob, hex — same vector as `license-golden.json`. */ +const GOLDEN_HEX = + '4f504c430100000102030405060708090a0b0c0d0e0fa0a1a2a3a4a5a6a711111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b6311445' + +const GOLDEN_B64 = Buffer.from(GOLDEN_HEX, 'hex').toString('base64') + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('checkDeviceActivation', () => { + it('sends only { deviceId, packageId } and returns the decoded 98-byte blob', async () => { + const req = mockResponse(200, { + statusCode: 200, + data: { licensed: true, deviceId: INPUT.deviceId, vppId: INPUT.packageId, license: GOLDEN_B64 }, + }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + expect(result.license).toHaveLength(98) + expect(Buffer.from(result.license ?? []).toString('hex')).toBe(GOLDEN_HEX) + + // Whole-key equality, not a per-field check: an extra field creeping back in + // (a nonce, a signature, a client-declared hwClass) has to fail here. + const sent = JSON.parse((req.write as jest.Mock).mock.calls[0][0] as string) as Record + expect(Object.keys(sent).sort()).toEqual(['deviceId', 'packageId']) + }) + + it('passes through licensed:false with a reason (no purchase on record)', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: false, reason: 'no active subscription' } }) + + const result = await checkDeviceActivation(INPUT) + + // `reason` present and `error` absent is what tells the caller demo mode is + // the CORRECT outcome, rather than a failure to reach the backend. + expect(result.licensed).toBe(false) + expect(result.reason).toBe('no active subscription') + expect(result.error).toBeUndefined() + expect(result.license).toBeUndefined() + }) + + it('reports a non-2xx as an error, not as "no purchase"', async () => { + // A 404 (unknown package), 429 (rate limited) or 503 (no signer) must NOT + // read as "this device has no license" — that is how someone who already paid + // gets told to buy again. + mockResponse(404, { statusCode: 404, data: { message: 'Unknown VPP' } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/404/) + expect(result.reason).toBeUndefined() + }) + + it('reports a transport failure as an error rather than throwing', async () => { + jest.spyOn(https, 'request').mockImplementation(((_options: unknown, _callback: unknown) => { + const emitter = new EventEmitter() + const req = Object.assign(emitter, { + write: jest.fn(), + end: jest.fn(() => { + emitter.emit('error', new Error('ECONNREFUSED')) + }), + setTimeout: jest.fn(), + }) + return req as unknown as ReturnType + }) as typeof https.request) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/ECONNREFUSED/) + }) + + it('rejects a response whose shape is off-contract', async () => { + mockResponse(200, { statusCode: 200, data: { somethingElse: true } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/Unexpected activation response shape/) + }) + + it('rejects licensed:true with no license field', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: true } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/missing license blob/) + }) + + it('rejects a blob that does not decode to exactly 98 bytes', async () => { + // `Buffer.from(s, 'base64')` never throws — it skips invalid characters and + // tolerates missing padding — so without an explicit length check a truncated + // field reaches the device as a short blob and comes back as a LIC_CORRUPT + // rejection that blames the hardware. + const truncated = Buffer.from(GOLDEN_HEX, 'hex').subarray(0, 40).toString('base64') + mockResponse(200, { statusCode: 200, data: { licensed: true, license: truncated } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/40 bytes, expected 98/) + expect(result.license).toBeUndefined() + }) + + it('rejects a license field that is not base64 at all', async () => { + mockResponse(200, { statusCode: 200, data: { licensed: true, license: '!!!not base64!!!' } }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(false) + expect(result.error).toMatch(/expected 98/) + }) + + it('accepts a response that is not wrapped in the { statusCode, data } envelope', async () => { + mockResponse(200, { licensed: true, license: GOLDEN_B64 }) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + expect(result.license).toHaveLength(98) + }) +}) + +/** + * There is NO dev license-minting path in this module. + * + * The removed mock (`OPLC_LICENSE_MOCK` / `OPLC_LICENSE_MOCK_KEY`) signed real, + * device-bound blobs a board would accept as FULL. It was compiled out of + * production builds, but it still shipped in dev builds and was one key leak away + * from unlimited offline licenses outside purchase and outside revocation. + * + * These tests assert the ABSENCE. A happy-path test would pass just as well with + * the mock silently reintroduced. + */ +describe('checkDeviceActivation (no dev mock)', () => { + const originalMock = process.env.OPLC_LICENSE_MOCK + const originalKey = process.env.OPLC_LICENSE_MOCK_KEY + + afterEach(() => { + if (originalMock === undefined) delete process.env.OPLC_LICENSE_MOCK + else process.env.OPLC_LICENSE_MOCK = originalMock + if (originalKey === undefined) delete process.env.OPLC_LICENSE_MOCK_KEY + else process.env.OPLC_LICENSE_MOCK_KEY = originalKey + }) + + it('still calls the backend, and honours its answer, with OPLC_LICENSE_MOCK=licensed set', async () => { + process.env.OPLC_LICENSE_MOCK = 'licensed' + mockResponse(200, { statusCode: 200, data: { licensed: false } }) + + const result = await checkDeviceActivation(INPUT) + + // If a mock were reachable this would be `true` with a minted blob. + expect(https.request).toHaveBeenCalled() + expect(result.licensed).toBe(false) + expect(result.license).toBeUndefined() + }) + + it('still calls the backend with OPLC_LICENSE_MOCK=demo set', async () => { + process.env.OPLC_LICENSE_MOCK = 'demo' + mockResponse(200, { statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } }) + + const result = await checkDeviceActivation(INPUT) + + // A `demo` short-circuit would have returned licensed:false without a request. + expect(https.request).toHaveBeenCalled() + expect(result.licensed).toBe(true) + }) +}) + +/** + * NO proof of possession. + * + * The activate request used to be preceded by `POST /vpp-licenses/challenge` and + * to carry `nonce` + `signature` signed by a keypair derived from the hardware + * anchor. All of it is gone: on bare metal the anchor is read inside the closed + * license-core and the blob is bound to `deviceId`, so the silicon is what proves + * possession. This asserts the absence. + */ +describe('checkDeviceActivation (no proof of possession)', () => { + it('makes exactly one request, and it is the activate', async () => { + const hits: string[] = [] + jest.spyOn(https, 'request').mockImplementation(((options: unknown, callback: (res: unknown) => void) => { + hits.push(options && typeof options === 'object' && 'path' in options ? String(options.path) : '') + const req = Object.assign(new EventEmitter(), { write: jest.fn(), end: jest.fn(), setTimeout: jest.fn() }) + const res = Object.assign(new EventEmitter(), { statusCode: 200, statusMessage: 'OK', setEncoding: jest.fn() }) + callback(res) + req.end.mockImplementation(() => { + res.emit('data', JSON.stringify({ statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } })) + res.emit('end') + }) + return req as unknown as ReturnType + }) as typeof https.request) + + const result = await checkDeviceActivation(INPUT) + + expect(result.licensed).toBe(true) + // A reintroduced challenge round-trip would show up as a second hit even if + // the flow still succeeded. + expect(hits).toEqual(['/vpp-licenses/activate']) + }) +}) + +/** + * Scheme-driven module selection is what makes local end-to-end testing possible: + * `OPENPLC_EDGE_API_URL=http://localhost:3333` must open a PLAIN socket. Sending a + * TLS ClientHello to it dies with EPROTO, the catch turns that into a generic + * failure, and the editor silently falls back to demo — so the one contract most + * worth exercising locally could not be exercised at all. + */ +describe('checkDeviceActivation (base URL scheme)', () => { + const original = process.env.OPENPLC_EDGE_API_URL + + afterEach(() => { + if (original === undefined) delete process.env.OPENPLC_EDGE_API_URL + else process.env.OPENPLC_EDGE_API_URL = original + }) + + it('uses node:http (not https) for an http base URL', async () => { + process.env.OPENPLC_EDGE_API_URL = 'http://localhost:3333' + const httpsSpy = jest.spyOn(https, 'request') + mockResponse(200, { statusCode: 200, data: { licensed: true, license: GOLDEN_B64 } }, http) + + const result = await checkDeviceActivation(INPUT) + + expect(http.request).toHaveBeenCalled() + expect(httpsSpy).not.toHaveBeenCalled() + expect(result.licensed).toBe(true) + }) +}) diff --git a/src/backend/editor/license/__tests__/license-flow.test.ts b/src/backend/editor/license/__tests__/license-flow.test.ts new file mode 100644 index 000000000..e37ad771b --- /dev/null +++ b/src/backend/editor/license/__tests__/license-flow.test.ts @@ -0,0 +1,425 @@ +import { serializeLicenseBlob } from '../../../shared/debug/license-blob' +import type { DebugLicenseReadResult, DebugLicenseWriteResult } from '../../../shared/debug/types' +import { deriveDeviceId, deriveVppId } from '../device-identity' +import { + inspectDeviceLicense, + type LicenseReadWritable, + resolveDeviceLicense, + verifyStoredLicenseBlob, +} from '../license-flow' + +jest.mock('../license-activation-client', () => ({ + checkDeviceActivation: jest.fn(), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { checkDeviceActivation } = require('../license-activation-client') as { + checkDeviceActivation: jest.Mock +} + +const LIC_SUCCESS = 0x7e +const LIC_EMPTY = 0x83 +const LIC_UNSUPPORTED = 0x85 + +/** The real NodeMCU hardware anchor, and the ids that derive from it. */ +const ANCHOR = Uint8Array.from([0, 177, 140, 237]) +const PACKAGE_ID = 'com.openplc.espressif-licensed' +const DEVICE_ID = deriveDeviceId(ANCHOR) + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +/** A well-formed 98-byte blob for the given device + package. */ +function blobFor({ deviceId = DEVICE_ID, packageId = PACKAGE_ID }: { deviceId?: string; packageId?: string } = {}) { + return serializeLicenseBlob({ + magic: 0, // forced to the canonical magic by the serializer + fmtVersion: 1, + keyId: 0, + deviceId: hexToBytes(deviceId), + productId: hexToBytes(deriveVppId(packageId)), + signature: new Uint8Array(64).fill(7), + crc32: 0, // recomputed by the serializer + }) +} + +/** A scripted transport: successive readLicense() calls pop the queue. */ +function transport(script: { + reads: DebugLicenseReadResult[] + write?: DebugLicenseWriteResult +}): LicenseReadWritable & { writes: Uint8Array[]; readCount: () => number } { + const reads = [...script.reads] + const writes: Uint8Array[] = [] + let readCount = 0 + return { + writes, + readCount: () => readCount, + readLicense: () => { + readCount++ + const next = reads.shift() + if (!next) throw new Error('test transport: unexpected extra readLicense()') + return Promise.resolve(next) + }, + writeLicense: (blob: Uint8Array) => { + writes.push(blob) + return Promise.resolve(script.write ?? { success: true, status: LIC_SUCCESS }) + }, + } +} + +beforeEach(() => { + checkDeviceActivation.mockReset() + jest.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +// --------------------------------------------------------------------------- +// verifyStoredLicenseBlob +// --------------------------------------------------------------------------- + +describe('verifyStoredLicenseBlob', () => { + it('accepts a blob bound to this device and this package', () => { + expect(verifyStoredLicenseBlob(blobFor(), DEVICE_ID, PACKAGE_ID)).toEqual({ ok: true }) + }) + + it('rejects a blob bound to ANOTHER device (the clone case)', () => { + // Valid bytes, wrong board. The device answers 0x7E for it and the closed + // gate answers DEVICE_MISMATCH — believing the status byte here is what makes + // the badge say "Licensed" on a board running demo. + const other = deriveDeviceId(Uint8Array.from([9, 9, 9, 9])) + const verdict = verifyStoredLicenseBlob(blobFor({ deviceId: other }), DEVICE_ID, PACKAGE_ID) + + expect(verdict).toEqual({ ok: false, reason: `stored license is bound to device ${other}, not ${DEVICE_ID}` }) + }) + + it('rejects a blob issued for another VPP', () => { + const verdict = verifyStoredLicenseBlob(blobFor({ packageId: 'com.openplc.other-licensed' }), DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toMatch(/stored license is for product/) + }) + + it('rejects a blob whose crc32 does not cover its bytes (tampered or truncated in place)', () => { + const tampered = blobFor() + tampered[40] ^= 0xff // flip a signature byte; the stored crc32 no longer matches + + const verdict = verifyStoredLicenseBlob(tampered, DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + if (!verdict.ok) expect(verdict.reason).toMatch(/crc32/) + }) + + it('rejects a blob with no OPLC magic', () => { + const noMagic = blobFor() + noMagic[0] = 0x00 + + const verdict = verifyStoredLicenseBlob(noMagic, DEVICE_ID, PACKAGE_ID) + + expect(verdict.ok).toBe(false) + // Magic is checked before crc32, so the message names the real problem. + if (!verdict.ok) expect(verdict.reason).toMatch(/no OPLC magic/) + }) + + it('rejects a short or absent blob rather than parsing past the end', () => { + expect(verifyStoredLicenseBlob(undefined, DEVICE_ID, PACKAGE_ID)).toEqual({ + ok: false, + reason: 'stored license is 0 bytes, expected 98', + }) + expect(verifyStoredLicenseBlob(blobFor().subarray(0, 40), DEVICE_ID, PACKAGE_ID)).toEqual({ + ok: false, + reason: 'stored license is 40 bytes, expected 98', + }) + }) + + it('leaves productId unverified when no package id is known, rather than assuming it good', () => { + const foreign = blobFor({ packageId: 'com.openplc.other-licensed' }) + + expect(verifyStoredLicenseBlob(foreign, DEVICE_ID, undefined)).toEqual({ ok: true }) + }) +}) + +// --------------------------------------------------------------------------- +// resolveDeviceLicense +// --------------------------------------------------------------------------- + +describe('resolveDeviceLicense', () => { + it('reports an already-stored license without asking the backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: blobFor() }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expect(checkDeviceActivation).not.toHaveBeenCalled() + expect(link.writes).toHaveLength(0) + }) + + it('recovers from the backend when storage is empty, then re-reads to confirm', async () => { + const blob = blobFor() + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blob) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: true, status: LIC_SUCCESS, blob }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'activated' } }) + expect(checkDeviceActivation).toHaveBeenCalledWith({ deviceId: DEVICE_ID, packageId: PACKAGE_ID }) + expect(Array.from(link.writes[0])).toEqual(Array.from(blob)) + // Two reads: the initial probe and the mandatory read-back. + expect(link.readCount()).toBe(2) + }) + + it('FALLS THROUGH to recovery when the device reports a license that does not verify', async () => { + // 0x7E with a blob for another board. This is the case recovery exists for: + // believing the status byte would skip the only automatic repair path and + // leave the board in demo with a "Licensed" badge. + const foreign = blobFor({ deviceId: deriveDeviceId(Uint8Array.from([1, 2, 3, 4])) }) + const good = blobFor() + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(good) }) + const link = transport({ + reads: [ + { success: true, status: LIC_SUCCESS, blob: foreign }, + { success: true, status: LIC_SUCCESS, blob: good }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(checkDeviceActivation).toHaveBeenCalled() + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + }) + + it('reports unlicensed (demo + buy) when the backend says there is no purchase', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: false, reason: 'no active subscription' }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + // `entitlementChecked: true` is what earns the UI the right to offer a + // purchase: the backend WAS asked and said no. + expect(result.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: true, + backendReason: 'no active subscription', + }) + expect(link.writes).toHaveLength(0) + }) + + it('reports check-failed — NOT unlicensed — when the backend could not be reached', async () => { + // The distinction that matters most in this module: collapsing a transport + // failure into "no purchase" tells someone who already paid to buy again. + checkDeviceActivation.mockResolvedValue({ licensed: false, error: 'Activation request failed: 429' }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Activation request failed: 429' }) + }) + + it('reports unsupported when the device has no storage backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_UNSUPPORTED, unsupported: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expect(checkDeviceActivation).not.toHaveBeenCalled() + }) + + it('never derives an id from an empty anchor', async () => { + // sha256(prefix || ) is a CONSTANT: every board without a unique id + // would share one device id, so one purchase would license the whole fleet. + const link = transport({ reads: [] }) + + const result = await resolveDeviceLicense(link, { anchor: new Uint8Array(0), packageId: PACKAGE_ID }) + + expect(result.deviceId).toBeUndefined() + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/no unique hardware id/) + } + expect(link.readCount()).toBe(0) + expect(checkDeviceActivation).not.toHaveBeenCalled() + }) + + it('reports check-failed when the device does not answer the read at all', async () => { + const link = transport({ reads: [{ success: false, error: 'Request timeout' }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'Request timeout' } }) + }) + + it('does not assert possession when the write succeeds but the read-back does not verify', async () => { + // 0x49 only stores bytes — no target validates them on write. A blob truncated + // in flight would otherwise read as "Licensed" while the board runs demo. + const good = blobFor() + const truncated = blobFor().subarray(0, 60) + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(good) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: true, status: LIC_SUCCESS, blob: truncated }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/written but could not be confirmed on the device/) + } + }) + + it('does not assert possession when the read-back itself fails', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [ + { success: true, status: LIC_EMPTY, empty: true }, + { success: false, error: 'Request timeout' }, + ], + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/the read-back failed/) + } + }) + + it('reports unsupported when the WRITE is the thing that reveals no storage backend', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [{ success: true, status: LIC_EMPTY, empty: true }], + write: { success: true, status: LIC_UNSUPPORTED, unsupported: true }, + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unsupported' }) + }) + + it('reports check-failed when the write is refused', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true, license: Array.from(blobFor()) }) + const link = transport({ + reads: [{ success: true, status: LIC_EMPTY, empty: true }], + write: { success: false, error: 'ERROR_OUT_OF_MEMORY' }, + }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'ERROR_OUT_OF_MEMORY' }) + }) + + it('reports check-failed when the backend claims licensed but returns no blob', async () => { + checkDeviceActivation.mockResolvedValue({ licensed: true }) + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome.state).toBe('check-failed') + if (result.outcome.state === 'check-failed') { + expect(result.outcome.error).toMatch(/no blob to write/) + } + expect(link.writes).toHaveLength(0) + }) + + it('turns an unexpected throw from the transport into check-failed, never a rejection', async () => { + const link: LicenseReadWritable = { + readLicense: () => Promise.reject(new Error('port closed')), + writeLicense: () => Promise.resolve({ success: true }), + } + + const result = await resolveDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'port closed' } }) + }) +}) + +// --------------------------------------------------------------------------- +// inspectDeviceLicense — read + verify, never the network +// --------------------------------------------------------------------------- + +describe('inspectDeviceLicense', () => { + it('confirms a stored, verified license', async () => { + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: blobFor() }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + }) + + it('NEVER contacts the backend, even when the device holds nothing', async () => { + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + // The whole point of this entry point: cheap enough for a screen open. + expect(checkDeviceActivation).not.toHaveBeenCalled() + expect(link.writes).toHaveLength(0) + }) + + it('reports unlicensed with entitlementChecked:false, so the UI offers a refresh and not a purchase', async () => { + // Nobody has asked whether a purchase exists. Rendering this as "buy a + // license" would be a guess, and the wrong one for anyone who already paid. + const link = transport({ reads: [{ success: true, status: LIC_EMPTY, empty: true }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unlicensed', entitlementChecked: false }) + }) + + it('treats a stored blob that fails verification as not licensed, not as licensed', async () => { + const foreign = blobFor({ deviceId: deriveDeviceId(Uint8Array.from([1, 2, 3, 4])) }) + const link = transport({ reads: [{ success: true, status: LIC_SUCCESS, blob: foreign }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unlicensed', entitlementChecked: false }) + }) + + it('reports unsupported when the device has no storage backend', async () => { + const link = transport({ reads: [{ success: true, status: LIC_UNSUPPORTED, unsupported: true }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'unsupported' }) + }) + + it('reports check-failed when the device does not answer', async () => { + const link = transport({ reads: [{ success: false, error: 'Request timeout' }] }) + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Request timeout' }) + }) + + it('refuses an empty anchor without reading anything', async () => { + const link = transport({ reads: [] }) + + const result = await inspectDeviceLicense(link, { anchor: new Uint8Array(0), packageId: PACKAGE_ID }) + + expect(result.deviceId).toBeUndefined() + expect(result.outcome.state).toBe('check-failed') + expect(link.readCount()).toBe(0) + }) + + it('turns an unexpected throw into check-failed', async () => { + const link: LicenseReadWritable = { + readLicense: () => Promise.reject(new Error('port closed')), + writeLicense: () => Promise.resolve({ success: true }), + } + + const result = await inspectDeviceLicense(link, { anchor: ANCHOR, packageId: PACKAGE_ID }) + + expect(result).toEqual({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'port closed' } }) + }) +}) diff --git a/src/backend/editor/license/device-identity.ts b/src/backend/editor/license/device-identity.ts new file mode 100644 index 000000000..4572e1bd8 --- /dev/null +++ b/src/backend/editor/license/device-identity.ts @@ -0,0 +1,72 @@ +/** + * License-identity derivation helpers. + * + * Both identifiers are deterministic SHA-256 digests, truncated to a + * fixed prefix and hex-encoded. They run in the Electron **main** + * process during the licensing routine, so they use `node:crypto` + * directly. + * + * This module lives under `backend/editor` (not the byte-identical + * `backend/shared` surface): `backend/shared` must compile identically + * for both openplc-editor and openplc-web and therefore cannot depend + * on `node:crypto`. The derivation only ever runs main-side, so an + * editor-only home is the correct boundary. + * + * CROSS-REPO CONTRACT. `deriveDeviceId` output is the PRIMARY KEY the + * autonomy-edge backend stores a purchase and a license against, and it + * accepts only LOWERCASE hex (`canonical-device-id.ts` there). `digest('hex')` + * is already lowercase, so this holds by construction — but nothing downstream + * may upper-case it: a purchase recorded as `7146…E5` and an activation for + * `7146…e5` become two devices, the customer pays, the seat check finds + * nothing, and the board runs demo with no error saying why. + */ + +import { createHash } from 'node:crypto' + +/** Domain-separation prefix for the device-id digest. Mixed + * in as raw ASCII bytes ahead of the hardware anchor so the same anchor + * can never collide with any other `sha256`-derived identifier. */ +const DEVICE_ID_PREFIX = 'openplc-dev-v1|' + +/** Device id = first 16 bytes of the digest (matches `lic_blob_t.deviceId`). */ +const DEVICE_ID_BYTES = 16 + +/** VPP id = first 8 bytes of the digest (matches `lic_blob_t.productId`). */ +const VPP_ID_BYTES = 8 + +/** + * Derive the 16-byte device identifier from a hardware anchor. + * + * `deviceId = sha256("openplc-dev-v1|" || anchor)[:16]`, hex-encoded + * (32 lowercase hex chars). The prefix is concatenated as ASCII bytes + * directly in front of the anchor bytes in a single buffer, so the + * hash input is exactly ``. + */ +export function deriveDeviceId(anchor: Uint8Array): string { + // Single contiguous buffer: . Built as + // a plain Uint8Array (the prefix is ASCII, so its UTF-8 encoding is + // byte-for-byte the ASCII bytes) to hand `createHash` a plain Uint8Array. + const prefix = Uint8Array.from(DEVICE_ID_PREFIX, (c) => c.charCodeAt(0)) + const input = new Uint8Array(prefix.length + anchor.length) + input.set(prefix, 0) + input.set(anchor, prefix.length) + return createHash('sha256') + .update(input) + .digest('hex') + .slice(0, DEVICE_ID_BYTES * 2) +} + +/** + * Derive the 8-byte VPP (product) identifier from a package id. + * + * `vppId = sha256(packageId)[:8]`, hex-encoded (16 lowercase hex + * chars). Must match `product_id[8]` of the on-device license blob + * so the firmware can bind a written license to its VPP. + */ +export function deriveVppId(packageId: string): string { + // Hash the package id as its UTF-8 (== ASCII) bytes. + return createHash('sha256') + .update(packageId) + .digest('hex') + .slice(0, VPP_ID_BYTES * 2) +} diff --git a/src/backend/editor/license/license-activation-client.ts b/src/backend/editor/license/license-activation-client.ts new file mode 100644 index 000000000..aa193cb2f --- /dev/null +++ b/src/backend/editor/license/license-activation-client.ts @@ -0,0 +1,200 @@ +/** + * Device license-activation client — the editor's only call to the licensing + * backend. + * + * `POST {edge}/vpp-licenses/activate` with `{ deviceId, packageId }` answers + * `{ licensed, deviceId, vppId, license }`, where `license` is the signed 98-byte + * blob base64-encoded. The route is PUBLIC and rate-limited: the editor has no + * login, and authorization is the seat/entitlement check server-side, not a + * token. It is idempotent — an already-licensed device gets the same blob back + * without consuming another seat — which is what makes it safe to call on every + * connect. + * + * NO PROOF OF POSSESSION. The activate body carries no nonce or signature. What + * makes that safe is the bare-metal premise: the hardware anchor is read INSIDE + * the closed license-core and the blob is bound to `deviceId`, so a blob only + * ever runs FULL on the silicon it names. Handing one to whoever knows the id + * benefits nobody with different hardware. + * + * WHAT THAT COSTS, EXPLICITLY: `/activate` is a blob distributor keyed on a + * PUBLIC identifier, which allows early harvesting (collect blobs now, use them + * the day forging an identity gets cheap) and works as an inventory oracle. Both + * are accepted, and both depend on the anchor staying unforgeable. + * + * NO DEV MOCK. An earlier revision carried a dev-build-only signer + * (`OPLC_LICENSE_MOCK` / `OPLC_LICENSE_MOCK_KEY`) that minted real, device-bound + * blobs locally. It is deliberately absent: the local test path now runs the REAL + * backend against the REAL per-VPP key (see `scripts/seed-vpp-licensing-local.ts` + * in autonomy-edge), so the mock bought nothing a developer needs while putting a + * license-minting path one key leak away from unlimited offline licenses. Point + * `OPENPLC_EDGE_API_URL` at the local backend instead — `httpModuleFor` makes a + * plain-http base URL work. + * + * Runs main-side: uses `node:http`/`node:https` and the same base-URL / + * `{ statusCode, data }` envelope conventions as the library catalog client. + */ + +import { LIC_BLOB_SIZE } from '../../shared/debug/license-blob' +import { getEdgeApiBaseUrl } from '../library-manager/desktop-catalog-transport' +import { defaultPortFor, httpModuleFor } from '../utils/http-module' + +/** + * Input to `checkDeviceActivation` — exactly the two fields the wire accepts. + * + * There is deliberately no `vppId` or `keyId` here. Both existed only to feed the + * removed dev signer: `ActivateVppLicenseDto` has no such field, and the backend + * derives its own product and signing-key material from `packageId`. Keeping them + * would advertise a choice the caller does not have. + */ +export interface DeviceActivationInput { + /** 16-byte device id, LOWERCASE hex (from `deriveDeviceId`). */ + deviceId: string + /** VPP package id (e.g. `com.openplc.espressif-licensed`) — `package.id`. */ + packageId: string +} + +/** + * Result of an activation check. Best-effort: transport / backend errors surface + * as `{ licensed: false, error }` rather than throwing, so the licensing routine + * can degrade without a hard failure. + * + * `reason` and `error` are SEPARATE on purpose and callers must keep them apart. + * `reason` is the backend's business answer ("no purchase on record") and means + * demo mode is correct. `error` means we never got an answer — a 429 from the + * rate limiter, a 503 when no signer is configured, a 404 for an unknown package, + * a dropped connection. Collapsing the two tells someone who already owns a + * license to go buy one. + */ +export interface DeviceActivationResult { + licensed: boolean + /** License blob bytes (98 B) when `licensed` — ready to write via FC 0x49. */ + license?: number[] + /** Backend-supplied reason (e.g. "no active subscription"). */ + reason?: string + /** Populated on transport / backend failure (best-effort path). */ + error?: string +} + +/** The exact JSON body `ActivateVppLicenseDto` accepts. */ +interface EdgeActivationRequestBody { + deviceId: string + packageId: string +} + +/** Response shape from the edge activation endpoint. */ +interface EdgeActivationResponse { + licensed: boolean + /** 98-byte license blob, base64-encoded. */ + license?: string + reason?: string +} + +const ACTIVATE_PATH = '/vpp-licenses/activate' +const REQUEST_TIMEOUT_MS = 30_000 + +/** + * Ask the backend whether this device is entitled to a license for this VPP, and + * get the signed blob when it is. + * + * Any failure (route missing → 404, network, non-2xx, bad JSON, wrong blob size) + * resolves to `{ licensed: false, error }` — never throws. + */ +export async function checkDeviceActivation(input: DeviceActivationInput): Promise { + try { + const body: EdgeActivationRequestBody = { deviceId: input.deviceId, packageId: input.packageId } + const raw = await postJson(`${getEdgeApiBaseUrl()}${ACTIVATE_PATH}`, body) + const data = unwrapHttpEnvelope(raw) as Partial | undefined + + if (!data || typeof data.licensed !== 'boolean') { + return { licensed: false, error: 'Unexpected activation response shape' } + } + if (!data.licensed) return { licensed: false, reason: data.reason } + if (typeof data.license !== 'string') { + return { licensed: false, error: 'Activation response missing license blob' } + } + + // `Buffer.from(s, 'base64')` NEVER throws: the decoder silently skips invalid + // characters and tolerates missing padding, so a truncated or corrupted field + // yields a SHORT buffer instead of an error. Writing that to the device + // produces a LIC_CORRUPT rejection whose message points at the hardware, + // giving no hint that the backend sent something malformed. Check the length. + const blob = Buffer.from(data.license, 'base64') + if (blob.length !== LIC_BLOB_SIZE) { + return { + licensed: false, + error: `Activation response license blob is ${blob.length} bytes, expected ${LIC_BLOB_SIZE}`, + } + } + + return { licensed: true, license: Array.from(blob), reason: data.reason } + } catch (err) { + return { licensed: false, error: err instanceof Error ? err.message : String(err) } + } +} + +function postJson(url: string, body: unknown): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const payload = JSON.stringify(body) + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(payload)), + Accept: 'application/json', + 'User-Agent': 'OpenPLC-Editor/license-activation', + } + // Sent when available. The route is anonymous today; an account token, when + // there is an authority to issue one, ties the purchase to a user. Without it + // the request still goes out and a 401/404 lands on the best-effort path. + const token = process.env.OPENPLC_EDGE_TOKEN?.trim() + if (token) headers.Authorization = `Bearer ${token}` + + // Scheme-driven, so OPENPLC_EDGE_API_URL can point at a local http backend + // for end-to-end testing. Production hosts stay https either way. + const req = httpModuleFor(parsed).request( + { + hostname: parsed.hostname, + port: parsed.port || defaultPortFor(parsed), + path: parsed.pathname + parsed.search, + method: 'POST', + headers, + }, + (res) => { + let responseBody = '' + res.setEncoding('utf-8') + res.on('data', (chunk: string) => { + responseBody += chunk + }) + res.on('end', () => { + const status = res.statusCode ?? 0 + if (status < 200 || status >= 300) { + reject(new Error(`Activation request failed: ${status} ${res.statusMessage ?? ''}`.trim())) + return + } + try { + resolve(JSON.parse(responseBody)) + } catch (err) { + reject( + new Error(`Activation response was not valid JSON: ${err instanceof Error ? err.message : String(err)}`), + ) + } + }) + }, + ) + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`Activation request timed out after ${REQUEST_TIMEOUT_MS}ms`)) + }) + req.on('error', (err) => reject(err)) + req.write(payload) + req.end() + }) +} + +/** autonomy-edge wraps JSON responses in `{ statusCode, data }`. Unwrap once so + * callers see the payload; off-spec responses fall through unchanged. */ +function unwrapHttpEnvelope(raw: unknown): unknown { + if (raw && typeof raw === 'object' && 'data' in raw && 'statusCode' in raw) { + return (raw as { data: unknown }).data + } + return raw +} diff --git a/src/backend/editor/license/license-flow.ts b/src/backend/editor/license/license-flow.ts new file mode 100644 index 000000000..aa572bb37 --- /dev/null +++ b/src/backend/editor/license/license-flow.ts @@ -0,0 +1,344 @@ +/** + * The VPP licensing machine: read what the device holds, decide whether to + * believe it, and recover from the backend when it is absent or wrong. + * + * Runs over an ALREADY-CONNECTED transport — it neither connects nor + * disconnects. The caller holds the link open, so classification, the on-device + * read (0x4A), the backend call and the write (0x49) all happen over a SINGLE + * port open. Pure orchestration over the transport plus the activation client, so + * it is unit-testable with mocks, and it never throws: every failure resolves to + * an outcome. + * + * Lives under `backend/editor` rather than the byte-identical `backend/shared` + * surface because it depends on `deriveDeviceId`, which needs `node:crypto`. + */ + +import type { DeviceLicenseReport, DeviceLicenseState } from '../../../middleware/shared/ports/device-port' +import { crc32IsoHdlc, deserializeLicenseBlob, LIC_BLOB_SIZE, LIC_MAGIC_LE } from '../../shared/debug/license-blob' +import type { DebugLicenseReadResult, DebugLicenseWriteResult } from '../../shared/debug/types' +import { deriveDeviceId, deriveVppId } from './device-identity' +import { checkDeviceActivation } from './license-activation-client' + +/** Just enough of a transport to run the licensing FCs. */ +export interface LicenseReadWritable { + readLicense(): Promise + writeLicense(blob: Uint8Array): Promise +} + +/** SUCCESS status byte of a read-license (0x4A) response = a stored license. */ +const LIC_STATUS_SUCCESS = 0x7e +/** LIC_UNSUPPORTED status byte (no on-device storage backend). */ +const LIC_STATUS_UNSUPPORTED = 0x85 +/** crc32 covers `[payload || signature]` = offsets 0..93; it never covers itself. */ +const LIC_CRC_COVERAGE = 94 + +/** + * The outcome shape is the PORT's `DeviceLicenseState`, not a private one. + * + * Deliberately not a second type mapped at the IPC boundary: the union's whole + * value is that `unlicensed` (the backend says there is no purchase) and + * `check-failed` (we could not find out) cannot be collapsed into each other, and + * a hand-written mapper between two near-identical unions is exactly where that + * distinction gets quietly lost. One type, one meaning, all the way to the badge. + */ +export type { DeviceLicenseReport, DeviceLicenseState } + +export interface DeviceLicenseInput { + /** Raw hardware anchor bytes from the board-id read (FC 0x48). */ + anchor: Uint8Array + /** Reverse-domain VPP package id (`package.id`) from `resolveLicensingTarget`. */ + packageId: string +} + +/** + * Verify a blob the device handed back on 0x4A: magic, crc32, `deviceId` and + * `productId`. + * + * WHY THIS EXISTS. The `0x7E` status byte does NOT mean "the stored license is + * good" — the two targets disagree about what it means. Bare metal validates + * magic + crc32 inside its store read; the Linux runtime checks ONLY that the + * file is 98 bytes long. So on a Pi a blob cloned from another board, or one + * half-written, answers `0x7E`. A caller that believed it would report + * `already-licensed` and NEVER ASK THE BACKEND — skipping the one automatic + * repair path there is. The closed license-core then refuses the blob and the + * board runs demo, stopping actuation 15 minutes in, while the badge says + * "Licensed". + * + * WHAT IT PROVES AND WHAT IT DOES NOT. It proves the bytes are a well-formed + * license FOR THIS DEVICE AND THIS VPP: length, magic, crc32 over 0..93, the + * 16-byte `deviceId` equal to the id derived from the anchor we just read, and + * the 8-byte `productId` equal to the id derived from this package. It does NOT + * verify the ECDSA signature or the `keyId`, so it cannot say the closed gate + * will run FULL — only the license-core can. Anything asserted in the UI must + * stay inside that boundary, which is why the badge says "Licensed" (possession) + * and never "Full mode" (execution). + * + * `productId` is checked only when a `packageId` is known; without one there is + * nothing to compare against and the field is left unverified rather than + * assumed good. + * + * Built on the shared `license-blob.ts` (`deserializeLicenseBlob`, + * `crc32IsoHdlc`) rather than a second parser: that module is byte-identical + * with openplc-web and cross-pinned to the C struct by a golden vector. A + * private re-implementation here is exactly the divergence this avoids. + */ +export function verifyStoredLicenseBlob( + blob: Uint8Array | undefined, + deviceIdHex: string, + packageId: string | undefined, +): { ok: true } | { ok: false; reason: string } { + if (!blob || blob.length !== LIC_BLOB_SIZE) { + // A 0x7E with no (or a short) blob is itself off-contract: the parser only + // fills `blob` when the device sent all `len` bytes. Treat as unverified. + return { ok: false, reason: `stored license is ${blob?.length ?? 0} bytes, expected ${LIC_BLOB_SIZE}` } + } + + const parsed = deserializeLicenseBlob(blob) + + if (parsed.magic !== LIC_MAGIC_LE) { + return { ok: false, reason: 'stored license has no OPLC magic' } + } + + const expectedCrc = crc32IsoHdlc(blob.subarray(0, LIC_CRC_COVERAGE)) + if (parsed.crc32 !== expectedCrc) { + return { ok: false, reason: 'stored license fails its crc32 (truncated or corrupted)' } + } + + const blobDeviceId = bytesToHex(parsed.deviceId) + if (blobDeviceId !== deviceIdHex) { + // The clone case: valid bytes, wrong board. The gate answers DEVICE_MISMATCH. + return { ok: false, reason: `stored license is bound to device ${blobDeviceId}, not ${deviceIdHex}` } + } + + if (packageId) { + const expectedProductId = deriveVppId(packageId) + const blobProductId = bytesToHex(parsed.productId) + if (blobProductId !== expectedProductId) { + return { ok: false, reason: `stored license is for product ${blobProductId}, not ${expectedProductId}` } + } + } + + return { ok: true } +} + +function bytesToHex(bytes: Uint8Array): string { + let out = '' + for (const byte of bytes) out += byte.toString(16).padStart(2, '0') + return out +} + +/** + * Run the licensing step for a licensable board over a live link. + * + * The caller has already decided the board is licensable + * (`resolveLicensingTarget`) and already read its anchor while classifying the + * link, so no round trip is repeated here. + * + * Sequence: + * 1. Derive `deviceId` from the anchor. No anchor -> `check-failed`. + * 2. Read 0x4A. `LIC_UNSUPPORTED` -> `unsupported`. + * 3. If a blob came back, VERIFY it. Good -> `licensed / already-stored`. + * Bad -> fall through to recovery, which is the only automatic repair. + * 4. Ask the backend. No entitlement -> `unlicensed`. Failure -> `check-failed`. + * 5. Write 0x49, then RE-READ and re-verify. Never trust the write. + */ +export async function resolveDeviceLicense( + transport: LicenseReadWritable, + input: DeviceLicenseInput, +): Promise { + const identity = deriveIdentity(input.anchor) + if ('outcome' in identity) return identity + const { deviceId } = identity + + try { + const stored = await readAndVerify(transport, deviceId, input) + if (stored.kind !== 'absent') return { deviceId, outcome: stored.outcome } + + return await recoverLicense(transport, { deviceId, packageId: input.packageId }) + } catch (error) { + return { deviceId, outcome: { state: 'check-failed', error: errorMessage(error) } } + } +} + +/** + * Read what the device is holding and verify it — WITHOUT contacting the backend. + * + * Answers "is this board licensed right now?", which is the question the badge + * asks. Cheap enough for a screen open or a poll, because it is one Modbus frame + * and some arithmetic. + * + * Note what it CANNOT say: `unlicensed` here always carries + * `entitlementChecked: false`, because nobody has asked whether a purchase + * exists. A caller that rendered that as "buy a license" would be guessing — + * offer a refresh instead. + */ +export async function inspectDeviceLicense( + transport: LicenseReadWritable, + input: DeviceLicenseInput, +): Promise { + const identity = deriveIdentity(input.anchor) + if ('outcome' in identity) return identity + const { deviceId } = identity + + try { + const stored = await readAndVerify(transport, deviceId, input) + if (stored.kind === 'absent') { + return { deviceId, outcome: { state: 'unlicensed', entitlementChecked: false, ...stored.detail } } + } + return { deviceId, outcome: stored.outcome } + } catch (error) { + return { deviceId, outcome: { state: 'check-failed', error: errorMessage(error) } } + } +} + +/** + * Derive the licensing identity, or explain why there is none. + * + * An anchor of zero bytes is a REAL reply, not a failure: cores without + * ArduinoUniqueID support, and boards that opt out with `OPENPLC_NO_UNIQUE_ID`, + * answer `id_len = 0` rather than fail to compile, and the link classifier + * correctly counts that as "firmware present". + * + * It is fatal HERE, and must be caught before deriving anything, because + * `sha256(prefix || )` is a CONSTANT: every such board would share one + * device id, so one purchase would license an entire fleet and a license issued + * for one board would verify on all of them. + */ +function deriveIdentity(anchor: Uint8Array): { deviceId: string } | DeviceLicenseReport { + if (anchor.length === 0) { + return { + outcome: { + state: 'check-failed', + error: + 'this board reports no unique hardware id, so no license can be bound to it. ' + + 'A licensed VPP requires a board whose core exposes a unique id.', + }, + } + } + return { deviceId: deriveDeviceId(anchor) } +} + +/** + * The read-and-verify step both entry points share. + * + * `absent` is not an outcome: it means "the device holds nothing usable", and what + * that IMPLIES differs between the two callers — a refresh recovers from it, an + * inspect can only report it. Returning a marker instead of an outcome is what + * keeps that decision at the call site instead of buried here. + */ +type StoredLicenseVerdict = + | { kind: 'settled'; outcome: DeviceLicenseState } + | { kind: 'absent'; detail?: { backendReason?: string } } + +async function readAndVerify( + transport: LicenseReadWritable, + deviceId: string, + input: DeviceLicenseInput, +): Promise { + const stored = await transport.readLicense() + + if (!stored.success) { + return { + kind: 'settled', + outcome: { state: 'check-failed', error: stored.error ?? 'the device did not answer 0x4A' }, + } + } + + if (stored.status === LIC_STATUS_UNSUPPORTED || stored.unsupported) { + return { kind: 'settled', outcome: { state: 'unsupported' } } + } + + if (stored.status === LIC_STATUS_SUCCESS) { + const verdict = verifyStoredLicenseBlob(stored.blob, deviceId, input.packageId) + if (verdict.ok) return { kind: 'settled', outcome: { state: 'licensed', how: 'already-stored' } } + + // Deliberately NOT a failure: a stored blob that does not verify is the case + // recovery exists for (a clone, a half-written file, a licence for another + // VPP). Treating it as absent is the only automatic way this board gets a + // good one. Logged because it is worth seeing in the console. + console.warn( + `[license] the device reported a stored license but it did not verify (${verdict.reason}) — ` + + 'treating it as absent.', + ) + } + + return { kind: 'absent' } +} + +/** + * Ask the backend for a license and write it. Reached when the device holds + * nothing usable — empty, corrupt, or a blob that failed verification. + */ +async function recoverLicense( + transport: LicenseReadWritable, + input: { deviceId: string; packageId: string }, +): Promise { + const { deviceId, packageId } = input + const activation = await checkDeviceActivation({ deviceId, packageId }) + + if (!activation.licensed) { + // A transport/backend failure is NOT the same as "no purchase on record". + // `checkDeviceActivation` already separates `reason` (business) from `error` + // (transport); honour the distinction instead of discarding it one layer up. + if (activation.error) { + return { deviceId, outcome: { state: 'check-failed', error: activation.error } } + } + // The backend WAS asked and said no. `entitlementChecked: true` is what earns + // the UI the right to offer a purchase. + return { deviceId, outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: activation.reason } } + } + + if (!activation.license) { + // Licensed on the backend's word but no blob to write. Nothing to store, and + // nothing we can assert about the device — the board will run demo, so say we + // could not confirm rather than claiming either state. + return { + deviceId, + outcome: { state: 'check-failed', error: 'the backend reported a license but returned no blob to write' }, + } + } + + const write = await transport.writeLicense(Uint8Array.from(activation.license)) + + if (write.unsupported) { + return { deviceId, outcome: { state: 'unsupported' } } + } + if (!write.success) { + return { deviceId, outcome: { state: 'check-failed', error: write.error ?? 'the license write failed' } } + } + + // RE-READ; do not trust the write. 0x49 only STORES bytes: no target checks + // magic, crc32, `deviceId` or `productId` on write. So `write.success` alone + // says nothing about what the board now holds, and asserting possession from it + // means a blob truncated in flight, or signed for another device, reads as + // "Licensed" while the board runs demo. + const readBack = await transport.readLicense() + if (!readBack.success) { + return { + deviceId, + outcome: { + state: 'check-failed', + error: `the license was written but could not be confirmed: the read-back failed (${readBack.error ?? 'no reply'})`, + }, + } + } + + const verdict = verifyStoredLicenseBlob( + readBack.status === LIC_STATUS_SUCCESS ? readBack.blob : undefined, + deviceId, + packageId, + ) + if (verdict.ok) return { deviceId, outcome: { state: 'licensed', how: 'activated' } } + + return { + deviceId, + outcome: { + state: 'check-failed', + error: `the license was written but could not be confirmed on the device: ${verdict.reason}`, + }, + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/backend/editor/modbus/__tests__/read-license-framing.test.ts b/src/backend/editor/modbus/__tests__/read-license-framing.test.ts new file mode 100644 index 000000000..cf00770b3 --- /dev/null +++ b/src/backend/editor/modbus/__tests__/read-license-framing.test.ts @@ -0,0 +1,120 @@ +/** + * Regression test for size-aware RTU framing. + * + * The RTU client used to detect end-of-frame purely by a 10ms idle timeout. A + * large variable-length response — the 98-byte license blob — arriving in + * multiple chunks at 9600 baud, where FTDI/CH340 latency timers push inter-chunk + * gaps to ~16ms, was cut at the first chunk ("Incomplete license blob, got 25"). + * `readLicense()` now passes an `expectedTotalLength` predictor (SUCCESS → total + * = 7 + len) so the read completes once the whole frame has arrived, regardless + * of the gaps between chunks. + * + * This test injects a fake serial port that emits the response in two chunks + * with a REAL 20ms gap (> FRAME_COMPLETE_TIMEOUT_MS = 10ms) and asserts the blob + * is reassembled intact. + */ + +import { EventEmitter } from 'node:events' + +import golden from '../../../shared/debug/__tests__/fixtures/license-golden.json' +import { ModbusRtuClient } from '../modbus-rtu-client' + +const DEBUG_READ_LICENSE = 0x4a +const STATUS_SUCCESS = 0x7e +const SLAVE_ID = 1 + +/** 98-byte golden license blob (`lic_blob_t` serialization). blob[0] === 0x4f ('O'). */ +function goldenBlob(): Buffer { + return Buffer.from(golden.expectedBytesHex, 'hex') +} + +/** + * Build the raw on-the-wire RTU READ_LICENSE frame: + * `[id][FC=0x4a][STATUS=0x7e][len:u16 BE][blob...][crc:2]`. + */ +function buildReadLicenseFrame(blob: Buffer): Buffer { + const frame = Buffer.alloc(3 + 2 + blob.length + 2) + frame.writeUInt8(SLAVE_ID, 0) + frame.writeUInt8(DEBUG_READ_LICENSE, 1) + frame.writeUInt8(STATUS_SUCCESS, 2) + frame.writeUInt16BE(blob.length, 3) + blob.copy(frame as unknown as Uint8Array, 5) + // CRC bytes: the debugger treats a CRC mismatch as non-fatal, so any 2 bytes + // work here — the client strips them regardless. + frame.writeUInt16BE(0x0000, 5 + blob.length) + return frame +} + +/** + * EventEmitter-based fake serial port matching the surface `sendRequestImpl` + * uses: on('data'/'error'), once, removeListener, write, flush, isOpen. + */ +class FakeSerialPort extends EventEmitter { + isOpen = true + emitPlan: (() => void) | null = null + + write(_data: Uint8Array, callback?: (err?: Error | null) => void) { + callback?.(null) + // Kick off the chunked response once the request has been written. + this.emitPlan?.() + } + + flush(callback?: (err?: Error | null) => void) { + callback?.(null) + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('ModbusRtuClient.readLicense — size-aware multi-chunk framing', () => { + it('reassembles the full 98-byte blob when the frame arrives in two chunks with a >idle-timeout gap', async () => { + const blob = goldenBlob() + expect(blob.length).toBe(98) + + const frame = buildReadLicenseFrame(blob) + // Split so the first chunk (25 bytes) is well short of the 105-byte frame and + // the second arrives after a gap larger than FRAME_COMPLETE_TIMEOUT_MS. + const firstChunk = frame.subarray(0, 25) + const restChunk = frame.subarray(25) + + const port = new FakeSerialPort() + port.emitPlan = () => { + setTimeout(() => port.emit('data', Buffer.from(firstChunk)), 0) + // Real 20ms gap > 10ms idle timeout: the old code truncated here. + void sleep(20).then(() => port.emit('data', Buffer.from(restChunk))) + } + + const client = new ModbusRtuClient({ port: 'x', baudRate: 9600, slaveId: SLAVE_ID, timeout: 500 }) + // Inject the fake port directly, bypassing connect(). + ;(client as unknown as { serialPort: unknown }).serialPort = port + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(result.blob).toBeDefined() + expect(result.blob?.length).toBe(98) + expect(result.blob?.[0]).toBe(0x4f) + expect(Buffer.from(result.blob ?? []).equals(blob as unknown as Uint8Array)).toBe(true) + }) + + it('completes a non-SUCCESS reply without waiting for a length that never comes', async () => { + // LIC_EMPTY is [id][FC][STATUS][crc:2] = 5 bytes and carries no len/blob. The + // predictor must recognise that from the STATUS byte alone; keying off `len` + // would stall until the request timeout. + const frame = Buffer.from([SLAVE_ID, DEBUG_READ_LICENSE, 0x83, 0x00, 0x00]) + + const port = new FakeSerialPort() + port.emitPlan = () => { + setTimeout(() => port.emit('data', Buffer.from(frame)), 0) + } + + const client = new ModbusRtuClient({ port: 'x', baudRate: 9600, slaveId: SLAVE_ID, timeout: 500 }) + ;(client as unknown as { serialPort: unknown }).serialPort = port + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) +}) diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index 5074b80bf..7d6af2cee 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -1,4 +1,25 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { Socket } from 'net' @@ -9,12 +30,31 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, + /** Store a license blob on the device. Write-only; the read back is 0x4A. */ + DEBUG_WRITE_LICENSE = 0x49, + /** Read the stored license blob back off the device. */ + DEBUG_READ_LICENSE = 0x4a, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** DEBUG_READ_LICENSE only: virgin storage — no license provisioned. */ + LIC_EMPTY = 0x83, + /** DEBUG_READ_LICENSE only: the magic matched but the crc32 did not. */ + LIC_CORRUPT = 0x84, + /** Licensing FCs: the target has no on-device license-store backend. */ + LIC_UNSUPPORTED = 0x85, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, } interface ModbusTcpClientOptions { @@ -23,7 +63,7 @@ interface ModbusTcpClientOptions { timeout: number } -export class ModbusTcpClient { +export class ModbusTcpClient implements DeviceModbusTransport { private host: string private port: number private timeout: number @@ -346,4 +386,158 @@ export class ModbusTcpClient { return { success: false, error: getErrorMessage(error) } } } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). TCP frame is + * [MBAP:6][FC@7][...], so the pure PDU `[FC][status][id_len:u8][id_bytes...]` + * starts at offset 7 — hand it to the shared parseGetBoardIdResponse rather + * than parsing inline. + */ + async getBoardId(): Promise { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + // buildGetBoardIdRequest() returns the [FC] PDU; the MBAP frame carries the + // function code + payload, empty for board-id. + const pdu = buildGetBoardIdRequest() + + const pduLength = 1 + pdu.length // unitId + PDU (FC only) + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(unitId, 6) + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + + try { + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + + const responseTransactionId = data.readUInt16BE(0) + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + const pduResponse = Uint8Array.prototype.slice.call(data, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * Wrap a pure PDU in a Modbus-TCP MBAP header, returning the frame and the + * transaction id to match against the reply. + * + * The older methods in this class build the same six bytes inline; new ones + * use this so the layout lives in one place. Migrating the rest is a + * mechanical follow-up, deliberately not done here. + */ + private buildTcpFrame(pdu: Uint8Array): { request: Buffer; transactionId: number } { + const transactionId = this.incrementTransactionId() + const pduLength = 1 + pdu.length // unitId + PDU + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(0x0000, 2) // protocol id + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(0x00, 6) // unit id + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + return { request, transactionId } + } + + /** + * FC 0x46 -- runtime status (run/stop state, scan counter, uptime). + * Single read path for run/stop; see the RTU client for the rationale. + */ + async getStatus(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildGetStatusRequest()) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. Command only; reads go through + * `getStatus()`. Refused while the mode switch reads STOP. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildPlcSetStateRequest(state)) + const data = await this.sendTcpRequest(request) + if (data.length < 8) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + // ------------------------------------------------------------------------- + // On-device license storage (FC 0x49/0x4A). TCP frame: [MBAP:6][FC@7][...]. + // The wire `len` is BIG-ENDIAN (matches the other FCs) while the blob content + // it frames is little-endian — do not confuse the two. + // + // No size-aware framing is needed here, unlike RTU: `sendTcpRequest` reads the + // MBAP length field, so a 98-byte blob is already framed by the protocol. + // ------------------------------------------------------------------------- + + /** + * FC 0x49 — store a license blob. Storing is NOT validation (see the RTU + * client): read the blob back to learn what the board actually holds. + */ + async writeLicense(blob: Uint8Array): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildWriteLicenseRequest(blob)) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseWriteLicenseResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** FC 0x4A — read the stored license blob back. */ + async readLicense(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildReadLicenseRequest()) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseReadLicenseResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts index 08746ab7a..4a6720f43 100644 --- a/src/backend/editor/modbus/modbus-rtu-client.ts +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -1,6 +1,27 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - serialport types are not available at build time but will be at runtime -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { SerialPort } from 'serialport' @@ -16,13 +37,37 @@ interface ModbusRtuClientOptions { serialPort?: any // Pre-built serial port (e.g. VirtualSerialPort for simulator) } +interface SendRequestOptions { + /** + * Optional size-aware end-of-frame predictor. Given the raw accumulated + * response buffer (the on-the-wire RTU frame, BEFORE the 6-byte TCP-compat + * padding `sendRequestImpl` prepends), returns the total number of bytes the + * complete frame is expected to have, or `null` when not enough bytes have + * arrived yet to make that call. + * + * WHY THIS EXISTS. The default framing declares the frame complete after + * `FRAME_COMPLETE_TIMEOUT_MS` of silence, which is only sound while a frame + * arrives faster than the gap it is measured against. A 98-byte license blob at + * 9600 baud takes ~102 ms to clock out, and the serial layer delivers it in + * several chunks whose inter-chunk gaps exceed 10 ms — so the idle timeout fires + * mid-frame and the response is TRUNCATED. The truncation is silent: the parser + * sees a short buffer and reports a malformed license, which points the blame at + * the board. + * + * With a predictor, the request completes as soon as the declared length has + * fully arrived, and a known-incomplete frame keeps waiting instead of being cut + * off. Callers that omit it fall back to the unchanged idle-timeout framing. + */ + expectedTotalLength?: (raw: Buffer) => number | null +} + const ARDUINO_BOOTLOADER_DELAY_MS = 2500 const MD5_REQUEST_MAX_RETRIES = 3 const MD5_REQUEST_RETRY_DELAY_MS = 500 const FRAME_COMPLETE_TIMEOUT_MS = 10 -export class ModbusRtuClient { +export class ModbusRtuClient implements DeviceModbusTransport { private port: string private baudRate: number private slaveId: number @@ -162,16 +207,16 @@ export class ModbusRtuClient { private sendRequestMutex: Promise = Promise.resolve() - private async sendRequest(request: Buffer): Promise { + private async sendRequest(request: Buffer, opts?: SendRequestOptions): Promise { return new Promise((resolve, reject) => { this.sendRequestMutex = this.sendRequestMutex.then( - () => this.sendRequestImpl(request).then(resolve, reject), - () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request, opts).then(resolve, reject), + () => this.sendRequestImpl(request, opts).then(resolve, reject), ) }) } - private async sendRequestImpl(request: Buffer): Promise { + private async sendRequestImpl(request: Buffer, opts?: SendRequestOptions): Promise { if (!this.serialPort || !this.serialPort.isOpen) { throw new Error('Serial port is not open') } @@ -196,35 +241,63 @@ export class ModbusRtuClient { reject(new Error('Request timeout')) }, this.timeout) - const onData = (data: Buffer) => { - responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) + // Strip the 2-byte CRC trailer, prepend the 6-byte TCP-compat padding the + // rest of the client expects, and resolve. Shared by both the size-aware + // completion and the idle-timeout fallback so they frame identically. + const complete = () => { + clearTimeout(timeoutHandle) + cleanup() - if (frameCompleteTimeout) { - clearTimeout(frameCompleteTimeout) + if (responseBuffer.length < 5) { + reject(new Error('Response too short')) + return } - frameCompleteTimeout = setTimeout(() => { - clearTimeout(timeoutHandle) - cleanup() + const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) + const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) - if (responseBuffer.length < 5) { - reject(new Error('Response too short')) - return - } + if (receivedCrc !== calculatedCrc) { + // OpenPLC debugger ignores CRC errors — mismatch is non-fatal + } + + const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) + const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) + paddedResponse.fill(0, 0, 6) + responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + + resolve(paddedResponse) + } - const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) - const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) + const onData = (data: Buffer) => { + responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) - if (receivedCrc !== calculatedCrc) { - // OpenPLC debugger ignores CRC errors — mismatch is non-fatal + // Size-aware framing (opt-in): once the caller can predict the full frame + // length, complete as soon as it has fully arrived, and while the frame is + // known-incomplete keep waiting for the remaining bytes rather than letting + // the idle timeout truncate a multi-chunk response. + if (opts?.expectedTotalLength) { + const expected = opts.expectedTotalLength(responseBuffer) + if (expected !== null) { + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + frameCompleteTimeout = null + } + if (responseBuffer.length >= expected) { + complete() + } + return } + } - const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) - const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) - paddedResponse.fill(0, 0, 6) - responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + // Fallback: idle-timeout end-of-frame detection (unchanged default for + // callers that pass no predictor, or before the predictor has enough + // bytes to decide). + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } - resolve(paddedResponse) + frameCompleteTimeout = setTimeout(() => { + complete() }, FRAME_COMPLETE_TIMEOUT_MS) } @@ -333,7 +406,19 @@ export class ModbusRtuClient { } const request = this.assembleRequest(functionCode, data) - const response = await this.sendRequest(request) + const response = await this.sendRequest(request, { + // Raw RTU frame (SUCCESS): id@0, FC@1, STATUS@2, lastIndex u16 @3..4, + // tick u32 @5..8, responseSize u16BE @9..10, data @11.., crc 2 -> total = + // 11 + responseSize + 2 = 13 + responseSize. A non-SUCCESS response is + // [id][FC][STATUS][crc:2] = 5 bytes, so key off STATUS. (Mirrors the C + // runtime's debugGetTraceList: mb_frame_len = 11 + responseSize, else 3.) + expectedTotalLength: (raw) => { + if (raw.length < 3) return null + if (raw.readUInt8(2) !== (ModbusDebugResponse.SUCCESS as number)) return 5 + if (raw.length < 11) return null + return 13 + raw.readUInt16BE(9) + }, + }) if (response.length < 9) { return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } @@ -451,4 +536,145 @@ export class ModbusRtuClient { return { success: false, error: getErrorMessage(error) } } } + + // ------------------------------------------------------------------------- + // On-device license storage (FC 0x49/0x4A). Response offsets account for the + // 6-byte TCP-compat padding sendRequestImpl prepends: FC@7, status@8, payload@9+. + // The wire `len` is BIG-ENDIAN (matches the other FCs) while the blob content + // it frames is little-endian — do not confuse the two. + // ------------------------------------------------------------------------- + + /** + * FC 0x49 — store a license blob. Storing is NOT validation: no target checks + * magic, crc32, `deviceId` or `productId` here, so a `success: true` means only + * that the bytes were accepted. Callers that need to know what the board holds + * read it back with `readLicense()`. + */ + async writeLicense(blob: Uint8Array): Promise { + try { + // buildWriteLicenseRequest() returns [FC][len:u16BE][blob]; assembleRequest + // writes the FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildWriteLicenseRequest(blob) + const payload = Buffer.from(pdu.subarray(1)) + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_WRITE_LICENSE, payload) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + // Strip the 6-byte TCP-compat padding; the pure PDU starts at offset 7. + return parseWriteLicenseResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4A — read the stored license blob back. Uses the size-aware framing: a + * 98-byte blob does not arrive inside one idle window at low baud rates, and the + * default framing would truncate it silently (see `SendRequestOptions`). + */ + async readLicense(): Promise { + try { + const pdu = buildReadLicenseRequest() + const payload = Buffer.from(pdu.subarray(1)) // bare [FC]; no trailing payload + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_READ_LICENSE, payload) + const response = await this.sendRequest(request, { + // Raw RTU frame: id@0, FC@1, STATUS@2, len u16BE @3..4, blob @5.., crc 2. + // SUCCESS -> total = 1+1+1+2+len+2 = 7+len. A non-SUCCESS response carries + // no len/blob ([id][FC][STATUS][crc:2] = 5 bytes), so key off STATUS. + expectedTotalLength: (raw) => { + if (raw.length < 3) return null + if (raw.readUInt8(2) !== (ModbusDebugResponse.SUCCESS as number)) return 5 + if (raw.length < 5) return null + return 7 + raw.readUInt16BE(3) + }, + }) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + // Strip the 6-byte TCP-compat padding; parse the pure PDU from offset 7. + return parseReadLicenseResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). Response offsets + * account for the 6-byte TCP-compat padding sendRequestImpl prepends, so the + * pure PDU `[FC][status][id_len:u8][id_bytes...]` starts at offset 7 — hand it + * to the shared parseGetBoardIdResponse rather than parsing inline. + */ + async getBoardId(): Promise { + try { + // buildGetBoardIdRequest() returns the [FC] PDU; assembleRequest writes + // the function code + slaveId itself and expects only the trailing payload + // (empty for board-id), so strip the leading FC byte. + const pdu = buildGetBoardIdRequest() + const payload = Buffer.from(pdu.subarray(1)) + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const pduResponse = Uint8Array.prototype.slice.call(response, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * FC 0x46 -- runtime status. Reports the run/stop state, the scan counter and + * uptime in one bare-FC round trip. + * + * This is the single read path for run/stop state: the frame's `running` byte + * carries it, so no second function code is needed. It also doubles as the + * liveness probe for a held link (any successful reply proves the firmware is + * answering), which is why the device liveness poll uses it. + */ + async getStatus(): Promise { + try { + // buildGetStatusRequest() returns the [FC] PDU; assembleRequest writes the + // function code + slaveId itself and expects only the trailing payload + // (empty here), so strip the leading FC byte. + const pdu = buildGetStatusRequest() + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only; reads go through `getStatus()`. A RUN request is refused (not + * queued) while the mode switch reads STOP, and the result says so via + * `refusedBySwitch` so the caller can tell the user to flip the switch. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + const pdu = buildPlcSetStateRequest(state) + const request = this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts b/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts new file mode 100644 index 000000000..0cf94653a --- /dev/null +++ b/src/backend/editor/package-manager/__tests__/get-installed-package-manifest.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { app } from 'electron' + +// Same trimming as the signature suite: this module pulls in winston transports +// and extract-zip's ESM entry point that this suite has no use for. +jest.mock('electron', () => ({ app: { getPath: jest.fn(() => '/mock/path') } })) +jest.mock('extract-zip', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('../../services/logger-service', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +import { PackageManagerModule } from '../package-manager-module' + +/** + * `getInstalledPackageManifest` is the LOAD path, and every board provided by a + * VPP resolves through it (`find-vpp-device` → `board-info-resolver` → the + * compile pipeline). The install gate is allowed to refuse; this is not, beyond + * a document that is not a manifest at all — a rejection here is a board + * disappearing from the lookup with nothing said to the user. + */ +describe('PackageManagerModule.getInstalledPackageManifest — reading an installed package', () => { + let userDataDir: string + let packagesDir: string + let warnSpy: jest.SpyInstance + + const manifestJson = (pkg: Record) => + JSON.stringify({ + formatVersion: '1.0', + package: { id: 'vendor.board', name: 'Vendor Board', version: '1.0.0', ...pkg }, + devices: [{ id: 'slm-rp4', name: 'SLM-RP4' }], + }) + + /** Write a package directory + registry entry, as an install would leave it. */ + function install(packageId: string, manifest: string): void { + const dir = join(packagesDir, packageId) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'manifest.json'), manifest) + writeFileSync( + join(packagesDir, 'registry.json'), + JSON.stringify({ + formatVersion: '1.0', + packages: { + [packageId]: { version: '1.0.0', installedAt: '2026-01-01T00:00:00Z', path: dir, devices: ['slm-rp4'] }, + }, + }), + ) + } + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'pkg-read-')) + packagesDir = join(userDataDir, 'packages') + ;(app.getPath as jest.Mock).mockReturnValue(userDataDir) + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + jest.clearAllMocks() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns the manifest of a package installed with a well-formed floor', () => { + install('vendor.board', manifestJson({ minEditorVersion: '4.3' })) + const manifest = new PackageManagerModule().getInstalledPackageManifest('vendor.board') + expect(manifest?.package.minEditorVersion).toBe('4.3') + }) + + it('keeps resolving a package whose stored floor this editor cannot compare', () => { + // The regression this guards: such a package was installed by an editor + // predating the format check (DOPE-448). Rejecting its manifest on load + // would unresolve its boards on upgrade, with no message anywhere. + install('vendor.board', manifestJson({ minEditorVersion: 'nightly' })) + + const manifest = new PackageManagerModule().getInstalledPackageManifest('vendor.board') + + expect(manifest?.package.id).toBe('vendor.board') + expect(manifest?.package).not.toHaveProperty('minEditorVersion') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('minEditorVersion')) + }) + + it('still returns null for a document that is not a manifest', () => { + install('vendor.board', JSON.stringify({ nothing: 'useful' })) + expect(new PackageManagerModule().getInstalledPackageManifest('vendor.board')).toBeNull() + }) +}) diff --git a/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts b/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts new file mode 100644 index 000000000..94a555a0f --- /dev/null +++ b/src/backend/editor/package-manager/__tests__/verify-board-package-integrity.test.ts @@ -0,0 +1,276 @@ +import { createHash, sign as cryptoSign } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { app } from 'electron' + +import { canonicalize, SIGNATURE_FILENAME } from '../../../shared/utils/vpp/verify-package-signature' + +// Same transitive-dependency stubs as the sweep suite: winston file transports +// and extract-zip's ESM entry point are irrelevant to a signature check. +jest.mock('electron', () => ({ app: { getPath: jest.fn(() => '/mock/path') } })) +jest.mock('extract-zip', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('../../services/logger-service', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +// Swap the real trusted-key store for a generated test keypair so fixtures can +// be signed with a private key this suite holds. The factory cannot close over +// outer scope, so it generates inline and re-exports the private PEM. +jest.mock('../../../shared/utils/vpp/trusted-keys', () => { + const { generateKeyPairSync } = jest.requireActual('node:crypto') + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + return { + TRUSTED_PACKAGE_KEYS: { 'test-key': publicKey.export({ type: 'spki', format: 'pem' }).toString() }, + __TEST_PRIVATE_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + } +}) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const PRIVATE_PEM: string = (require('../../../shared/utils/vpp/trusted-keys') as { __TEST_PRIVATE_PEM: string }) + .__TEST_PRIVATE_PEM + +import { formatPackageIntegrityError, PackageManagerModule } from '../package-manager-module' + +const KEY_ID = 'test-key' +const BOARD_NAME = 'Test VPP Board' + +const sha256 = (s: string): string => + createHash('sha256') + .update(Uint8Array.from(Buffer.from(s, 'utf-8'))) + .digest('hex') + +/** A manifest the installed-read path accepts, providing one named device. */ +const manifestFor = (packageId: string): string => + JSON.stringify({ + formatVersion: '1.0', + package: { id: packageId, name: 'Test Package', version: '1.0.0' }, + devices: [ + { + id: 'test-device', + name: BOARD_NAME, + target: { type: 'runtime-v4' }, + hal: { pluginEntry: 'plugin/main.c' }, + }, + ], + }) + +interface FixtureOpts { + /** Extra files beyond manifest.json, keyed by package-relative POSIX path. */ + files?: Record + /** Override fields on the signed payload (e.g. a foreign keyId). */ + payloadOverride?: Record + /** Rewrite a file AFTER signing, to simulate a mid-session edit. */ + tamperFile?: { rel: string; content: string } + /** Skip signature.json entirely (a hand-assembled package directory). */ + omitSignature?: boolean +} + +describe('PackageManagerModule.verifyBoardPackageIntegrity', () => { + let userDataDir: string + let packagesDir: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'pkg-build-gate-')) + packagesDir = join(userDataDir, 'packages') + ;(app.getPath as jest.Mock).mockReturnValue(userDataDir) + }) + + afterEach(() => { + jest.clearAllMocks() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + /** Write a signed package directory under packagesDir and register it. */ + function installFixture(packageId: string, opts: FixtureOpts = {}): string { + const dir = join(packagesDir, packageId) + const files: Record = { + 'manifest.json': manifestFor(packageId), + 'plugin/main.c': 'int vpp_init(void) { return 0; }', + ...opts.files, + } + + const fileHashes: Record = {} + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel) + mkdirSync(dirname(full), { recursive: true }) + writeFileSync(full, content) + fileHashes[rel] = sha256(content) + } + + if (!opts.omitSignature) { + const payload = { + formatVersion: '1.0', + alg: 'ed25519', + keyId: KEY_ID, + packageId, + version: '1.0.0', + signedAt: '2026-06-01T00:00:00.000Z', + files: fileHashes, + ...opts.payloadOverride, + } + const signature = cryptoSign( + null, + Uint8Array.from(Buffer.from(canonicalize(payload), 'utf-8')), + PRIVATE_PEM, + ).toString('base64') + writeFileSync(join(dir, SIGNATURE_FILENAME), JSON.stringify({ ...payload, signature }, null, 2)) + } + + if (opts.tamperFile) { + writeFileSync(join(dir, opts.tamperFile.rel), opts.tamperFile.content) + } + + mkdirSync(packagesDir, { recursive: true }) + writeFileSync( + join(packagesDir, 'registry.json'), + JSON.stringify( + { + formatVersion: '1.0', + packages: { + [packageId]: { + version: '1.0.0', + installedAt: '2026-06-01T00:00:00.000Z', + path: dir, + devices: ['test-device'], + }, + }, + }, + null, + 2, + ), + ) + + return dir + } + + it('passes an untouched signed package — the negative control', () => { + installFixture('com.test.valid') + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ ok: true }) + }) + + it('passes a board no installed package provides (built-in hals.json board)', () => { + installFixture('com.test.valid') + + expect(new PackageManagerModule().verifyBoardPackageIntegrity('Arduino Uno')).toEqual({ ok: true }) + }) + + it('passes when nothing is installed at all', () => { + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ ok: true }) + }) + + it('fails when the plugin payload was edited after installation', () => { + // The DOPE-539 scenario for runtime-v4: vendor C that the runtime compiles + // on the PLC, rewritten between project open and build. + installFixture('com.test.tampered', { + tamperFile: { rel: 'plugin/main.c', content: 'int vpp_init(void) { /* injected */ return 0; }' }, + }) + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toEqual({ + ok: false, + packageId: 'com.test.tampered', + reason: expect.stringContaining('plugin/main.c'), + }) + }) + + it('fails when the manifest itself was edited after installation', () => { + // The licensing-bypass shape: `capabilities.isLicensable` is a manifest + // field, so an edit here is what the gate has to catch even though the + // board still resolves. + const dir = join(packagesDir, 'com.test.relicensed') + installFixture('com.test.relicensed') + writeFileSync( + join(dir, 'manifest.json'), + JSON.stringify({ + formatVersion: '1.0', + package: { id: 'com.test.relicensed', name: 'Test Package', version: '1.0.0' }, + devices: [ + { + id: 'test-device', + name: BOARD_NAME, + target: { type: 'runtime-v4' }, + hal: { pluginEntry: 'plugin/main.c' }, + capabilities: { isLicensable: false }, + }, + ], + }), + ) + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toEqual({ + ok: false, + packageId: 'com.test.relicensed', + reason: expect.stringContaining('manifest.json'), + }) + }) + + it('fails when a file was added to the package after signing', () => { + const dir = installFixture('com.test.injected') + writeFileSync(join(dir, 'extra.c'), 'void backdoor(void) {}') + + const result = new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(result).toMatchObject({ ok: false, packageId: 'com.test.injected' }) + }) + + it('fails when the package carries no signature at all', () => { + installFixture('com.test.unsigned', { omitSignature: true }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ + ok: false, + packageId: 'com.test.unsigned', + reason: expect.stringContaining('not signed'), + }) + }) + + it('fails when the signature names a key the editor does not trust', () => { + installFixture('com.test.selfsigned', { payloadOverride: { keyId: 'untrusted-key' } }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toEqual({ + ok: false, + packageId: 'com.test.selfsigned', + reason: expect.stringContaining('untrusted-key'), + }) + }) + + it('fails when the package directory is gone but the registry entry is not', () => { + const dir = installFixture('com.test.ghost') + // Resolve the board while the files still exist, then remove them — the + // registry entry alone must not be enough to build. + rmSync(join(dir, 'plugin'), { recursive: true, force: true }) + + expect(new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME)).toMatchObject({ + ok: false, + packageId: 'com.test.ghost', + }) + expect(existsSync(join(dir, 'manifest.json'))).toBe(true) + }) + + it('leaves the package on disk and in the registry — refusing is not removing', () => { + const dir = installFixture('com.test.keep', { omitSignature: true }) + + new PackageManagerModule().verifyBoardPackageIntegrity(BOARD_NAME) + + expect(existsSync(dir)).toBe(true) + expect(new PackageManagerModule().listInstalled().map((p) => p.packageId)).toEqual(['com.test.keep']) + }) +}) + +describe('formatPackageIntegrityError', () => { + it('names the board, the package, the reason and the remedy', () => { + const message = formatPackageIntegrityError('Test VPP Board', { + packageId: 'com.test.tampered', + reason: 'Tampered file detected: plugin/main.c', + }) + + expect(message).toContain('Test VPP Board') + expect(message).toContain('com.test.tampered') + expect(message).toContain('Tampered file detected: plugin/main.c') + expect(message).toContain('Reinstall the package') + }) +}) diff --git a/src/backend/editor/package-manager/index.ts b/src/backend/editor/package-manager/index.ts index bc74408ef..8d88baddf 100644 --- a/src/backend/editor/package-manager/index.ts +++ b/src/backend/editor/package-manager/index.ts @@ -1,2 +1,2 @@ -export { PackageManagerModule } from './package-manager-module' -export type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' +export { formatPackageIntegrityError, PackageManagerModule } from './package-manager-module' +export type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } from './types' diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 7319b5837..e53ed46d5 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -3,13 +3,20 @@ import extract from 'extract-zip' import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' -import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' +import { APP_VERSION } from '../../../frontend/data/constants/app-version' +import { isCompatibleEditorVersion } from '../../../frontend/utils/semver' +import { + PackageManifestSchema, + parseInstalledPackageManifest, +} from '../../../middleware/shared/ports/package-manifest-schema' +import type { VppDeviceMatch } from '../../shared/hardware/find-vpp-device' +import { findVppDeviceByBoardName } from '../../shared/hardware/find-vpp-device' import { validatePathId } from '../../shared/utils/path-safety' import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' -import type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' +import type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } from './types' /** * Enforce cryptographic signature verification on every import. Strict by @@ -79,6 +86,28 @@ class PackageManagerModule { } } + // Compatibility floor (DOPE-448). This is the ONLY place the editor + // enforces `minEditorVersion`, and it sits here because both entry paths + // — remote catalog install and the local "Add from file…" picker — + // converge on this method. The catalog UI's "Editor outdated" button + // state is a courtesy that stops the user earlier; it is not the gate, + // and before this check existed a `.vpp` dragged in from disk bypassed + // the constraint entirely. + // + // A package declares a floor when it needs an editor feature it cannot + // work without — a UI engine, a new screen widget, a layout the renderer + // learned in some release. Installing it on an older editor produces a + // board that renders wrong rather than an error, so refuse up front. + if (!isCompatibleEditorVersion(manifest.package.minEditorVersion, APP_VERSION)) { + return { + success: false, + error: + `Package "${manifest.package.name}" ${manifest.package.version} requires ` + + `OpenPLC Editor ${manifest.package.minEditorVersion} or newer. This editor is ${APP_VERSION}. ` + + `Update the editor, or install an older version of this package.`, + } + } + // Validate package.id BEFORE using it as a path component. Without // this, a malicious .vpp with `"id": "../../something"` would have // `targetDir` resolve outside packagesDir and the rmSync below @@ -173,6 +202,41 @@ class PackageManagerModule { return removed } + /** + * Re-verify the package that provides `boardName`, at the moment a build is + * about to consume it (DOPE-539). + * + * The import check and the project-open sweep both happen strictly BEFORE + * this point, and nothing between them and the compile stops the user from + * editing the installed package: `getInstalledPackageManifest`, the HAL + * source, the licence-store backend and the runtime-v4 plugin payload are + * all read straight off `userData/packages//` when the build runs. So a + * package that passed on open is not evidence about the package being + * compiled — an edit lands in the firmware, or in C the runtime compiles on + * a live PLC, and (because `capabilities.isLicensable` is a manifest field) + * can switch the whole licensing flow off. + * + * This is the gate that actually protects a build, so it fails CLOSED and + * the callers refuse to compile. It deliberately does NOT de-list or delete + * the package the way the open-time sweep does: tearing a directory out from + * under a build in flight is a worse failure than stopping the build and + * saying why. The sweep still owns removal. + * + * A built-in hals.json board has no package behind it and is `ok` — the + * common case, and the reason this costs nothing for most builds. + */ + verifyBoardPackageIntegrity(boardName: string): PackageIntegrityResult { + if (!REQUIRE_SIGNATURE) return { ok: true } + + const match = this.findDeviceByBoardName(boardName) + if (!match) return { ok: true } + + const reason = this.signatureRejectionReason(match.pkg.packageId, match.pkg.path) + if (!reason) return { ok: true } + + return { ok: false, packageId: match.pkg.packageId, reason } + } + /** * Returns null when the installed package recorded at `packagePath` is * genuinely signed by a trusted key, or a short human-readable reason when it @@ -268,8 +332,13 @@ class PackageManagerModule { } catch { return null } - const parsed = PackageManifestSchema.safeParse(raw) - return parsed.success ? (parsed.data as unknown as PackageManifest) : null + // Read path, not the trust boundary: `importFromFile` above is where a + // manifest is refused. Here the package is already installed, and a + // manifest that was accepted by an older editor — one whose schema did + // not yet check the floor format (DOPE-448) — must keep resolving, or the + // boards it provides vanish from the board lookup with no message. An + // unreadable floor is dropped and logged; everything else still rejects. + return parseInstalledPackageManifest(raw) } getPackagePath(packageId: string): string | null { @@ -278,6 +347,38 @@ class PackageManagerModule { return pkg?.path ?? null } + /** + * The installed VPP device named `boardName`, with its package and + * manifest — or null when no installed package provides it. + * + * `boardTarget` travels through the compile pipeline as a device + * *name*, so every consumer that needs the package behind a board + * starts here. Delegates to the shared `findVppDeviceByBoardName` so + * this and `board-info-resolver` (which cannot import this module) + * resolve a board the same way. + */ + findDeviceByBoardName(boardName: string): VppDeviceMatch | null { + return findVppDeviceByBoardName(this, boardName) + } + + /** + * `package.minRuntimeVersion` of the installed package that provides + * `boardName`, or null when no installed package does, when the + * matching device is not a `runtime-v4` target, or when the package + * declares no floor (DOPE-448). + * + * Only runtime-v4 devices can carry a meaningful floor: their HAL is + * plugin code built against the runtime's API. An `arduino-cli` + * device never talks to the runtime, so a floor there would be a + * claim nothing can check — openplc-packages' `validate.ts` rejects + * it at authoring time, and this returns null if one slips through. + */ + getRuntimeFloorForBoard(boardName: string): string | null { + const match = this.findDeviceByBoardName(boardName) + if (!match || match.device.target.type !== 'runtime-v4') return null + return match.manifest.package.minRuntimeVersion ?? null + } + private readRegistry(): PackageRegistry { if (!existsSync(this.registryPath)) { return { formatVersion: '1.0', packages: {} } @@ -294,4 +395,21 @@ class PackageManagerModule { } } -export { PackageManagerModule } +/** + * The one wording every build-time integrity refusal uses. + * + * Three call sites in the compiler abort on the same condition, and the user + * reads exactly one of the three; they must not each explain it differently. + * The message names the package (what to reinstall), the reason (what is + * wrong) and the remedy, because "signature verification failed" on its own + * reads as an editor bug rather than as "the file on your disk changed". + */ +function formatPackageIntegrityError(boardName: string, failure: { packageId: string; reason: string }): string { + return ( + `Board "${boardName}" is provided by the VPP package "${failure.packageId}", which no longer matches ` + + `its signature: ${failure.reason}. The package files appear to have been modified after installation, ` + + 'so they cannot be trusted for a build. Reinstall the package from a trusted .vpp file and try again.' + ) +} + +export { formatPackageIntegrityError, PackageManagerModule } diff --git a/src/backend/editor/package-manager/types.ts b/src/backend/editor/package-manager/types.ts index 613dfcb00..ff9528a1b 100644 --- a/src/backend/editor/package-manager/types.ts +++ b/src/backend/editor/package-manager/types.ts @@ -15,4 +15,21 @@ type PackageRegistry = { packages: Record> } -export type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } +/** + * Outcome of the build-time integrity gate + * (`PackageManagerModule.verifyBoardPackageIntegrity`). + * + * `ok: true` covers three genuinely different situations that all mean + * "nothing stands in the way of this build": the board is a built-in + * hals.json entry with no package behind it, the package still matches its + * signature, or enforcement is switched off for local development. The + * caller does not need to tell them apart — it either builds or it does not. + * + * The failure arm carries the `packageId` because the message a user can act + * on has to name the package they must reinstall, and `reason` because + * "files are missing" and "tampered file detected: hal/pi.cpp" send them to + * very different places. + */ +type PackageIntegrityResult = { ok: true } | { ok: false; packageId: string; reason: string } + +export type { ImportResult, InstalledPackage, PackageIntegrityResult, PackageManifest, PackageRegistry } diff --git a/src/backend/editor/services/project-service/index.ts b/src/backend/editor/services/project-service/index.ts index 00f102408..38c53ade0 100644 --- a/src/backend/editor/services/project-service/index.ts +++ b/src/backend/editor/services/project-service/index.ts @@ -192,6 +192,7 @@ class ProjectService { pouFiles: Array<{ relativePath: string; content: string }> serverFiles: Array<{ relativePath: string; content: string }> remoteDeviceFiles: Array<{ relativePath: string; content: string }> + dataTypeFiles: Array<{ relativePath: string; content: string }> } error?: { title: string; description: string } }> { @@ -242,6 +243,7 @@ class ProjectService { const readDirRecursive = async ( dirPath: string, basePath: string, + validExtensions: string[] = VALID_POU_EXTENSIONS, ): Promise> => { const results: Array<{ relativePath: string; content: string }> = [] try { @@ -250,11 +252,11 @@ class ProjectService { const fullPath = join(dirPath, entry.name) const relPath = join(basePath, entry.name) if (entry.isDirectory()) { - const subResults = await readDirRecursive(fullPath, relPath) + const subResults = await readDirRecursive(fullPath, relPath, validExtensions) results.push(...subResults) } else if (entry.isFile()) { const ext = entry.name.slice(entry.name.lastIndexOf('.')) - if (!VALID_POU_EXTENSIONS.includes(ext)) continue + if (!validExtensions.includes(ext)) continue const content = await promises.readFile(fullPath, 'utf-8') results.push({ relativePath: relPath, content }) } @@ -285,6 +287,11 @@ class ProjectService { const serverFiles = await readDirRecursive(join(projectPath, 'devices', 'servers'), 'devices/servers') const remoteDeviceFiles = await readDirRecursive(join(projectPath, 'devices', 'remote'), 'devices/remote') + // Data type files live at datatypes/.dt — collected into + // their own bucket, never into pouFiles (pou-path detection + // throws for paths outside the pou folders). + const dataTypeFiles = await readDirRecursive(join(projectPath, 'datatypes'), 'datatypes', ['.dt']) + // Library projects own a `library.json` at the project root. // Read it as a plain string (parsing happens upstream in the // build pipeline + manifest editor — same convention POUs use: @@ -310,6 +317,7 @@ class ProjectService { pouFiles, serverFiles, remoteDeviceFiles, + dataTypeFiles, }, } } catch (error) { @@ -460,9 +468,14 @@ class ProjectService { // arrays are empty (e.g. fresh library with no servers yet), // so callers can rely on them existing. await Promise.all( - ['pous/programs', 'pous/functions', 'pous/function-blocks', 'devices/servers', 'devices/remote'].map((d) => - promises.mkdir(join(dir, d), { recursive: true }), - ), + [ + 'pous/programs', + 'pous/functions', + 'pous/function-blocks', + 'devices/servers', + 'devices/remote', + 'datatypes', + ].map((d) => promises.mkdir(join(dir, d), { recursive: true })), ) // Single source of truth for the file shape lives in the diff --git a/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts new file mode 100644 index 000000000..9d905eee8 --- /dev/null +++ b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts @@ -0,0 +1,159 @@ +import { parse } from 'yaml' + +import { reconcileArduinoCliConfig } from '../arduino-cli-config' +import { ARDUINO_DATA } from '../types' + +/** + * The config every install created before this change: two board-manager URLs + * and the colour suppression the console no longer needs. + */ +const LEGACY_CONFIG = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://espressif.github.io/arduino-esp32/package_esp32_index.json +output: + no_color: true +` + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Board-manager URLs in a YAML document, without asserting its shape. */ +function urlsOf(yaml: string): string[] { + const parsed: unknown = parse(yaml) + if (!isRecord(parsed) || !isRecord(parsed.board_manager)) return [] + const urls = parsed.board_manager.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * The reconciled config, failing the test if nothing changed. + * + * `reconcileArduinoCliConfig` returns `null` for "already up to date", which + * is a real outcome worth asserting on separately -- so a test that expects a + * rewrite says so here rather than casting the null away and failing later + * with a confusing message. + */ +function requireUpdated(result: string | null): string { + if (result === null) throw new Error('expected the config to be rewritten, but it needed no changes') + return result +} + +describe('reconcileArduinoCliConfig', () => { + // --------------------------------------------------------------------- + // The upgrade path that matters: an install that already has no_color. + // --------------------------------------------------------------------- + it('drops output.no_color from a legacy config', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + expect(updated).not.toBeNull() + expect(updated).not.toContain('no_color') + // The whole `output` map existed only to hold it. + expect(updated).not.toContain('output:') + }) + + it('backfills the board manager URLs the legacy config never received', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + const result = urlsOf(requireUpdated(updated)) + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('produces a config that still parses as valid YAML', () => { + const updated = requireUpdated(reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA)) + expect(() => parse(updated)).not.toThrow() + expect(parse(updated)).toMatchObject({ board_manager: { additional_urls: expect.any(Array) } }) + }) + + it('is idempotent — a second pass reports nothing left to do', () => { + const once = requireUpdated(reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA)) + expect(reconcileArduinoCliConfig(once, ARDUINO_DATA)).toBeNull() + }) + + // --------------------------------------------------------------------- + // Don't destroy what the user put there. + // --------------------------------------------------------------------- + it('keeps user-added URLs that the editor does not ship', () => { + const withCustom = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://example.com/package_mine_index.json +output: + no_color: true +` + const updated = requireUpdated(reconcileArduinoCliConfig(withCustom, ARDUINO_DATA)) + const result = urlsOf(updated) + expect(result).toContain('https://example.com/package_mine_index.json') + // ...and the shipped ones still get added alongside it. + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('preserves unrelated settings and comments', () => { + const withExtras = `# my notes\nlogging:\n level: debug\n${LEGACY_CONFIG}` + const updated = requireUpdated(reconcileArduinoCliConfig(withExtras, ARDUINO_DATA)) + expect(updated).toContain('# my notes') + expect(parse(updated)).toMatchObject({ logging: { level: 'debug' } }) + }) + + it('keeps an output map that still holds other keys', () => { + const withOtherOutput = `board_manager:\n additional_urls: []\noutput:\n no_color: true\n format: json\n` + const updated = requireUpdated(reconcileArduinoCliConfig(withOtherOutput, ARDUINO_DATA)) + expect(updated).not.toContain('no_color') + expect(parse(updated)).toMatchObject({ output: { format: 'json' } }) + }) + + // --------------------------------------------------------------------- + // No-op and failure cases. + // --------------------------------------------------------------------- + it('returns null when the file already matches what we ship', () => { + expect(reconcileArduinoCliConfig(ARDUINO_DATA, ARDUINO_DATA)).toBeNull() + }) + + it('installs the shipped URL list when the key is missing entirely', () => { + const updated = requireUpdated(reconcileArduinoCliConfig('output:\n no_color: true\n', ARDUINO_DATA)) + expect(urlsOf(updated)).toEqual(urlsOf(ARDUINO_DATA)) + }) + + // A hand-edited config can be parseable but structurally odd. yaml's nested + // `*In()` helpers throw on a scalar parent ("Expected YAML collection at + // board_manager"), which would abort the whole reconciliation and silently + // leave the user un-migrated. + it('does not throw when board_manager is a scalar', () => { + expect(() => reconcileArduinoCliConfig('board_manager: 5\n', ARDUINO_DATA)).not.toThrow() + }) + + it('does not throw when output is a scalar, and still backfills URLs', () => { + const updated = reconcileArduinoCliConfig('output: "text"\n', ARDUINO_DATA) + expect(updated).not.toBeNull() + for (const url of urlsOf(ARDUINO_DATA)) expect(urlsOf(requireUpdated(updated))).toContain(url) + }) + + it('leaves a scalar board_manager untouched rather than guessing', () => { + // Nothing safe to merge into a scalar: leave it for the user to fix. + const updated = reconcileArduinoCliConfig('board_manager: 5\noutput:\n no_color: true\n', ARDUINO_DATA) + // The no_color retirement still happens; the URL backfill is skipped. + expect(updated).not.toContain('no_color') + expect(updated).toContain('board_manager: 5') + }) + + it('does not throw when additional_urls is a scalar', () => { + expect(() => reconcileArduinoCliConfig('board_manager:\n additional_urls: 7\n', ARDUINO_DATA)).not.toThrow() + }) + + it('leaves an unparseable config alone rather than clobbering it', () => { + expect(reconcileArduinoCliConfig('board_manager: [oops\n : :\n', ARDUINO_DATA)).toBeNull() + }) +}) + +describe('ARDUINO_DATA', () => { + it('no longer ships the obsolete colour suppression', () => { + // The console renders SGR colour now; forcing it off would make the + // renderer dead code on every fresh install. + expect(ARDUINO_DATA).not.toContain('no_color') + }) + + it('is valid YAML with a non-empty board manager list', () => { + expect(urlsOf(ARDUINO_DATA).length).toBeGreaterThan(0) + }) +}) diff --git a/src/backend/editor/services/user-service/data/arduino-cli-config.ts b/src/backend/editor/services/user-service/data/arduino-cli-config.ts new file mode 100644 index 000000000..f3c11b403 --- /dev/null +++ b/src/backend/editor/services/user-service/data/arduino-cli-config.ts @@ -0,0 +1,92 @@ +/** + * Reconcile an existing `arduino-cli.yaml` with the one the editor ships. + * + * The config used to be written once with `{ flag: 'wx' }` and skipped + * forever after, so anything added to `ARDUINO_DATA` later never reached an + * existing install — the only fix was deleting the file by hand. This brings + * a stale file up to date in place. + * + * Two rules, and deliberately only two: + * + * - **Add missing board-manager URLs.** Never remove one: users add their own + * vendor indexes here, and VPP-declared indexes arrive at compile time. + * - **Drop `output.no_color`.** The editor forced it on to stop raw `ESC[92m` + * bytes appearing in the console. The console now renders SGR colour + * itself, so the suppression is obsolete; leaving it behind would silently + * keep colour off on every machine that has ever launched an older build. + * + * Everything else is left exactly as the user left it, comments and ordering + * included — hence the `Document` API for the existing file rather than a + * parse/serialise round-trip through plain objects. + */ + +import { isMap, isSeq, parse, parseDocument } from 'yaml' + +const BOARD_MANAGER_URLS_PATH = ['board_manager', 'additional_urls'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Board-manager URLs declared by the shipped template (which we author). */ +function shippedBoardManagerUrls(shipped: string): string[] { + const parsed: unknown = parse(shipped) + if (!isRecord(parsed)) return [] + + const boardManager = parsed.board_manager + if (!isRecord(boardManager)) return [] + + const urls = boardManager.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * Return the updated file contents, or `null` when nothing needed changing. + * + * Also returns `null` when `existing` is unparseable — a broken config is the + * user's to fix, and rewriting it would discard whatever they were editing. + */ +export function reconcileArduinoCliConfig(existing: string, shipped: string): string | null { + const doc = parseDocument(existing) + if (doc.errors.length > 0) return null + + let changed = false + + // Nested `*In()` calls walk the tree and throw ("Expected YAML collection + // at board_manager") if a parent turns out to be a scalar. A hand-edited + // config can absolutely be parseable-but-odd, and throwing here would abort + // the whole reconciliation — leaving the user with no migration and only a + // console error to explain it. So fetch each parent and check it first. + const boardManager = doc.get('board_manager') + const output = doc.get('output') + + // 1. Board-manager URLs — union, never subtract. + const shippedUrls = shippedBoardManagerUrls(shipped) + if (shippedUrls.length > 0 && (boardManager === undefined || boardManager === null || isMap(boardManager))) { + const current = isMap(boardManager) ? boardManager.get('additional_urls') : undefined + const present = new Set(isSeq(current) ? current.toJSON().map(String) : []) + const missing = shippedUrls.filter((url) => !present.has(url)) + + if (missing.length > 0) { + if (isSeq(current)) { + for (const url of missing) current.add(url) + } else { + // No `additional_urls` key, or it is not a list — install the shipped + // set wholesale rather than guessing at a merge. + doc.setIn(BOARD_MANAGER_URLS_PATH, shippedUrls) + } + changed = true + } + } + + // 2. Retire the obsolete colour suppression. + if (isMap(output) && output.has('no_color')) { + output.delete('no_color') + changed = true + + // Don't leave an empty `output:` behind once its only key is gone. + if (output.items.length === 0) doc.delete('output') + } + + return changed ? String(doc) : null +} diff --git a/src/backend/editor/services/user-service/data/types.ts b/src/backend/editor/services/user-service/data/types.ts index 57575b7dc..5d2c6dd8e 100644 --- a/src/backend/editor/services/user-service/data/types.ts +++ b/src/backend/editor/services/user-service/data/types.ts @@ -11,8 +11,6 @@ board_manager: - https://raw.githubusercontent.com/VEA-SRL/IRUINO_Library/main/package_vea_index.json - https://github.com/CONTROLLINO-PLC/controllino_rp2/releases/download/global/package_controllino_rp2_index.json - https://downloads.arduino.cc/packages/package_zephyr_index.json -output: - no_color: true ` export const HISTORY_DATA = { diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index 5e6aae3c3..ee32c2742 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -1,10 +1,11 @@ import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { exec } from 'child_process' import { app } from 'electron' -import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises' +import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { basename, join } from 'path' import { promisify } from 'util' +import { reconcileArduinoCliConfig } from './data/arduino-cli-config' import { ARDUINO_DATA, HISTORY_DATA, SETTINGS_DATA } from './data/types' import type { ArduinoListOutput } from './types' @@ -144,22 +145,53 @@ class UserService { } /** - * Checks if the Arduino CLI configuration file exists and creates it if it doesn't. + * Create the Arduino CLI configuration file, or bring an existing one up to + * date with what the editor ships. + * + * This used to write with `{ flag: 'wx' }` and swallow `EEXIST`, which made + * the file effectively write-once: an install that had launched an older + * build kept a stale config forever, and the only fix was deleting it by + * hand. Two things went stale that way — board-manager URLs added to + * `ARDUINO_DATA` never reached existing users, and `output.no_color` stayed + * on, which would keep the console monochrome even though it renders SGR + * colour itself now. + * + * Merge, never overwrite: users add their own indexes and change other + * settings in this file, and clobbering it would silently discard them. + * See `reconcileArduinoCliConfig` for the (deliberately narrow) rules. */ async #checkIfArduinoCliConfigExists(): Promise { const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') + try { await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) + return } catch (err) { - // If the error is due to the file already existing, log a warning and continue. - if (err instanceof Error && err.message.includes('EEXIST')) { - console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`) - } else if (err instanceof Error) { - console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) - } else { + if (!(err instanceof Error && err.message.includes('EEXIST'))) { console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + return } } + + // File already exists — reconcile it with what we ship. + try { + const existing = await readFile(pathToArduinoCliConfig, 'utf-8') + const updated = reconcileArduinoCliConfig(existing, UserService.ARDUINO_FILE_CONTENT) + if (!updated) return + + // Write via a sibling temp file and rename over the original. This runs + // on every start against a file the user owns and arduino-cli must be + // able to parse; a crash midway through a direct write would leave it + // truncated and break every build until the user deleted it by hand. + // `rename` within the same directory is atomic, so the config is either + // the old one or the new one, never half of each. + const tempPath = `${pathToArduinoCliConfig}.tmp` + await writeFile(tempPath, updated, 'utf-8') + await rename(tempPath, pathToArduinoCliConfig) + console.warn(`Updated Arduino CLI config at ${pathToArduinoCliConfig}.`) + } catch (err) { + console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } } async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> { diff --git a/src/backend/editor/utils/__tests__/http-module.test.ts b/src/backend/editor/utils/__tests__/http-module.test.ts new file mode 100644 index 000000000..4f7db6d58 --- /dev/null +++ b/src/backend/editor/utils/__tests__/http-module.test.ts @@ -0,0 +1,38 @@ +import http from 'http' +import https from 'https' + +import { defaultPortFor, httpModuleFor } from '../http-module' + +describe('httpModuleFor', () => { + it('returns the http module for an http: URL', () => { + expect(httpModuleFor('http://localhost:3333/vpp-licenses/activate')).toBe(http) + }) + + it('returns the https module for an https: URL', () => { + expect(httpModuleFor('https://api.autonomylogic.com/vpp-licenses/activate')).toBe(https) + }) + + it('accepts an already-parsed URL', () => { + expect(httpModuleFor(new URL('http://127.0.0.1:3333/x'))).toBe(http) + }) + + // An unparseable or exotic URL must never silently downgrade to plaintext. + it('falls back to https for an unparseable URL', () => { + expect(httpModuleFor('not a url')).toBe(https) + }) + + it('falls back to https for a non-http(s) scheme', () => { + expect(httpModuleFor('ftp://example.com/x')).toBe(https) + }) +}) + +describe('defaultPortFor', () => { + it('is 80 for http and 443 for https', () => { + expect(defaultPortFor('http://localhost/x')).toBe(80) + expect(defaultPortFor('https://localhost/x')).toBe(443) + }) + + it('falls back to 443 when the URL cannot be parsed', () => { + expect(defaultPortFor('not a url')).toBe(443) + }) +}) diff --git a/src/backend/editor/utils/http-module.ts b/src/backend/editor/utils/http-module.ts new file mode 100644 index 000000000..9fb3555a5 --- /dev/null +++ b/src/backend/editor/utils/http-module.ts @@ -0,0 +1,46 @@ +/** + * Pick the Node request module that matches a URL's scheme. + * + * The edge clients (`desktop-catalog-transport`, `license-activation-client`) + * were both written against `https.request` directly. That is right for + * production — every deployed edge host is HTTPS — but it makes the base-URL + * override (`OPENPLC_EDGE_API_URL`) a lie: pointing it at a local backend + * (`http://localhost:3333`, the port the autonomy-edge dev server binds) sends + * a TLS ClientHello to a plain HTTP socket. The connection dies with EPROTO / + * ECONNRESET, the client's catch turns that into a generic failure, and the + * editor silently falls back to demo mode. The result is that the one contract + * we most need to exercise end-to-end cannot be exercised locally at all. + * + * `http.request` and `https.request` share a call signature, so a scheme-driven + * lookup is a drop-in for either call site. + */ + +import http from 'http' +import https from 'https' + +/** Node's `http`/`https` share the `request` signature we rely on. */ +export type NodeRequestModule = Pick + +/** + * The request module for `url`'s scheme. Defaults to `https` for anything that + * is not explicitly `http:` — an unparseable or exotic URL should not silently + * downgrade to plaintext. + */ +export function httpModuleFor(url: string | URL): NodeRequestModule { + try { + const parsed = typeof url === 'string' ? new URL(url) : url + return parsed.protocol === 'http:' ? http : https + } catch { + return https + } +} + +/** Default port for a URL that does not carry one, by scheme. */ +export function defaultPortFor(url: string | URL): number { + try { + const parsed = typeof url === 'string' ? new URL(url) : url + return parsed.protocol === 'http:' ? 80 : 443 + } catch { + return 443 + } +} diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 0ef0902b3..3b9dfb64f 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -153,6 +153,149 @@ describe('generateDefinesContent — simulator comms block', () => { }) }) +describe('generateDefinesContent — Debugger block (always-on debug)', () => { + it('emits DEBUGGER_ENABLED for a baremetal arduino-cli target with no Modbus', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('//Debugger\n#define DEBUGGER_ENABLED\n') + }) + + it('emits DEBUGGER_ENABLED when the Modbus screen is present but disabled', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { modbus_rtu: { enabled: false }, modbus_tcp: { enabled: false } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + }) + + it('emits DEBUGGER_ENABLED even when full Modbus is enabled (always-on serial debugger)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, + }, + defaultSerial: 'Serial', + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + // RTU on the default serial → shares the debugger's port (single begin()). + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('emits DEBUG_IFACE from defaultSerial and DEBUG_BAUD from the Serial section', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { serial: { baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + it('falls back to DEBUG_IFACE Serial and DEBUG_BAUD 115200 when unset', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 115200') + }) + + // A PUBLISHED VPP has no `serial` section — only the legacy RTU fields. The + // debugger and the RTU then share one port, so ONE rate must come out of this + // file. Emitting 115200 while MBSERIAL_BAUD said 9600 built a firmware the + // editor could not talk to, and the user was told "No Firmware Detected" about + // a board that was running fine. + it('aligns DEBUG_BAUD with MBSERIAL_BAUD for a published VPP (no `serial` section)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + // The reported failure, end to end: Modbus off, 9600 saved on the screen. The + // editor dials 9600 (spec params ignore `enabledWhen`), so a firmware built at + // 115200 opened the port and answered nothing — "No Firmware Detected" on a + // healthy board. + it('aligns DEBUG_BAUD with the screen baud when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + expect(out).toContain('#define DEBUG_BAUD 9600') + // Modbus itself stays out of the build. + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + + it('keeps DEBUG_BAUD at the firmware default when the RTU has its own second port', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + // Two distinct ports, two distinct rates — and the debugger keeps the default. + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') + expect(out).toContain('#define DEBUG_BAUD 115200') + }) + + it('emits DEBUG_SLAVE from the RTU screen so it matches the id the editor addresses', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 3 }, + }, + }) + expect(out).toContain('#define MBSERIAL_SLAVE 3') + expect(out).toContain('#define DEBUG_SLAVE 3') + }) + + // The slave-id twin of the DEBUG_BAUD regression above, and the harsher one: + // Connect sweeps baud rates, but nothing sweeps slave ids. With Modbus off and + // slave id 7 saved on the screen, the editor addresses 7 while a firmware left + // on modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback frames on 1 — every + // frame dropped at the id check, reported as "No Firmware Detected". + it('aligns DEBUG_SLAVE with the screen slave id when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_slave_id: 7 } }, + }) + expect(out).toContain('#define DEBUG_SLAVE 7') + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + + it('falls back to DEBUG_SLAVE 1 when the project states no slave id', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_SLAVE 1') + }) + + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED for openplc-compiler runtimes', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'openplc-compiler' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) +}) + describe('generateDefinesContent — IO Config (pin masks)', () => { it('emits empty pin masks when devicePinMapping is empty', () => { const out = generateDefinesContent(EMPTY_INPUTS) @@ -358,6 +501,13 @@ describe('generateDefinesContent — full output snapshot', () => { '//Program MD5', '#define PROGRAM_MD5 "ffffffffffffffffffffffffffffffff"', '', + '//Debugger', + '#define DEBUGGER_ENABLED', + '#define DEBUG_IFACE Serial', + '#define DEBUG_BAUD 115200', + '#define DEBUG_SLAVE 1', + '', + '', '//IO Config', '#define PINMASK_DIN ', '#define PINMASK_AIN 4', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index a16fe762e..918fa67ed 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -1,4 +1,114 @@ -import { generateModbusDefines } from '../steps/modbus-defines' +import { + DEFAULT_DEBUG_BAUD, + DEFAULT_DEBUG_SLAVE, + generateModbusDefines, + resolveDebugBaud, + resolveDebugSlave, +} from '../steps/modbus-defines' + +/** + * The baud the always-on debugger answers on. It has to agree with the rate the + * editor dials, and the two are derived in different places — so these pin the + * derivation against the shapes real projects actually persist. + */ +describe('resolveDebugBaud', () => { + it('prefers an explicit `serial` section when a package declares one', () => { + expect( + resolveDebugBaud({ serial: { baud_rate: '57600' }, modbus_rtu: { enabled: true, rtu_baud_rate: '9600' } }), + ).toBe('57600') + }) + + // The regression this function exists for: a PUBLISHED VPP has no `serial` + // section, so the RTU's baud is the only statement of the default port's speed. + // Reading 115200 instead compiled a firmware listening at one rate while the + // editor dialled another, and the board answered nothing at all. + it('takes the RTU baud when the RTU shares the default port (published VPP shape)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('takes the RTU baud when the RTU names no port at all (defaults to the default one)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_baud_rate: '19200' } })).toBe('19200') + }) + + it('honours a board whose default serial is not called `Serial`', () => { + expect( + resolveDebugBaud( + { modbus_rtu: { enabled: true, rtu_interface: 'SerialUSB', rtu_baud_rate: '38400' } }, + 'SerialUSB', + ), + ).toBe('38400') + }) + + it('ignores the RTU baud when the RTU is on a SECOND port', () => { + // There the debugger keeps the default port to itself and nothing in the + // project states its speed, so the firmware default is the only answer. + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + DEFAULT_DEBUG_BAUD, + ) + }) + + // The editor dials `rtu_baud_rate` whether or not the RTU is enabled — a debug + // spec's `params` are read independently of its `enabledWhen`. So the firmware + // must listen there too, or a project with Modbus turned off and a non-default + // baud saved on the screen is unreachable. + it('still takes the RTU baud when the RTU is DISABLED', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe('9600') + }) + + it('takes the RTU baud when the RTU is disabled and names a second port', () => { + // The rate is unused by Modbus, and the debugger owns the default port. What + // decides this is what the editor dials, which is this value. + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('falls back when the RTU section states no baud at all', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_BAUD) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugBaud({})).toBe(DEFAULT_DEBUG_BAUD) + }) +}) + +/** + * The slave id the always-on debugger frames on. Unlike the baud, a mismatch here + * is NOT recoverable by the connect flow's rate sweep — the firmware silently + * drops every frame whose first byte isn't this id, and that check is the only + * validation debug function codes get. So these pin exact agreement with the id + * the editor addresses (`screens.modbus_rtu.rtu_slave_id`, read regardless of + * whether the RTU is enabled). + */ +describe('resolveDebugSlave', () => { + it('uses the RTU screen slave id when the RTU is enabled', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, rtu_slave_id: 3 } })).toBe(3) + }) + + it('uses the RTU screen slave id even when the RTU is DISABLED', () => { + // The regression this exists for: a TCP-only (or Modbus-off) project still + // has the editor addressing the RTU screen's id over serial, because a debug + // spec's `params` are read independently of its `enabledWhen`. Defaulting to + // 1 here made a healthy board report "No Firmware Detected". + expect(resolveDebugSlave({ modbus_rtu: { enabled: false, rtu_slave_id: 7 } })).toBe(7) + }) + + it('uses the RTU screen slave id even when the RTU runs on a secondary UART', () => { + // Not a conflict: MBSERIAL_SLAVE frames that id on Serial1 while DEBUG_SLAVE + // frames it on Serial. Two distinct ports, and the editor still dials this id. + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, serial_port: 'Serial1', rtu_slave_id: 4 } })).toBe(4) + }) + + it('falls back when the RTU section states no slave id', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_SLAVE) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugSlave({})).toBe(DEFAULT_DEBUG_SLAVE) + }) +}) describe('generateModbusDefines', () => { it('returns an empty string when neither RTU nor TCP is enabled', () => { @@ -22,6 +132,7 @@ describe('generateModbusDefines', () => { '#define MBSERIAL_IFACE Serial', '#define MBSERIAL_BAUD 115200', '#define MBSERIAL_SLAVE 1', + '#define MBSERIAL_SHARES_DEBUG_SERIAL', '#define MBSERIAL', '#define MODBUS_ENABLED', '', @@ -29,6 +140,58 @@ describe('generateModbusDefines', () => { ) }) + it('Phase 2: RTU on the default port takes its baud from the Serial section and shares the debug serial', () => { + const out = generateModbusDefines({ + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, + }) + expect(out).toContain('#define MBSERIAL_IFACE Serial') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).not.toContain('MBSERIAL_ON_SECONDARY') + }) + + it('Phase 2: RTU on a secondary port uses its own baud and does NOT share the debug serial', () => { + const out = generateModbusDefines( + { + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial1', baud_rate: '19200', rtu_slave_id: 1 }, + }, + 'Serial', + ) + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 19200') + expect(out).not.toContain('MBSERIAL_SHARES_DEBUG_SERIAL') + // Distinct UART from the debugger's default → firmware services two serials. + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') + }) + + it('Phase 2: honors a non-default `defaultSerial` when deciding the shares flag', () => { + const out = generateModbusDefines( + { serial: { baud_rate: '9600' }, modbus_rtu: { enabled: true, serial_port: 'Serial1' } }, + 'Serial1', + ) + // serial_port === defaultSerial → shares, and baud from the Serial section. + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: reads TCP network config from the network section', () => { + const out = generateModbusDefines({ + network: { + interface: 'Wi-Fi', + wifi_ssid: 'MyNet', + wifi_password: 'super-secret', + enable_dhcp: true, + }, + modbus_tcp: { enabled: true, unit_id: 1 }, + }) + expect(out).toContain('#define MBTCP_SSID "MyNet"') + expect(out).toContain('#define MBTCP_PWD "super-secret"') + expect(out).toContain('#define MBTCP_WIFI') + }) + it('applies RTU schema defaults when only `enabled: true` is persisted (form-layout writes only touched fields)', () => { // Real-world scenario: user toggles "Enable Modbus RTU" without // editing baud/interface/slave — form-layout writes only the field diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 06f2f3353..54c668441 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -40,6 +40,17 @@ jest.mock('../../firmware/runtime-version-gate', () => ({ describeIncompatibleRuntime: jest.fn( (v: string | null) => `Runtime ${String(v)} is too old; please upgrade to 4.1.0+.`, ), + // The two DOPE-448 message builders. Only the message text is stubbed — + // the *decisions* are made by `isVersionAtLeast` from + // `frontend/utils/semver`, which is deliberately NOT mocked so these tests + // exercise the real comparison. + describeEditorTooOldForRuntime: jest.fn( + (a: { minEditorVersion: string }) => `This editor is older than the runtime requires (${a.minEditorVersion}).`, + ), + describeVppRuntimeMismatch: jest.fn( + (a: { boardTarget: string; minRuntimeVersion: string }) => + `Board "${a.boardTarget}" needs runtime ${a.minRuntimeVersion} or newer.`, + ), })) // Mock the conf-generator step so tests can deterministically force // the runtime-v4 confs branch to throw (covers the pipeline's outer @@ -188,6 +199,42 @@ describe('runCompilePipeline — simulator path', () => { expect(callArgs.argv).toEqual(['compile', '-b', 'arduino:avr:mega']) }) + // Regression: the board's `boardManagerUrl` (VPP `target.boardManagerUrl`) + // was resolved onto boardEntry but never forwarded to installArduinoCore, + // so vendor cores outside arduino-cli's built-in index could not be + // installed — "Platform 'industrialshields:esp32' not found". + it('forwards boardEntry.boardManagerUrl to installArduinoCore', async () => { + const port = makePort() + const { emit } = captureEvents() + const boardManagerUrl = + 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + await runCompilePipeline( + makeArgs({ + isSimulator: false, + boardRuntime: 'arduino-cli', + boardEntry: { + platform: 'industrialshields:esp32:esp32plc', + core: 'industrialshields:esp32', + boardManagerUrl, + }, + }), + port, + emit, + ) + expect(port.installArduinoCore).toHaveBeenCalledWith( + expect.objectContaining({ coreId: 'industrialshields:esp32', boardManagerUrl }), + expect.any(Function), + ) + }) + + it('omits boardManagerUrl for boards that do not declare one', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline(makeArgs({ isSimulator: false, boardRuntime: 'arduino-cli' }), port, emit) + const [coreArgs] = port.installArduinoCore.mock.calls[0] + expect(coreArgs).not.toHaveProperty('boardManagerUrl') + }) + it('calls installArduinoCore + installArduinoLib before compileArduino (no-op semantics for web)', async () => { const port = makePort() const { emit } = captureEvents() @@ -379,6 +426,122 @@ describe('runCompilePipeline — runtime v4 path', () => { expect(events.some((e) => /too old|upgrade/i.test(e.message))).toBe(true) }) + // ------------------------------------------------------------------------- + // DOPE-448: the two floors the runtime and the VPP declare + // ------------------------------------------------------------------------- + + const v4Args = (overrides: Partial = {}) => + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + // The host app injects its own version; `4.2.0` is low enough to be + // refused by the floors below and high enough to clear the passing ones. + editorVersion: '4.2.0', + ...overrides, + }) + + it('aborts when the runtime declares a minEditorVersion above this editor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ + ok: true, + version: 'v4.2.0', + minEditorVersion: '4.2.1', + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(false) + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /older than the runtime requires \(4\.2\.1\)/.test(e.message))).toBe(true) + }) + + // The second, independent way the gate stays inert: a caller that never + // opts in. Web passes no `editorVersion` until its adapter wires one up, + // and must keep uploading. + it('uploads when the caller passes no editorVersion, even against a declared floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0', minEditorVersion: '99.0.0' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args({ editorVersion: undefined }), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + // This is every runtime currently in the field: it answers /api/version + // only, so no floor reaches the pipeline. The gate must be completely + // inert for them — that is what makes shipping this safe. + const NO_FLOOR_DECLARED: Array<[string | null | undefined, string]> = [ + [undefined, 'the field is absent (runtime predates /api/capabilities)'], + [null, 'the runtime declares no floor'], + ] + + it.each(NO_FLOOR_DECLARED)('uploads normally when minEditorVersion is %p — %s', async (minEditorVersion) => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7', minEditorVersion }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(result.uploaded).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('uploads when this editor satisfies the runtime-declared floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0', minEditorVersion: '1.0.0' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('aborts when the VPP requires a newer runtime than the device reports', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: '4.1.9' }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(false) + // Blocked BEFORE the upload: the failure this prevents is a vendor plugin + // that loads on a live PLC and dies at scan time. + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /needs runtime 4\.1\.9 or newer/.test(e.message))).toBe(true) + }) + + it('uploads when the runtime satisfies the VPP floor', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.2.0' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: '4.1.9' }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + + it('uploads when the board is not from a VPP (no floor declared)', async () => { + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: 'v4.1.7' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {}, minRuntimeVersion: null }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(v4Args(), port, emit) + + expect(result.success).toBe(true) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + }) + it('compileOnly on v4 returns success without invoking checkRuntimeVersion or uploadRuntimeV4', async () => { const port = makePort() const { emit } = captureEvents() diff --git a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts index 1998eb024..146a6c04a 100644 --- a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts +++ b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts @@ -239,4 +239,59 @@ describe('resolveBoardSelection', () => { expect(result.boardEntry.extra_libraries).toEqual(['P1AM']) } }) + + it('carries target.boardManagerUrl through to boardEntry', () => { + // A VPP whose core is not in arduino-cli's built-in index must surface + // its vendor index here, or the pipeline has nothing to hand to + // `installArduinoCore` and the install fails with "Platform not found". + const boardManagerUrl = + 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + const pkg: InstalledPackage = { + packageId: 'com.openplc.industrialshields', + version: '1.0.1', + installedAt: '2026-01-01T00:00:00.000Z', + path: '/fake/packages/industrialshields', + devices: ['esp32-plc-14-0-10v'], + } + const manifest: PackageManifest = { + formatVersion: '1.0', + package: { + id: 'com.openplc.industrialshields', + name: 'IndustrialShields PLCs', + version: '1.0.1', + vendor: { name: 'Industrial Shields', logo: 'l.png' }, + description: 'd', + }, + devices: [ + { + id: 'esp32-plc-14-0-10v', + name: 'ESP32 PLC 14 0-10V', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'industrialshields:esp32', + platform: 'industrialshields:esp32:plc14ios:cpu=plc14ios', + boardManagerUrl, + }, + hal: { + type: 'arduino-hal', + source: 'hal/arduino/esp32plc.cpp', + define: 'ISPLC_ESP32_PLC_14_0_10V', + }, + }, + ], + } + const packageManager: PackageManagerPort = { + listInstalled: () => [pkg], + getInstalledPackageManifest: (id) => (id === pkg.packageId ? manifest : null), + } + const resolver = makeResolver({}, { packageManager }) + + const result = resolveBoardSelection(resolver, 'ESP32 PLC 14 0-10V') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardEntry.core).toBe('industrialshields:esp32') + expect(result.boardEntry.boardManagerUrl).toBe(boardManagerUrl) + } + }) }) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index c3922edf1..9100be5ec 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -20,6 +20,7 @@ * `emit` callback (progress events). No disk I/O, no globals. */ +import { isVersionAtLeast } from '../../../frontend/utils/semver' import type { CompilerPlatformPort, PlatformDeviceContext, @@ -30,7 +31,12 @@ import { composeRuntimeV4Bundle } from '../../../middleware/shared/utils/library import { resolveTargetCapabilities } from '../../../middleware/shared/utils/target-capabilities' import type { BoardHalsCompileEntry } from '../firmware/build-arduino-cli-args' import { buildArduinoCliCompileArgs } from '../firmware/build-arduino-cli-args' -import { describeIncompatibleRuntime, isStrucppCompatibleRuntime } from '../firmware/runtime-version-gate' +import { + describeEditorTooOldForRuntime, + describeIncompatibleRuntime, + describeVppRuntimeMismatch, + isStrucppCompatibleRuntime, +} from '../firmware/runtime-version-gate' import { buildKnownPous, emitCompileErrorEvents } from '../library/program-build-helpers' import { runProgramBuildPipeline } from '../library/program-build-pipeline' import type { DevicePin } from '../types/PLC/devices' @@ -112,6 +118,11 @@ export interface BoardHalsBuildEntry extends BoardHalsCompileEntry { /** Exact Arduino core version to install/verify before linking a prebuilt * arduino library (ABI-locked). From the VPP manifest `target.coreVersion`. */ coreVersion?: string + /** Vendor board-manager index (`package__index.json`). From the + * VPP manifest `target.boardManagerUrl` or hals.json `board_manager_url`. + * Forwarded to `installArduinoCore`, which passes it to arduino-cli as + * `--additional-urls` so cores outside the built-in index resolve. */ + boardManagerUrl?: string /** Compiler / runtime identifier (`'arduino-cli' | 'openplc-compiler' * | 'simulator'`). Used by `resolveTargetCapabilities`'s * preset lookup — without this the resolver can't pick the right @@ -211,6 +222,19 @@ export interface RunCompilePipelineArgs { * addresses without re-reading the file. Called once per * successful strucpp compile. */ cacheDebugData?: (md5: string, debugMapJson: string) => void + /** + * This editor's own version, compared against the `minEditorVersion` + * a runtime publishes at `GET /api/capabilities` (DOPE-448). + * + * Injected rather than imported: `APP_VERSION` lives in + * `frontend/data/`, and the layer rules forbid `backend/shared/` + * from reaching into `data` — correctly, since which build is + * running is a fact about the host app, not about the compile. + * + * Absent means the caller opts out of the check, so the gate is + * inert for callers written before it existed. + */ + editorVersion?: string /** Persisted VPP Modbus screen state for the target device, * sourced from `DeviceConfiguration.vendorScreenData` under * the `modbus_rtu` / `modbus_tcp` keys. Threaded straight @@ -353,6 +377,7 @@ async function runCompilePipelineInner( cacheDebugData, vppModbusState, vendorScreenData, + editorVersion, } = args // Resolve the board's effective capabilities from `boardEntry`. @@ -588,6 +613,51 @@ async function runCompilePipelineInner( return bailError(emit, 'runtime-version', describeIncompatibleRuntime(versionCheck.version)) } + // The other direction (DOPE-448): the runtime published a + // `minEditorVersion` at `/api/capabilities` and this editor is below + // it. Inert in two independent ways, both of which describe the + // world as it is today: `versionCheck.minEditorVersion` is null for + // every runtime predating that endpoint, and `editorVersion` is + // absent for any caller that hasn't opted in — `isVersionAtLeast` + // passes on an absent floor either way. + if (editorVersion && !isVersionAtLeast(editorVersion, versionCheck.minEditorVersion)) { + return bailError( + emit, + 'runtime-version', + describeEditorTooOldForRuntime({ + runtimeVersion: versionCheck.version, + // Narrowed by the guard above: `isVersionAtLeast` only returns + // false when it parsed a real floor out of this field. + minEditorVersion: versionCheck.minEditorVersion ?? '', + editorVersion, + // Only the editor's direct-HTTPS context knows an address the + // user would recognise; on web the device sits behind an + // orchestrator agent, so the message omits the label rather + // than printing an agent id nobody can act on. + deviceLabel: deviceContext.kind === 'editor-https' ? deviceContext.ip : undefined, + }), + ) + } + + // Arc 4 (DOPE-448): the VPP whose HAL is about to be built on this + // device declares a runtime floor, and this runtime is below it. + // Checked here rather than at install time because the target device + // is unknown until the user connects to one — and checked BEFORE the + // upload, because the failure mode it prevents is a plugin that + // loads on a live PLC and dies at scan time. + if (!isVersionAtLeast(versionCheck.version, vppResult.minRuntimeVersion)) { + return bailError( + emit, + 'runtime-version', + describeVppRuntimeMismatch({ + boardTarget, + minRuntimeVersion: vppResult.minRuntimeVersion ?? '', + runtimeVersion: versionCheck.version, + deviceLabel: deviceContext.kind === 'editor-https' ? deviceContext.ip : undefined, + }), + ) + } + emit({ stage: 'upload', message: 'Uploading Runtime v4 bundle...', level: 'info' }) const uploadResult = await port.uploadRuntimeV4({ bundle, context: deviceContext }, makePlatformLog(emit, 'upload')) if (!uploadResult.ok) { @@ -654,6 +724,12 @@ async function runCompilePipelineInner( coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '', // Pin the exact core version for prebuilt arduino libraries (ABI-locked). ...(boardEntry.coreVersion ? { coreVersion: boardEntry.coreVersion } : {}), + // Vendor board-manager index for cores outside arduino-cli's built-in + // list. Resolved from the VPP manifest's `target.boardManagerUrl`; the + // editor turns it into `--additional-urls` (and refreshes the index) + // so the core can be auto-installed instead of erroring out with + // "Platform not found". + ...(boardEntry.boardManagerUrl ? { boardManagerUrl: boardEntry.boardManagerUrl } : {}), }, makePlatformLog(emit, 'core-install'), ) diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index ecddca33e..adc10c136 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -18,7 +18,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' -import { generateModbusDefines, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, resolveDebugSlave, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -79,6 +79,10 @@ export interface GenerateDefinesInput { * fixed RTU-over-USART0 block. Web passes `undefined` until * VPP screens land on the web build. */ vppModbusState?: VppModbusScreenState + /** Name of the board's default serial port (from the VPP manifest device's + * `defaultSerial`; `BoardInfo.defaultSerial`). Drives `DEBUG_IFACE` and the + * RTU "shares the debug serial" flag. Absent → `Serial`. */ + defaultSerial?: string } /** @@ -100,7 +104,15 @@ export interface GenerateDefinesInput { * editor-produced and web-produced firmware comes out clean). */ export function generateDefinesContent(input: GenerateDefinesInput): string { - const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState } = input + const { + boardEntry, + devicePinMapping, + stProgramFileContent, + buildMD5Hash, + boardRuntime, + vppModbusState, + defaultSerial, + } = input let DEFINES_CONTENT = '' @@ -155,13 +167,41 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` } else if (boardRuntime !== 'openplc-compiler' && vppModbusState) { - const modbusBlock = generateModbusDefines(vppModbusState) + const modbusBlock = generateModbusDefines(vppModbusState, defaultSerial) if (modbusBlock.length > 0) { DEFINES_CONTENT += modbusBlock DEFINES_CONTENT += '\n\n' } } + // 4b. Debugger — always-on serial debugger for baremetal Arduino targets. + // The default serial port is ALWAYS initialised (DEBUG_IFACE @ DEBUG_BAUD) + // so the debug function codes (0x41-0x48) respond over serial regardless + // of whether Modbus is configured — without allocating operation buffers. + // When Modbus RTU runs on that same default port, generateModbusDefines + // emits MBSERIAL_SHARES_DEBUG_SERIAL so the firmware begins the port once. + // Simulator uses its fixed MODBUS_ENABLED block above; openplc-compiler + // runtimes don't use this firmware at all. + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { + DEFINES_CONTENT += '//Debugger\n' + DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' + DEFINES_CONTENT += `#define DEBUG_IFACE ${defaultSerial ?? 'Serial'}\n` + // Not `serial.baud_rate ?? 115200`: a package published without a `serial` + // section still configures a baud — on the RTU section — and when the RTU + // shares the default port that IS this port's speed. Ignoring it compiled a + // firmware listening at 115200 while the editor dialled the RTU's baud, and + // the board answered nothing ("No Firmware Detected" on a healthy board). + DEFINES_CONTENT += `#define DEBUG_BAUD ${resolveDebugBaud(vppModbusState ?? {}, defaultSerial)}\n` + // Same two-sided agreement as the baud, and the same symptom when it breaks: + // the firmware drops every frame whose slave id doesn't match, and that check + // is the only validation debug function codes get. The editor addresses the + // RTU screen's slave id whether or not the RTU is enabled, so emit it rather + // than leaving modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback to disagree + // with a project that configured anything else. + DEFINES_CONTENT += `#define DEBUG_SLAVE ${resolveDebugSlave(vppModbusState ?? {})}\n` + DEFINES_CONTENT += `\n\n` + } + // 5. IO Config — derived from devicePinMapping. Pin order is // the iteration order of the input array; callers are // expected to have sorted by address. diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 693eb0b96..7921c5022 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -28,9 +28,32 @@ * VPP screen field set evolves. */ export interface VppModbusScreenState { + /** Phase 2 Serial section — always-on serial baud (debugger + RTU on the + * default port). */ + serial?: { + baud_rate?: string + } + /** Phase 2 Network section — Ethernet/Wi-Fi config lifted out of modbus_tcp. */ + network?: { + enabled?: boolean + interface?: 'Ethernet' | 'Wi-Fi' + mac_address?: string + wifi_ssid?: string + wifi_password?: string + enable_dhcp?: boolean + ip_address?: string + gateway?: string + subnet?: string + dns?: string + } modbus_rtu?: { enabled?: boolean + /** Phase 2: chosen serial port. Legacy projects use `rtu_interface`. */ + serial_port?: string rtu_interface?: string + /** Phase 2: baud for RTU on a secondary port. On the default port the + * Serial section's baud is used. Legacy projects use `rtu_baud_rate`. */ + baud_rate?: string rtu_baud_rate?: string rtu_slave_id?: number enable_rs485_en_pin?: boolean @@ -38,6 +61,9 @@ export interface VppModbusScreenState { } modbus_tcp?: { enabled?: boolean + unit_id?: number + // Legacy network fields (pre-Phase-2 projects still on the old screen). + // Read as a fallback when the `network` section is absent. tcp_interface?: 'Ethernet' | 'Wi-Fi' tcp_mac_address?: string tcp_wifi_ssid?: string @@ -50,6 +76,79 @@ export interface VppModbusScreenState { } } +/** Baud the always-on debugger falls back to when nothing else says otherwise. */ +export const DEFAULT_DEBUG_BAUD = '115200' + +/** + * Baud rate the DEFAULT serial port comes up at — the one the always-on debugger + * answers on, and therefore the one the editor must dial to reach it. + * + * The two sides derive this independently (the firmware from here, the editor + * from the board's `debug` spec), so they have to agree or the port opens and + * decodes nothing. What the editor dials is + * `screens.modbus_rtu.rtu_baud_rate` — ALWAYS, whether or not the RTU is + * enabled, because a spec's `params` are read independently of its + * `enabledWhen`. This function mirrors that: + * + * 1. A `serial` section, when a package declares one — it exists precisely to + * configure this port, and a package that has it also points its debug spec + * at it. + * 2. Otherwise the RTU's baud, which for a package published today is the only + * serial speed the project states at all. This holds even when the RTU is + * DISABLED: the rate is then unused by Modbus, but the editor still dials it, + * so the firmware had better listen there. + * 3. `115200` only when the RTU is enabled on a SECOND UART — the one case where + * that rate genuinely belongs to a different port and the debugger keeps the + * default one to itself. Nothing states that port's speed, so this is a + * guess, and it is exactly the case the connect flow's baud sweep exists for. + */ +export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { + const declared = state.serial?.baud_rate + if (declared) return declared + + const rtu = state.modbus_rtu + if (!rtu) return DEFAULT_DEBUG_BAUD + + // An enabled RTU on its own UART takes its baud with it; the debugger is then + // on a port whose speed the project never mentions. + if (rtu.enabled === true) { + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + if (iface !== defaultSerial) return DEFAULT_DEBUG_BAUD + } + + return rtu.baud_rate ?? rtu.rtu_baud_rate ?? DEFAULT_DEBUG_BAUD +} + +/** Slave id the always-on debugger frames on when the project states none. */ +export const DEFAULT_DEBUG_SLAVE = 1 + +/** + * Modbus slave id the always-on debugger answers on — and therefore the id the + * editor must address to reach it. + * + * The same two-sided agreement `resolveDebugBaud` describes, and the same failure + * when it breaks: `handle_serial_port` drops any frame whose first byte is not + * this id, and that check is the ONLY validation applied to debug function codes + * (CRC is skipped on them). A mismatch is therefore total silence on a healthy + * board — reported as "No Firmware Detected". + * + * What the editor addresses is `screens.modbus_rtu.rtu_slave_id`, ALWAYS: a + * spec's `params` are read independently of its `enabledWhen`, so an RTU screen + * left at slave id 7 with the RTU toggle OFF still sends id 7 down the cable. + * So this returns that id unconditionally — including when the RTU runs on a + * SECOND UART, where it is not a conflict but the same number on two distinct + * ports. + * + * Deliberately NOT the `resolveDebugBaud` shape of "guess 115200 for a secondary + * port": a wrong baud is recoverable, because Connect sweeps the plausible rates. + * There is no sweep for slave ids, so this has to match exactly rather than + * approximately. + */ +export function resolveDebugSlave(state: VppModbusScreenState): number { + const slave = state.modbus_rtu?.rtu_slave_id + return typeof slave === 'number' ? slave : DEFAULT_DEBUG_SLAVE +} + /** * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical @@ -88,7 +187,6 @@ function formatIpForDefine(raw: string): string { // to compile (ModbusSlave.cpp uses them as object/literal values). // Keep these in sync if the screen schema's defaults change. const RTU_DEFAULTS = { - rtu_interface: 'Serial', rtu_baud_rate: '115200', rtu_slave_id: 1, } as const @@ -110,9 +208,10 @@ const TCP_DEFAULTS = { * The output always ends with a trailing newline so callers can * concatenate without adding their own. */ -export function generateModbusDefines(state: VppModbusScreenState): string { +export function generateModbusDefines(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { const rtu = state.modbus_rtu ?? {} const tcp = state.modbus_tcp ?? {} + const net = state.network ?? {} const rtuOn = rtu.enabled === true const tcpOn = tcp.enabled === true @@ -122,12 +221,28 @@ export function generateModbusDefines(state: VppModbusScreenState): string { lines.push('//Comms Configuration') if (rtuOn) { - const iface = rtu.rtu_interface ?? RTU_DEFAULTS.rtu_interface - const baud = rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate + // Phase 2: RTU picks a serial port (`serial_port`); legacy projects carry + // `rtu_interface`. On the default port the RTU shares the always-on Serial + // baud; on a secondary port it uses its own (`baud_rate`), with the legacy + // `rtu_baud_rate` as a fallback for pre-migration projects. + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + const onDefaultPort = iface === defaultSerial + const baud = onDefaultPort + ? (state.serial?.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) + : (rtu.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) const slave = typeof rtu.rtu_slave_id === 'number' ? rtu.rtu_slave_id : RTU_DEFAULTS.rtu_slave_id lines.push(`#define MBSERIAL_IFACE ${iface}`) lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) + // On the default port the RTU IS the debugger's serial → tell the firmware + // to begin the port once (the always-on debugger already begins it). On a + // secondary port the RTU runs on a DISTINCT UART while the debugger keeps + // the default serial, so the firmware services two serial ports. + if (onDefaultPort) { + lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') + } else { + lines.push('#define MBSERIAL_ON_SECONDARY') + } if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) } @@ -135,34 +250,34 @@ export function generateModbusDefines(state: VppModbusScreenState): string { } if (tcpOn) { - // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET - // are referenced unconditionally inside the `#ifdef MBTCP` block in - // `resources/sources/Baremetal/Baremetal.ino` (it builds five byte - // arrays and uses `sizeof(arr) < 4` as a compile-time DHCP-vs-static - // selector that cascades through to `mbconfig_ethernet_iface(mac, - // …, NULL, NULL, …)`). Missing a single macro fails compilation; an - // unset macro is signalled by emitting a single-byte `0` so the - // array has `sizeof == 1`, the `< 4` check fires, and the runtime - // falls back to the DHCP/NULL path. Wi-Fi mode ignores these args - // inside `mbconfig_ethernet_iface` (see `ModbusSlave.cpp:199-225`), - // so the placeholder values are harmless there too. - const macLiteral = tcp.tcp_mac_address ? formatMacForDefine(tcp.tcp_mac_address) : '0' - lines.push(`#define MBTCP_MAC ${macLiteral}`) - - const dhcpOn = tcp.enable_dhcp === true - const ipLiteral = !dhcpOn && tcp.ip_address ? formatIpForDefine(tcp.ip_address) : '0' - const dnsLiteral = !dhcpOn && tcp.dns ? formatIpForDefine(tcp.dns) : '0' - const gatewayLiteral = !dhcpOn && tcp.gateway ? formatIpForDefine(tcp.gateway) : '0' - const subnetLiteral = !dhcpOn && tcp.subnet ? formatIpForDefine(tcp.subnet) : '0' - lines.push(`#define MBTCP_IP ${ipLiteral}`) - lines.push(`#define MBTCP_DNS ${dnsLiteral}`) - lines.push(`#define MBTCP_GATEWAY ${gatewayLiteral}`) - lines.push(`#define MBTCP_SUBNET ${subnetLiteral}`) - - const iface = tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface - if (iface === 'Wi-Fi') { - if (tcp.tcp_wifi_ssid) lines.push(`#define MBTCP_SSID "${tcp.tcp_wifi_ssid}"`) - if (tcp.tcp_wifi_password) lines.push(`#define MBTCP_PWD "${tcp.tcp_wifi_password}"`) + // Network config comes from the Phase 2 `network` section, falling back to + // the legacy `modbus_tcp` fields for pre-migration projects. + // + // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET are + // referenced unconditionally inside the `#ifdef MBTCP` block in + // `Baremetal.ino` (five byte arrays, `sizeof(arr) < 4` as a compile-time + // DHCP-vs-static selector). A missing macro fails compilation; an unset + // value is signalled by a single-byte `0` so the `< 4` check fires and the + // runtime falls back to the DHCP/NULL path. + const mac = net.mac_address ?? tcp.tcp_mac_address + const ifaceSel = net.interface ?? tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface + const dhcpOn = (net.enable_dhcp ?? tcp.enable_dhcp) === true + const ip = net.ip_address ?? tcp.ip_address + const dns = net.dns ?? tcp.dns + const gateway = net.gateway ?? tcp.gateway + const subnet = net.subnet ?? tcp.subnet + const ssid = net.wifi_ssid ?? tcp.tcp_wifi_ssid + const pwd = net.wifi_password ?? tcp.tcp_wifi_password + + lines.push(`#define MBTCP_MAC ${mac ? formatMacForDefine(mac) : '0'}`) + lines.push(`#define MBTCP_IP ${!dhcpOn && ip ? formatIpForDefine(ip) : '0'}`) + lines.push(`#define MBTCP_DNS ${!dhcpOn && dns ? formatIpForDefine(dns) : '0'}`) + lines.push(`#define MBTCP_GATEWAY ${!dhcpOn && gateway ? formatIpForDefine(gateway) : '0'}`) + lines.push(`#define MBTCP_SUBNET ${!dhcpOn && subnet ? formatIpForDefine(subnet) : '0'}`) + + if (ifaceSel === 'Wi-Fi') { + if (ssid) lines.push(`#define MBTCP_SSID "${ssid}"`) + if (pwd) lines.push(`#define MBTCP_PWD "${pwd}"`) lines.push('#define MBTCP_WIFI') } else { lines.push('#define MBTCP_ETHERNET') diff --git a/src/backend/shared/compile/steps/resolve-board-selection.ts b/src/backend/shared/compile/steps/resolve-board-selection.ts index 492055486..11dc29c62 100644 --- a/src/backend/shared/compile/steps/resolve-board-selection.ts +++ b/src/backend/shared/compile/steps/resolve-board-selection.ts @@ -68,6 +68,11 @@ export function resolveBoardSelection(resolver: BoardInfoResolver, boardTarget: // come from the VPP manifest via BoardBuildInfo; absent for source boards. ...(boardInfo.precompiledLibraryDir ? { precompiledLibraryDir: boardInfo.precompiledLibraryDir } : {}), ...(boardInfo.coreVersion ? { coreVersion: boardInfo.coreVersion } : {}), + // Vendor board-manager index, so a core outside arduino-cli's built-in + // list can be auto-installed. The resolver fills this from the VPP + // manifest's `target.boardManagerUrl` (or hals.json `board_manager_url`); + // dropping it here is what made every VPP-declared index dead data. + ...(boardInfo.boardManagerUrl ? { boardManagerUrl: boardInfo.boardManagerUrl } : {}), // Capability resolution inputs. `resolveTargetCapabilities` // reads `compiler` + `vpp` + `capabilities` on whatever board // shape it's handed — without forwarding all three the diff --git a/src/backend/shared/debug/__tests__/fixtures/license-golden.json b/src/backend/shared/debug/__tests__/fixtures/license-golden.json new file mode 100644 index 000000000..911b02a22 --- /dev/null +++ b/src/backend/shared/debug/__tests__/fixtures/license-golden.json @@ -0,0 +1,18 @@ +{ + "_comment": "Golden cross-language fixture for lic_blob_t (OLS-14). Shared by the TS test (license-blob.test.ts) and the C host-test (T04). 'input' is the structured license; 'expectedBytesHex' is the deterministic 98-byte LE serialization produced by serializeLicenseBlob; 'expectedCrc32' is CRC-32/ISO-HDLC over [payload||signature] (offsets 0..93). Any byte here MUST match both the TS serializer and the C memcpy of the packed struct. Do not hand-edit expectedBytesHex.", + "input": { + "magic": 1129074767, + "fmtVersion": 1, + "keyId": 0, + "deviceId": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "productId": [160, 161, 162, 163, 164, 165, 166, 167], + "signature": [ + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17 + ], + "crc32": 0 + }, + "expectedBytesHex": "4f504c430100000102030405060708090a0b0c0d0e0fa0a1a2a3a4a5a6a711111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b6311445", + "expectedCrc32": 1158951350 +} diff --git a/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts new file mode 100644 index 000000000..f03a51975 --- /dev/null +++ b/src/backend/shared/debug/__tests__/license-blob-c-parity.test.ts @@ -0,0 +1,123 @@ +/** + * Cross-language parity between the TypeScript license-blob codec and the C + * struct the firmware compiles. + * + * WHY THIS EXISTS. The blob crosses a language boundary: the editor serializes it + * in TypeScript, the board parses it as `lic_blob_t`. A layout disagreement does + * not fail loudly — it produces a blob the board stores happily and the + * license-core then rejects, which surfaces as "the device says it is licensed and + * still runs demo". That is the single most expensive way for these two files to + * drift, so it must fail a test instead. + * + * WHAT THIS CAN AND CANNOT CATCH. It reads `license_blob.h` as text and asserts + * the two sides agree on the sizes, the offsets, the magic and the CRC parameters. + * It cannot catch a compiler that inserts padding — but nothing needs to: the + * header carries both `#pragma pack` and `__attribute__((packed))`, plus + * `LIC_STATIC_ASSERT` on both struct sizes, so padding fails the FIRMWARE BUILD + * on the device toolchain rather than shipping. + * + * A real C host-test compiling the header and comparing bytes against + * `license-golden.json` would be stronger and is the right follow-up; the repo has + * no C test harness to hang one on today. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { crc32IsoHdlc, LIC_BLOB_SIZE, LIC_MAGIC_LE, LIC_PAYLOAD_SIZE } from '../license-blob' + +/** + * This test file is byte-identical across openplc-editor and openplc-web, but the + * firmware sources it reads are NOT in the same place: the sync gate maps the + * editor's `resources/sources/Baremetal` onto the web's `src/assets/firmware/ + * Baremetal` (MAPPED_SURFACES in compare-surfaces.py) rather than mirroring the + * path. So resolve whichever one this checkout has. + * + * Deliberately throws when neither exists instead of skipping. A parity test that + * quietly disappears when it cannot find the header is worse than no test: the + * suite goes green while the layout it is supposed to pin drifts unwatched. + */ +const HEADER_CANDIDATES = [ + join(__dirname, '..', '..', '..', '..', '..', 'resources', 'sources', 'Baremetal', 'license_blob.h'), + join(__dirname, '..', '..', '..', '..', 'assets', 'firmware', 'Baremetal', 'license_blob.h'), +] + +const HEADER_PATH = HEADER_CANDIDATES.find((candidate) => existsSync(candidate)) + +if (HEADER_PATH === undefined) { + throw new Error(`license_blob.h not found. Looked in:\n${HEADER_CANDIDATES.join('\n')}`) +} + +const header = readFileSync(HEADER_PATH, 'utf-8') + +/** Read a `#define NAME 0x...u` / decimal value out of the C header. */ +function cDefine(name: string): number { + const match = new RegExp(`#define\\s+${name}\\s+(0x[0-9A-Fa-f]+|\\d+)u?`).exec(header) + if (!match) throw new Error(`${name} not found in license_blob.h`) + return Number(match[1]) +} + +describe('license_blob.h ↔ license-blob.ts parity', () => { + it('agrees on the blob and payload sizes', () => { + expect(cDefine('LIC_BLOB_SIZE')).toBe(LIC_BLOB_SIZE) + expect(cDefine('LIC_PAYLOAD_SIZE')).toBe(LIC_PAYLOAD_SIZE) + }) + + it('agrees on the little-endian magic', () => { + expect(cDefine('LIC_MAGIC_LE')).toBe(LIC_MAGIC_LE) + }) + + it('declares the struct fields in the order and widths the TS serializer writes', () => { + // The offsets the TS side writes at, as the header's own layout table states + // them. Parsed from the table rather than from the struct so a change to + // either the table or the struct that leaves them disagreeing shows up here — + // the table is what a firmware author reads. + const expected: Array<[string, number, number]> = [ + ['magic', 0, 4], + ['fmt_version', 4, 1], + ['key_id', 5, 1], + ['device_id', 6, 16], + ['product_id', 22, 8], + ['signature', 30, 64], + ['crc32', 94, 4], + ] + + for (const [field, offset, size] of expected) { + const row = new RegExp(`^//\\s*${offset}\\s+${field}\\s+\\S+.*?\\s${size}\\s`, 'm') + expect(header).toMatch(row) + } + }) + + it('carries the static assertions that make padding a build failure, not a runtime surprise', () => { + // These are what stop a toolchain that ignores one of the two packing + // directives from silently producing a 100-byte struct. + expect(header).toMatch(/LIC_STATIC_ASSERT\(sizeof\(lic_payload_t\)\s*==\s*30/) + expect(header).toMatch(/LIC_STATIC_ASSERT\(sizeof\(lic_blob_t\)\s*==\s*98/) + expect(header).toMatch(/#pragma pack\(push, 1\)/) + // Anchored to the DECLARATION, not just a mention: the header also discusses + // the attribute in prose, so a looser match passed even after the struct lost + // it. AVR-GCC and xtensa-GCC honour the two directives differently, which is + // why both are required rather than either. + expect(header).toMatch(/typedef struct __attribute__\(\(packed\)\) \{/) + }) + + it('uses the same CRC-32/ISO-HDLC parameters on both sides', () => { + // The reflected polynomial and the init/xorout constants. A different + // polynomial produces a blob whose crc32 the board rejects as CORRUPT — the + // failure mode that looks like flaky hardware. + expect(header).toMatch(/0xEDB88320u/) + expect(header).toMatch(/crc\s*=\s*0xFFFFFFFFu/) + expect(header).toMatch(/crc\s*\^\s*0xFFFFFFFFu/) + + // And the header documents the canonical test vector the TS side satisfies, + // so both implementations are pinned to the same published value. + expect(header).toContain('0xCBF43926') + expect(crc32IsoHdlc(Uint8Array.from([...'123456789'].map((c) => c.charCodeAt(0))))).toBe(0xcbf43926) + }) + + it('states the endianness duality that the two layers must not confuse', () => { + // The blob content is little-endian; the Modbus `len` framing it is + // big-endian. Every place this was got wrong cost a debugging session. + expect(header).toMatch(/blob CONTENT is LITTLE-ENDIAN/) + }) +}) diff --git a/src/backend/shared/debug/__tests__/license-blob.test.ts b/src/backend/shared/debug/__tests__/license-blob.test.ts new file mode 100644 index 000000000..617750774 --- /dev/null +++ b/src/backend/shared/debug/__tests__/license-blob.test.ts @@ -0,0 +1,140 @@ +// jsdom (editor's Jest environment) doesn't ship TextEncoder by default — +// polyfill from Node's util before the CRC test vector uses it. +import { TextEncoder as NodeTextEncoder } from 'node:util' + +if (typeof globalThis.TextEncoder === 'undefined') { + ;(globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = NodeTextEncoder as unknown as typeof TextEncoder +} + +import { + crc32IsoHdlc, + deserializeLicenseBlob, + LIC_BLOB_SIZE, + LIC_MAGIC_LE, + LIC_PAYLOAD_SIZE, + type LicenseBlob, + serializeLicenseBlob, +} from '../license-blob' +import golden from './fixtures/license-golden.json' + +const TextEnc = globalThis.TextEncoder + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +/** Build the LicenseBlob input from the shared golden fixture. */ +function goldenInput(): LicenseBlob { + return { + magic: golden.input.magic, + fmtVersion: golden.input.fmtVersion, + keyId: golden.input.keyId, + deviceId: Uint8Array.from(golden.input.deviceId), + productId: Uint8Array.from(golden.input.productId), + signature: Uint8Array.from(golden.input.signature), + crc32: golden.input.crc32, + } +} + +describe('crc32IsoHdlc', () => { + it('matches the canonical CRC-32/ISO-HDLC test vector for "123456789"', () => { + const crc = crc32IsoHdlc(new TextEnc().encode('123456789')) + expect(crc).toBe(0xcbf43926) + }) + + it('returns an unsigned 32-bit value', () => { + const crc = crc32IsoHdlc(new TextEnc().encode('123456789')) + expect(crc).toBeGreaterThanOrEqual(0) + expect(crc).toBeLessThanOrEqual(0xffffffff) + }) + + it('produces 0 for an empty input (init ^ xorout)', () => { + // CRC of no data: 0xFFFFFFFF ^ 0xFFFFFFFF === 0. + expect(crc32IsoHdlc(new Uint8Array())).toBe(0) + }) +}) + +describe('constants', () => { + it('mirror the C struct sizes and magic', () => { + expect(LIC_BLOB_SIZE).toBe(98) + expect(LIC_PAYLOAD_SIZE).toBe(30) + expect(LIC_MAGIC_LE).toBe(0x434c504f) + }) +}) + +describe('serializeLicenseBlob', () => { + it('produces exactly the golden fixture bytes', () => { + const bytes = serializeLicenseBlob(goldenInput()) + expect(bytes).toHaveLength(LIC_BLOB_SIZE) + expect(bytesToHex(bytes)).toBe(golden.expectedBytesHex) + }) + + it('writes the magic as the four bytes 4F 50 4C 43', () => { + const bytes = serializeLicenseBlob(goldenInput()) + expect(Array.from(bytes.subarray(0, 4))).toEqual([0x4f, 0x50, 0x4c, 0x43]) + }) + + it('recomputes crc32 over [payload||signature] and stores it LE at offset 94', () => { + const bytes = serializeLicenseBlob(goldenInput()) + const view = new DataView(bytes.buffer) + const storedCrc = view.getUint32(94, true) + expect(storedCrc).toBe(golden.expectedCrc32) + // Independent recomputation over offsets 0..93 must agree. + expect(crc32IsoHdlc(bytes.subarray(0, 94))).toBe(golden.expectedCrc32) + }) + + it('ignores the crc32 field on the input (always recomputes)', () => { + const tampered = goldenInput() + tampered.crc32 = 0xdeadbeef + const bytes = serializeLicenseBlob(tampered) + expect(new DataView(bytes.buffer).getUint32(94, true)).toBe(golden.expectedCrc32) + }) +}) + +describe('deserializeLicenseBlob', () => { + it('reproduces the golden input from the golden bytes', () => { + const parsed = deserializeLicenseBlob(hexToBytes(golden.expectedBytesHex)) + expect(parsed.magic).toBe(LIC_MAGIC_LE) + expect(parsed.fmtVersion).toBe(golden.input.fmtVersion) + expect(parsed.keyId).toBe(golden.input.keyId) + expect(Array.from(parsed.deviceId)).toEqual(golden.input.deviceId) + expect(Array.from(parsed.productId)).toEqual(golden.input.productId) + expect(Array.from(parsed.signature)).toEqual(golden.input.signature) + expect(parsed.crc32).toBe(golden.expectedCrc32) + }) + + it('throws on a truncated buffer', () => { + expect(() => deserializeLicenseBlob(new Uint8Array(LIC_BLOB_SIZE - 1))).toThrow(/too short/) + }) +}) + +describe('round-trip serialize -> deserialize', () => { + it('is identical for all fields (magic and crc32 canonicalized)', () => { + const input = goldenInput() + const parsed = deserializeLicenseBlob(serializeLicenseBlob(input)) + + // magic is forced to canonical LIC_MAGIC_LE on serialize; input already uses it. + expect(parsed.magic).toBe(input.magic) + expect(parsed.fmtVersion).toBe(input.fmtVersion) + expect(parsed.keyId).toBe(input.keyId) + expect(Array.from(parsed.deviceId)).toEqual(Array.from(input.deviceId)) + expect(Array.from(parsed.productId)).toEqual(Array.from(input.productId)) + expect(Array.from(parsed.signature)).toEqual(Array.from(input.signature)) + // crc32 is recomputed on serialize; the round-tripped value is the real crc. + expect(parsed.crc32).toBe(golden.expectedCrc32) + }) + + it('re-serializes the deserialized blob to the same bytes (byte-stable)', () => { + const bytes = hexToBytes(golden.expectedBytesHex) + const reserialized = serializeLicenseBlob(deserializeLicenseBlob(bytes)) + expect(bytesToHex(reserialized)).toBe(golden.expectedBytesHex) + }) +}) diff --git a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts new file mode 100644 index 000000000..79772b12d --- /dev/null +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -0,0 +1,150 @@ +/** + * Run/stop wire-protocol codec tests (FC 0x4b command, FC 0x46 read). + * + * These bytes are the contract between the editor and the baremetal runtime's + * `plcSetState()` / `debugGetStatus()` handlers in modbus_debug.cpp, so the + * layouts are asserted + * byte-for-byte rather than round-tripped through the builder. + */ + +import { buildPlcSetStateRequest, parseGetStatusResponse, parsePlcSetStateResponse } from '../modbus-pdu' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../../simulator/types' + +describe('run/stop command builder (FC 0x4b)', () => { + it('builds RUN as [FC][0x01]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.RUNNING))).toEqual([0x4b, 0x01]) + }) + + it('builds STOP as [FC][0x00]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.STOPPED))).toEqual([0x4b, 0x00]) + }) + + it('claims 0x4b, clear of every other debug function code', () => { + expect(ModbusFunctionCode.PLC_SET_STATE).toBe(0x4b) + const others = Object.values(ModbusFunctionCode).filter( + (value): value is number => typeof value === 'number' && value !== ModbusFunctionCode.PLC_SET_STATE, + ) + expect(others).not.toContain(ModbusFunctionCode.PLC_SET_STATE) + }) + + it('claims 0x86 for REFUSED_BY_SWITCH, clear of every other status code', () => { + expect(ModbusDebugResponse.REFUSED_BY_SWITCH).toBe(0x86) + const others = Object.values(ModbusDebugResponse).filter( + (value): value is number => typeof value === 'number' && value !== ModbusDebugResponse.REFUSED_BY_SWITCH, + ) + expect(others).not.toContain(ModbusDebugResponse.REFUSED_BY_SWITCH) + }) +}) + +describe('parsePlcSetStateResponse', () => { + const frame = (status: number, state: number, position: number) => + new Uint8Array([ModbusFunctionCode.PLC_SET_STATE, status, state, position]) + + it('parses a running device with the switch in RUN', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ) + expect(result).toEqual({ + success: true, + state: PlcRuntimeState.RUNNING, + switchPosition: PlcSwitchPosition.RUN, + }) + }) + + it('parses a stopped device with the switch in STOP', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + expect(result.success).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(result.refusedBySwitch).toBeUndefined() + }) + + it('flags a RUN refused by the hardware switch', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + // Not a success, and specifically identified so the editor shows the + // "flip the switch to RUN" warning rather than a generic failure. + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('reports ERROR state', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.ERROR, PlcSwitchPosition.RUN), + ) + expect(result.state).toBe(PlcRuntimeState.ERROR) + }) + + it('describes any other failure status rather than failing silently', () => { + // Neither SUCCESS nor REFUSED_BY_SWITCH — e.g. the runtime ran out of memory + // servicing the request. Without the error text the editor would report + // "Failed to start PLC: Unknown error" and give the user nothing to act on. + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.ERROR_OUT_OF_MEMORY, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ) + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBeUndefined() + expect(result.unsupported).toBeUndefined() + expect(result.error).toBeTruthy() + }) + + it('detects old firmware via the Modbus exception form', () => { + // A runtime built before the state machine answers (FC | 0x80). The editor turns this + // into "rebuild and upload", never an error, so field devices don't look + // broken after an editor upgrade. + const result = parsePlcSetStateResponse(new Uint8Array([0x4b + 0x80, 0x01])) + expect(result.unsupported).toBe(true) + expect(result.success).toBe(false) + }) + + it('rejects a mismatched function code', () => { + const result = parsePlcSetStateResponse(new Uint8Array([0x44, 0x7e, 0x01, 0x01])) + expect(result.success).toBe(false) + expect(result.unsupported).toBeUndefined() + expect(result.error).toMatch(/mismatch/i) + }) + + it('rejects a truncated response', () => { + expect(parsePlcSetStateResponse(new Uint8Array([])).success).toBe(false) + expect(parsePlcSetStateResponse(new Uint8Array([0x4b, 0x7e])).success).toBe(false) + }) +}) + +describe('status read carries run/stop state (FC 0x46)', () => { + /** [FC][status][running][tick:u32][uptime:u32][switch] */ + const statusFrame = (running: number, sw?: number) => { + const bytes = [ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, running, 0, 0, 0, 7, 0, 0, 0, 9] + if (sw !== undefined) bytes.push(sw) + return new Uint8Array(bytes) + } + + it('reports RUNNING plus the switch position', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN)) + expect(r.success).toBe(true) + expect(r.running).toBe(true) + expect(r.plcState).toBe(PlcRuntimeState.RUNNING) + expect(r.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(r.tick).toBe(7) + expect(r.uptimeMs).toBe(9) + }) + + it('reports STOPPED with the switch in STOP', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP)) + expect(r.running).toBe(false) + expect(r.plcState).toBe(PlcRuntimeState.STOPPED) + expect(r.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('omits switchPosition on firmware that predates the state machine', () => { + // 11-byte frame: the field simply is not there, which callers read as + // "no switch gating" rather than a guessed RUN. + const r = parseGetStatusResponse(statusFrame(1)) + expect(r.success).toBe(true) + expect(r.switchPosition).toBeUndefined() + }) +}) diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 1bb6c2864..b1fa8fd5a 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -12,12 +12,22 @@ if (typeof globalThis.TextDecoder === 'undefined') { import { ModbusDebugResponse, ModbusFunctionCode } from '../../simulator/types' import { + buildGetBoardIdRequest, buildGetListRequest, buildGetMd5Request, + buildGetStatusRequest, + buildGetVersionRequest, + buildReadLicenseRequest, buildSetVariableRequest, + buildWriteLicenseRequest, + parseGetBoardIdResponse, parseGetListResponse, parseGetMd5Response, + parseGetStatusResponse, + parseGetVersionResponse, + parseReadLicenseResponse, parseSetVariableResponse, + parseWriteLicenseResponse, responseFunctionCode, } from '../modbus-pdu' @@ -190,6 +200,195 @@ describe('buildSetVariableRequest / parseSetVariableResponse', () => { }) }) +describe('buildGetStatusRequest / parseGetStatusResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetStatusRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_STATUS) + }) + + it('parses running / tick / uptime on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x01, // running = true + 0x00, + 0x00, + 0x00, + 0x2a, // tick = 42 + 0x00, + 0x00, + 0x01, + 0x00, // uptime = 256 + ]) + const result = parseGetStatusResponse(buf) + // `plcState` is the same byte as `running`, surfaced as the tri-state the + // run/stop state machine actually has. An 11-byte frame (this one) carries + // no switch position, so the field is absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]) + expect(parseGetStatusResponse(buf).running).toBe(false) + }) + + it('flags too-short buffer', () => { + const result = parseGetStatusResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetStatusResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags an incomplete success payload', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, 0x01]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete/) + }) +}) + +describe('buildGetVersionRequest / parseGetVersionResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetVersionRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_VERSION) + }) + + it('parses the ASCII version string on success', () => { + const ver = new TextEnc().encode('4.2.7') + const buf = new Uint8Array(2 + ver.length) + buf[0] = ModbusFunctionCode.DEBUG_GET_VERSION + buf[1] = ModbusDebugResponse.SUCCESS + buf.set(ver, 2) + expect(parseGetVersionResponse(buf)).toEqual({ success: true, version: '4.2.7' }) + }) + + it('strips a trailing NUL terminator', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_VERSION, + ModbusDebugResponse.SUCCESS, + 0x31, + 0x2e, + 0x30, + 0x00, + ]) + expect(parseGetVersionResponse(buf).version).toBe('1.0') + }) + + it('flags too-short buffer', () => { + const result = parseGetVersionResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetVersionResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetVersionResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + }) +}) + +describe('buildGetBoardIdRequest / parseGetBoardIdResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetBoardIdRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_BOARD_ID) + }) + + it('parses id bytes and hex on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + ModbusDebugResponse.SUCCESS, + 0x03, // id_len = 3 + 0x0a, + 0xbc, + 0x01, + ]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x00]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('flags too-short buffer', () => { + const result = parseGetBoardIdResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetBoardIdResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags a missing id_len byte', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/at least 3/) + }) + + it('flags truncated id bytes', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete board-id data/) + }) +}) + describe('responseFunctionCode', () => { it('returns the first byte', () => { expect(responseFunctionCode(new Uint8Array([0x45, 0x00]))).toBe(0x45) @@ -199,3 +398,175 @@ describe('responseFunctionCode', () => { expect(responseFunctionCode(new Uint8Array(0))).toBeUndefined() }) }) +describe('buildWriteLicenseRequest', () => { + it('emits [FC][len:U16BE][blob] with big-endian length', () => { + // 260-byte blob proves the length is BE (0x01 0x04), not LE. + const blob = new Uint8Array(260).fill(0xab) + blob[0] = 0x4f // 'O' — magic first byte, sanity marker + const buf = buildWriteLicenseRequest(blob) + expect(buf).toHaveLength(3 + 260) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_WRITE_LICENSE) + expect(buf[1]).toBe(0x01) // len hi + expect(buf[2]).toBe(0x04) // len lo (260 = 0x0104) + expect(buf[3]).toBe(0x4f) + expect(buf[buf.length - 1]).toBe(0xab) + }) + + it('handles a zero-length blob', () => { + const buf = buildWriteLicenseRequest(new Uint8Array(0)) + expect(buf).toHaveLength(3) + expect(buf[1]).toBe(0) + expect(buf[2]).toBe(0) + }) +}) + +describe('parseWriteLicenseResponse', () => { + it('returns success on SUCCESS status', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.SUCCESS]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.status).toBe(ModbusDebugResponse.SUCCESS) + }) + + it('surfaces an out-of-bounds (TOO_LARGE, 0x81) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_BOUNDS) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('surfaces an out-of-memory (0x82) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_MEMORY) + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + }) + + it('treats LIC_EMPTY / LIC_CORRUPT statuses on a WRITE response as failures (not valid write states)', () => { + // EMPTY/CORRUPT are read-side device states; a WRITE that echoes them is + // not SUCCESS, so the write must be reported as failed. + const empty = parseWriteLicenseResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_EMPTY]), + ) + expect(empty.success).toBe(false) + expect(empty.status).toBe(ModbusDebugResponse.LIC_EMPTY) + + const corrupt = parseWriteLicenseResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_CORRUPT]), + ) + expect(corrupt.success).toBe(false) + expect(corrupt.status).toBe(ModbusDebugResponse.LIC_CORRUPT) + }) + + it('classifies LIC_UNSUPPORTED (0x85) as success + unsupported (no backend)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.LIC_UNSUPPORTED]) + const result = parseWriteLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.status).toBe(ModbusDebugResponse.LIC_UNSUPPORTED) + expect(result.unsupported).toBe(true) + }) + + it('rejects a function-code mismatch', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS]) + expect(parseWriteLicenseResponse(buf).success).toBe(false) + }) + + it('rejects a too-short frame', () => { + expect(parseWriteLicenseResponse(new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE])).success).toBe(false) + }) +}) + +describe('buildReadLicenseRequest', () => { + it('emits a bare [FC] frame', () => { + const buf = buildReadLicenseRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_READ_LICENSE) + }) +}) + +describe('parseReadLicenseResponse', () => { + it('extracts the blob on SUCCESS using a BE length', () => { + const blob = new Uint8Array([0x4f, 0x50, 0x4c, 0x43, 0xde, 0xad]) + const buf = new Uint8Array(4 + blob.length) + buf[0] = ModbusFunctionCode.DEBUG_READ_LICENSE + buf[1] = ModbusDebugResponse.SUCCESS + buf[2] = 0x00 // len hi + buf[3] = blob.length // len lo + buf.set(blob, 4) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.empty).toBeUndefined() + expect(result.corrupt).toBeUndefined() + expect(result.blob).toBeDefined() + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + // magic first byte survives the round-trip (endianness sanity) + expect(result.blob?.[0]).toBe(0x4f) + }) + + it('classifies LIC_EMPTY as success + empty (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_EMPTY]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_CORRUPT as success + corrupt (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_CORRUPT]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.corrupt).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_UNSUPPORTED as success + unsupported (no blob)', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.LIC_UNSUPPORTED]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(true) + expect(result.unsupported).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('surfaces an out-of-bounds (TOO_LARGE, 0x81) status as failure with no blob', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_BOUNDS) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + expect(result.blob).toBeUndefined() + }) + + it('surfaces an out-of-memory (0x82) status as failure', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.status).toBe(ModbusDebugResponse.ERROR_OUT_OF_MEMORY) + }) + + it('rejects a function-code mismatch', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_WRITE_LICENSE, ModbusDebugResponse.SUCCESS]) + expect(parseReadLicenseResponse(buf).success).toBe(false) + }) + + it('flags a truncated blob', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS, 0x00, 0x08, 0x01]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete license blob/) + }) + + it('flags a success frame missing the length field', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE, ModbusDebugResponse.SUCCESS]) + const result = parseReadLicenseResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/at least 4/) + }) + + it('rejects a frame with no status byte at all', () => { + const result = parseReadLicenseResponse(new Uint8Array([ModbusFunctionCode.DEBUG_READ_LICENSE])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) +}) diff --git a/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts new file mode 100644 index 000000000..fc6dc00ab --- /dev/null +++ b/src/backend/shared/debug/__tests__/websocket-debug-transport-license.test.ts @@ -0,0 +1,150 @@ +/** + * License function codes (0x49 write / 0x4A read) over the runtime-v4 debug + * WebSocket. Mocks socket.io-client so the test exercises the REAL PDU framing: + * builder -> spaced-hex envelope -> canned runtime response -> parser. + * + * This is the editor side of the transport-agnostic activation: the same PDUs the + * serial and TCP clients send, so a network target licenses through one code path + * instead of a second, medium-specific one. The runtime answers these FCs at the + * webserver level (covered by the runtime's own tests). + * + * MOCKING, IN BOTH RUNNERS. This file is on the byte-identical shared surface, so + * it runs under Vitest (web) and Jest (editor, which aliases `vi` to `jest`). + * Hence `vi.mock`, never `jest.mock` — the latter is undefined in Vitest, the + * factory never installs, and the real socket.io-client tries to dial a server. + * + * The `import { WebSocketDebugTransport }` below sits AFTER the `vi.mock` call, + * and that position is LOAD-BEARING: + * - Vitest hoists `vi.mock` above the imports, so it would work either way. + * - The editor's Jest does NOT hoist it (ts-jest only hoists literal + * `jest.mock`), so the call has to physically precede the import. + * Do not move it into the import block at the top. + */ +import type { Socket } from 'socket.io-client' + +type Handler = (arg: unknown) => void +type Responder = (commandHex: string) => { success: boolean; data?: string; error?: string } + +/** + * Fake Socket.IO socket: auto-connects and answers `debug_command` with whatever + * the active responder returns for the PDU it was handed. + */ +function makeFakeSocket(responder: Responder): Socket { + const handlers: Record = {} + const socket = { + on(event: string, cb: Handler) { + ;(handlers[event] ||= []).push(cb) + if (event === 'connected') setTimeout(() => cb({ status: 'ok' }), 0) + return socket + }, + off(event: string, cb: Handler) { + handlers[event] = (handlers[event] || []).filter((h) => h !== cb) + return socket + }, + emit(event: string, payload: { command: string }) { + if (event === 'debug_command') { + const resp = responder(payload.command) + setTimeout(() => (handlers['debug_response'] || []).forEach((h) => h(resp)), 0) + } + return socket + }, + disconnect() { + return socket + }, + io: { on() {} }, + } + return socket as unknown as Socket +} + +let currentResponder: Responder = () => ({ success: false, error: 'no responder' }) + +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => makeFakeSocket((cmd) => currentResponder(cmd))), +})) + +// Deliberately AFTER `vi.mock` — see the module docstring. +import { WebSocketDebugTransport } from '../websocket-debug-transport' + +async function connected(): Promise { + const transport = new WebSocketDebugTransport({ host: '127.0.0.1', port: 8443, token: 'jwt' }) + await transport.connect() + return transport +} + +function toSpacedHex(bytes: number[]): string { + return bytes.map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ') +} + +describe('WebSocketDebugTransport license function codes', () => { + it('readLicense (0x4A) parses a full 98-byte blob on SUCCESS', async () => { + const blob = new Uint8Array(98) + for (let i = 0; i < blob.length; i++) blob[i] = i & 0xff + currentResponder = () => ({ success: true, data: toSpacedHex([0x4a, 0x7e, 0x00, 98, ...blob]) }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.blob?.length).toBe(98) + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + }) + + it('readLicense (0x4A) maps LIC_EMPTY to a valid empty state, not an error', async () => { + currentResponder = () => ({ success: true, data: '4A 83' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.empty).toBe(true) + expect(result.blob).toBeUndefined() + }) + + it('readLicense (0x4A) maps LIC_UNSUPPORTED to a valid state (no store backend)', async () => { + currentResponder = () => ({ success: true, data: '4A 85' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(true) + expect(result.unsupported).toBe(true) + }) + + it('writeLicense (0x49) frames [FC][len:u16BE][blob] and parses SUCCESS', async () => { + const sent: string[] = [] + currentResponder = (cmd) => { + sent.push(cmd) + return { success: true, data: '49 7E' } + } + + const transport = await connected() + const result = await transport.writeLicense(new Uint8Array(98).fill(0xab)) + + const parts = sent[0].split(' ') + expect(parts[0]).toBe('49') + expect(parts[1]).toBe('00') // len high + expect(parts[2]).toBe('62') // len low (98) + expect(parts).toHaveLength(3 + 98) + expect(result.success).toBe(true) + }) + + it('surfaces a runtime-side error as a structured failure rather than throwing', async () => { + currentResponder = () => ({ success: false, error: 'no license backend wired' }) + + const transport = await connected() + const result = await transport.readLicense() + + expect(result.success).toBe(false) + expect(result.error).toContain('no license backend wired') + }) + + it('refuses both calls when the socket is not connected', async () => { + const transport = new WebSocketDebugTransport({ host: '127.0.0.1', port: 8443, token: 'jwt' }) + + await expect(transport.readLicense()).resolves.toEqual({ success: false, error: 'Not connected to target' }) + await expect(transport.writeLicense(new Uint8Array(98))).resolves.toEqual({ + success: false, + error: 'Not connected to target', + }) + }) +}) diff --git a/src/backend/shared/debug/index.ts b/src/backend/shared/debug/index.ts index 8cc69ef57..b3156975f 100644 --- a/src/backend/shared/debug/index.ts +++ b/src/backend/shared/debug/index.ts @@ -1,2 +1,17 @@ +export { + crc32IsoHdlc, + deserializeLicenseBlob, + LIC_BLOB_SIZE, + LIC_MAGIC_LE, + LIC_PAYLOAD_SIZE, + type LicenseBlob, + serializeLicenseBlob, +} from './license-blob' export { ModbusRtuTransport } from './modbus-rtu-transport' -export type { DebugConnectionType, DebugSetResult, DebugTransport, DebugTransportResult } from './types' +export type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, +} from './types' diff --git a/src/backend/shared/debug/license-blob.ts b/src/backend/shared/debug/license-blob.ts new file mode 100644 index 000000000..0a91a9f2d --- /dev/null +++ b/src/backend/shared/debug/license-blob.ts @@ -0,0 +1,147 @@ +/** + * TS mirror of the firmware `lic_blob_t` on-device license blob (OLS-02). + * + * Byte-identical to the C struct in `resources/sources/Baremetal/license_blob.h`. + * All multi-byte fields are **little-endian**, written explicitly through a + * `DataView` so the result never depends on the host byte order. + * + * The cross-language pin is `__tests__/fixtures/license-golden.json`: the same + * vector is asserted by this module's test and by the firmware host-test, so a + * layout change on either side fails a test instead of producing a blob the + * board silently rejects. + * + * Blob layout (98 bytes, packed, no padding): + * + * | Offset | Field | Type | Size | + * |-------:|-------------|---------------|-----:| + * | 0 | magic | uint32 LE | 4 | 'OPLC' -> bytes 4F 50 4C 43 + * | 4 | fmtVersion | uint8 | 1 | + * | 5 | keyId | uint8 | 1 | signing-key id (rotation) + * | 6 | deviceId | uint8[16] | 16 | + * | 22 | productId | uint8[8] | 8 | vpp id + * | 30 | signature | uint8[64] | 64 | ECDSA P-256 r||s raw + * | 94 | crc32 | uint32 LE | 4 | CRC-32/ISO-HDLC over [payload||signature] (0..93) + * | 98 | (end) | | | + * + * The signed payload is offsets 0..29 (30 bytes). The crc32 covers + * `[payload || signature]` = offsets 0..93 (94 bytes) and never itself. + */ + +/** Total blob size in bytes (`sizeof(lic_blob_t)`). */ +export const LIC_BLOB_SIZE = 98 +/** Signed payload size in bytes (`sizeof(lic_payload_t)`). */ +export const LIC_PAYLOAD_SIZE = 30 +/** + * Magic as a little-endian uint32. The first four bytes of the blob are + * always `4F 50 4C 43` ('OPLC'); read as an LE uint32 that is 0x434C504F. + */ +export const LIC_MAGIC_LE = 0x434c504f + +// Field offsets (mirror the C struct exactly). +const OFF_MAGIC = 0 +const OFF_FMT_VERSION = 4 +const OFF_KEY_ID = 5 +const OFF_DEVICE_ID = 6 +const OFF_PRODUCT_ID = 22 +const OFF_SIGNATURE = 30 +const OFF_CRC32 = 94 + +const DEVICE_ID_SIZE = 16 +const PRODUCT_ID_SIZE = 8 +const SIGNATURE_SIZE = 64 + +export interface LicenseBlob { + /** LE uint32 magic; canonical value is `LIC_MAGIC_LE` (0x434C504F). */ + magic: number + fmtVersion: number + /** signing-key id (enables signing-key rotation). */ + keyId: number + /** 16-byte device identifier. */ + deviceId: Uint8Array + /** 8-byte product identifier (vpp id). */ + productId: Uint8Array + /** 64-byte ECDSA P-256 signature (r||s, raw). */ + signature: Uint8Array + /** CRC-32/ISO-HDLC over `[payload||signature]`. */ + crc32: number +} + +// --------------------------------------------------------------------------- +// CRC-32/ISO-HDLC (a.k.a. CRC-32, zlib/PKZIP) +// poly (reflected) 0xEDB88320 · init 0xFFFFFFFF · refin/refout true · xorout 0xFFFFFFFF +// test vector: crc32IsoHdlc("123456789") === 0xCBF43926 +// Same definition as the firmware bitwise implementation — the golden test +// proves cross-language parity. +// --------------------------------------------------------------------------- + +/** Compute CRC-32/ISO-HDLC over `data`, returned as an unsigned 32-bit number. */ +export function crc32IsoHdlc(data: Uint8Array): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc ^= data[i] + for (let bit = 0; bit < 8; bit++) { + // Reflected: process LSB first, XOR with poly when the low bit is set. + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1 + } + } + return (crc ^ 0xffffffff) >>> 0 +} + +// --------------------------------------------------------------------------- +// Serialize / deserialize +// --------------------------------------------------------------------------- + +function copyFixed(dst: Uint8Array, offset: number, src: Uint8Array, size: number): void { + // Copy at most `size` bytes; a short source leaves the rest zero-filled. + dst.set(src.subarray(0, size), offset) +} + +/** + * Serialize a `LicenseBlob` into its 98-byte on-wire representation. + * + * All multi-byte fields are written little-endian via explicit + * `DataView.setUint32(off, v, true)`. The `magic` is forced to the canonical + * `LIC_MAGIC_LE`, and the `crc32` is **recomputed** over `[payload||signature]` + * (offsets 0..93) — the `crc32` field on the input is ignored. + */ +export function serializeLicenseBlob(b: LicenseBlob): Uint8Array { + const out = new Uint8Array(LIC_BLOB_SIZE) + const view = new DataView(out.buffer) + + view.setUint32(OFF_MAGIC, LIC_MAGIC_LE, true) + out[OFF_FMT_VERSION] = b.fmtVersion & 0xff + out[OFF_KEY_ID] = b.keyId & 0xff + copyFixed(out, OFF_DEVICE_ID, b.deviceId, DEVICE_ID_SIZE) + copyFixed(out, OFF_PRODUCT_ID, b.productId, PRODUCT_ID_SIZE) + copyFixed(out, OFF_SIGNATURE, b.signature, SIGNATURE_SIZE) + + // Recompute crc32 over [payload || signature] = offsets 0..93 (94 bytes). + const crc = crc32IsoHdlc(out.subarray(0, OFF_CRC32)) + view.setUint32(OFF_CRC32, crc, true) + + return out +} + +/** + * Deserialize a 98-byte blob into a `LicenseBlob`. All multi-byte fields are + * read little-endian. `deviceId` / `productId` / `signature` are fresh copies + * (not views onto `buf`). Does not validate magic or crc32 — that is the + * caller's / firmware's responsibility. + */ +export function deserializeLicenseBlob(buf: Uint8Array): LicenseBlob { + if (buf.length < LIC_BLOB_SIZE) { + throw new Error(`Invalid license blob: too short (${buf.length} bytes, expected ${LIC_BLOB_SIZE})`) + } + + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + + return { + magic: view.getUint32(OFF_MAGIC, true), + fmtVersion: buf[OFF_FMT_VERSION], + keyId: buf[OFF_KEY_ID], + deviceId: buf.slice(OFF_DEVICE_ID, OFF_DEVICE_ID + DEVICE_ID_SIZE), + productId: buf.slice(OFF_PRODUCT_ID, OFF_PRODUCT_ID + PRODUCT_ID_SIZE), + signature: buf.slice(OFF_SIGNATURE, OFF_SIGNATURE + SIGNATURE_SIZE), + crc32: view.getUint32(OFF_CRC32, true), + } +} diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 1c7d60e69..5c3eabc9c 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -23,6 +23,21 @@ * getList request: [FC=0x44] [numIndexes: U16BE] [arr0:U8 elem0:U16BE] [arr1:U8 elem1:U16BE] ... * getList response: [FC=0x44] [status] [lastIndex: U16BE] [tick: U32BE] [size: U16BE] [data...] * + * plcSetState request: [FC=0x4b] [state: U8] (0 = STOP, 1 = RUN) + * plcSetState response: [FC=0x4b] [status] [plcState: U8] [switchPosition: U8] + * + * writeLicense request: [FC=0x49] [len: U16BE] [blob...] + * writeLicense response: [FC=0x49] [status] + * + * readLicense request: [FC=0x4a] + * readLicense response: [FC=0x4a] [status] [len: U16BE] [blob...] (SUCCESS) + * [FC=0x4a] [status] (otherwise) + * + * The license `len` is BIG-ENDIAN like every other length on this wire, while + * the blob it frames is little-endian content (see license-blob.ts). The two + * conventions coexist by design: the framing is Modbus, the payload is a C + * struct. + * * set request: [FC=0x42] [arr: U8] [elem: U16BE] [force: U8] [dataLen: U16BE] [value...] * set response: [FC=0x42] [status] * @@ -41,8 +56,18 @@ */ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' -import type { DebugSetResult, DebugTransportResult, Md5ProbeResult } from './types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from '../simulator/types' +import type { + DebugBoardIdResult, + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugSetResult, + DebugStatusResult, + DebugTransportResult, + DebugVersionResult, + Md5ProbeResult, + PlcControlResult, +} from './types' // --------------------------------------------------------------------------- // Uint8Array helpers — host-endian-agnostic, no typed-array views on wire data. @@ -135,6 +160,50 @@ export function buildSetVariableRequest(index: number, force: boolean, valueBuff return buf } +// Always-on debugger extras. Each is a bare [FC] PDU — no payload — mirroring +// the firmware's `mb_rtu_frame_len` entry of 4 (id + FC + 2 CRC bytes). + +export function buildGetStatusRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_STATUS) + return buf +} + +export function buildGetVersionRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_VERSION) + return buf +} + +export function buildGetBoardIdRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_BOARD_ID) + return buf +} + +/** + * Build a write-license request (FC 0x49). + * PDU: `[FC][len:U16BE][blob...]`. + * + * The `len` on the wire is BIG-ENDIAN (matches every other debug FC), even + * though the blob *content* is little-endian (see license-blob.ts). Do not + * confuse the two: `writeU16BE` is deliberate here. + */ +export function buildWriteLicenseRequest(blob: Uint8Array): Uint8Array { + const buf = alloc(3 + blob.length) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_WRITE_LICENSE) + writeU16BE(buf, 1, blob.length) + buf.set(blob, 3) + return buf +} + +/** Build a read-license request (FC 0x4A). Bare `[FC]` PDU — no payload. */ +export function buildReadLicenseRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_READ_LICENSE) + return buf +} + // --------------------------------------------------------------------------- // Parse responses // --------------------------------------------------------------------------- @@ -248,6 +317,198 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { return { success: true } } +/** + * Parse a status response (FC 0x46). + * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE][switch:u8]` + * (12 PDU bytes; 11 on firmware predating the run/stop state machine). + * + * This is the ONE read path for run/stop state — `running` was always this + * frame's first payload byte, so reporting the real state there rather than a + * hardcoded 1 costs no extra round trip and needs no second function code. The + * switch position is appended, which older parsers ignore and older firmware + * simply omits. + */ +export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_STATUS) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 11) { + return { success: false, error: `Incomplete status response (${data.length} bytes, expected 11)` } + } + + const running = readU8(data, 2) + return { + success: true, + running: running !== 0, + // Same byte as `running`, as the tri-state the run/stop machine actually + // has (STOPPED / RUNNING / ERROR). + plcState: running, + tick: readU32BE(data, 3), + uptimeMs: readU32BE(data, 7), + // Appended by firmware carrying the run/stop state machine; absent on older + // firmware, which callers read as "no switch gating". + ...(data.length >= 12 ? { switchPosition: readU8(data, 11) } : {}), + } +} + +/** + * Parse a version response (FC 0x47). + * Layout: `[FC][status][version ASCII...]` (no NUL terminator on the wire). + */ +export function parseGetVersionResponse(data: Uint8Array): DebugVersionResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_VERSION) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + const version = new TextDecoder('utf-8').decode(data.subarray(2)).replace(/\0+$/, '').trim() + return { success: true, version } +} + +/** + * Parse a board-id response (FC 0x48). + * Layout: `[FC][status][id_len:u8][id_bytes...]`. `id_len === 0` means the + * target has no unique-id support — success with an empty id. + */ +export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_BOARD_ID) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 3) { + return { success: false, error: `Incomplete board-id response (${data.length} bytes, expected at least 3)` } + } + + const idLen = readU8(data, 2) + if (data.length < 3 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${data.length - 3})`, + } + } + + const boardId = data.slice(3, 3 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } +} + +/** + * Parse a write-license response (FC 0x49). + * Layout: `[FC][status]`. SUCCESS → `{ success: true, status }`. LIC_UNSUPPORTED + * (the board has no backend) is a valid device state → `{ success: true, + * unsupported: true }`, not a transport error. Any other non-SUCCESS status is a + * device-side failure surfaced as `{ success: false, error }`. + */ +export function parseWriteLicenseResponse(data: Uint8Array): DebugLicenseWriteResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_WRITE_LICENSE) { + return { success: false, error: 'Function code mismatch' } + } + + if (status === ModbusDebugResponse.LIC_UNSUPPORTED) { + return { success: true, status, unsupported: true } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, status, error: statusError(status) } + } + + return { success: true, status } +} + +/** + * Parse a read-license response (FC 0x4A). + * Layout (OK): `[FC][status=SUCCESS][len:U16BE][blob...]`. + * Layout (other): `[FC][status]` — no len, no blob. + * + * `len` is BIG-ENDIAN (readU16BE) — the wire convention — while the blob it + * frames is little-endian content. LIC_EMPTY / LIC_CORRUPT / LIC_UNSUPPORTED are + * valid device states (`success: true` with the corresponding flag), not + * transport errors. + */ +export function parseReadLicenseResponse(data: Uint8Array): DebugLicenseReadResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_READ_LICENSE) { + return { success: false, error: 'Function code mismatch' } + } + + if (status === ModbusDebugResponse.LIC_EMPTY) { + return { success: true, status, empty: true } + } + + if (status === ModbusDebugResponse.LIC_CORRUPT) { + return { success: true, status, corrupt: true } + } + + if (status === ModbusDebugResponse.LIC_UNSUPPORTED) { + return { success: true, status, unsupported: true } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, status, error: statusError(status) } + } + + if (data.length < 4) { + return { success: false, status, error: `Incomplete license response (${data.length} bytes, expected at least 4)` } + } + + const len = readU16BE(data, 2) + if (data.length < 4 + len) { + return { + success: false, + status, + error: `Incomplete license blob (expected ${len} bytes, got ${data.length - 4})`, + } + } + + return { success: true, status, blob: data.slice(4, 4 + len) } +} + /** * Extract the function code from a Modbus PDU response. * Returns `undefined` if the buffer is empty. @@ -255,3 +516,60 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { export function responseFunctionCode(data: Uint8Array): number | undefined { return data.length > 0 ? readU8(data, 0) : undefined } + +// --------------------------------------------------------------------------- +// FC 0x4b — run/stop command +// +// Command only. Reading the state is `buildGetStatusRequest` / +// `parseGetStatusResponse` (FC 0x46) above, which already reports it. +// --------------------------------------------------------------------------- + +export function buildPlcSetStateRequest(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Uint8Array { + const pdu = alloc(2) + writeU8(pdu, 0, ModbusFunctionCode.PLC_SET_STATE) + writeU8(pdu, 1, state === PlcRuntimeState.RUNNING ? 1 : 0) + return pdu +} + +/** + * Parse a run/stop command acknowledgement. + * + * Three outcomes the caller must tell apart: + * - success: the request was accepted; `state` is as of the last scan. + * - `refusedBySwitch`: a RUN was rejected because the hardware switch reads + * STOP. The editor turns this into the "flip the switch" warning, not an + * error. + * - `unsupported`: the target answered the Modbus exception form (FC | 0x80), + * i.e. firmware built before the run/stop state machine. The editor degrades + * to "rebuild and upload" so field devices never look broken. + */ +export function parsePlcSetStateResponse(data: Uint8Array): PlcControlResult { + if (data.length < 1) { + return { success: false, error: 'Response too short' } + } + + const fc = readU8(data, 0) + if (fc === (ModbusFunctionCode.PLC_SET_STATE as number) + 0x80) { + return { success: false, unsupported: true, error: 'Firmware does not implement run/stop control' } + } + if (fc !== (ModbusFunctionCode.PLC_SET_STATE as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (data.length < 4) { + return { success: false, error: 'Response too short' } + } + + const status = readU8(data, 1) + const result: PlcControlResult = { + success: status === (ModbusDebugResponse.SUCCESS as number), + state: readU8(data, 2), + switchPosition: readU8(data, 3), + } + if (status === (ModbusDebugResponse.REFUSED_BY_SWITCH as number)) { + result.refusedBySwitch = true + result.error = 'Refused: the hardware mode switch is in STOP' + } else if (!result.success) { + result.error = statusError(status) + } + return result +} diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 8b2ebe373..9795bb88e 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -1,3 +1,5 @@ +import type { PlcRuntimeState } from '../simulator/types' + /** * Debug Transport Interface * @@ -5,11 +7,15 @@ * Mirrors the implicit interface from openplc-editor where ModbusTcpClient, * ModbusRtuClient, and WebSocketDebugClient all implement the same methods. * - * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport (WebRTC), HttpTransport. + * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport + * (a WebRTC data channel, falling back to the Autonomy Edge relay per request). + * + * Which medium a session ends up on is NOT named here: the connection manager + * publishes it as a `DebugMedium` (middleware/shared/ports/types), which is the one + * vocabulary the debug poller reads. A second, near-identical union living here is + * how the poller came to have two disagreeing sources for the same fact. */ -export type DebugConnectionType = 'webrtc' | 'http' | 'simulator' - export interface DebugTransportResult { success: boolean tick?: number @@ -23,6 +29,89 @@ export interface DebugSetResult { error?: string } +/** + * Result of the always-on debugger status probe (FC 0x46). `running` is the + * PLC scan liveness flag, `tick` the scan counter (advances each cycle), and + * `uptimeMs` the milliseconds since the board booted. + */ +export interface DebugStatusResult { + success: boolean + running?: boolean + tick?: number + uptimeMs?: number + /** Run/stop state (0 = STOPPED, 1 = RUNNING, 2 = ERROR). This is the single + * read path for run/stop — there is no separate query FC. `running` above is + * the same information as a boolean, kept for callers that only need + * liveness. Absent on firmware predating the run/stop state machine. */ + plcState?: number + /** Mode-switch position (0 = STOP, 1 = RUN). Boards with no physical switch + * report RUN. Absent on firmware predating the run/stop state machine, which + * callers should read as "no gating". */ + switchPosition?: number + error?: string +} + +/** Result of the runtime version probe (FC 0x47) — ASCII version string. */ +export interface DebugVersionResult { + success: boolean + version?: string + error?: string +} + +/** + * Result of the board-id probe (FC 0x48). `boardId` is the raw unique-id bytes + * (empty when the target has no unique-id support); `boardIdHex` is the same + * bytes as a lowercase hex string for display. + */ +export interface DebugBoardIdResult { + success: boolean + boardId?: Uint8Array + boardIdHex?: string + error?: string +} + +/** + * Result of a write-license call (FC 0x49). The device stores the raw blob + * bytes; `status` is the ModbusDebugResponse code the target returned + * (SUCCESS/ERROR_OUT_OF_BOUNDS/ERROR_OUT_OF_MEMORY). `unsupported` (status + * LIC_UNSUPPORTED) means the board has no license-store backend — a valid + * device state (`success: true`), not a transport failure. + * + * A `success: true` here means ONLY "the bytes were accepted for storage". No + * target validates magic, crc32, `deviceId` or `productId` on write, so a caller + * that needs to know what the board now holds must read it back (FC 0x4A) and + * verify — see the write path in the licensing flow. + */ +export interface DebugLicenseWriteResult { + success: boolean + status?: number + unsupported?: boolean + error?: string +} + +/** + * Result of a read-license call (FC 0x4A). `blob` is present only on SUCCESS. + * `empty` (status LIC_EMPTY) means virgin storage — no license provisioned; + * `corrupt` (status LIC_CORRUPT) means the magic matched but the crc32 failed. + * `unsupported` (status LIC_UNSUPPORTED) means the board has no license-store + * backend at all. All three are `success: true` — they are valid device + * states, not transport failures — the caller distinguishes via the flags. + * + * A SUCCESS status does NOT mean the stored license is good: the two targets + * disagree about what they check before answering it (bare metal validates magic + * + crc32; the Linux runtime only checks the file length). Callers must verify + * the returned bytes themselves. + */ +export interface DebugLicenseReadResult { + success: boolean + status?: number + empty?: boolean + corrupt?: boolean + unsupported?: boolean + blob?: Uint8Array + error?: string +} + /** * Result of an MD5-probe call. The `md5` is the runtime's program hash; * `targetEndian` is the byte order detected from the 2-byte sentinel the @@ -50,3 +139,146 @@ export interface DebugTransport { getVariablesList(indexes: number[]): Promise setVariable(index: number, force: boolean, valueBuffer?: Uint8Array): Promise } + +/** + * The channel-level operations every medium offers, independent of the debug + * payload surface: open/close, the board-id read (FC 0x48) that classifies + * whether a firmware is answering at all, and — for baremetal targets — run/stop. + * + * The same PDUs ride serial (ModbusRtuClient), TCP (ModbusTcpClient) and the + * runtime-v4 debug WebSocket (WebSocketDebugTransport), so a connection is + * established and classified identically on every target. + */ +export interface DeviceChannelTransport { + connect(): Promise + disconnect(): void + /** Board-id read (FC 0x48) — the readiness probe that says whether an OpenPLC + * firmware is answering at all. Optional for the same reason as `getStatus`: + * it is a BAREMETAL question. The runtime-v4 WebSocket talks to a target whose + * identity came from the REST login, so it never answers this. */ + getBoardId?(): Promise + /** Runtime status (FC 0x46): run/stop state, mode-switch position, scan + * counter, uptime. Doubles as the liveness probe for a held link — any + * successful reply proves the firmware is answering — so the device liveness + * poll prefers it and gets the run/stop state for free. + * + * Optional because run/stop is a BAREMETAL concern: the Modbus RTU/TCP + * clients implement it, while the runtime-v4 WebSocket transport does not — + * v4 drives run/stop over its REST API, so implementing it there would be + * dead code. */ + getStatus?(): Promise + /** Run/stop command (FC 0x4b). Reads go through `getStatus()`. Optional for + * the same reason as `getStatus`. */ + setPlcState?(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + /** Read the stored VPP license blob (FC 0x4A). Optional here because not every + * medium carries it — but unlike run/stop, every medium that CAN is expected + * to: licensing is a property of the device, not of the target family, so the + * Modbus clients and the runtime-v4 WebSocket all implement it (see + * `DeviceModbusTransport`, where it is required). */ + readLicense?(): Promise + /** Store a VPP license blob (FC 0x49). Optional for the same reason as + * `readLicense`. Storing does not validate — read back to confirm. */ + writeLicense?(blob: Uint8Array): Promise +} + +/** + * What a DEBUG channel must offer, whatever medium it runs over: the channel + * operations plus the debug payload surface. + * + * Deliberately narrower than `DeviceModbusTransport`: it does NOT require + * `getStatus` / `setPlcState`, because those are CONTROL operations and a debug + * channel is not always the control channel. The runtime-v4 WebSocket implements + * exactly this and nothing more — v4 is controlled over REST. + */ +export interface DeviceDebugChannel extends DeviceChannelTransport { + getMd5Hash(): Promise + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + /** `Buffer | Uint8Array`: the Node Modbus clients hand back the former, the + * browser-shared WebSocket transport the latter, and TypeScript does not + * treat one as a substitute for the other. */ + data?: Uint8Array | Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Uint8Array | Buffer): Promise +} + +/** + * The full command surface of a Modbus link to a device: the debug operations + * (`DebugTransport`) plus the channel operations (`DeviceChannelTransport`) + * plus run/stop. + * + * `ModbusRtuClient` and `ModbusTcpClient` are separate classes that differ only + * in framing (RTU: slave id + CRC; TCP: MBAP header). The PDUs they carry, and + * therefore the operations they expose, are identical. Naming that shared + * surface is what lets ONE held connection serve every caller regardless of how + * it was established, instead of each caller picking a client class and opening + * its own connection. + * + * A caller-by-caller choice is exactly what broke run/stop over Modbus TCP: the + * command path recognised only RTU clients as reusable, so with a live TCP link + * it opened a second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. + * + * `getStatus` / `setPlcState` are REQUIRED here, narrowing the optionals on + * `DeviceChannelTransport`: both Modbus clients implement run/stop, and only the + * runtime-v4 WebSocket (a different protocol, driving run/stop over REST) does + * not. + * + * `readLicense` / `writeLicense` are required for a different reason: the license + * FCs are transport-agnostic by design, so device activation runs identically on + * serial and on TCP. A Modbus link that could not carry them would give the + * licensing flow a second, target-dependent shape — which is the divergence the + * one-transport-interface refactor exists to prevent. + */ +export interface DeviceModbusTransport + extends Omit, + DeviceChannelTransport { + getBoardId(): Promise + getStatus(): Promise + setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + readLicense(): Promise + writeLicense(blob: Uint8Array): Promise + /** + * The two payload-carrying operations, restated for the main process. + * + * `DebugTransport` types payloads as `Uint8Array` because it is also + * implemented in the browser-shared layer; the Node Modbus clients hand back + * `Buffer`, which TypeScript does not treat as a substitute for `Uint8Array` + * since @types/node made `Buffer` generic. Everything else — connect, + * disconnect, getMd5Hash — is inherited unchanged. + */ + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Buffer): Promise +} + +/** + * Result of a run/stop command (FC 0x4b `PLC_SET_STATE`). + * + * Reads are NOT done through this — they come from `DebugStatusResult` via + * FC 0x46. This is the command's acknowledgement, which carries the resulting + * state so a caller can react without waiting for the next poll. + */ +export interface PlcControlResult { + success: boolean + /** State as of the target's last scan cycle. The runtime derives the new + * state inside its next cycle, so a caller that needs the settled value + * reads it from the next status poll (at most one scan period later). */ + state?: number + switchPosition?: number + /** A RUN request was refused because the switch reads STOP. Drives the + * "flip the switch to RUN" warning. */ + refusedBySwitch?: boolean + /** Firmware predates the run/stop state machine. Drives an informational + * "rebuild and upload" message instead of an error. */ + unsupported?: boolean + error?: string +} diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 5a9e39236..8c02f33ef 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -26,12 +26,24 @@ import { getErrorMessage } from '../../../frontend/utils/get-error-message' import { buildGetListRequest, buildGetMd5Request, + buildReadLicenseRequest, buildSetVariableRequest, + buildWriteLicenseRequest, parseGetListResponse, parseGetMd5Response, + parseReadLicenseResponse, parseSetVariableResponse, + parseWriteLicenseResponse, } from './modbus-pdu' -import type { DebugSetResult, DebugTransport, DebugTransportResult, Md5ProbeResult } from './types' +import type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, + DeviceDebugChannel, + Md5ProbeResult, +} from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 @@ -67,7 +79,7 @@ function hexSpacedToBytes(hex: string): Uint8Array { return out } -export class WebSocketDebugTransport implements DebugTransport { +export class WebSocketDebugTransport implements DebugTransport, DeviceDebugChannel { private host: string private port: number private token: string @@ -145,6 +157,28 @@ export class WebSocketDebugTransport implements DebugTransport { ) } + // License function codes (0x49/0x4A), byte-identical to the serial/TCP + // clients: the runtime answers them at the webserver level, but the editor sees + // ONE transport-agnostic contract, so the licensing flow has a single shape on + // every target instead of a network-specific branch. + // + // `getBoardId` (0x48) stays absent on purpose: a runtime-v4 target's identity + // comes from the REST login, so it never answers that FC. That is why + // `DeviceChannelTransport` declares the license methods optional — this class + // implements the pair it can serve without claiming the one it cannot. + + async readLicense(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + + return this.sendCommand(buildReadLicenseRequest(), (bytes) => parseReadLicenseResponse(bytes), 'resolve') + } + + async writeLicense(blob: Uint8Array): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + + return this.sendCommand(buildWriteLicenseRequest(blob), (bytes) => parseWriteLicenseResponse(bytes), 'resolve') + } + /** * Send a Modbus PDU over the `debug_command` event and parse the * matching `debug_response`. `errorMode` controls whether a @@ -157,11 +191,9 @@ export class WebSocketDebugTransport implements DebugTransport { * client method routes through here, so the hex encoding / * timeout / event registration logic lives in exactly one place. */ - private sendCommand( - pdu: Uint8Array, - parse: (bytes: Uint8Array) => T, - errorMode: 'resolve', - ): Promise + private sendCommand< + T extends DebugTransportResult | DebugSetResult | DebugLicenseReadResult | DebugLicenseWriteResult, + >(pdu: Uint8Array, parse: (bytes: Uint8Array) => T, errorMode: 'resolve'): Promise private sendCommand( pdu: Uint8Array, parse: (bytes: Uint8Array) => T, diff --git a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts index 3f8733187..f0148cc15 100644 --- a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts +++ b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts @@ -1,7 +1,11 @@ +import { isVersionAtLeast } from '../../../../frontend/utils/semver' import { + describeEditorTooOldForRuntime, describeIncompatibleRuntime, + describeVppRuntimeMismatch, isStrucppCompatibleRuntime, isUserManagementCapableRuntime, + MIN_RUNTIME_VERSION, MIN_STRUCPP_RUNTIME_VERSION, MIN_USER_MANAGEMENT_RUNTIME_VERSION, parseRuntimeVersion, @@ -59,13 +63,22 @@ describe('parseRuntimeVersion', () => { }) }) - it('rejects the legacy hardcoded "v4" string', () => { - expect(parseRuntimeVersion('v4')).toBeNull() + // A missing component is zero everywhere in this codebase, so the legacy + // header parses rather than being rejected as junk. The gate's answer is + // unchanged — see `isStrucppCompatibleRuntime` below — because 4.0.0 is + // genuinely below the 4.1.0 floor. The distinction matters: "I cannot read + // this" and "this is old" are different facts and only one of them is true. + it('reads the legacy hardcoded "v4" string as 4.0.0', () => { + expect(parseRuntimeVersion('v4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) + }) + + it('fills a missing patch component with zero', () => { + expect(parseRuntimeVersion('v4.1')).toEqual({ major: 4, minor: 1, patch: 0, prerelease: undefined }) + expect(parseRuntimeVersion('v4.1')).toEqual(parseRuntimeVersion('4.1.0')) }) it('rejects ambiguous / non-numeric strings', () => { expect(parseRuntimeVersion('dev')).toBeNull() - expect(parseRuntimeVersion('v4.1')).toBeNull() expect(parseRuntimeVersion('v4.x.0')).toBeNull() expect(parseRuntimeVersion(' ')).toBeNull() }) @@ -102,10 +115,72 @@ describe('isStrucppCompatibleRuntime', () => { }) it('rejects the legacy "v4" header + any other unparseable string', () => { + // `v4` now parses (as 4.0.0) and is refused on its merits; the rest are + // unreadable and are refused because an unknown runtime never clears a + // floor. Both paths must stay closed. expect(isStrucppCompatibleRuntime('v4')).toBe(false) expect(isStrucppCompatibleRuntime('dev')).toBe(false) expect(isStrucppCompatibleRuntime(null)).toBe(false) expect(isStrucppCompatibleRuntime(undefined)).toBe(false) + expect(isStrucppCompatibleRuntime('')).toBe(false) + }) + + it('accepts a two-part version at or above the floor', () => { + expect(isStrucppCompatibleRuntime('v4.1')).toBe(true) + expect(isStrucppCompatibleRuntime('4.2')).toBe(true) + expect(isStrucppCompatibleRuntime('4.0')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Every capability gate is "a constant + isVersionAtLeast", nothing more +// --------------------------------------------------------------------------- +// +// These read as tautologies today, and that is the point: they fail the moment +// someone re-inlines a comparison next to a constant. The version this replaced +// answered `v.minor >= 1` for the strucpp gate — correct only because the floor +// happened to end in `.0`. Raise the floor to `4.1.5` and that body keeps +// admitting 4.1.0 while every behavioural test still passes, because the tests +// were written against the old floor too. Deriving the expectation from the +// constant is the only assertion that survives a bump. +describe('capability gates track their constants', () => { + const CANDIDATES = [ + null, + undefined, + '', + 'dev', + 'garbage', + 'v4', + '4.0', + '4.1', + '3.9.9', + '4.0.9', + '4.1.0', + '4.1.0-rc.3', + '4.1.8', + '4.1.9', + '4.1.9-rc.1', + '4.1.10', + '4.2.0', + '4.10.0', + '5.0.0', + ] + + it.each(CANDIDATES)('isStrucppCompatibleRuntime(%p) === isVersionAtLeast(%p, MIN_RUNTIME_VERSION)', (raw) => { + expect(isStrucppCompatibleRuntime(raw)).toBe(isVersionAtLeast(raw, MIN_RUNTIME_VERSION)) + }) + + it.each(CANDIDATES)('isUserManagementCapableRuntime(%p) tracks its own constant', (raw) => { + expect(isUserManagementCapableRuntime(raw)).toBe(isVersionAtLeast(raw, MIN_USER_MANAGEMENT_RUNTIME_VERSION)) + }) + + it('puts each gate exactly on its own floor', () => { + expect(isStrucppCompatibleRuntime(MIN_RUNTIME_VERSION)).toBe(true) + expect(isUserManagementCapableRuntime(MIN_USER_MANAGEMENT_RUNTIME_VERSION)).toBe(true) + // The floors are ordered, so the lower gate must be open where the higher + // one is shut — a single `minor >= 1` body cannot express that difference. + expect(isStrucppCompatibleRuntime('4.1.0')).toBe(true) + expect(isUserManagementCapableRuntime('4.1.0')).toBe(false) }) }) @@ -123,3 +198,76 @@ describe('describeIncompatibleRuntime', () => { expect(describeIncompatibleRuntime(' ')).toContain('unknown') }) }) + +describe('describeEditorTooOldForRuntime', () => { + const args = { runtimeVersion: 'v4.3.0', minEditorVersion: '4.3.0', editorVersion: '4.2.10' } + + it('names both versions and the action that fixes it', () => { + const msg = describeEditorTooOldForRuntime(args) + expect(msg).toContain('v4.3.0') + expect(msg).toContain('4.3.0') + expect(msg).toContain('4.2.10') + expect(msg).toContain('Update the editor') + }) + + it('includes the device address when the platform knows one', () => { + expect(describeEditorTooOldForRuntime({ ...args, deviceLabel: '10.0.0.1' })).toContain('on 10.0.0.1') + }) + + it('omits the device clause when no label is available', () => { + // Web reaches the device through an orchestrator agent, so there is no + // address the user would recognise — better to say nothing than to print + // an agent id nobody can act on. + expect(describeEditorTooOldForRuntime(args)).not.toContain(' on ') + }) + + // The message must never contain a hole where a version should be. A blank + // runtime version is not "no version" — it is a version we failed to + // establish, and the user needs to be told which of the two we could not read. + const UNREADABLE: Array<[string | null | undefined, string]> = [ + ['', 'an empty string'], + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] + + it.each(UNREADABLE)('renders a %p runtime version as "unknown" (%s)', (runtimeVersion) => { + const msg = describeEditorTooOldForRuntime({ ...args, runtimeVersion }) + expect(msg).toContain('Runtime unknown requires') + }) +}) + +describe('describeVppRuntimeMismatch', () => { + const args = { boardTarget: 'SLM-RP4', minRuntimeVersion: '4.2.0', runtimeVersion: 'v4.1.7' } + + it('names the board, the floor, and the reported runtime', () => { + const msg = describeVppRuntimeMismatch(args) + expect(msg).toContain('"SLM-RP4"') + expect(msg).toContain('v4.2.0') + expect(msg).toContain('v4.1.7') + expect(msg).toContain('Upgrade the runtime') + }) + + it('names the device when the platform knows its address', () => { + expect(describeVppRuntimeMismatch({ ...args, deviceLabel: '10.0.0.1' })).toContain( + 'The runtime at 10.0.0.1 reports', + ) + }) + + it('falls back to "the connected runtime" without a label', () => { + expect(describeVppRuntimeMismatch(args)).toContain('The connected runtime reports') + }) + + const UNREADABLE: Array<[string | null | undefined, string]> = [ + ['', 'an empty string'], + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] + + it.each(UNREADABLE)('renders a %p runtime version as "unknown" (%s)', (runtimeVersion) => { + const msg = describeVppRuntimeMismatch({ ...args, runtimeVersion }) + expect(msg).toContain('reports unknown.') + expect(msg).not.toContain('reports .') + }) +}) diff --git a/src/backend/shared/firmware/runtime-version-gate.ts b/src/backend/shared/firmware/runtime-version-gate.ts index 179010a1a..8313493ad 100644 --- a/src/backend/shared/firmware/runtime-version-gate.ts +++ b/src/backend/shared/firmware/runtime-version-gate.ts @@ -14,65 +14,81 @@ * * The string is the GitHub release tag baked in at image build time * (see openplc-runtime/.github/workflows/docker.yml). Older - * runtimes that pre-date this work return a hardcoded "v4" string; - * that's intentionally unparseable here so the gate blocks them. + * runtimes that pre-date this work return a hardcoded "v4" string, + * which reads as 4.0.0 and is blocked on its merits — 4.0.0 is the + * MatIEC line. + * + * Every gate below is a MINIMUM VERSION and nothing else, so each one + * is a constant plus a call to `isVersionAtLeast`. No gate open-codes + * its own comparison: a hand-rolled `v.minor >= 1` hardcodes the shape + * of the constant beside it, and the two then drift the moment someone + * raises the constant to a version whose patch is not zero — the gate + * keeps answering for the old floor and every test still passes. + * Adding a capability gate means adding a constant, not a comparator. * * Shared by both openplc-editor (which uploads to remote runtimes) * and openplc-web (which gates push-to-device through the * orchestrator-agent the same way). */ -/** Minimum runtime version that speaks the STruC++ wire format. */ -export const MIN_STRUCPP_RUNTIME_VERSION = '4.1.0' +// Relative import on purpose: `npm run validate:arch` only inspects relative +// specifiers, so this path is actually checked against the layer rules — +// `backend-shared -> utils` is allowed, and using `@root/` here would have +// skipped the check rather than passed it. +import type { ParsedVersion } from '../../../frontend/utils/semver' +import { formatVersionForDisplay, isVersionAtLeast, parseVersion } from '../../../frontend/utils/semver' -export interface ParsedRuntimeVersion { - major: number - minor: number - patch: number - /** Pre-release identifier (e.g. `rc.3`) if present, otherwise undefined. */ - prerelease?: string -} +/** + * Oldest runtime this editor will upload to — the editor's own + * `minRuntimeVersion` declaration (DOPE-448). 4.1.0 is the floor + * because that is where the STruC++ pipeline landed. + * + * `MIN_STRUCPP_RUNTIME_VERSION` is kept as an alias so existing call + * sites and their tests keep working; new code should use the plain + * name, which says what the constant is rather than why it was + * introduced. + */ +export const MIN_RUNTIME_VERSION = '4.1.0' + +/** @deprecated Use `MIN_RUNTIME_VERSION`. */ +export const MIN_STRUCPP_RUNTIME_VERSION = MIN_RUNTIME_VERSION + +/** @deprecated Use `ParsedVersion` from `frontend/utils/semver`. */ +export type ParsedRuntimeVersion = ParsedVersion /** - * Parses a runtime version string. Returns null when the string - * doesn't carry enough information to compare against - * `MIN_STRUCPP_RUNTIME_VERSION` — e.g. the legacy `"v4"` or `"dev"` - * builds. Callers treat null as "incompatible". + * Parses a runtime version string. Returns null when the string is not + * a version at all — `"dev"`, `"garbage"`, `""`. Callers treat null as + * "incompatible", so a runtime that cannot say what it is never clears + * a floor. + * + * Note what is NOT null: the legacy `"v4"` header parses as `4.0.0` and + * `"4.1"` as `4.1.0`, because a missing component is zero everywhere in + * this codebase. Neither changes any gate's answer — `4.0.0` is still + * below `MIN_RUNTIME_VERSION`, so the legacy header is still refused, + * now for the honest reason that 4.0.0 predates STruC++ rather than + * because the string looked odd. + * + * Delegates to the shared parser so the VPP surface and the runtime + * gates can never drift apart on what a given string means. */ export function parseRuntimeVersion(raw: string | null | undefined): ParsedRuntimeVersion | null { - if (!raw) return null - const trimmed = raw.trim() - if (trimmed.length === 0) return null - // Require all three numeric components — `v4` alone is the legacy - // hardcoded header and must be rejected. - const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/) - if (!match) return null - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - prerelease: match[4], - } + return parseVersion(raw) } /** * Returns true iff the runtime version string represents a runtime - * that speaks the STruC++ wire format (i.e. ≥ 4.1.0, including - * pre-release tags like `v4.1.0-rc.3`). + * that speaks the STruC++ wire format (i.e. ≥ `MIN_RUNTIME_VERSION`, + * including pre-release tags like `v4.1.0-rc.3`). * * Note: by strict semver, `4.1.0-rc.3 < 4.1.0`. We deliberately * deviate here because the rc tags on the v4.1.0 line ARE the * builds shipping STruC++ — there is no "older 4.1.0" the rc lineage - * would be a pre-release of. + * would be a pre-release of. `isVersionAtLeast` ignores pre-release + * in ordering for exactly this reason. */ export function isStrucppCompatibleRuntime(raw: string | null | undefined): boolean { - const v = parseRuntimeVersion(raw) - if (!v) return false - if (v.major > 4) return true - if (v.major < 4) return false - // major === 4: minor must be ≥ 1 (i.e. v4.1.x is the strucpp line). - // patch + prerelease don't matter past that. - return v.minor >= 1 + return isVersionAtLeast(raw, MIN_RUNTIME_VERSION) } /** Minimum runtime version that ships the user-management API @@ -88,11 +104,7 @@ export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9' * gate's treatment of the rc lineage. */ export function isUserManagementCapableRuntime(raw: string | null | undefined): boolean { - const v = parseRuntimeVersion(raw) - if (!v) return false - if (v.major !== 4) return v.major > 4 - if (v.minor !== 1) return v.minor > 1 - return v.patch >= 9 + return isVersionAtLeast(raw, MIN_USER_MANAGEMENT_RUNTIME_VERSION) } /** @@ -101,10 +113,54 @@ export function isUserManagementCapableRuntime(raw: string | null | undefined): * "unknown") is included so the user can match it to the device. */ export function describeIncompatibleRuntime(raw: string | null | undefined): string { - const reported = raw && raw.trim().length > 0 ? raw.trim() : 'unknown' + const reported = formatVersionForDisplay(raw) return ( `Runtime version ${reported} is not compatible with this editor. ` + - `Upload requires OpenPLC Runtime v${MIN_STRUCPP_RUNTIME_VERSION} or newer (STruC++ pipeline). ` + + `Upload requires OpenPLC Runtime v${MIN_RUNTIME_VERSION} or newer (STruC++ pipeline). ` + `Please upgrade the runtime on the target device before pushing this build.` ) } + +/** + * The other direction: this runtime declared a `minEditorVersion` at + * `GET /api/capabilities` and this editor is below it. + * + * Names both versions and the single action that fixes it — a bare + * "incompatible versions" turns into a support ticket. + */ +export function describeEditorTooOldForRuntime(args: { + runtimeVersion: string | null | undefined + minEditorVersion: string + editorVersion: string + deviceLabel?: string +}): string { + const runtime = formatVersionForDisplay(args.runtimeVersion) + const where = args.deviceLabel ? ` on ${args.deviceLabel}` : '' + return ( + `Runtime ${runtime}${where} requires OpenPLC Editor ${args.minEditorVersion} or newer. ` + + `This editor is ${args.editorVersion}. ` + + `Update the editor, or connect to a runtime that accepts ${args.editorVersion}.` + ) +} + +/** + * The VPP providing the selected board declares a runtime floor the + * connected runtime does not meet. + * + * Names the package's board rather than the package id — the board is + * what the user picked and recognises. + */ +export function describeVppRuntimeMismatch(args: { + boardTarget: string + minRuntimeVersion: string + runtimeVersion: string | null | undefined + deviceLabel?: string +}): string { + const runtime = formatVersionForDisplay(args.runtimeVersion) + const where = args.deviceLabel ? `The runtime at ${args.deviceLabel} reports` : 'The connected runtime reports' + return ( + `Board "${args.boardTarget}" requires OpenPLC Runtime v${args.minRuntimeVersion} or newer. ` + + `${where} ${runtime}. ` + + `Upgrade the runtime on that device, or select a board supported by ${runtime}.` + ) +} diff --git a/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts b/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts index ae35251fd..e140c3733 100644 --- a/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts +++ b/src/backend/shared/hardware/__tests__/board-info-resolver.test.ts @@ -360,6 +360,87 @@ describe('BoardInfoResolver', () => { ]) }) + // --------------------------------------------------------------------- + // Licensing fields (hal.licenseStore / hal.licenseKeyId) + // + // `hal.licenseStore` resolves to BUILD INPUTS and nothing else. There is no + // capability mirroring it: every licensable VPP targets hardware that + // persists a licence, so a second derived boolean would be true wherever + // `isLicensable` is. These pin that the paths resolve and that the + // capability block is passed through untouched. + // --------------------------------------------------------------------- + + /** A licensed-VPP device, with whatever licensing fields the test is about. */ + function licensedDevice(hal: Record) { + return { + id: 'esp32-generic', + name: 'ESP32 Generic', + preview: 'p.png', + target: { type: 'arduino-cli', core: 'esp32:esp32', platform: 'esp32:esp32:esp32' }, + hal: { type: 'arduino-hal', source: 'hal/arduino/esp32.cpp', ...hal }, + capabilities: { isLicensable: true }, + } as PackageManifest['devices'][number] + } + + function resolveLicensed(hal: Record) { + const pkg = makePkg() + const manifest = makeManifest({ devices: [licensedDevice(hal)] }) + return makeResolver({}, makePackageManager([pkg], { [pkg.packageId]: manifest })).resolve('ESP32 Generic') + } + + it('resolves hal.licenseStore to package-relative paths and flips the licenseStore capability', () => { + const info = resolveLicensed({ + licenseStore: 'hal/arduino/license_store_esp32.cpp', + licenseKeyId: 'espressif-licensed-2026', + }) + + // Asserted through the same platform-supplied resolver the production code + // is handed, which is the actual contract — hard-coding a joined path bakes + // in POSIX assumptions the resolver does not make. + expect(info.licenseStoreFiles).toEqual([packageRelative(PKG_PATH, 'hal/arduino/license_store_esp32.cpp')]) + expect(info.licenseKeyId).toBe('espressif-licensed-2026') + // The capability block is whatever the manifest declared — nothing derived + // into it, so it cannot disagree with `licenseStoreFiles`. + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('accepts hal.licenseStore as an array of sources', () => { + const info = resolveLicensed({ licenseStore: ['hal/a.cpp', 'hal/b.cpp'] }) + + expect(info.licenseStoreFiles).toEqual([ + packageRelative(PKG_PATH, 'hal/a.cpp'), + packageRelative(PKG_PATH, 'hal/b.cpp'), + ]) + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('resolves no store files for a licensable VPP that ships no backend', () => { + // A PACKAGING fault, not a device state: the compiler warns, and the board + // links the weak default and reports its storage as missing. + const info = resolveLicensed({}) + + expect(info.licenseStoreFiles).toBeUndefined() + expect(info.capabilities).toEqual({ isLicensable: true }) + }) + + it('treats a blank licenseStore as absent rather than resolving it to the package root', () => { + // `''` would resolve to PKG_PATH itself and read as "storage present", + // advertising a backend that is not there. + const info = resolveLicensed({ licenseStore: ' ' }) + + expect(info.licenseStoreFiles).toBeUndefined() + }) + + it('does not invent a capability block for a device that declares nothing', () => { + const pkg = makePkg() + const manifest = makeManifest() + const info = makeResolver({}, makePackageManager([pkg], { [pkg.packageId]: manifest })).resolve('Arduino Mega') + + expect(info.capabilities).toBeUndefined() + expect(info.licenseStoreFiles).toBeUndefined() + expect(info.licenseKeyId).toBeUndefined() + }) + it('omits platformOptions when the manifest does not declare any', () => { const pkg = makePkg() const manifest = makeManifest({ diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts new file mode 100644 index 000000000..20f47ab24 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -0,0 +1,359 @@ +/** + * The Connect flow and the debugger resolve the SAME debug spec. + * + * `use-device-connect.ts` resolves it (via `resolveDeviceLinkCandidates`) to derive + * the ways it can OPEN a connection, with nothing connected + * yet — that is the whole point of Connect. So a precondition on a baremetal + * board's spec gates Connect as well as the debugger, and Connect can then never + * succeed: it would need a connection to establish one. The user-visible symptom + * was "Select a communication port for this device first" with a port already + * selected, because the resolver returned `error` instead of `config`. + * + * A debugger-only requirement therefore cannot be expressed as a spec + * precondition; it belongs in the debugger entry point. These tests pin both + * halves of that. + */ +import { + resolveDebugConnection, + resolveDeviceLinkCandidates, + type DebugResolverContext, + type DebugSpec, +} from '../debug-spec' + +/** What an Arduino target declares: serial always, TCP when an ethernet shield is + * configured. The ORDER is the capability matrix's, not the resolver's. */ +const ARDUINO_TRANSPORTS = ['modbus-serial', 'modbus-tcp'] as const + +/** Mirrors what `buildUsbResolverContext` builds: nothing is connected. + * `port === undefined` models "no port selected", which is how the store looks + * before the user picks one — the builder omits the key entirely. */ +const disconnectedUsbContext = (port?: string): DebugResolverContext => ({ + state: { + configuration: { + deviceBoard: 'AutomationDirect P1AM-100', + ...(port !== undefined ? { communicationPort: port } : {}), + }, + screens: { modbus_rtu: { enabled: true, rtu_baud_rate: '115200', rtu_slave_id: 1 } }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +/** Shaped like the P1AM package's `debug` block: an RTU channel whose params + * come from the selected port and the Modbus screen. */ +const baremetalSpec: DebugSpec = { + channels: [ + { + label: 'Modbus RTU', + channel: 'rtu', + enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, + params: { + port: { $ref: 'configuration.communicationPort', required: 'No serial port selected.' }, + baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate', default: '115200', as: 'number' }, + slaveId: { $ref: 'screens.modbus_rtu.rtu_slave_id', default: 1, as: 'number' }, + }, + }, + ], +} + +/** A P1AM-shaped spec with BOTH transports declared — the real package shape. + * Which one is eligible is decided by the project's Modbus screens. */ +const bothChannelsSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { host: { $ref: 'screens.modbus_tcp.tcp_ip' }, port: 502 }, + }, + ...baremetalSpec.channels, + ], +} + +/** Only Modbus TCP enabled, with a serial port selected in the dropdown. */ +const tcpOnlyContext = (): DebugResolverContext => ({ + state: { + configuration: { deviceBoard: 'AutomationDirect P1AM-100', communicationPort: '/dev/cu.usbmodem11101' }, + screens: { + modbus_tcp: { enabled: true, tcp_ip: '192.168.0.50' }, + modbus_rtu: { enabled: false, rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +describe('Connect resolves a baremetal debug spec while disconnected', () => { + it('returns an rtu config carrying the selected port', () => { + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + + // `kind: 'config'` + rtu is exactly what use-device-connect requires before + // it will call device.connect(); anything else becomes "Select a + // communication port for this device first". + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(String(result.config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + expect(Number(result.config.connectionParams.baudRate)).toBe(115200) + } + }) + + it('still reports a genuinely missing port, so that message is not lost', () => { + // The store omits `communicationPort` until one is picked; that is what the + // spec's `required` message exists for, and it must survive the fix above. + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext(), undefined) + expect(result.kind).toBe('error') + expect(result).toMatchObject({ body: 'No serial port selected.' }) + }) + + it('auto-selects TCP in a Modbus-TCP-only project, which Connect cannot use', () => { + // Second regression, same misleading dialog. A project with ONLY Modbus TCP + // enabled leaves exactly one eligible channel — tcp — so auto-select returns + // a tcp config. Connect opens serial and nothing else, so it rejected that + // config and reported "Select a communication port" with a port selected. + const result = resolveDebugConnection(bothChannelsSpec, tcpOnlyContext(), undefined) + + expect(result.kind).toBe('config') + if (result.kind === 'config') expect(result.config.connectionType).toBe('tcp') + }) + + it('offers BOTH transports, SERIAL first, when the project enables Modbus TCP', () => { + // Serial leads: it is the direct, local path, with no address to be stale and + // nothing to ask the user. Modbus TCP is the remote fallback. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu', 'tcp']) + expect(String(result.candidates[0].config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + }) + + it('offers serial even with Modbus RTU turned off', () => { + // Modbus RTU disabled does not mean serial is unreachable: the always-on + // debugger keeps the serial protocol compiled into every baremetal firmware. + // Requiring `enabledWhen` here is what made Connect refuse a Modbus-TCP-only + // project with "select a communication port" while one was plainly selected. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.some((candidate) => candidate.config.connectionType === 'rtu')).toBe(true) + }) + + it('offers serial ALONE when Modbus TCP is not enabled', () => { + const rtuOnly = resolveDeviceLinkCandidates(bothChannelsSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), { + transports: [...ARDUINO_TRANSPORTS], + }) + if (rtuOnly.kind !== 'candidates') throw new Error('expected candidates') + expect(rtuOnly.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + it('resolves a Runtime v4 target, whose only transport is a WebSocket', () => { + // The regression that broke every v4 target: with eligibility hardcoded to + // serial-then-TCP, a `websocket` channel was never a candidate, so no session + // was opened and every command answered "not connected" on a target the user + // had connected to and uploaded to. Eligibility comes from the TARGET's + // declared transports, so this needs no special case — only the right facts. + const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing.' }, + }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: true }, + } + + const result = resolveDeviceLinkCandidates(v4Spec, connectedRuntime, { transports: ['websocket'] }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['websocket']) + expect(result.candidates[0].config.connectionParams.jwtToken).toBe('jwt') + }) + + it('resolves a Runtime v3 target over Modbus TCP', () => { + const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v3', runtimeIpAddress: '192.168.0.9' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: false }, + } + + const result = resolveDeviceLinkCandidates(v3Spec, connectedRuntime, { transports: ['modbus-tcp'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['tcp']) + }) + + it('ignores a channel the target cannot actually speak', () => { + // A spec may declare more than the target supports; the capability matrix wins. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['modbus-serial'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + it('lets a caller skip a channel it has decided against', () => { + // Channel 0 in this spec is the TCP one. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + skipChannels: [0], + }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + describe('a DHCP address is asked for LAST, and only if needed', () => { + const dhcpSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { ipAddress: { $ref: 'screens.modbus_tcp.ip_address' } }, + prompts: [ + { + when: { $ref: 'screens.modbus_tcp.enable_dhcp' }, + field: 'ipAddress', + title: 'Target IP Address', + message: 'Enter the DHCP-assigned address.', + cacheKey: 'lastDhcpIp', + }, + ], + }, + ...baremetalSpec.channels, + ], + } + const dhcpContext = (): DebugResolverContext => { + const context = tcpOnlyContext() + context.state.screens.modbus_tcp = { enabled: true, enable_dhcp: true } + return context + } + + it('sets the DHCP channel aside instead of asking, when prompts are deferred', () => { + // The user's report: with DHCP on, Connect hung on a dialog before trying + // anything. With a cable attached, that question is pure interruption. + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + expect(result.awaitingInput).toHaveLength(1) + }) + + it('asks once the caller resolves that channel on its own', () => { + // The second pass, run only after everything silent has failed. + const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) + if (deferred.kind !== 'candidates') throw new Error('expected candidates') + + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + onlyChannels: deferred.awaitingInput, + }) + expect(result.kind).toBe('prompt') + }) + + it('reports candidates even when ONLY a prompting channel is eligible', () => { + // No serial port selected and DHCP on: there is nothing to try silently, but + // the attempt must not be reported as impossible — the address dialog is + // exactly what is missing. + const context = dhcpContext() + delete context.state.configuration.communicationPort + const result = resolveDeviceLinkCandidates(dhcpSpec, context, { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates).toHaveLength(0) + expect(result.awaitingInput).toHaveLength(1) + }) + }) + + it('still reports a missing port when serial is the only candidate', () => { + // Candidate resolution must not swallow a channel's own `required` message. + const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) + expect(result).toMatchObject({ kind: 'error', body: 'No serial port selected.' }) + }) + + it('reports unsupported when the board declares nothing reachable', () => { + const malformed = {} as unknown as DebugSpec + expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) + expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) + }) + + it('reports an error when no declared channel matches a transport the target speaks', () => { + // A target whose capability matrix says `['websocket']` cannot use a spec that + // only declares serial and TCP — nothing is eligible. Reported as an error, not + // silently as an empty candidate list, because an empty list downstream reads as + // "connected to nothing" and every later command then times out unexplained. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['websocket'] }) + + expect(result.kind).toBe('error') + if (result.kind === 'error') expect(result.body).toBeTruthy() + }) + + it('prefers the spec-supplied noneEnabled message when it has one', () => { + const withMessage: DebugSpec = { + ...bothChannelsSpec, + messages: { noneEnabled: { title: 'Nope', body: 'This board needs an ethernet shield.' } }, + } + const result = resolveDeviceLinkCandidates(withMessage, tcpOnlyContext(), { transports: ['websocket'] }) + expect(result).toMatchObject({ kind: 'error', title: 'Nope', body: 'This board needs an ethernet shield.' }) + }) + + it('shows why a precondition cannot express a debugger-only requirement', () => { + // Adding ANY precondition to the spec above breaks Connect, because Connect + // resolves this same spec with nothing connected. + const gated: DebugSpec = { ...baremetalSpec, preconditions: ['runtimeConnected'] } + + const result = resolveDebugConnection(gated, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + expect(result.kind).toBe('error') + }) +}) diff --git a/src/backend/shared/hardware/__tests__/debug-spec.test.ts b/src/backend/shared/hardware/__tests__/debug-spec.test.ts index d34d78455..cfa6efbbf 100644 --- a/src/backend/shared/hardware/__tests__/debug-spec.test.ts +++ b/src/backend/shared/hardware/__tests__/debug-spec.test.ts @@ -65,7 +65,10 @@ describe('resolveDebugConnection', () => { }) describe('channel selection', () => { - it('errors with `noneEnabled` message when no channel matches', () => { + it('falls back to the serial (rtu) channel when no channel matches (always-on debugger)', () => { + // The always-on debugger keeps serial debug compiled into every + // baremetal firmware even with Modbus disabled, so an rtu channel is + // always usable as a fallback instead of surfacing "Modbus Required". const spec: DebugSpec = { channels: [ { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, @@ -74,14 +77,30 @@ describe('resolveDebugConnection', () => { messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, } const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(result.channelLabel).toBe('RTU') + } + }) + + it('errors with `noneEnabled` message when nothing matches and there is no serial fallback', () => { + // Only a non-serial channel exists, so there is no always-on serial + // fallback — the board genuinely has no usable debug channel. + const spec: DebugSpec = { + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], + messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, + } + const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ kind: 'error', title: 'Modbus Required', body: 'Enable RTU or TCP.' }) }) - it('falls back to generic copy when `noneEnabled` message is absent', () => { - // `messages.noneEnabled` is optional — boards may omit it and - // expect the resolver to provide a sensible default. + it('falls back to generic copy when `noneEnabled` message is absent and no serial fallback', () => { + // `messages.noneEnabled` is optional — boards may omit it and expect the + // resolver to provide a sensible default. Uses a tcp-only spec so the + // serial fallback does not apply. const spec: DebugSpec = { - channels: [{ label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }], + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], } const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ @@ -91,6 +110,25 @@ describe('resolveDebugConnection', () => { }) }) + it('does NOT fall back to serial when a non-serial channel is enabled (TCP-only Modbus)', () => { + // TCP-only Modbus build: the tcp channel matches, so the resolver uses + // it and never offers serial (which the firmware does not expose here). + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ state: { screens: { modbus_tcp: { enabled: true } } } }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('tcp') + } + }) + it('returns `pick` when multiple channels match', () => { const spec: DebugSpec = { channels: [ diff --git a/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts b/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts new file mode 100644 index 000000000..b82307924 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/find-vpp-device.test.ts @@ -0,0 +1,75 @@ +import type { InstalledPackage, PackageManifest } from '../../../../middleware/shared/ports/types' +import type { PackageManagerPort } from '../board-info-resolver' +import { findVppDeviceByBoardName } from '../find-vpp-device' + +const pkg = (packageId: string): InstalledPackage => + ({ packageId, path: `/packages/${packageId}` }) as unknown as InstalledPackage + +const manifest = (packageId: string, deviceNames: string[]): PackageManifest => + ({ + package: { id: packageId, name: packageId, version: '1.0.0' }, + devices: deviceNames.map((name) => ({ id: name.toLowerCase(), name, target: { type: 'runtime-v4' } })), + }) as unknown as PackageManifest + +/** A package source backed by plain in-memory maps. */ +const source = ( + installed: InstalledPackage[], + manifests: Record, +): PackageManagerPort => ({ + listInstalled: () => installed, + getInstalledPackageManifest: (packageId) => manifests[packageId] ?? null, +}) + +describe('findVppDeviceByBoardName', () => { + it('returns the package, manifest and device for a board a VPP provides', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4', 'P2-722']) }) + const match = findVppDeviceByBoardName(port, 'P2-722') + expect(match?.pkg.packageId).toBe('vendor.a') + expect(match?.manifest.package.id).toBe('vendor.a') + expect(match?.device.name).toBe('P2-722') + }) + + it('searches every installed package, not just the first', () => { + const port = source([pkg('vendor.a'), pkg('vendor.b')], { + 'vendor.a': manifest('vendor.a', ['SLM-RP4']), + 'vendor.b': manifest('vendor.b', ['P2-722']), + }) + expect(findVppDeviceByBoardName(port, 'P2-722')?.pkg.packageId).toBe('vendor.b') + }) + + it('returns null for a board no installed package provides', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4']) }) + // The ordinary case: a built-in hals.json board with no VPP behind it. + expect(findVppDeviceByBoardName(port, 'Arduino Uno')).toBeNull() + }) + + it('returns null when nothing is installed', () => { + expect(findVppDeviceByBoardName(source([], {}), 'SLM-RP4')).toBeNull() + }) + + // The behaviour every copy of this loop shared and none of them stated. + // Pinned here so a future change to it is a deliberate, single edit rather + // than three sites drifting apart. + it('takes the first match in listInstalled order when two packages collide', () => { + const port = source([pkg('vendor.a'), pkg('vendor.b')], { + 'vendor.a': manifest('vendor.a', ['SLM-RP4']), + 'vendor.b': manifest('vendor.b', ['SLM-RP4']), + }) + expect(findVppDeviceByBoardName(port, 'SLM-RP4')?.pkg.packageId).toBe('vendor.a') + }) + + it('skips a package whose manifest cannot be read and keeps searching', () => { + // A single corrupt install must not hide a board another package provides. + const port = source([pkg('vendor.broken'), pkg('vendor.b')], { + 'vendor.broken': null, + 'vendor.b': manifest('vendor.b', ['SLM-RP4']), + }) + expect(findVppDeviceByBoardName(port, 'SLM-RP4')?.pkg.packageId).toBe('vendor.b') + }) + + it('matches on device name exactly', () => { + const port = source([pkg('vendor.a')], { 'vendor.a': manifest('vendor.a', ['SLM-RP4']) }) + expect(findVppDeviceByBoardName(port, 'slm-rp4')).toBeNull() + expect(findVppDeviceByBoardName(port, 'SLM-RP4 ')).toBeNull() + }) +}) diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index c241b2f9d..5d6487c6a 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -26,6 +26,7 @@ import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { InstalledPackage, PackageManifest, PlatformOption } from '../../../middleware/shared/ports/types' import type { TargetCapabilities } from '../../../middleware/shared/utils/target-capabilities/types' +import { findVppDeviceByBoardName } from './find-vpp-device' // --------------------------------------------------------------------------- // Public shapes @@ -160,6 +161,21 @@ export interface BoardBuildInfo { * library dir, linked via a 2nd `--library`. Its presence marks an arduino * prebuilt board (the source HAL still compiles as the integration layer). */ precompiledLibraryDir?: string + /** Resolved paths of the VPP's on-device license-storage backend sources + * (`device.hal.licenseStore`), injected into the Baremetal sketch so they + * define the STRONG `license_store_*` symbols over the weak default. + * + * This is the ONE representation of "this VPP ships storage". There is + * deliberately no capability mirroring it: every licensable VPP targets + * hardware that persists a licence, so a second derived boolean would be + * true wherever `isLicensable` is and only add a way for the two to + * disagree. */ + licenseStoreFiles?: string[] + /** Per-VPP signing key id (`device.hal.licenseKeyId`). Informational in the + * editor: the activation request carries only `{ deviceId, packageId }` and + * the backend resolves its own key. Carried for the build side and for + * diagnosing a board that stores a blob and still runs demo. */ + licenseKeyId?: string /** Exact Arduino core version to install/verify before linking a prebuilt * arduino library (ABI-locked). From `target.coreVersion`. */ coreVersion?: string @@ -257,14 +273,8 @@ export class BoardInfoResolver { #tryVppLookup( boardName: string, ): Omit | null { - for (const pkg of this.config.packageManager.listInstalled()) { - const manifest = this.config.packageManager.getInstalledPackageManifest(pkg.packageId) - if (!manifest) continue - const device = manifest.devices.find((d) => d.name === boardName) - if (!device) continue - return this.#fromVppDevice(device, pkg, manifest) - } - return null + const match = findVppDeviceByBoardName(this.config.packageManager, boardName) + return match ? this.#fromVppDevice(match.device, match.pkg, match.manifest) : null } #fromVppDevice( @@ -303,6 +313,17 @@ export class BoardInfoResolver { if (flags) info.compilerFlags = flags if (device.hal.define) info.define = device.hal.define if (device.hal.extraArduinoLibraries) info.extraArduinoLibraries = device.hal.extraArduinoLibraries + if (device.hal.licenseKeyId) info.licenseKeyId = device.hal.licenseKeyId + + // `hal.licenseStore` resolves to build inputs and nothing else. It used to + // ALSO derive a `capabilities.licenseStore` boolean; that is gone, because + // every licensable VPP targets hardware that persists a licence, so the + // boolean was true wherever `isLicensable` was and its only job was one + // diagnostic sentence. + const licenseStoreFiles = normalizeToArray(device.hal.licenseStore) + if (licenseStoreFiles.length > 0) { + info.licenseStoreFiles = licenseStoreFiles.map((file) => resolveRel(pkg.path, file)) + } if (device.capabilities) info.capabilities = device.capabilities if (device.hal.pluginType === 'python' || device.hal.pluginType === 'native') { @@ -341,3 +362,14 @@ export class BoardInfoResolver { return out } } + +/** + * A manifest field declared as `string | string[]` as a flat list of non-empty + * entries. Blank strings are dropped rather than resolved: a `licenseStore: ""` + * would otherwise resolve to the package root and read as "storage present". + */ +function normalizeToArray(value: string | string[] | undefined): string[] { + if (value === undefined) return [] + const list = Array.isArray(value) ? value : [value] + return list.filter((entry) => entry.trim().length > 0) +} diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index fabd68fab..963dcf24a 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -19,6 +19,7 @@ import type { DebugCondition, DebugParam, DebugRef, DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { DebugConnectionConfig } from '../../../middleware/shared/ports/types' +import type { DebuggerTransport } from '../../../middleware/shared/utils/target-capabilities' // Re-export types so importers have one canonical entry point. The // types themselves live in the ports layer (architecture rule); the @@ -111,6 +112,44 @@ export type DebugResolverOutcome = | { kind: 'error'; title: string; body: string } | { kind: 'unsupported' } +/** + * Which capability transport a declared channel kind belongs to. + * + * `simulator` maps to `modbus-serial` because that is what it is: RTU over the + * emulated serial port the in-process simulator exposes. + */ +const CHANNEL_TRANSPORT: Record = { + rtu: 'modbus-serial', + simulator: 'modbus-serial', + tcp: 'modbus-tcp', + websocket: 'websocket', +} + +/** One resolved way to reach the device, with the channel it came from. */ +export interface DeviceLinkCandidateConfig { + config: DebugConnectionConfig + channelLabel: string + /** Index in `spec.channels`, so a caller can skip this channel on a re-resolve. */ + channelIndex: number +} + +/** + * Outcome of resolving link candidates. Shares `prompt` / `error` / `unsupported` + * with `DebugResolverOutcome` so ONE renderer loop can drive both this and the + * debugger's single-channel resolution. + */ +export type DeviceLinkCandidatesOutcome = + | { + kind: 'candidates' + candidates: DeviceLinkCandidateConfig[] + /** + * Channels that could be tried, but only after asking the user something + * (a DHCP address). Empty unless the caller passed `deferPrompts`. + */ + awaitingInput: number[] + } + | Extract + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -199,14 +238,26 @@ export function resolveDebugConnection( .map((channel, index) => ({ channel, index })) .filter(({ channel }) => evaluateCondition(channel.enabledWhen, context.state)) if (enabled.length === 0) { - const msg = spec.messages?.noneEnabled - return { - kind: 'error', - title: msg?.title ?? 'No Debug Channel', - body: msg?.body ?? 'No debug channel is enabled for this board.', + // Always-on debugger: every baremetal firmware keeps the serial debug + // function codes compiled in even when no Modbus transport is enabled + // (the DEBUGGER_ENABLED gate brings up the serial port without Modbus + // operation buffers). So when no channel's `enabledWhen` matches, fall + // back to the serial (`rtu`) channel instead of erroring — serial debug + // is always available. A TCP-only Modbus build leaves `enabled` non-empty + // (the `tcp` channel matches), so it never reaches this fallback and + // correctly debugs over TCP, matching the firmware which does NOT bring + // up the serial debugger in that configuration. + const rtuFallbackIndex = spec.channels.findIndex((channel) => channel.channel === 'rtu') + if (rtuFallbackIndex < 0) { + const msg = spec.messages?.noneEnabled + return { + kind: 'error', + title: msg?.title ?? 'No Debug Channel', + body: msg?.body ?? 'No debug channel is enabled for this board.', + } } - } - if (enabled.length > 1) { + activeIndex = rtuFallbackIndex + } else if (enabled.length > 1) { const msg = spec.messages?.pickProtocol return { kind: 'pick', @@ -214,8 +265,9 @@ export function resolveDebugConnection( title: msg?.title ?? 'Select Debug Channel', body: msg?.body ?? 'Multiple debug channels are enabled. Which one should the debugger use?', } + } else { + activeIndex = enabled[0].index } - activeIndex = enabled[0].index } const channel = spec.channels[activeIndex] @@ -279,3 +331,128 @@ export function resolveDebugConnection( }, } } + +/** + * Resolve the ordered ways to reach a target — ANY target. + * + * The caller does not pick a medium; it gets candidates in preference order and the + * connection manager tries them until one answers. That makes this the single + * interpreter of a `debug` spec, whatever kind of target declared it: + * + * 1. `rtu` — serial. Always a candidate for a board that declares it, regardless + * of whether Modbus RTU is enabled, because the always-on debugger keeps the + * serial protocol compiled into every baremetal firmware. Preferred because it + * is the direct, local, physically unambiguous path: if a cable is attached, + * that is the device in front of you, with no address to be stale and nothing + * to ask the user. + * 2. `tcp` — Modbus TCP, when enabled. A baremetal board's remote path, and a + * Runtime v3's debug channel. + * 3. `websocket` — a Runtime v4's debug channel. + * 4. `simulator` — in-process. + * + * In practice the sets are disjoint: a baremetal board declares serial and possibly + * Modbus TCP, a runtime declares exactly one network channel, the simulator one + * in-process channel. So the ordering only ever decides anything for a baremetal + * board — but it is expressed once, for every kind, rather than once per caller. + * Resolving a runtime's channel through a serial-and-TCP-only version of this + * function is what left Runtime v4 targets with no session at all. + * + * Order is a preference, not a promise: the manager still verifies each candidate + * before keeping it, so a cable attached to a board with no firmware falls through + * to the next option rather than stranding the user. + * + * A channel needing user input (a DHCP address) is asked for LAST — see + * `deferPrompts` — so a user with a cable attached is never interrupted by a + * question about an address they do not need to know. + * + * If nothing can be built the caller gets the reason the first candidate failed: an + * editor reporting "connected" with nothing connected is what makes every later + * request time out for no visible reason. + */ +export function resolveDeviceLinkCandidates( + spec: DebugSpec | undefined, + context: DebugResolverContext, + options: { + /** + * The target's `debuggerTransports`, in preference order, from its capability + * matrix. Required: which media a target speaks is a fact about the target, and + * the resolver has no business guessing it. + */ + transports: DebuggerTransport[] + /** Channels to leave out — e.g. one the user has declined. */ + skipChannels?: number[] + /** Consider ONLY these channels. Used for the second pass, after a prompt. */ + onlyChannels?: number[] + /** + * Don't ask the user anything: a channel that needs input is left out and + * reported in `awaitingInput` instead of bubbling up as a `prompt`. Lets a + * caller try everything that works silently before interrupting anyone. + */ + deferPrompts?: boolean + }, +): DeviceLinkCandidatesOutcome { + // `channels` arrives from a VPP manifest, so treat it as possibly absent + // rather than trusting the type: a malformed package must produce a dialog, + // not an exception inside a click handler. + const channels = spec?.channels + if (!spec || !channels?.length) return { kind: 'unsupported' } + + const transports = options.transports + const skip = new Set(options.skipChannels ?? []) + const only = options.onlyChannels ? new Set(options.onlyChannels) : null + const included = (index: number): boolean => !skip.has(index) && (only === null || only.has(index)) + + // Order and eligibility come from the TARGET's declared transports, not from any + // list kept here: `debuggerTransports` already says which media a target speaks + // and in what order (`['modbus-serial', 'modbus-tcp']` for an Arduino board, + // `['websocket']` for a Runtime v4, `['modbus-tcp']` for a v3). Honouring that + // declaration is what makes one resolver serve every kind of target — and a + // channel the target cannot actually speak is not a candidate, however the spec + // describes it. + const eligible: number[] = [] + for (const transport of transports) { + channels.forEach((channel, index) => { + if (!included(index) || CHANNEL_TRANSPORT[channel.channel] !== transport) return + // Serial is exempt from `enabledWhen`: "Modbus RTU disabled" does not mean the + // board is unreachable over serial, because the always-on debugger keeps the + // serial protocol compiled in either way. Every other kind must be turned on. + if (channel.channel !== 'rtu' && !evaluateCondition(channel.enabledWhen, context.state)) return + eligible.push(index) + }) + } + + if (eligible.length === 0) { + const message = spec.messages?.noneEnabled + return { + kind: 'error', + title: message?.title ?? 'No Connection Channel', + body: message?.body ?? 'This target declares no channel the editor can connect through.', + } + } + + const candidates: DeviceLinkCandidateConfig[] = [] + const awaitingInput: number[] = [] + let firstFailure: Extract | null = null + + for (const index of eligible) { + const outcome = resolveDebugConnection(spec, context, index) + if (outcome.kind === 'config') { + candidates.push({ config: outcome.config, channelLabel: outcome.channelLabel, channelIndex: index }) + continue + } + if (outcome.kind === 'prompt') { + if (options.deferPrompts) { + awaitingInput.push(index) + continue + } + return outcome + } + // 'pick' cannot occur: every resolve above names its channel by index. + if (outcome.kind === 'error' || outcome.kind === 'unsupported') firstFailure ??= outcome + } + + if (candidates.length === 0 && awaitingInput.length === 0) { + return firstFailure ?? { kind: 'unsupported' } + } + return { kind: 'candidates', candidates, awaitingInput } +} diff --git a/src/backend/shared/hardware/find-vpp-device.ts b/src/backend/shared/hardware/find-vpp-device.ts new file mode 100644 index 000000000..28cfe07a1 --- /dev/null +++ b/src/backend/shared/hardware/find-vpp-device.ts @@ -0,0 +1,58 @@ +/** + * The one way to answer "which installed VPP provides this board?". + * + * `boardTarget` travels through the compile pipeline as a plain device + * *name* — the string the user picked in the board dropdown. Four call + * sites needed to turn it back into the package and manifest entry it + * came from (board build info, VPP plugin packaging, module config + * screens, the runtime-version floor), and each had grown its own copy + * of the same loop. + * + * They agreed, which is the only reason this was a latent bug and not + * an open one: four copies of "first package whose `devices` contains a + * matching name wins" that nothing forced to stay in agreement. Change + * the tie-break, add a namespacing rule, start matching on `id` as well + * as `name` — and three of the four would keep the old behaviour, in a + * codebase where the symptom is a board that compiles against the wrong + * package's HAL. + * + * Lives in `backend/shared` because `board-info-resolver` (also shared) + * is one of the callers and cannot reach into `backend/editor`. It takes + * the same narrow `PackageManagerPort` the resolver already injects, so + * editor and web both satisfy it without new plumbing. + */ + +import type { InstalledPackage, PackageManifest } from '../../../middleware/shared/ports/types' +import type { PackageManagerPort } from './board-info-resolver' + +/** An installed VPP device, with the package and manifest it came from. */ +export interface VppDeviceMatch { + /** Registry entry — carries `packageId` and the on-disk `path`. */ + pkg: InstalledPackage + /** The full manifest, for package-level fields (`minRuntimeVersion`, …). */ + manifest: PackageManifest + /** The matched `devices[]` entry. */ + device: PackageManifest['devices'][number] +} + +/** + * Find the installed VPP device whose name is `boardName`, or null when + * no installed package provides it (the ordinary case for a built-in + * hals.json board). + * + * **First match wins**, in `listInstalled()` order. Two packages + * shipping a device of the same name is an authoring collision, not a + * situation with a right answer; resolving it consistently everywhere + * matters more than which one is picked. Packages whose manifest cannot + * be read are skipped rather than treated as empty, so a single corrupt + * install cannot hide a board another package provides. + */ +export function findVppDeviceByBoardName(packageManager: PackageManagerPort, boardName: string): VppDeviceMatch | null { + for (const pkg of packageManager.listInstalled()) { + const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + const device = manifest.devices.find((d) => d.name === boardName) + if (device) return { pkg, manifest, device } + } + return null +} diff --git a/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts b/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts index e19623923..78486b345 100644 --- a/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts +++ b/src/backend/shared/library/__tests__/deploy-runtime-program.test.ts @@ -1,4 +1,4 @@ -import { deployRuntimeProgram } from '../deploy-runtime-program' +import { deployReachedDevice, type DeployRuntimeProgramOutcome, deployRuntimeProgram } from '../deploy-runtime-program' import type { RuntimeCompilationStatus } from '../poll-runtime-compilation' function makeStatusFetcher(...responses: Array) { @@ -133,4 +133,84 @@ describe('deployRuntimeProgram', () => { }) expect(outcome).toBe('START_TIMEOUT') }) + + it('returns UPLOADED_NOT_STARTED when the hardware mode switch declines the start', async () => { + const start = jest.fn(makeStartFetcher('START:ERROR_SWITCH_STOP')) + const logs: Array<{ level: string; message: string }> = [] + + const outcome = await deployRuntimeProgram({ + uploadProgram: async () => ({ success: true }), + fetchCompilationStatus: makeStatusFetcher({ status: 'SUCCESS', logs: [], exit_code: 0 }), + fetchStartResponse: start, + onLog: (level, message) => logs.push({ level, message }), + pollIntervalMs: 1, + startIntervalMs: 1, + }) + + expect(outcome).toBe('UPLOADED_NOT_STARTED') + // Asked once and accepted the answer: nothing changes until someone moves + // the switch, so retrying until the deadline would only stall the deploy. + expect(start).toHaveBeenCalledTimes(1) + // Reported as a warning, never an error — the program is on the device. + expect(logs.some((l) => l.level === 'warning')).toBe(true) + expect(logs.some((l) => l.level === 'error')).toBe(false) + }) +}) + +describe('deployReachedDevice', () => { + /** + * Exhaustive by construction: a new member of `DeployRuntimeProgramOutcome` + * fails to type-check here until someone decides which side of the + * "did the program reach the device?" line it falls on. + */ + const reachedDeviceByOutcome: Record = { + STARTED: true, + UPLOADED_NOT_STARTED: true, + UPLOAD_FAILED: false, + BUILD_FAILED: false, + BUILD_TIMEOUT: false, + BUILD_ERROR: false, + START_FAILED: false, + START_TIMEOUT: false, + } + + /** Typed walk over the table above — the length check below keeps the two in step. */ + const allOutcomes: DeployRuntimeProgramOutcome[] = [ + 'STARTED', + 'UPLOADED_NOT_STARTED', + 'UPLOAD_FAILED', + 'BUILD_FAILED', + 'BUILD_TIMEOUT', + 'BUILD_ERROR', + 'START_FAILED', + 'START_TIMEOUT', + ] + + it('classifies every outcome the deploy can produce', () => { + expect(allOutcomes).toHaveLength(Object.keys(reachedDeviceByOutcome).length) + }) + + it('is true when the runtime took the program and ran it', () => { + expect(deployReachedDevice('STARTED')).toBe(true) + }) + + it('is true when the runtime took the program but declined to run it', () => { + // The mode switch reads STOP. Reporting this as a failed upload sends the + // user looking for a problem that does not exist. + expect(deployReachedDevice('UPLOADED_NOT_STARTED')).toBe(true) + }) + + it('is false for every outcome where the program never landed', () => { + const failures = allOutcomes.filter((outcome) => !reachedDeviceByOutcome[outcome]) + expect(failures).toHaveLength(6) + for (const outcome of failures) { + expect(deployReachedDevice(outcome)).toBe(false) + } + }) + + it('agrees with the full outcome table', () => { + for (const outcome of allOutcomes) { + expect(deployReachedDevice(outcome)).toBe(reachedDeviceByOutcome[outcome]) + } + }) }) diff --git a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts index e090b5c77..c5d8b9c88 100644 --- a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts +++ b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts @@ -16,7 +16,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: '4.1.2' } }), log, }) - expect(result).toEqual({ version: '4.1.2' }) + expect(result).toEqual({ version: '4.1.2', minEditorVersion: null }) expect(log).not.toHaveBeenCalled() }) @@ -26,7 +26,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: '4.0.5' } }), log, }) - expect(result).toEqual({ version: '4.0.5' }) + expect(result).toEqual({ version: '4.0.5', minEditorVersion: null }) }) it('returns version=null and logs a warning when the transport fails', async () => { @@ -35,7 +35,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: false, error: 'ECONNREFUSED' }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime: ECONNREFUSED'), 'warning') }) @@ -47,7 +47,7 @@ describe('probeRuntimeVersion', () => { }, log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith( expect.stringContaining('Runtime version probe failed: orchestrator HTTP down'), 'warning', @@ -63,7 +63,7 @@ describe('probeRuntimeVersion', () => { }, log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).toHaveBeenCalledWith(expect.stringContaining('plain string failure'), 'warning') }) @@ -73,7 +73,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { otherField: 'noise' } }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) expect(log).not.toHaveBeenCalled() }) @@ -83,7 +83,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: { version: 4 } }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) }) it('returns version=null when the body is null', async () => { @@ -92,7 +92,7 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: null }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) }) it('returns version=null when the body is a primitive (not an object)', async () => { @@ -101,6 +101,188 @@ describe('probeRuntimeVersion', () => { fetchVersion: async () => ({ success: true, body: 'a string' }), log, }) - expect(result).toEqual({ version: null }) + expect(result).toEqual({ version: null, minEditorVersion: null }) + }) +}) + +// --------------------------------------------------------------------------- +// /api/capabilities (DOPE-448) +// --------------------------------------------------------------------------- + +describe('probeRuntimeVersion — capabilities endpoint', () => { + /** A `fetchVersion` that fails the test if the fallback is reached. */ + const versionMustNotBeCalled = () => { + const spy = jest.fn(async () => ({ success: true as const, body: { version: 'FALLBACK' } })) + return spy + } + + it('prefers the capabilities endpoint and reads both fields from it', async () => { + const log = jest.fn() + const fetchVersion = versionMustNotBeCalled() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: '4.2.1' }, + }), + fetchVersion, + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: '4.2.1' }) + // One round-trip, not two: the capabilities answer is complete. + expect(fetchVersion).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + }) + + // The shorthands a runtime is likely to publish by hand all parse, and are + // enforced as their zero-filled equivalent — no warning, because nothing is + // being dropped. + it.each([['4.2'], ['4'], ['v5'], ['4.2.1-rc.1']])( + 'passes a %p floor through without complaint', + async (minEditorVersion) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { runtimeVersion: 'v4.2.0', minEditorVersion } }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion }) + expect(log).not.toHaveBeenCalled() + }, + ) + + // A floor that is present but unreadable declares nothing, which is the safe + // answer for the upload and the wrong one for whoever wrote it: the runtime + // believes it is enforcing a constraint that is not being applied. The + // upload still proceeds — refusing to talk to a device over a typo in its + // metadata would be worse — but it can no longer happen in silence. + it('warns when the runtime declares a floor nobody can read, and still returns it', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: 'garbage' }, + }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: 'garbage' }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('unreadable minEditorVersion ("garbage")'), 'warning') + expect(log).toHaveBeenCalledWith(expect.stringContaining('not being enforced'), 'warning') + }) + + it('reports minEditorVersion=null when the endpoint answers without that field', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { runtimeVersion: 'v4.2.0' } }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: null }) + }) + + it('ignores a non-string minEditorVersion rather than passing it to a comparison', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ + success: true, + body: { runtimeVersion: 'v4.2.0', minEditorVersion: 421 }, + }), + fetchVersion: versionMustNotBeCalled(), + log, + }) + expect(result).toEqual({ version: 'v4.2.0', minEditorVersion: null }) + }) + + // The single most important case: this is every runtime currently + // deployed. The fallback must be silent and unremarkable — if it warned, + // every existing device would nag on every upload. + // + // Note the 401: a pre-DOPE-448 runtime does NOT answer 404 for an unknown + // path. Its `restapi.py` ends in a catch-all `/` route guarded by + // `@jwt_required()`, so `/api/capabilities` lands there and comes back as + // "Missing Authorization Header". Observed against a real container — the + // 404 row is kept because a future runtime could answer either way. + const LEGACY_RESPONSES: Array<[string, string]> = [ + ['401 Missing Authorization Header', 'the / catch-all swallows the unknown path'], + ['404 Not Found', 'a runtime that routes unknown paths properly'], + ] + + it.each(LEGACY_RESPONSES)('falls back to /api/version on %p (%s), without warning', async (error) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: false, error }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + // Belt and braces: if a transport surfaces the 401 as a *successful* fetch + // carrying the error body (rather than as a failure), the probe must still + // fall back — there is no `runtimeVersion` to read out of it. + it('falls back when the 401 body arrives as a successful fetch', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body: { msg: 'Missing Authorization Header' } }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + it('falls back when the capabilities transport throws', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => { + throw new Error('TLS handshake failed') + }, + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + expect(log).not.toHaveBeenCalled() + }) + + // A partial answer is rejected wholesale: if we cannot read the version out + // of this response we do not trust the minEditorVersion beside it either. + const UNUSABLE_BODIES: Array<[unknown, string]> = [ + [{ minEditorVersion: '4.2.1' }, 'runtimeVersion is missing'], + [{ runtimeVersion: 42, minEditorVersion: '4.2.1' }, 'runtimeVersion is not a string'], + [null, 'the body is null'], + ['a string', 'the body is a primitive'], + ] + + it.each(UNUSABLE_BODIES)('falls back when %j (%s)', async (body) => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: true, body }), + fetchVersion: async () => ({ success: true, body: { version: 'v4.1.7' } }), + log, + }) + expect(result).toEqual({ version: 'v4.1.7', minEditorVersion: null }) + }) + + it('behaves exactly as before when no capabilities transport is wired', async () => { + // Platforms that have not adopted the endpoint yet (and every caller + // written before it existed) keep their previous behaviour. + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { version: '4.1.2' } }), + log, + }) + expect(result).toEqual({ version: '4.1.2', minEditorVersion: null }) + }) + + it('still warns about an unreachable device when the fallback also fails', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchCapabilities: async () => ({ success: false, error: '404 Not Found' }), + fetchVersion: async () => ({ success: false, error: 'ECONNREFUSED' }), + log, + }) + expect(result).toEqual({ version: null, minEditorVersion: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime: ECONNREFUSED'), 'warning') }) }) diff --git a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts index 15d97ab21..6829b160a 100644 --- a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts +++ b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts @@ -54,6 +54,25 @@ describe('startPlcAfterBuild', () => { expect(calls()).toBe(3) }) + it('treats a mode switch in STOP as a warning, not a failure', async () => { + // The runtime refuses to start while a hardware switch reads STOP. The + // program is on the device, so this must NOT read as a failed upload -- that + // sent users hunting for a problem that did not exist. + const { fetch, calls } = scriptedFetch(['START:ERROR_SWITCH_STOP']) + const logs: Array<{ level: string; message: string }> = [] + const outcome = await startPlcAfterBuild({ + fetchStart: fetch, + onLog: (level, message) => logs.push({ level, message }), + pollIntervalMs: 1, + }) + expect(outcome).toBe('SWITCH_IN_STOP') + // Not retried: nothing changes until a human moves the switch. + expect(calls()).toBe(1) + expect(logs[logs.length - 1].level).toBe('warning') + expect(logs[logs.length - 1].message).toMatch(/uploaded/i) + expect(logs[logs.length - 1].message).toMatch(/switch is in STOP/i) + }) + it('bails with FAILED on a non-BUSY error reply', async () => { const { fetch, calls } = scriptedFetch(['ERROR:INVALID_PROGRAM']) const logs: Array<{ level: string; message: string }> = [] diff --git a/src/backend/shared/library/deploy-runtime-program.ts b/src/backend/shared/library/deploy-runtime-program.ts index 783ae5676..2ae037970 100644 --- a/src/backend/shared/library/deploy-runtime-program.ts +++ b/src/backend/shared/library/deploy-runtime-program.ts @@ -23,6 +23,7 @@ import { startPlcAfterBuild, type StartPlcAfterBuildOptions } from './start-plc- export type DeployRuntimeProgramOutcome = | 'STARTED' + | 'UPLOADED_NOT_STARTED' | 'UPLOAD_FAILED' | 'BUILD_FAILED' | 'BUILD_TIMEOUT' @@ -30,6 +31,23 @@ export type DeployRuntimeProgramOutcome = | 'START_FAILED' | 'START_TIMEOUT' +/** + * Did the program reach the device? + * + * Distinct from "did it start", because those are different questions and the + * upload step must only fail for the first one. A runtime that refuses to start + * while its hardware mode switch reads STOP has still taken the program, and + * reporting that as a failed upload sends the user looking for a problem that + * does not exist. + * + * Shared rather than re-derived per platform: the editor and web adapters both + * turn this outcome into `UploadResult.ok`, and they must not drift on which + * outcomes count. + */ +export function deployReachedDevice(outcome: DeployRuntimeProgramOutcome): boolean { + return outcome === 'STARTED' || outcome === 'UPLOADED_NOT_STARTED' +} + export type DeployRuntimeProgramLogLevel = 'info' | 'error' | 'warning' | 'debug' export interface DeployRuntimeProgramOptions { @@ -109,6 +127,9 @@ export async function deployRuntimeProgram(opts: DeployRuntimeProgramOptions): P pollIntervalMs: opts.startIntervalMs, }) if (startOutcome === 'STARTED') return 'STARTED' + // Refused by the hardware mode switch: uploaded, deliberately not running. + // startPlcAfterBuild has already explained it as a warning. + if (startOutcome === 'SWITCH_IN_STOP') return 'UPLOADED_NOT_STARTED' if (startOutcome === 'TIMEOUT') return 'START_TIMEOUT' return 'START_FAILED' } diff --git a/src/backend/shared/library/probe-runtime-version.ts b/src/backend/shared/library/probe-runtime-version.ts index ecf1b3cfc..5a2f46209 100644 --- a/src/backend/shared/library/probe-runtime-version.ts +++ b/src/backend/shared/library/probe-runtime-version.ts @@ -27,6 +27,11 @@ * Pure: no I/O. Caller supplies the transport via `fetchVersion`. */ +// Relative import on purpose: `npm run validate:arch` only inspects relative +// specifiers, so this path is actually checked against the layer rules — +// `backend-shared -> utils` is allowed. +import { isValidVersion } from '../../../frontend/utils/semver' + /** Outcome of the transport-level fetch. Adapters return this from * their HTTPS / orchestrator round-trip; the shared helper takes * it from here. */ @@ -50,6 +55,25 @@ export interface ProbeRuntimeVersionOptions { * hit the device's `/api/version`; web POSTs to the * orchestrator's `run-command` with `api: 'api/version'`. */ fetchVersion(): Promise + /** + * Transport callback for `GET /api/capabilities` — the endpoint + * where a runtime declares what it requires of an editor + * (DOPE-448). Optional: a platform that hasn't wired it up yet + * behaves exactly as before. + * + * A runtime predating the endpoint does NOT answer 404. Its + * `restapi.py` ends in a catch-all `@restapi_bp.route("/")` + * guarded by `@jwt_required()`, so an unknown path under `/api/` + * falls into it and comes back as **401 Missing Authorization + * Header** — verified against a real pre-DOPE-448 container. Both + * outcomes land here the same way (a failed fetch, or a body with + * no `runtimeVersion`), which is why this probe keys off "can I + * read a version out of the answer" rather than off a status code. + * + * Either way `minEditorVersion` comes back `null`, meaning "this + * runtime declares no floor" — never "the editor is too old". + */ + fetchCapabilities?(): Promise /** Warning channel for diagnostics the user can see in the * compile console (e.g. "Could not reach runtime: ECONNREFUSED"). * Wired to the platform port's `log` callback by the caller so @@ -62,24 +86,88 @@ export interface ProbeRuntimeVersionResult { * when the probe couldn't extract one. The shared compile * pipeline feeds this verbatim to `isStrucppCompatibleRuntime`. */ version: string | null + /** + * The oldest editor this runtime accepts programs from, as + * declared at `GET /api/capabilities`, or `null` when the runtime + * declares nothing — it predates the endpoint, the platform has no + * transport for it, or the field was missing/malformed. + * + * `null` must be treated as "no constraint", not as a failure: it + * is the state of every runtime currently in the field, and the + * whole point of the runtime advertising rather than enforcing is + * that shipping this can't lock those out. + */ + minEditorVersion: string | null } /** * Run the probe. Always resolves — never throws — so the pipeline * gets a deterministic answer it can branch on. + * + * `/api/capabilities` is preferred where available because it carries + * both halves of the compatibility question in one round-trip, and it + * reports the version under `runtimeVersion` rather than `version`. + * When it is absent or unusable the probe falls back to + * `/api/version`, which every runtime has. */ export async function probeRuntimeVersion(opts: ProbeRuntimeVersionOptions): Promise { + const capabilities = await tryFetchCapabilities(opts) + if (capabilities) return capabilities + try { const result = await opts.fetchVersion() if (!result.success) { opts.log(`Could not reach runtime: ${result.error}`, 'warning') - return { version: null } + return { version: null, minEditorVersion: null } } - return { version: extractVersionFromBody(result.body) } + return { version: extractVersionFromBody(result.body), minEditorVersion: null } } catch (error) { const message = error instanceof Error ? error.message : String(error) opts.log(`Runtime version probe failed: ${message}`, 'warning') - return { version: null } + return { version: null, minEditorVersion: null } + } +} + +/** + * Attempt the capabilities endpoint. Returns null — meaning "fall + * back to /api/version" — for every unusable outcome: no transport + * wired, a 404 from a runtime that predates the endpoint, a thrown + * transport error, or a body with no usable `runtimeVersion`. + * + * A partial answer is deliberately not accepted. If we cannot read + * the version out of this response we do not trust the + * `minEditorVersion` beside it either, and `/api/version` is the + * authority on the version anyway. + */ +async function tryFetchCapabilities(opts: ProbeRuntimeVersionOptions): Promise { + if (!opts.fetchCapabilities) return null + try { + const result = await opts.fetchCapabilities() + if (!result.success) { + // Expected against every runtime older than the endpoint — in + // practice a 401 from the `/` catch-all, not a 404 — so + // this is an ordinary fact, not a problem worth a warning. + return null + } + const version = extractStringField(result.body, 'runtimeVersion') + if (version === null) return null + const minEditorVersion = extractStringField(result.body, 'minEditorVersion') + // A floor that is present but unreadable is the one case that must not + // pass in silence. `isVersionAtLeast` treats it as "declares nothing", + // which is the safe answer for an upload but the wrong one for whoever + // wrote it: the runtime believes it is enforcing a constraint that is + // not being applied, and without this line the only way to find out is a + // field incident. Accepted shorthands (`"4.3"`, `"4"`, `"v5"`) parse, so + // this fires on genuine junk only. + if (minEditorVersion !== null && !isValidVersion(minEditorVersion)) { + opts.log( + `Runtime declared an unreadable minEditorVersion ("${minEditorVersion}") — the editor-version floor is not being enforced.`, + 'warning', + ) + } + return { version, minEditorVersion } + } catch { + return null } } @@ -91,8 +179,18 @@ export async function probeRuntimeVersion(opts: ProbeRuntimeVersionOptions): Pro * answer as incompatible. */ function extractVersionFromBody(body: unknown): string | null { + return extractStringField(body, 'version') +} + +/** + * Read a top-level string field out of a response body, collapsing + * every other shape (not an object, field absent, field not a string) + * to `null` so callers get one "unknown" value to branch on instead of + * having to distinguish the ways a body can disappoint them. + */ +function extractStringField(body: unknown, field: string): string | null { if (typeof body !== 'object' || body === null) return null - if (!('version' in body)) return null - const v = (body as { version: unknown }).version - return typeof v === 'string' ? v : null + if (!(field in body)) return null + const value = (body as Record)[field] + return typeof value === 'string' ? value : null } diff --git a/src/backend/shared/library/start-plc-after-build.ts b/src/backend/shared/library/start-plc-after-build.ts index 583b60a51..fda248154 100644 --- a/src/backend/shared/library/start-plc-after-build.ts +++ b/src/backend/shared/library/start-plc-after-build.ts @@ -21,12 +21,17 @@ * - 150-ms interval between attempts. * - `START:OK` or `ALREADY_RUNNING` in the runtime's reply → SUCCESS. * - `BUSY` in the reply → keep retrying. + * - `ERROR_SWITCH_STOP` → SWITCH_IN_STOP: not an error. The runtime + * refuses to start while a hardware mode switch reads STOP, and + * leaving the switch there is a normal thing for someone to do + * while uploading. The upload succeeded; only the start was + * declined, and the user decides when to allow it. * - Any other reply → terminal failure (e.g. invalid program). * - Network error on the fetch → terminal failure. * - Deadline elapsed → TIMEOUT (a warning, not a fatal error). */ -export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' +export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' | 'SWITCH_IN_STOP' export type StartPlcAfterBuildLogLevel = 'info' | 'error' | 'warning' @@ -74,6 +79,18 @@ export async function startPlcAfterBuild(opts: StartPlcAfterBuildOptions): Promi return 'STARTED' } + // A hardware mode switch in STOP is a refusal, not a failure: the + // program is on the device, and the runtime is doing exactly what it + // should by declining to run it until the switch says so. Retrying + // would be wrong too — nothing changes until someone moves it. + if (status.includes('ERROR_SWITCH_STOP')) { + opts.onLog( + 'warning', + 'Program uploaded. The PLC was not started because the mode switch is in STOP — move it to RUN to start.', + ) + return 'SWITCH_IN_STOP' + } + // Only BUSY is retryable — everything else is a real error // from the runtime (invalid program, compile error, …). if (!status.includes('BUSY')) { diff --git a/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts b/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts index b3ffd025a..e27b92e01 100644 --- a/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts +++ b/src/backend/shared/project/__tests__/iterate-write-project-files.test.ts @@ -18,6 +18,7 @@ const baseFiles: WriteProjectFiles = { pouFiles: [], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], deletions: [], } @@ -126,6 +127,24 @@ describe('iterateWriteProjectFiles', () => { { category: 'remote-device', relativePath: 'devices/remote/bus0.json', content: '{"id":"bus0"}' }, ]) }) + + it('yields each data type file preserving relativePath verbatim', () => { + const entries = collect({ + ...baseFiles, + dataTypeFiles: [ + { relativePath: 'datatypes/Motor.dt', content: 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n' }, + { relativePath: 'datatypes/Color.dt', content: 'TYPE\n Color : (Red);\nEND_TYPE\n' }, + ], + }) + expect(entries.filter((e) => e.category === 'data-type')).toEqual([ + { + category: 'data-type', + relativePath: 'datatypes/Motor.dt', + content: 'TYPE\n Motor : STRUCT\n END_STRUCT;\nEND_TYPE\n', + }, + { category: 'data-type', relativePath: 'datatypes/Color.dt', content: 'TYPE\n Color : (Red);\nEND_TYPE\n' }, + ]) + }) }) describe('whole-project shapes', () => { @@ -158,7 +177,7 @@ describe('iterateWriteProjectFiles', () => { }) describe('ordering', () => { - it('emits in a stable order: project.json, device-config, pin-mapping, library-manifest, pous, servers, remote-devices', () => { + it('emits in a stable order: project.json, device-config, pin-mapping, library-manifest, pous, servers, remote-devices, data-types', () => { const entries = collect({ ...baseFiles, deviceConfig: 'D', @@ -167,6 +186,7 @@ describe('iterateWriteProjectFiles', () => { pouFiles: [{ relativePath: 'pous/programs/a.st', content: 'A' }], serverFiles: [{ relativePath: 'devices/servers/s.json', content: 'S' }], remoteDeviceFiles: [{ relativePath: 'devices/remote/r.json', content: 'R' }], + dataTypeFiles: [{ relativePath: 'datatypes/T.dt', content: 'T' }], }) expect(entries.map((e) => e.category)).toEqual([ 'project-json', @@ -176,6 +196,7 @@ describe('iterateWriteProjectFiles', () => { 'pou', 'server', 'remote-device', + 'data-type', ]) }) }) diff --git a/src/backend/shared/project/iterate-write-project-files.ts b/src/backend/shared/project/iterate-write-project-files.ts index 8c698a058..6957c592c 100644 --- a/src/backend/shared/project/iterate-write-project-files.ts +++ b/src/backend/shared/project/iterate-write-project-files.ts @@ -35,6 +35,7 @@ export type WriteProjectFileEntry = | { category: 'pou'; relativePath: string; content: string } | { category: 'server'; relativePath: string; content: string } | { category: 'remote-device'; relativePath: string; content: string } + | { category: 'data-type'; relativePath: string; content: string } /** * Yield every file in a WriteProjectFiles as a `WriteProjectFileEntry`. @@ -67,4 +68,7 @@ export function* iterateWriteProjectFiles(files: WriteProjectFiles): Generator { // non-deterministic and this test already validated at the SET layer // that the protocol accepts the unforce request. }, 30000) + + it('FC 0x46 DEBUG_GET_STATUS reports the PLC running with an advancing tick', async () => { + const first = await client.getStatus() + expect(first.success).toBe(true) + expect(first.running).toBe(true) + expect(typeof first.tick).toBe('number') + expect(typeof first.uptimeMs).toBe('number') + + // Let a few scan cycles run — the scan counter must advance. + await new Promise((r) => setTimeout(r, 200)) + const second = await client.getStatus() + expect(second.success).toBe(true) + expect(second.tick!).toBeGreaterThan(first.tick!) + }, 30000) + + it('FC 0x47 DEBUG_GET_VERSION returns the runtime version string', async () => { + const result = await client.getVersion() + expect(result.success).toBe(true) + // OPENPLC_RUNTIME_VERSION is a dotted version like "4.2.7". + expect(result.version).toMatch(/^\d+\.\d+\.\d+/) + }, 30000) + + it('FC 0x48 DEBUG_GET_BOARD_ID returns the AVR unique id (9 bytes on ATmega2560)', async () => { + const result = await client.getBoardId() + expect(result.success).toBe(true) + // ArduinoUniqueID reports 9 bytes on AVR (10 on ATmega328PB). The + // emulated ATmega2560 yields a non-empty id; assert it round-trips. + expect(result.boardId!.length).toBeGreaterThan(0) + expect(result.boardIdHex).toMatch(/^[0-9a-f]+$/) + }, 30000) }) diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 462c8c1b4..ca3a5eee9 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -7,7 +7,7 @@ */ import { ModbusRtuClient, type SerialPortLike } from '../modbus-rtu-client' -import { ModbusDebugResponse, ModbusFunctionCode } from '../types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../types' // jsdom polyfill if (typeof globalThis.TextEncoder === 'undefined') { @@ -551,6 +551,219 @@ describe('ModbusRtuClient', () => { }) }) + // ----------------------------------------------------------------------- + // getStatus (FC 0x46) + // ----------------------------------------------------------------------- + describe('getStatus', () => { + function statusPayload(running: number, tick: number, uptime: number): Uint8Array { + const payload = new Uint8Array(10) + payload[0] = ModbusDebugResponse.SUCCESS + payload[1] = running + payload[2] = (tick >>> 24) & 0xff + payload[3] = (tick >>> 16) & 0xff + payload[4] = (tick >>> 8) & 0xff + payload[5] = tick & 0xff + payload[6] = (uptime >>> 24) & 0xff + payload[7] = (uptime >>> 16) & 0xff + payload[8] = (uptime >>> 8) & 0xff + payload[9] = uptime & 0xff + return payload + } + + it('returns running / tick / uptime on success', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(1, 42, 256))) + const result = await client.getStatus() + // `plcState` mirrors `running` as the run/stop machine's tri-state; the + // fixture frame carries no switch byte, so that field stays absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(0, 1, 1))) + const result = await client.getStatus() + expect(result.running).toBe(false) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([0x99]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete success payload', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1])), + ) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete status response') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_STATUS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getVersion (FC 0x47) + // ----------------------------------------------------------------------- + describe('getVersion', () => { + it('returns the ASCII version string on success', async () => { + await connectClient() + const ver = new TextEncoder().encode('4.2.7') + const payload = new Uint8Array(1 + ver.length) + payload[0] = ModbusDebugResponse.SUCCESS + payload.set(ver, 1) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, payload)) + const result = await client.getVersion() + expect(result).toEqual({ success: true, version: '4.2.7' }) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, new Uint8Array([0x99]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_VERSION]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getBoardId (FC 0x48) + // ----------------------------------------------------------------------- + describe('getBoardId', () => { + it('returns id bytes and hex on success', async () => { + await connectClient() + const payload = new Uint8Array([ModbusDebugResponse.SUCCESS, 0x03, 0x0a, 0xbc, 0x01]) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload)) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00])), + ) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([0x99, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete id data', async () => { + await connectClient() + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]), + ), + ) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete board-id data') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(frame.length + 2) + full.set(frame, 0) + full[frame.length] = (crc >>> 8) & 0xff + full[frame.length + 1] = crc & 0xff + autoRespond(full) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + // ----------------------------------------------------------------------- // sendRequestImpl edge cases // ----------------------------------------------------------------------- @@ -684,6 +897,71 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(false) expect(result.error).toBe('non-error string') }) + + it('getStatus handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getStatus handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getStatus() + expect(result.error).toBe('non-error string') + }) + + it('getVersion handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getVersion handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getVersion() + expect(result.error).toBe('non-error string') + }) + + it('getBoardId handles response too short (<10 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('setPlcState handles response too short (<8 bytes)', async () => { + // Belongs here rather than with the wire-framing tests: `sendRequest` prepends + // 6 bytes of TCP-compat padding, so no real reply can be short enough to reach + // this guard — a 4-byte frame lands on the PARSER's own "too short" instead. + // Both messages read alike, which is how a fixture can appear to cover this + // branch while never entering it. + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0])) + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid response: too short') + }) + + it('setPlcState reports a non-Error rejection', async () => { + await connectClient() + mockSendRequest(client, 'serial port vanished') + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result).toEqual({ success: false, error: 'serial port vanished' }) + }) + + it('getBoardId handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getBoardId() + expect(result.error).toBe('non-error string') + }) }) // ----------------------------------------------------------------------- @@ -775,4 +1053,239 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(true) }) }) + + // ----------------------------------------------------------------------- + // setPlcState (FC 0x4b) + // + // The end-to-end coverage for run/stop lives in plc-control-e2e.test.ts, which + // only runs when it is pointed at built firmware — so without these the wire + // framing here is unexercised on any ordinary test run. + // ----------------------------------------------------------------------- + describe('setPlcState', () => { + /** `[status][plcState][switchPosition]` — the FC 0x4b acknowledgement payload. */ + function ackPayload(status: number, state: number, switchPosition: number): Uint8Array { + return new Uint8Array([status, state, switchPosition]) + } + + it('sends the run request and returns the acknowledged state', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Request is [slaveId][FC][state] + CRC — the state byte is what distinguishes + // run from stop, and getting it backwards would stop a PLC on a start click. + expect(written[0][1]).toBe(ModbusFunctionCode.PLC_SET_STATE) + expect(written[0][2]).toBe(1) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.RUNNING }) + }) + + it('encodes a stop request as state 0', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + + expect(written[0][2]).toBe(0) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.STOPPED }) + }) + + it('surfaces a RUN refused by the hardware mode switch', async () => { + await connectClient() + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ), + ) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Drives the "flip the switch to RUN" warning rather than a generic failure. + expect(result).toMatchObject({ success: false, refusedBySwitch: true }) + }) + + it('rejects a well-framed reply whose PDU is truncated', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.PLC_SET_STATE]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // On-device license storage (FC 0x49 write / 0x4A read) + // + // The end-to-end coverage against a real license store needs firmware built + // with the store backend compiled in, so without these the wire framing here + // is unexercised on an ordinary test run. + // ----------------------------------------------------------------------- + describe('writeLicense / readLicense', () => { + /** A recognisable 98-byte blob whose first byte is the 'O' of the LE magic. */ + function sampleBlob(): Uint8Array { + const blob = new Uint8Array(98) + for (let i = 0; i < blob.length; i++) blob[i] = i & 0xff + blob[0] = 0x4f + return blob + } + + it('frames a write as [FC][len:u16BE][blob] and reports SUCCESS', async () => { + await connectClient() + const blob = sampleBlob() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse(1, ModbusFunctionCode.DEBUG_WRITE_LICENSE, new Uint8Array([ModbusDebugResponse.SUCCESS])), + ), + 0, + ) + } + + const result = await client.writeLicense(blob) + + // [slaveId][FC][len hi][len lo][blob...] + CRC. The length is BIG-endian + // even though the blob content it frames is little-endian. + expect(written[0][1]).toBe(ModbusFunctionCode.DEBUG_WRITE_LICENSE) + expect(written[0][2]).toBe(0x00) + expect(written[0][3]).toBe(98) + expect(written[0][4]).toBe(0x4f) + expect(result).toMatchObject({ success: true, status: ModbusDebugResponse.SUCCESS }) + }) + + it('reports LIC_UNSUPPORTED on a write as a valid device state, not a failure', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_WRITE_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_UNSUPPORTED])), + ) + + const result = await client.writeLicense(sampleBlob()) + + expect(result).toMatchObject({ success: true, unsupported: true }) + }) + + it('reads the stored blob back byte-for-byte', async () => { + await connectClient() + const blob = sampleBlob() + const payload = new Uint8Array(3 + blob.length) + payload[0] = ModbusDebugResponse.SUCCESS + payload[1] = 0x00 // len hi + payload[2] = blob.length // len lo + payload.set(blob, 3) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, payload)) + + const result = await client.readLicense() + + expect(result.success).toBe(true) + expect(Array.from(result.blob ?? [])).toEqual(Array.from(blob)) + // The LE uint32 magic 0x434C504F serializes to 4F 50 4C 43, so a leading + // 0x4F proves the byte order survived the write -> store -> read path. + expect(result.blob?.[0]).toBe(0x4f) + }) + + it('classifies LIC_EMPTY as virgin storage rather than an error', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_EMPTY])), + ) + + const result = await client.readLicense() + + expect(result).toMatchObject({ success: true, empty: true }) + expect(result.blob).toBeUndefined() + }) + + it('classifies LIC_CORRUPT as a valid device state rather than an error', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_READ_LICENSE, new Uint8Array([ModbusDebugResponse.LIC_CORRUPT])), + ) + + const result = await client.readLicense() + + expect(result).toMatchObject({ success: true, corrupt: true }) + }) + + it('rejects a read whose declared length exceeds the bytes received', async () => { + await connectClient() + // len says 98, only 2 blob bytes follow — the truncation must be reported, + // not silently handed on as a short "license". + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.DEBUG_READ_LICENSE, + new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00, 98, 0x4f, 0x50]), + ), + ) + + const result = await client.readLicense() + + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete license blob') + }) + + it('returns an error on a read timeout', async () => { + await connectClient() + const result = await client.readLicense() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + + it('returns an error on a write timeout', async () => { + await connectClient() + const result = await client.writeLicense(sampleBlob()) + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) }) diff --git a/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts new file mode 100644 index 000000000..6b540b7f0 --- /dev/null +++ b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts @@ -0,0 +1,254 @@ +/** + * End-to-end validation of the baremetal run/stop state machine over avr8js. + * + * Boots simulator firmware in avr8js and exercises the run/stop wire protocol against it: + * query, stop, output de-energisation, program re-initialisation, restart, and + * that the debug channel survives a stop. + * + * The firmware must be built first, by the editor's own compile pipeline. Same + * gating style as debug-e2e.test.ts: the test skips unless the artefacts are + * pointed at, so CI without an AVR toolchain stays green. + * + * PLC_CONTROL_HEX=/path/to/Baremetal.ino.hex \ + * PLC_CONTROL_DEBUG_MAP=/path/to/debug-map.json \ + * npx jest src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts + * + * The firmware must be built from a program with a `counter : INT := 0` + * variable incremented every scan and a `pulse AT %QX0.0 : BOOL` driven + * unconditionally TRUE -- `counter` proves execution and re-init, `pulse` + * proves the stop clamp. + */ + +import fs from 'node:fs' +import path from 'node:path' + +import { ModbusRtuClient } from '../modbus-rtu-client' +import { SimulatorModule } from '../simulator-module' +import { PlcRuntimeState, PlcSwitchPosition } from '../types' +import { VirtualSerialPort } from '../virtual-serial-port' + +// jsdom polyfill -- matches modbus-rtu-client.test.ts. +if (typeof globalThis.TextDecoder === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { TextEncoder, TextDecoder } = require('util') + globalThis.TextEncoder = TextEncoder + globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder +} + +const HEX_PATH = process.env.PLC_CONTROL_HEX ?? '' +const MAP_PATH = process.env.PLC_CONTROL_DEBUG_MAP ?? '' +const ENABLED = HEX_PATH !== '' && MAP_PATH !== '' && fs.existsSync(HEX_PATH) && fs.existsSync(MAP_PATH) +const describeIfEnabled: typeof describe = ENABLED ? describe : describe.skip + +// Second firmware, built from the same program but with a HAL that overrides +// `hardwareStateSwitch()` to read STOP for the first 3 seconds of uptime and +// RUN afterwards. Exercises the paths a switchless board can't reach. +const SWITCH_HEX_PATH = process.env.PLC_CONTROL_SWITCH_HEX ?? '' +const SWITCH_ENABLED = SWITCH_HEX_PATH !== '' && fs.existsSync(SWITCH_HEX_PATH) +const describeIfSwitch: typeof describe = SWITCH_ENABLED ? describe : describe.skip + +/** Resolve a variable path from debug-map.json into the packed + * `(arr << 16) | elem` address the debug FCs take. */ +function resolveDebugAddr(debugMapJson: string, pathSuffix: string): number { + const map = JSON.parse(debugMapJson) as { + leaves: Array<{ arrayIdx: number; elemIdx: number; path: string }> + } + const leaf = map.leaves.find((l) => l.path.toUpperCase().endsWith(pathSuffix.toUpperCase())) + if (!leaf) { + throw new Error(`No debug leaf matching "${pathSuffix}". Available: ${map.leaves.map((l) => l.path).join(', ')}`) + } + return (leaf.arrayIdx << 16) | leaf.elemIdx +} + +describeIfEnabled('Baremetal run/stop state machine end-to-end (FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + let counterAddr: number + let pulseAddr: number + + /** Read a single variable's raw bytes via FC 0x44. */ + async function readVar(addr: number): Promise { + const res = await client.getVariablesList([addr]) + if (!res.success || !res.data) throw new Error(`getVariablesList failed: ${res.error ?? 'no data'}`) + return res.data + } + + async function readCounter(): Promise { + // FC 0x44's payload is the requested variables' raw bytes concatenated, + // with no per-variable size prefix. One INT => 2 bytes, little-endian. + const data = await readVar(counterAddr) + expect(data.length).toBe(2) + return data[0] | (data[1] << 8) + } + + async function readPulse(): Promise { + const data = await readVar(pulseAddr) + expect(data.length).toBe(1) + return data[0] + } + + /** Let the target run for a while in real time so scan cycles elapse. */ + async function settle(ms = 400): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) + } + + beforeAll(async () => { + const hex = fs.readFileSync(path.resolve(HEX_PATH), 'utf-8') + const debugMapJson = fs.readFileSync(path.resolve(MAP_PATH), 'utf-8') + counterAddr = resolveDebugAddr(debugMapJson, 'counter') + pulseAddr = resolveDebugAddr(debugMapJson, 'pulse') + + sim = new SimulatorModule() + sim.loadAndRun(hex) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + await client.connect() + }, 600000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots RUNNING with the virtual switch in RUN', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + // The simulator HAL implements no hardwareStateSwitch() override, so the + // weak default must report RUN -- this is the "nothing changes for boards + // that opt out" guarantee. + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + }, 30000) + + it('executes the program while running', async () => { + const first = await readCounter() + await settle() + const second = await readCounter() + expect(second).not.toBe(first) + }, 30000) + + it('drives the located output TRUE while running', async () => { + expect(await readPulse()).toBe(1) + }, 30000) + + it('run/stop command STOPs the PLC to STOPPED', async () => { + const res = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(res.success).toBe(true) + await settle() + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('freezes the program while stopped', async () => { + const first = await readCounter() + await settle() + expect(await readCounter()).toBe(first) + }, 30000) + + it('de-energises the located output while stopped', async () => { + expect(await readPulse()).toBe(0) + }, 30000) + + it('re-initialised the program on the STOP edge', async () => { + // counter is declared `INT := 0` and increments every scan, so a value of + // 0 while stopped can only come from the STOP-edge re-init. + expect(await readCounter()).toBe(0) + }, 30000) + + it('run/stop command RUNs it again, from cycle 1', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(res.success).toBe(true) + expect(res.refusedBySwitch).toBeFalsy() + await settle() + + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + + // Counting resumed, and the output is driven again. + const first = await readCounter() + await settle() + expect(await readCounter()).not.toBe(first) + expect(await readPulse()).toBe(1) + }, 30000) + + it('reading the status never changes state', async () => { + const before = await client.getStatus() + const after = await client.getStatus() + expect(after.plcState).toBe(before.plcState) + expect(after.switchPosition).toBe(before.switchPosition) + }, 30000) + + it('keeps the debug channel alive across a stop/start cycle', async () => { + await client.setPlcState(PlcRuntimeState.STOPPED) + await settle() + // FC 0x45 must still answer while stopped -- the control channel IS the + // Modbus link, so it cannot depend on the PLC running. + const md5 = await client.getMd5Hash() + expect(md5.md5).toMatch(/^[0-9a-f]{32}$/) + await client.setPlcState(PlcRuntimeState.RUNNING) + await settle() + }, 60000) +}) + +describeIfSwitch('Hardware mode switch (HAL override, FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + + beforeAll(async () => { + sim = new SimulatorModule() + sim.loadAndRun(fs.readFileSync(path.resolve(SWITCH_HEX_PATH), 'utf-8')) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + // connect() already waits 2.5s for setup(); the override reads STOP until + // 3s of firmware uptime, so the first assertions land inside the STOP + // window. + await client.connect() + }, 120000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots STOPPED when the switch reads STOP, and reports the position', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('refuses a RUN request while the switch reads STOP', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + // Refused, not queued: success is false and the reason is specific enough + // for the editor to tell the user to flip the switch. + expect(res.success).toBe(false) + expect(res.refusedBySwitch).toBe(true) + expect(res.state).toBe(PlcRuntimeState.STOPPED) + expect(res.switchPosition).toBe(PlcSwitchPosition.STOP) + + // Still stopped afterwards -- the refusal did not leave a pending start. + const after = await client.getStatus() + expect(after.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('runs by itself on the STOP -> RUN rising edge, with no command sent', async () => { + // Wait past the override's 3s flip point. Nothing is sent to the target in + // between: the transition must come from the switch alone (rule 3). + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const state = await client.getStatus() + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) + + it('accepts software stop and start once the switch reads RUN', async () => { + const stopped = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(stopped.success).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.STOPPED) + + const started = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(started.success).toBe(true) + expect(started.refusedBySwitch).toBeFalsy() + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) +}) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 0e628d582..3f963fcb6 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -1,7 +1,21 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildPlcSetStateRequest, + buildReadLicenseRequest, + buildWriteLicenseRequest, + parsePlcSetStateResponse, + parseReadLicenseResponse, + parseWriteLicenseResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugLicenseReadResult, + DebugLicenseWriteResult, + DebugStatusResult, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' import { detectTargetEndian } from '@root/frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from './types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from './types' export interface SerialPortLike { isOpen: boolean @@ -462,4 +476,175 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + // ------------------------------------------------------------------------- + // Always-on debugger extras (FC 0x46/0x47/0x48). Each is a bare-FC request. + // Response offsets account for the 6-byte TCP-compat padding sendRequestImpl + // prepends: slaveId@6, FC@7, status@8, payload@9+. + // ------------------------------------------------------------------------- + + async getStatus(): Promise { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_STATUS as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + if (response.length < 18) { + return { success: false, error: `Incomplete status response (${response.length} bytes, expected at least 18)` } + } + + return { + success: true, + running: readUint8(response, 9) !== 0, + // Same byte as `running`, as the tri-state the run/stop machine has. + plcState: readUint8(response, 9), + tick: readUint32BE(response, 10), + uptimeMs: readUint32BE(response, 14), + // Appended by firmware carrying the run/stop state machine; absent on + // older firmware, which callers read as "no switch gating". + ...(response.length >= 19 ? { switchPosition: readUint8(response, 18) } : {}), + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getVersion(): Promise<{ success: boolean; version?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_VERSION, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_VERSION as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const version = new TextDecoder().decode(response.slice(9)).replace(/\0+$/, '').trim() + return { success: true, version } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getBoardId(): Promise<{ success: boolean; boardId?: Uint8Array; boardIdHex?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 10) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 10)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_BOARD_ID as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const idLen = readUint8(response, 9) + if (response.length < 10 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${response.length - 10})`, + } + } + + const boardId = response.slice(10, 10 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only: reading the state is `getStatus()` (FC 0x46), which already + * reports it. A RUN request is refused (not queued) while the mode switch + * reads STOP; `refusedBySwitch` says so. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + // buildPlcSetStateRequest returns [FC][state]; assembleRequest writes the + // FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildPlcSetStateRequest(state) + const response = await this.sendRequest(this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1))) + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * FC 0x49 DEBUG_WRITE_LICENSE. Storing is not validation — read the blob back + * to learn what the target now holds. + */ + async writeLicense(blob: Uint8Array): Promise { + try { + // buildWriteLicenseRequest returns [FC][len:u16BE][blob]; assembleRequest + // writes the FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildWriteLicenseRequest(blob) + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.DEBUG_WRITE_LICENSE, pdu.subarray(1)), + ) + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseWriteLicenseResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * FC 0x4A DEBUG_READ_LICENSE. Bare `[FC]` request; the response carries the + * blob framed by a big-endian length (the content itself is little-endian). + * + * No size-aware framing here, unlike the editor's serial client: this transport + * runs against an in-process virtual port, so a 98-byte reply arrives in one + * piece and there is no inter-chunk gap to be truncated by. + */ + async readLicense(): Promise { + try { + const pdu = buildReadLicenseRequest() + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.DEBUG_READ_LICENSE, pdu.subarray(1)), + ) + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseReadLicenseResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index a2ac377f6..126ac4661 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -4,10 +4,48 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, + /** Store a license blob on the device (VPP licensing). Write-only: the read + * back is DEBUG_READ_LICENSE, and it is a SEPARATE round trip on purpose — + * 0x49 only stores bytes, it validates nothing. */ + DEBUG_WRITE_LICENSE = 0x49, + /** Read the stored license blob back off the device. */ + DEBUG_READ_LICENSE = 0x4a, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports the state — there is deliberately no second FC for + * querying it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** DEBUG_READ_LICENSE only: virgin storage — no license has been provisioned. */ + LIC_EMPTY = 0x83, + /** DEBUG_READ_LICENSE only: the magic matched but the crc32 did not. */ + LIC_CORRUPT = 0x84, + /** Licensing FCs: the target has no on-device license-store backend at all. + * A valid device state, not a transport failure. */ + LIC_UNSUPPORTED = 0x85, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, +} + +/** Runtime states reported by DEBUG_GET_STATUS and PLC_SET_STATE (and by + * Runtime v4's `/api/status`). */ +export enum PlcRuntimeState { + STOPPED = 0, + RUNNING = 1, + ERROR = 2, +} + +/** Mode-switch positions. Boards with no physical switch always report RUN, so + * callers need no "absent" case. */ +export enum PlcSwitchPosition { + STOP = 0, + RUN = 1, } diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 31c808344..ab36973ea 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -85,6 +85,9 @@ const PLCStructureVariableSchema = z.object({ }), }) .optional(), + // Preserved through load — the .dt serializer emits it as a + // trailing (* … *) comment; omitting it here strips it on open. + documentation: z.string().optional(), }) const PLCStructureDatatypeSchema = z.object({ name: z.string(), @@ -803,7 +806,10 @@ const PLCProjectLibraryRefSchema = z.object({ type PLCProjectLibraryRef = z.infer const PLCProjectDataSchema = z.object({ - dataTypes: z.array(PLCDataTypeSchema), + // Defaulted: once data types live in datatypes/.dt files the + // field disappears from newly-written project.json (DOPE-385); + // legacy projects still carry it and keep validating. + dataTypes: z.array(PLCDataTypeSchema).default([]), pous: z.array(PLCPouSchema).default([]), configuration: PLCConfigurationSchema, servers: z.array(PLCServerSchema).optional(), diff --git a/src/backend/shared/utils/__tests__/parse-project-files.test.ts b/src/backend/shared/utils/__tests__/parse-project-files.test.ts index 929a0f8b0..98c11e93e 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -823,3 +823,89 @@ describe('parseProjectFiles — POU name derivation', () => { expect(result.projectData.pous[0].name).toBe('Derived') }) }) + +// --------------------------------------------------------------------------- +// Data type files (datatypes/.dt) — hydration matrix (DOPE-533) +// --------------------------------------------------------------------------- + +describe('data type file hydration', () => { + const legacyEnum = { + name: 'Color', + derivation: 'enumerated', + initialValue: '', + values: [{ description: 'Red' }, { description: 'Green' }], + } + + const parse = (dataTypeFiles: RawProjectFile[], jsonDataTypes: unknown[] = []) => + parseProjectFiles( + '/p', + makeProjectJson({ dataTypes: jsonDataTypes }), + makeDeviceConfig(), + makePinMapping(), + [], + [], + [], + '', + dataTypeFiles, + ) + + it('parses .dt files into dataTypes (files win over legacy JSON)', () => { + const result = parse( + [{ relativePath: 'datatypes/Mode.dt', content: 'TYPE\n Mode : (Auto, Manual);\nEND_TYPE\n' }], + [legacyEnum], + ) + expect(result.projectData.dataTypes).toEqual([ + { + name: 'Mode', + derivation: 'enumerated', + values: [{ description: 'Auto' }, { description: 'Manual' }], + initialValue: '', + }, + ]) + expect(result.warnings).toBeUndefined() + expect(result.unparsedDataTypeFiles).toBeUndefined() + }) + + it('falls back to legacy project.json dataTypes when no .dt files exist', () => { + const result = parse([], [legacyEnum]) + expect(result.projectData.dataTypes).toEqual([legacyEnum]) + }) + + it('yields an empty list when neither files nor legacy JSON carry types', () => { + const result = parse([], []) + expect(result.projectData.dataTypes).toEqual([]) + }) + + it('preserves unparseable .dt files raw with a warning instead of dropping them', () => { + const broken = { relativePath: 'datatypes/Broken.dt', content: 'TYPE\n Broken : ???;\nEND_TYPE\n' } + const result = parse([broken, { relativePath: 'datatypes/Ok.dt', content: 'TYPE\n Ok : (A);\nEND_TYPE\n' }]) + expect(result.projectData.dataTypes.map((d) => d.name)).toEqual(['Ok']) + expect(result.unparsedDataTypeFiles).toEqual([broken]) + expect(result.warnings?.some((w) => w.includes('datatypes/Broken.dt'))).toBe(true) + }) + + it('treats a declared-name/file-name mismatch as unparseable (raw preserved)', () => { + const mismatched = { relativePath: 'datatypes/Alpha.dt', content: 'TYPE\n Beta : (A);\nEND_TYPE\n' } + const result = parse([mismatched]) + expect(result.projectData.dataTypes).toEqual([]) + expect(result.unparsedDataTypeFiles).toEqual([mismatched]) + expect(result.warnings?.some((w) => w.includes('does not match'))).toBe(true) + }) + + it('keeps struct field documentation through schema validation (legacy JSON)', () => { + const structWithDoc = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '' } }, + documentation: 'target speed in rpm', + }, + ], + } + const result = parse([], [structWithDoc]) + expect(result.projectData.dataTypes).toEqual([structWithDoc]) + }) +}) diff --git a/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts b/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts index e29d95644..c4ba27dd9 100644 --- a/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts +++ b/src/backend/shared/utils/modbus/__tests__/generate-modbus-master-config.test.ts @@ -112,6 +112,48 @@ describe('generateModbusMasterConfig', () => { expect(ioPoints[0].iec_location).toBe('%MW0') expect(ioPoints[0].len).toBe(10) expect(ioPoints[0].cycle_time_ms).toBe(100) + expect(ioPoints[0].error_handling).toBe('keep-last-value') + }) + + // Regression, openplc-editor#691: the per-group "Keep last value / Set to + // zero" choice existed only in the UI. It was stored in the project and + // dropped here, so the runtime never learned about it and always kept the + // last value when a device went unreachable. + it('emits the error handling each IO group is configured with', () => { + const device = makeTcpDevice() + device.modbusTcpConfig!.ioGroups[0].errorHandling = 'set-to-zero' + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points[0].error_handling).toBe('set-to-zero') + }) + + it('emits error handling per point, not per device', () => { + const device = makeTcpDevice() + const [firstGroup] = device.modbusTcpConfig!.ioGroups + device.modbusTcpConfig!.ioGroups = [ + { ...firstGroup, id: 'g1', errorHandling: 'set-to-zero' }, + { ...firstGroup, id: 'g2', errorHandling: 'keep-last-value' }, + ] + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points.map((p: { error_handling: string }) => p.error_handling)).toEqual([ + 'set-to-zero', + 'keep-last-value', + ]) + }) + + it('defaults to keeping the last value when a group predates the field', () => { + // Projects saved before the option shipped have no value stored; the + // runtime's historical behaviour is to keep the last value. + const device = makeTcpDevice() + const group = device.modbusTcpConfig!.ioGroups[0] + delete (group as { errorHandling?: unknown }).errorHandling + + const parsed = JSON.parse(generateModbusMasterConfig([device])!) + + expect(parsed[0].config.io_points[0].error_handling).toBe('keep-last-value') }) it('generates RTU config with serial port parameters', () => { diff --git a/src/backend/shared/utils/modbus/generate-modbus-master-config.ts b/src/backend/shared/utils/modbus/generate-modbus-master-config.ts index 661c77333..23b5b363d 100644 --- a/src/backend/shared/utils/modbus/generate-modbus-master-config.ts +++ b/src/backend/shared/utils/modbus/generate-modbus-master-config.ts @@ -6,6 +6,12 @@ interface ModbusMasterIOPoint { iec_location: string len: number cycle_time_ms: number + /** What the runtime does with this point's IEC buffer while the device is + * unreachable. The UI has offered the choice per I/O group for a while, but + * it never reached the config — so the runtime always kept the last value + * (openplc-editor#691). Emitted for every point so a config never leaves + * the behaviour implicit. */ + error_handling: ModbusIOGroup['errorHandling'] } // Base device config with common fields @@ -83,6 +89,9 @@ const convertIOGroupToIOPoint = (ioGroup: ModbusIOGroup): ModbusMasterIOPoint => iec_location: iecLocation, len: ioGroup.length, cycle_time_ms: ioGroup.cycleTime, + // Groups saved before the field existed have no value; the runtime's + // historical behaviour is to keep the last value, so default to it. + error_handling: ioGroup.errorHandling ?? 'keep-last-value', } } diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 0564d583d..a242d539f 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -8,6 +8,7 @@ * The backend only reads raw files from disk; all parsing happens here. */ +import { parseDataTypeFromText } from '../../../frontend/utils/PLC/data-type-text-parser' import { detectLanguageFromExtension, findLastEndVarIndex, @@ -79,6 +80,11 @@ export interface ParsedProjectData { devicePinMapping?: DevicePin[] | Record /** Warnings collected during parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse (or whose declared + * name mismatched the file name). Preserved raw so the save flow + * can echo them back verbatim — an unreadable file must never be + * silently dropped from disk. */ + unparsedDataTypeFiles?: RawProjectFile[] } // --------------------------------------------------------------------------- @@ -387,6 +393,9 @@ function deduplicatePouFiles(pouFiles: RawProjectFile[]): RawProjectFile[] { * @param pouFiles - Raw POU files (.st, .il, .ld, .fbd, .py, .cpp, .json) * @param serverFiles - Raw server config files from devices/servers/ * @param remoteDeviceFiles - Raw remote device config files from devices/remote/ + * @param libraryManifest - Raw content of library.json (library projects) + * @param dataTypeFiles - Raw datatypes/*.dt files; when any are present they + * win over the legacy `project.json` `data.dataTypes` field */ export function parseProjectFiles( projectPath: string, @@ -397,6 +406,7 @@ export function parseProjectFiles( serverFiles: RawProjectFile[], remoteDeviceFiles: RawProjectFile[], libraryManifest: string = '', + dataTypeFiles: RawProjectFile[] = [], ): ParsedProjectData { const warnings: string[] = [] @@ -525,6 +535,26 @@ export function parseProjectFiles( } } + // Parse data type files (datatypes/.dt). Own loop — never + // through parsePouFile (pou-path detection throws for datatypes/). + // The declared name must match the file name; a mismatch or any + // parse failure preserves the raw file so the save flow writes it + // back verbatim instead of silently dropping it from disk. + const dataTypesFromFiles: PLCDataType[] = [] + const unparsedDataTypeFiles: RawProjectFile[] = [] + for (const file of dataTypeFiles) { + const expectedName = getBaseNameFromPath(file.relativePath) + const result = parseDataTypeFromText(file.content, expectedName) + if (result.dataType) { + dataTypesFromFiles.push(result.dataType) + } else { + warnings.push( + `Data type file "${file.relativePath}" could not be parsed and was preserved as-is: ${result.error ?? 'unknown error'}`, + ) + unparsedDataTypeFiles.push(file) + } + } + // Extract project data fields const data = project.data ?? {} const configuration = (data.configuration ?? @@ -552,7 +582,10 @@ export function parseProjectFiles( return { meta, projectData: { - dataTypes: (data.dataTypes as PLCDataType[]) ?? [], + // Migration rule: any .dt file present ⇒ the files are the + // source of truth; the legacy JSON field is only the fallback + // for projects that predate the format. + dataTypes: dataTypeFiles.length > 0 ? dataTypesFromFiles : ((data.dataTypes as PLCDataType[]) ?? []), pous, configurations: configuration, servers: servers.length > 0 ? servers : ((data.servers as PLCServer[]) ?? []), @@ -574,5 +607,6 @@ export function parseProjectFiles( deviceConfiguration, devicePinMapping, warnings: warnings.length > 0 ? warnings : undefined, + ...(unparsedDataTypeFiles.length > 0 ? { unparsedDataTypeFiles } : {}), } } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx index 92d4dc968..d0f3ebe61 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx @@ -9,8 +9,11 @@ import { scopeCompletionToVariable, } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' +import type { CreateGraphicalVariableModalData } from '../../../../../store/slices/modal/types' import { cn } from '../../../../../utils/cn' import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' +import type { BoundBlockPin } from '../../../../../utils/PLC/validate-variable-type' +import { isGenericTypeName } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { buildGenericNode } from '../../../../_molecules/graphical-editor/fbd/fbd-utils/nodes' @@ -20,6 +23,53 @@ import { CustomFbdNodeTypes } from '..' import { BasicNodeData } from '../utils' import { getFBDPouVariablesRungNodeAndEdges } from '../utils/utils' +/** Minimal shape of an FBD rung this module needs — nodes plus their wiring. */ +type FBDRungGraph = { + nodes: Node[] + edges: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }[] +} + +const VARIABLE_BOX_TYPES = ['input-variable', 'output-variable', 'inout-variable'] + +/** + * The pins of the same block instance that already carry a variable. A generic + * pin (`ANY`, `ANY_NUM`, …) has no type of its own, so a variable created on it + * takes the concrete type the block already settled on elsewhere (#479). + * + * Walks the graph the same way `expectedType` does — from this box to the block + * it is wired to — and then across that block's remaining edges. + */ +const boundPinsOfConnectedBlock = (rung: FBDRungGraph, boxId: string, isInputBox: boolean): BoundBlockPin[] => { + const linkToBlock = rung.edges.find((edge) => (isInputBox ? edge.source === boxId : edge.target === boxId)) + const blockId = isInputBox ? linkToBlock?.target : linkToBlock?.source + if (!blockId) return [] + + const pinDefinitions = (rung.nodes.find((node) => node.id === blockId)?.data.variant as BlockVariant | undefined) + ?.variables + if (!pinDefinitions) return [] + + const pins: BoundBlockPin[] = [] + for (const edge of rung.edges) { + const intoBlock = edge.target === blockId + if (!intoBlock && edge.source !== blockId) continue + + const otherId = intoBlock ? edge.source : edge.target + if (otherId === boxId) continue + + // Only variable boxes carry a concrete type; a block on the other end + // exposes its own pins, not a variable's type. + const otherNode = rung.nodes.find((node) => node.id === otherId) + if (!otherNode || !VARIABLE_BOX_TYPES.includes(otherNode.type ?? '')) continue + + const pinType = pinDefinitions.find((pin) => pin.name === (intoBlock ? edge.targetHandle : edge.sourceHandle))?.type + .value + const variable = (otherNode.data as BasicNodeData | undefined)?.variable + const variableType = variable && 'type' in variable ? variable.type.value : undefined + if (pinType && variableType) pins.push({ pinType, variableType }) + } + return pins +} + type FBDBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { block: unknown isOpen?: boolean @@ -35,6 +85,7 @@ const FBDBlockAutoComplete = forwardRef state.projectActions.createVariable) const fbdFlows = useOpenPLCStore((state) => state.fbdFlows) const { updateNode, addNode } = useOpenPLCStore((state) => state.fbdFlowActions) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const block = unknownBlock as Node & { positionAbsoluteX?: number; positionAbsoluteY?: number } const { edges, rung } = useMemo(() => { @@ -145,17 +196,77 @@ const FBDBlockAutoComplete = forwardRef createAndBindVariable(choice), + onCancel: clearBoundVariable, + } satisfies CreateGraphicalVariableModalData) + return + } + + createAndBindVariable({ name: variableName, class: 'local', type: variableType }) + } + + /** + * Empty this box. Opening the type dialog blurs the box, which binds the + * typed text as a raw reference — abandoning the dialog must not leave that + * dangling name behind. + */ + const clearBoundVariable = () => { + const { project, fbdFlows: freshFlows } = useOpenPLCStore.getState() + const { node } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, freshFlows, { + nodeId: block.id, + }) + if (!node) return + + updateNode({ + editorName: pouName, + nodeId: node.id, + node: { ...node, data: { ...node.data, variable: { id: '', name: '' } } }, + }) + } + + /** + * Create the local variable and bind it to this box. Reads the rung/node + * fresh from the store because the type dialog may have resolved long after + * the dropdown that started this closed. + */ + const createAndBindVariable = ({ + name, + class: variableClass, + type, + }: { + name: string + class: PLCVariable['class'] + type: { definition: PLCVariable['type']['definition']; value: string } + }) => { + const { project, fbdFlows: freshFlows } = useOpenPLCStore.getState() + const { node } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, freshFlows, { + nodeId: block.id, + }) + if (!node) return const res = createVariable({ data: { id: crypto.randomUUID(), - name: variableName, + name, type: { - definition: variableType.definition, - value: variableType.value, + definition: type.definition, + value: type.value, }, - class: 'local', + class: variableClass, location: '', documentation: '', debug: false, diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx index 3f1a02777..86a68985e 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx @@ -10,15 +10,8 @@ import { resolveScopeExpressionType } from '../../../../services/graphical-scope import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { resolveArrayVariableByName } from '../../../../utils/PLC/array-variable-utils' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../../utils/variable-sizes' +import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { Modal, ModalContent, ModalTitle } from '../../../_molecules/modal' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -338,50 +331,21 @@ const VariableElement = (block: VariableProps) => { return } - const typeInfo = getVariableTypeInfo(varType) - if (!typeInfo) { + let valueBuffer: Uint8Array + try { + valueBuffer = encodeForceValue(forceValue, varType) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) setForceValueModalOpen(false) setForceValue('') return } - const normalizedType = varType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' - - let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) - } - - await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState, varType) + await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, isForcedValueHigh(forceValue), varType) setForceValueModalOpen(false) setForceValue('') diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index 4a87655e0..7655b851e 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -10,8 +10,11 @@ import { scopeCompletionToVariable, } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' +import type { CreateGraphicalVariableModalData } from '../../../../../store/slices/modal/types' import { cn } from '../../../../../utils/cn' import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' +import type { BoundBlockPin } from '../../../../../utils/PLC/validate-variable-type' +import { isGenericTypeName } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { GraphicalEditorAutocomplete } from '../../autocomplete' @@ -48,6 +51,32 @@ const expectedTypeForBlock = ( } } +/** + * The pins of the same block instance that already carry a variable. A generic + * pin (`ANY`, `ANY_NUM`, …) has no type of its own, so a variable created on it + * takes the concrete type the block already settled on elsewhere (#479). + * + * Read from the rung's own variable nodes: each one knows both its pin's + * declared type and the variable bound to it. + */ +const boundPinsOfSameBlock = (nodes: Node[], variableNode: VariableNode): BoundBlockPin[] => { + const blockId = variableNode.data.block?.id + if (!blockId) return [] + + const pins: BoundBlockPin[] = [] + for (const node of nodes) { + if (node.id === variableNode.id) continue + const data = node.data as Partial + if (data.block?.id !== blockId) continue + + const pinType = data.block.variableType?.type?.value + const variable = data.variable + const variableType = variable && 'type' in variable ? variable.type.value : undefined + if (pinType && variableType) pins.push({ pinType, variableType }) + } + return pins +} + const VariablesBlockAutoComplete = forwardRef( ( { block, blockType = 'other', isOpen, setIsOpen, keyPressed, valueToSearch }: VariablesBlockAutoCompleteProps, @@ -57,6 +86,7 @@ const VariablesBlockAutoComplete = forwardRef state.project.data.pous) const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const expectedType = expectedTypeForBlock(block, blockType) @@ -202,17 +232,78 @@ const VariablesBlockAutoComplete = forwardRef createAndBindVariable(choice), + onCancel: clearBoundVariable, + } satisfies CreateGraphicalVariableModalData) + return + } + + createAndBindVariable({ name: variableName, class: 'local', type: variableType }) + } + + /** + * Empty this box. Opening the type dialog blurs the box, which binds the + * typed text as a raw reference — abandoning the dialog must not leave that + * dangling name behind. + */ + const clearBoundVariable = () => { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (!rung || !node) return + + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: node.id, + node: { ...node, data: { ...node.data, variable: { id: '', name: '' } } }, + }) + } + + /** + * Create the local variable and bind it to this box. Reads the rung/node + * fresh from the store because the type dialog may have resolved long after + * the dropdown that started this closed. + */ + const createAndBindVariable = ({ + name, + class: variableClass, + type, + }: { + name: string + class: PLCVariable['class'] + type: { definition: PLCVariable['type']['definition']; value: string } + }) => { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (!rung || !node) return const res = createVariable({ data: { id: uuidv4(), - name: variableName, + name, type: { - definition: variableType.definition, - value: variableType.value, + definition: type.definition, + value: type.value, }, - class: 'local', + class: variableClass, location: '', documentation: '', debug: false, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index 9cbaf03a7..70bb1d726 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -11,15 +11,8 @@ import { useOpenPLCStore } from '../../../../store' import { RungLadderState } from '../../../../store/slices/ladder' import { cn } from '../../../../utils/cn' import { getLiteralType } from '../../../../utils/keywords' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../../utils/variable-sizes' +import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { Modal, ModalContent, ModalTitle } from '../../../_molecules/modal' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -290,50 +283,28 @@ const VariableElement = (block: VariableProps) => { return } - const typeInfo = getVariableTypeInfo(variableType) - if (!typeInfo) { + let valueBuffer: Uint8Array + try { + valueBuffer = encodeForceValue(forceValue, variableType) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) setForceValueModalOpen(false) setForceValue('') return } - const normalizedType = variableType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' - - let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - setForceValueModalOpen(false) - setForceValue('') - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) - } - - await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState, variableType) + await forceDebugVariable( + debugger_, + compositeKey, + debugIndex, + valueBuffer, + isForcedValueHigh(forceValue), + variableType, + ) setForceValueModalOpen(false) setForceValue('') diff --git a/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx new file mode 100644 index 000000000..f6851127c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +// The code view pulls in Monaco, which cannot run in jsdom. +vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({ + VariablesCodeEditor: () =>
, +})) + +vi.mock('@root/frontend/utils/feature-flags', () => ({ + isDataTypeFilesEnabled: () => true, +})) + +import { useOpenPLCStore } from '@root/frontend/store' + +import { DataTypeEditor } from '../index' + +/** Reproduce what a go-to-definition redirect leaves behind. */ +function arriveFromGotoDefinition(name: string) { + const { editorActions } = useOpenPLCStore.getState() + editorActions.updateModelStructureForName(name, { display: 'code' }) + editorActions.setEditorCursor(name, { lineNumber: 2, column: 3, offset: 0, target: 'data-type' }) +} + +describe('DataTypeEditor code view toggle', () => { + it('lets the user switch back to the table after a goto-definition jump', () => { + const created = useOpenPLCStore.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' }) + expect(created.ok).toBe(true) + + arriveFromGotoDefinition('Motor') + render() + expect(screen.getByTestId('variables-code-editor')).toBeTruthy() + + fireEvent.click(screen.getByLabelText('Data type table visualization')) + + // The jump's cursor is still on the model; it must not re-assert code + // mode and pin the tab there. + expect(screen.queryByTestId('variables-code-editor')).toBeNull() + }) +}) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index 42b1046a2..2c6eabadf 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -1,12 +1,23 @@ -import { ComponentPropsWithoutRef, useEffect, useState } from 'react' +import { ComponentPropsWithoutRef, useEffect, useMemo, useRef, useState } from 'react' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' +import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon' +import { TableIcon } from '../../../../assets/icons/interface/TableIcon' +import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot' +import { dtViewUri } from '../../../../services/st-lsp/types' import { useOpenPLCStore } from '../../../../store' import { extractSearchQuery } from '../../../../store/slices/search/utils' +import { cn } from '../../../../utils/cn' +import { isDataTypeFilesEnabled } from '../../../../utils/feature-flags' +import { getErrorMessage } from '../../../../utils/get-error-message' +import { serializeDataTypeToText } from '../../../../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../../../../utils/PLC/data-type-text-parser' import { InputWithRef } from '../../../_atoms/input' import { ArrayDataType } from '../../../_molecules/data-types/array' import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated' import { StructureDataType } from '../../../_molecules/data-types/structure' +import { VariablesCodeEditor } from '../../../_organisms/variables-code-editor' +import { toast } from '../../[app]/toast/use-toast' type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { dataTypeName: string @@ -14,25 +25,206 @@ type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const { + editor, + editors, project: { data: { dataTypes }, }, - tabsActions: { updateTabName }, - editorActions: { updateEditorModel }, - projectActions: { updateDatatype }, + unparsedDataTypeFiles, + workspace: { + systemConfigs: { shouldUseDarkMode }, + }, + datatypeActions: { rename }, + editorActions: { updateModelStructureForName }, + projectActions: { createDatatype, removeUnparsedDataTypeFile, updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, searchQuery, } = useOpenPLCStore() + const { captureAndPush } = usePouSnapshot() + + // Every open data type is mounted at once, so the view state comes from + // this type's own model — never from the active `editor`. + const model = editor.meta.name === dataTypeName ? editor : editors.find((e) => e.meta.name === dataTypeName) + const modelStructure = model?.type === 'plc-datatype' ? model.structure : undefined + const codeViewEnabled = isDataTypeFilesEnabled() + const display = codeViewEnabled && modelStructure?.display === 'code' ? 'code' : 'table' + const modelCode = modelStructure?.display === 'code' ? modelStructure.code : undefined + + // An unparseable file has no entry in `dataTypes` — raw text is all there is. + const rawFile = unparsedDataTypeFiles.find( + (file) => file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') === dataTypeName, + ) + const [editorContent, setEditorContent] = useState() const [isEditing, setIsEditing] = useState(false) + const [editorCode, setEditorCode] = useState(() => { + if (typeof modelCode === 'string') return modelCode + const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName) + return dataType ? serializeDataTypeToText(dataType) : (rawFile?.content ?? '') + }) + const [parseError, setParseError] = useState(null) + + const containerRef = useRef(null) + const latestCodeRef = useRef(editorCode) + const latestDisplayRef = useRef(display) + const lastParsedCodeRef = useRef(editorCode) + const lastRejectedCodeRef = useRef(null) + const lastMirroredCodeRef = useRef(editorCode) + const isParsingRef = useRef(false) + const commitCodeRef = useRef<() => boolean>(() => false) useEffect(() => { - const dataTypeIndex = dataTypes.findIndex((dataType) => dataType.name === dataTypeName) - if (dataTypeIndex !== -1) { - const dataType = dataTypes[dataTypeIndex] - setEditorContent(dataType) - } + const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName) + if (dataType) setEditorContent(dataType) }, [dataTypes, dataTypeName]) + // In table mode the form is the committed state: it seeds both the buffer + // and the watermark, so the toggle is instant and can't commit a no-op. + useEffect(() => { + if (display === 'code') return + const text = editorContent ? serializeDataTypeToText(editorContent) : (rawFile?.content ?? '') + setEditorCode(text) + lastParsedCodeRef.current = text + }, [editorContent, display, rawFile?.content]) + + // Adopt store-written buffers (rename, undo), never the echo of our own + // mirror below — that would race the keystroke that produced it. + useEffect(() => { + if (display !== 'code' || typeof modelCode !== 'string') return + if (modelCode === lastMirroredCodeRef.current) return + setEditorCode(modelCode) + }, [display, modelCode]) + + useEffect(() => { + if (display !== 'code') return + lastMirroredCodeRef.current = editorCode + updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode }) + }, [editorCode, display, dataTypeName, updateModelStructureForName]) + + useEffect(() => { + latestCodeRef.current = editorCode + latestDisplayRef.current = display + }, [editorCode, display]) + + useEffect(() => { + return () => { + if (latestDisplayRef.current === 'code') { + updateModelStructureForName(dataTypeName, { display: 'code', code: latestCodeRef.current }) + } + } + }, [dataTypeName, updateModelStructureForName]) + + // No type yet means a broken file — surface why while editing, not on commit. + useEffect(() => { + if (display !== 'code' || editorContent) return + setParseError(parseDataTypeFromText(editorCode, dataTypeName).error ?? null) + }, [display, editorContent, editorCode, dataTypeName]) + + const commitCode = (): boolean => { + const { dataType, error } = parseDataTypeFromText(editorCode, dataTypeName) + if (!dataType) { + const message = error ?? 'Unexpected syntax error.' + setParseError(message) + toast({ title: 'Syntax error', description: message, variant: 'fail' }) + return false + } + + captureAndPush(dataTypeName) + + if (editorContent) { + updateDatatype(dataTypeName, dataType) + } else { + const result = createDatatype({ data: dataType }) + if (!result.ok) { + const message = result.message ?? 'Could not create the data type.' + setParseError(message) + toast({ title: 'Syntax error', description: message, variant: 'fail' }) + return false + } + if (rawFile) removeUnparsedDataTypeFile(rawFile.relativePath) + } + + handleFileAndWorkspaceSavedState(dataTypeName) + setParseError(null) + return true + } + + useEffect(() => { + commitCodeRef.current = commitCode + }) + + // Stable reference, or the child's cursor-jump effect re-fires every + // keystroke and re-selects the navigated line. + const codeCursorPosition = useMemo( + () => + model?.cursorPosition?.target === 'data-type' + ? { + lineNumber: model.cursorPosition.lineNumber, + column: model.cursorPosition.column, + target: 'data-type' as const, + } + : undefined, + [model?.cursorPosition?.target, model?.cursorPosition?.lineNumber, model?.cursorPosition?.column], + ) + + // Goto-definition can land here while the tab is still in table mode. + // Keyed on the cursor alone: including `display` would re-fire on the + // user's own switch back to table and pin the tab in code mode. + useEffect(() => { + if (!codeCursorPosition || display === 'code') return + updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeCursorPosition]) + + useEffect(() => { + if (display !== 'code') return + + // Clicking away raises mousedown then focusout, and the commit is + // synchronous, so `isParsingRef` is clear by the second one. Both + // watermarks make the pair one attempt whatever its outcome. + const tryCommit = () => { + if (isParsingRef.current) return + if (editorCode === lastParsedCodeRef.current) return + if (editorCode === lastRejectedCodeRef.current) return + isParsingRef.current = true + if (commitCodeRef.current()) { + lastParsedCodeRef.current = editorCode + lastRejectedCodeRef.current = null + } else { + lastRejectedCodeRef.current = editorCode + } + isParsingRef.current = false + } + + const onDocMouseDown = (e: MouseEvent) => { + if (!containerRef.current) return + if (containerRef.current.contains(e.target as Node)) return + tryCommit() + } + + // Covers focus moves with no mousedown: Tab, shortcuts. + const onFocusOut = (e: FocusEvent) => { + if (!containerRef.current) return + const newTarget = e.relatedTarget as Node | null + if (newTarget && containerRef.current.contains(newTarget)) return + tryCommit() + } + + const container = containerRef.current + document.addEventListener('mousedown', onDocMouseDown, true) + container?.addEventListener('focusout', onFocusOut) + return () => { + document.removeEventListener('mousedown', onDocMouseDown, true) + container?.removeEventListener('focusout', onFocusOut) + } + }, [display, editorCode]) + + const handleVisualizationTypeChange = (value: 'code' | 'table') => { + if (display === value) return + if (display === 'code' && !commitCode()) return + updateModelStructureForName(dataTypeName, { display: value, code: value === 'code' ? editorCode : undefined }) + } + const handleStartEditing = () => { setIsEditing(true) } @@ -52,20 +244,31 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const handleBlur = (e: React.FocusEvent) => { const { value } = e.target if (dataTypeName !== value) { - // `updateDatatype` is a full replace. Spread the current - // entry so the rename only changes `name` — without this the - // entry would lose every other field (variable / values / - // dimensions / baseType / initialValue). - if (!editorContent) return - updateDatatype(dataTypeName, { ...editorContent, name: value }) - updateEditorModel(dataTypeName, value) - updateTabName(dataTypeName, value) - setIsEditing(false) + // `datatypeActions.rename` validates the new name and rekeys the + // editor model, tab, and file entry, then flags the file dirty. + // Async: a referenced type awaits the impact modal first. + void rename(dataTypeName, value) + .then((result) => { + if (!result.ok) { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + // A declined impact modal is a user choice, not a failure. + if (!result.cancelled) { + toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + } + } + setIsEditing(false) + }) + .catch((error: unknown) => { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + toast({ title: 'Rename failed', description: getErrorMessage(error), variant: 'fail' }) + setIsEditing(false) + }) } } return (
{ > {isEditing ? ( { aria-label='Data type name' className='h-full w-full bg-transparent p-2 text-start font-caption text-xs text-neutral-850 outline-none dark:text-neutral-100' onClick={handleStartEditing} - dangerouslySetInnerHTML={{ __html: extractSearchQuery(editorContent?.name || '', searchQuery) }} + dangerouslySetInnerHTML={{ + __html: extractSearchQuery(editorContent?.name ?? dataTypeName, searchQuery), + }} /> )}
+ {codeViewEnabled && ( +
+ handleVisualizationTypeChange('table')} + size='md' + currentVisible={display === 'table'} + className={cn( + display === 'table' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900', + 'rounded-l-md transition-colors ease-in-out hover:cursor-pointer', + )} + /> + handleVisualizationTypeChange('code')} + size='md' + currentVisible={display === 'code'} + className={cn( + display === 'code' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900', + 'rounded-r-md transition-colors ease-in-out hover:cursor-pointer', + )} + /> +
+ )} -
- {editorContent?.derivation === 'array' && } - {editorContent?.derivation === 'enumerated' && } - {editorContent?.derivation === 'structure' && } +
+ {display === 'table' ? ( + <> + {editorContent?.derivation === 'array' && } + {editorContent?.derivation === 'enumerated' && } + {editorContent?.derivation === 'structure' && } + + ) : ( + <> +
+ +
+ {parseError &&

Error: {parseError}

} + + )}
) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx new file mode 100644 index 000000000..6c36ce0a6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/__tests__/device-license-status.test.tsx @@ -0,0 +1,214 @@ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import { fireEvent, render, screen } from '@testing-library/react' + +import { DeviceLicenseStatus } from '../components/device-license-status' + +const DEVICE_ID = '659a3520540f803625ddc34081e893d3' +const BUY_URL = `https://edge.example.com/buy?vppId=com.openplc.espressif-licensed&deviceId=${DEVICE_ID}` + +function setup(report: DeviceLicenseReport | null, overrides: { isChecking?: boolean; buyUrl?: string | null } = {}) { + const onBuy = jest.fn() + const onRecheck = jest.fn() + render( + , + ) + return { onBuy, onRecheck } +} + +function expand() { + fireEvent.click(screen.getByRole('button', { name: 'Licence status' })) +} + +describe('DeviceLicenseStatus', () => { + it('renders nothing before any licensing call has landed', () => { + // The state every non-licensable board stays in, and a licensable one before + // Connect. A placeholder badge would invite the user to read meaning into a + // check that never happened. + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('shows progress while a check is in flight with nothing known yet', () => { + setup(null, { isChecking: true }) + expect(screen.getByText('Checking licence…')).toBeTruthy() + }) + + it('labels a stored, verified licence as Licensed', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expect(screen.getByText('Licensed')).toBeTruthy() + }) + + it('labels a definitive negative as Not licensed', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }) + expect(screen.getByText('Not licensed')).toBeTruthy() + }) + + it('labels an unanswered check as a FAILURE, not as Not licensed', () => { + // Three states, deliberately not two: collapsing these either tells a paying + // customer to buy again or hides a real failure behind a reassuring badge. + setup({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + expect(screen.getByText('Licence check failed')).toBeTruthy() + expect(screen.queryByText('Not licensed')).toBeNull() + }) + + it('labels missing storage as a FIRMWARE fault, distinctly from having no licence', () => { + // "Not storable" read as a hardware limitation and sent us looking at a + // NodeMCU that stores licences fine — the image was built without the backend. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expect(screen.getByText('Storage missing')).toBeTruthy() + expect(screen.queryByText('Not licensed')).toBeNull() + }) + + it('never claims to know the execution mode the board is running in', () => { + // The editor verifies POSSESSION, not the ECDSA signature — it cannot know + // whether the closed licence-core will run FULL. No label may say otherwise. + const outcomes: DeviceLicenseReport['outcome'][] = [ + { state: 'licensed', how: 'already-stored' }, + { state: 'licensed', how: 'activated' }, + { state: 'unlicensed', entitlementChecked: true }, + { state: 'unsupported' }, + { state: 'check-failed', error: 'x' }, + ] + + for (const outcome of outcomes) { + const { container, unmount } = render( + , + ) + expect(container.textContent ?? '').not.toMatch(/full mode|unlocked|demo mode/i) + unmount() + } + }) + + it('keeps the details OUT of the layout flow, so opening them cannot move the page', () => { + // The regression this exists for: the panel used to be a conditional
+ // next to the trigger, so opening it grew the connect row by ~140px and + // pushed "Specs" and the board image down. A portalled popover renders under + // document.body, so the row's own subtree never changes. + const { container } = render( + , + ) + + const before = container.innerHTML + expand() + + // The panel is visible… + expect(screen.getByText('Device ID')).toBeTruthy() + // …and it is NOT inside the component's own container. + expect(container.querySelector('code')).toBeNull() + // The trigger's subtree is byte-identical apart from the attributes Radix + // toggles on it (aria-expanded / data-state). + expect(before.replace(/(aria-expanded|data-state)="[^"]*"/g, '')).toBe( + container.innerHTML.replace(/(aria-expanded|data-state)="[^"]*"/g, ''), + ) + }) + + describe('details panel', () => { + it('exposes the device id and copies it on request', () => { + const writeText = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + + setup({ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }) + expand() + + // The id is what a support ticket needs and what the /buy page accepts pasted. + expect(screen.getByText(DEVICE_ID)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + expect(writeText).toHaveBeenCalledWith(DEVICE_ID) + }) + + it('omits the device id when there was no anchor to derive one from', () => { + setup({ outcome: { state: 'check-failed', error: 'no unique hardware id' } }) + expand() + expect(screen.queryByText('Device ID')).toBeNull() + }) + + it('always offers a re-check', () => { + const { onRecheck } = setup({ deviceId: DEVICE_ID, outcome: { state: 'check-failed', error: 'x' } }) + expand() + fireEvent.click(screen.getByRole('button', { name: 'Check again' })) + expect(onRecheck).toHaveBeenCalledTimes(1) + }) + + it('disables the re-check while one is already running', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }, { isChecking: true }) + expand() + expect(screen.getByRole('button', { name: 'Check again' }).hasAttribute('disabled')).toBe(true) + }) + + it('offers a purchase ONLY when the backend confirmed there is no entitlement', () => { + const { onBuy } = setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }) + expand() + fireEvent.click(screen.getByRole('button', { name: 'Buy licence' })) + expect(onBuy).toHaveBeenCalledTimes(1) + }) + + it('does NOT offer a purchase when nobody asked whether one exists', () => { + // Offering to buy here is a guess, and the wrong one for anyone who paid. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: false } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('does NOT offer a purchase when the check failed', () => { + setup({ outcome: { state: 'check-failed', error: 'Activation request failed: 429' } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('does NOT offer a purchase when the device cannot store a licence', () => { + // Buying would not make the device able to store one. + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('hides the purchase button when no valid link could be built', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unlicensed', entitlementChecked: true } }, { buyUrl: null }) + expand() + expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull() + }) + + it('tells an unsupported device to rebuild, and does not blame the hardware', () => { + setup({ deviceId: DEVICE_ID, outcome: { state: 'unsupported' } }) + expand() + expect(screen.getByText(/rebuild and upload/i)).toBeTruthy() + expect(screen.getByText(/This hardware supports it/)).toBeTruthy() + }) + + it("quotes the backend's reason when it gave one", () => { + setup({ + deviceId: DEVICE_ID, + outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }, + }) + expand() + expect(screen.getByText(/no active subscription/)).toBeTruthy() + }) + + it('states that a failed check is not the same as having no licence', () => { + setup({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + expand() + expect(screen.getByText(/not the same as having no licence/)).toBeTruthy() + }) + }) +}) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index ca74ca98e..f3aa8982c 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -8,20 +8,25 @@ import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifie import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' +import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' +import { useDeviceLicense } from '../../../../../../hooks/use-device-license' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' import { isOpenPLCRuntimeTarget, isSimulatorTarget, validateRuntimeVersion } from '../../../../../../utils/device' +import { serialPortDisplay } from '../../../../../../utils/serial-port-label' import { DropdownSearchInput } from '../../../../../_atoms/dropdown-search-input' import { Label } from '../../../../../_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' import TableActions from '../../../../../_atoms/table-actions' +import { DeviceConnectButton } from '../../../../../_molecules/device-connect-button' import { EtherCATStats } from '../../../../../_molecules/ethercat-stats' import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../../../../../_molecules/modal' import { PluginStatsPanel } from '../../../../../_molecules/plugin-stats-panel' import { ScanCycleStats } from '../../../../../_molecules/scan-cycle-stats' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' +import { DeviceLicenseStatus } from './components/device-license-status' import { PinMappingTable } from './components/pin-mapping-table' const Board = memo(function () { @@ -50,12 +55,36 @@ const Board = memo(function () { const currentBoardInfo = availableBoards.get(deviceBoard) + // CONNECT flow (D72): open the device channel, classify it, and drive the + // flash follow-up when nothing answered the debug protocol. + const { + connect: connectDevice, + disconnect: disconnectDevice, + isConnected, + status: serialStatus, + } = useDeviceConnect(currentBoardInfo) + + // VPP licensing. Inert for every board whose VPP is not sold licensed, which is + // every built-in board — `isLicensable` gates the whole affordance. + const licensing = useDeviceLicense(currentBoardInfo) + // Whether this target exposes the GPIO pin-mapping table. Arduino boards // enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry // Pi HAL) opt in with `capabilities.pinMapping` in their VPP manifest. const pinMappingEnabled = resolveTargetCapabilities(currentBoardInfo).pinMapping const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress || '') + // Read from the same place the connection resolver reads it, so the button and + // the resolution never disagree about whether a network path exists. + const modbusTcpConfigured = useOpenPLCStore( + (state) => + ( + (state.deviceDefinitions.configuration.vendorScreenData ?? {}) as Record< + string, + Record | undefined + > + )['modbus_tcp']?.['enabled'] === true, + ) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress) const setRuntimeConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setRuntimeConnectionStatus) @@ -349,6 +378,9 @@ const Board = memo(function () { setRuntimeJwtToken(null) setRuntimeConnectionStatus('disconnected') await runtime.clearCredentials() + // The session goes with it: control was this REST connection, and any debug + // channel opened off it has nothing left to belong to. + await device.closeRuntimeSession?.() return } @@ -593,90 +625,107 @@ const Board = memo(function () { Search
-
+ + {connectionStatus === 'connected' && plcStatus && ( + PLC: {plcStatus} + )} + + + ) : capabilities.hasLocalSerialPorts ? ( + <> +
+ + - {connectionStatus === 'connected' && ( -
- ● Connected - {plcStatus && ( - | PLC: {plcStatus} - )} -
- )} - {connectionStatus === 'error' && ( - ● Connection failed - )}
- - ) : capabilities.hasLocalSerialPorts ? ( -
- - - -
+ {/* Licensing sits in the connect row because it answers an adjacent + question about the same device -- but it renders nothing at all + unless this board's VPP is sold licensed AND a check has landed, + so a free board's row is unchanged. */} + {licensing.isLicensable ? ( + void licensing.buy()} + onRecheck={() => void licensing.refresh()} + /> + ) : null} + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && (
diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx new file mode 100644 index 000000000..94602b16f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/device-license-status.tsx @@ -0,0 +1,248 @@ +/** + * Licence status for the device screen: a quiet, monochrome line next to + * "Connected", plus a details panel with the device id and the actions the + * outcome warrants. + * + * WHAT "LICENSED" IS ALLOWED TO MEAN HERE. It means the main process read the + * blob off the device (FC 0x4A) and verified its magic, crc32, `deviceId` and + * `productId` — i.e. the device HOLDS a well-formed licence for this board and + * this VPP. The ECDSA signature and the key id are NOT checked (only the closed + * license-core can), so a blob signed by a key the board does not trust would + * read "Licensed" here and the board would still run demo. + * + * Therefore every label in this component is about POSSESSION — Licensed / Not + * licensed / Licence check failed — and never about EXECUTION. No string here may + * say "full mode", "unlocked" or "running in demo": the editor cannot know that. + * + * THREE states, deliberately not two. "Not licensed" is an ANSWER; "Licence check + * failed" is the ABSENCE of one. Collapsing them either tells a paying customer to + * buy again or hides a real failure behind a reassuring badge. + */ + +import * as Popover from '@radix-ui/react-popover' +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import { useState } from 'react' + +import { cn } from '../../../../../../../utils/cn' + +/** Filled shield + check — "this device holds a licence". */ +function ShieldLicensedIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +/** Outline shield + dash — "no licence on this device". */ +function ShieldUnlicensedIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +/** Outline shield + question — "we could not tell". */ +function ShieldUnknownIcon({ size = 14 }: { size?: number }) { + return ( + + ) +} + +export interface DeviceLicenseStatusProps { + /** Null before any licensing call has landed — nothing is rendered. */ + report: DeviceLicenseReport | null + isChecking: boolean + /** Null when no valid purchase link can be built; the button is then hidden. */ + buyUrl: string | null + onBuy: () => void + onRecheck: () => void +} + +/** The label + icon for an outcome. One place, so no branch can drift. */ +function describeOutcome(report: DeviceLicenseReport): { + label: string + Icon: typeof ShieldLicensedIcon + /** Whether the label reads as a definitive negative (drives the dashed underline). */ + negative: boolean + detail: string +} { + switch (report.outcome.state) { + case 'licensed': + return { + label: 'Licensed', + Icon: ShieldLicensedIcon, + negative: false, + detail: + report.outcome.how === 'activated' + ? 'A licence was just written to this device and read back to confirm it.' + : 'This device is holding a licence issued for it and for this VPP.', + } + case 'unlicensed': + return { + label: 'Not licensed', + Icon: ShieldUnlicensedIcon, + negative: true, + detail: report.outcome.entitlementChecked + ? `No licence is registered for this device.${ + report.outcome.backendReason ? ` The licence server said: ${report.outcome.backendReason}` : '' + }` + : 'This device is not holding a valid licence. Nobody has checked yet whether one was purchased for it.', + } + case 'unsupported': + // The label points at the FIRMWARE, not the board. "Not storable" read as a + // hardware limitation and cost a debugging session on a NodeMCU that stores + // licences fine — the image was simply built without the backend. + return { + label: 'Storage missing', + Icon: ShieldUnknownIcon, + negative: false, + detail: + 'The firmware running on this device reports no licence storage. This hardware supports it, ' + + 'so the image was built without the storage backend — rebuild and upload.', + } + case 'check-failed': + return { + label: 'Licence check failed', + Icon: ShieldUnknownIcon, + negative: false, + detail: `${report.outcome.error}\n\nThis is not the same as having no licence — nothing on the device has changed.`, + } + } +} + +export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onRecheck }: DeviceLicenseStatusProps) { + const [copied, setCopied] = useState(false) + + // Nothing has run: every non-licensable board stays here, and so does a + // licensable one before Connect. Showing a placeholder badge would invite the + // user to read meaning into a check that never happened. + if (!report) { + return isChecking ? ( + + Checking licence… + + ) : null + } + + const { label, Icon, negative, detail } = describeOutcome(report) + const deviceId = report.deviceId + + // The purchase button appears ONLY where buying is the honest next step: the + // backend was asked and reported no entitlement. On `check-failed` or an + // unchecked `unlicensed` it would be a guess, and a costly one. + const offerPurchase = !!buyUrl && report.outcome.state === 'unlicensed' && report.outcome.entitlementChecked === true + + return ( + // Radix Popover, PORTALLED. The details used to be a conditional
in the + // flow, which is what broke the layout: opening it pushed "Specs" and the + // board image down and displaced the Connected label, because the panel is + // ~140px tall and this badge sits inside the connect row. A portalled popover + // is out of the flow entirely, so the row never changes height. + + + + + + + +
+ + + +
+

{label}

+

+ {detail} +

+
+
+ + {deviceId ? ( +
+ Device ID +
+ + {deviceId} + + +
+
+ ) : null} + +
+ + {offerPurchase ? ( + + ) : null} +
+
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx index 99071c2eb..bc920f2bf 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx @@ -4,6 +4,7 @@ import { ToggleSwitch } from '@root/frontend/components/_atoms/toggle-switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@root/frontend/components/_atoms/tooltip' import { useOpenPLCStore } from '@root/frontend/store' import { evalVisible, type VisibleCondition } from '@root/frontend/utils/vpp/eval-visible' +import { resolveFieldOptions } from '@root/frontend/utils/vpp/field-options' import { getSectionPersistenceKey } from '@root/frontend/utils/vpp/persistence-keys' import type { ScreenSection } from '../index' @@ -19,6 +20,10 @@ type FieldDef = { unit?: string help?: string options?: string[] | Array<{ value: string; label: string }> + // Dynamic option source (VPP screen schema): a dotted path resolved against + // per-board context, e.g. "board.serialPorts". Wins over `options` when it + // resolves to a non-empty array; otherwise `options` is the fallback. + optionsRef?: string // Honored by text-like inputs (text, password, ip-address, mac-address). // Mirrors the VPP screen schema's optional field props — empty strings // are skipped so HTML5 placeholder/maxLength/pattern stay unset when @@ -77,6 +82,10 @@ function FormLayout({ section }: FormLayoutProps) { const vendorScreenData = useOpenPLCStore((s) => s.deviceDefinitions.configuration.vendorScreenData) const setVendorScreenData = useOpenPLCStore((s) => s.deviceActions.setVendorScreenData) + // Board context for dynamic `optionsRef` resolution (e.g. the Modbus RTU + // serial-port picker reading `board.serialPorts`). + const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) + const currentBoardInfo = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards.get(deviceBoard)) // Single-source-of-truth for the per-section storage key — see // `getSectionPersistenceKey` in ../index.tsx. Every layout that // persists must derive its key through this helper so the @@ -162,7 +171,9 @@ function FormLayout({ section }: FormLayoutProps) { align='center' side='bottom' > - {(field.options ?? []).map((opt) => { + {resolveFieldOptions(field, { + board: currentBoardInfo as Record | undefined, + }).map((opt) => { const value = typeof opt === 'string' ? opt : opt.value const label = typeof opt === 'string' ? opt : opt.label return ( diff --git a/src/frontend/components/_molecules/charts/line-chart.tsx b/src/frontend/components/_molecules/charts/line-chart.tsx index 8100f3534..cd6d0149d 100644 --- a/src/frontend/components/_molecules/charts/line-chart.tsx +++ b/src/frontend/components/_molecules/charts/line-chart.tsx @@ -46,7 +46,8 @@ const LineChart = ({ data, isBool = false, range, now, startTime, label }: LineC return { chart: { id: chartId, - animations: { enabled: true, easing: 'linear' as const, dynamicAnimation: { speed: 500 } }, + // Tweening ApexCharts' full-SVG rebuild replays partial states and reads as flicker. + animations: { enabled: false }, toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', @@ -81,7 +82,7 @@ const LineChart = ({ data, isBool = false, range, now, startTime, label }: LineC xaxis: { lines: { show: false } }, yaxis: { lines: { show: true } }, }, - stroke: { curve: isBool ? ('stepline' as const) : ('smooth' as const), width: 2 }, + stroke: { curve: 'stepline' as const, width: 2 }, tooltip: { enabled: false }, states: { hover: { filter: { type: 'none' } }, diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index a8ecec386..0b236d98e 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useState } from 'react' +import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react' import { baseTypeEnum } from '../../../../../middleware/shared/ports/plc-schemas' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' @@ -30,6 +30,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { data: { dataTypes }, }, libraries: sliceLibraries, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -72,7 +73,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const ROWS_NOT_SELECTED = -1 const [arrayTable, setArrayTable] = useState<{ selectedRow: number }>({ selectedRow: ROWS_NOT_SELECTED }) - const [initialValueData, setInitialValueData] = useState('') + const [initialValueData, setInitialValueData] = useState(data.initialValue || '') const [baseType, setBaseType] = useState(data.baseType.value) const [tableData, setTableData] = useState([]) @@ -81,10 +82,21 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { setTableData(data.dimensions) }, [data.dimensions]) + // One history entry per typing burst: armed on the first keystroke, + // rearmed on blur or when the store value changes under us (undo/redo). + const initialValueCaptured = useRef(false) + // `data` lags one render behind the store (the parent copies it via effect), + // so compare against our own last write to spot genuinely external changes. + const lastWrittenInitialValue = useRef(data.initialValue || '') + useEffect(() => { - setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const storeValue = data.initialValue || '' + if (storeValue !== lastWrittenInitialValue.current) { + setInitialValueData(storeValue) + lastWrittenInitialValue.current = storeValue + initialValueCaptured.current = false + } + }, [data.initialValue]) useEffect(() => { setBaseType(data.baseType.value) @@ -92,17 +104,25 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) + lastWrittenInitialValue.current = e.target.value + if (!initialValueCaptured.current) { + captureAndPush(editor.meta.name) + initialValueCaptured.current = true + } const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleSelect = (definition: string, value: string) => { setBaseType(value) + captureAndPush(editor.meta.name) updateDatatype(data.name, { ...data, baseType: { value, definition }, } as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -110,17 +130,15 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { // gets stripped and downstream selectors lose the entry. const writeDimensions = (newRows: PLCArrayDatatype['dimensions']) => { updateDatatype(data.name, { ...data, dimensions: newRows }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { + captureAndPush(editor.meta.name) + setTableData((prevRows) => { - const isFirst = prevRows.length === 0 const newRows = [...prevRows, { dimension: '' }] - if (isFirst) { - captureAndPush(editor.meta.name) - } - setArrayTable({ selectedRow: newRows.length - 1 }) writeDimensions(newRows) return newRows @@ -211,6 +229,9 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { { + initialValueCaptured.current = false + }} value={initialValueData} className='flex h-7 w-full max-w-44 items-center justify-between gap-2 rounded-lg border border-neutral-400 bg-white px-3 py-2 font-caption text-xs font-normal text-neutral-950 focus-within:border-brand focus:border-brand focus:outline-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100' /> diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index ec1035076..8b84e1413 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -36,6 +36,7 @@ const DimensionsTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -48,6 +49,7 @@ const DimensionsTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'array') return updateDatatype(name, { ...current, dimensions: newDimensions }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ dimension: string }>() diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index 93a794df5..6c57a6f06 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -19,6 +19,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { const { editor, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -32,8 +33,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { useEffect(() => { setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [data.initialValue]) useEffect(() => { setTableData(data.values) @@ -46,6 +46,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -53,6 +54,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx index 4970dd488..d6a3b4441 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx @@ -38,6 +38,7 @@ const EnumeratedTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -50,6 +51,7 @@ const EnumeratedTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'enumerated') return updateDatatype(name, { ...current, values: newValues }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ description: string }>() diff --git a/src/frontend/components/_molecules/data-types/structure/index.tsx b/src/frontend/components/_molecules/data-types/structure/index.tsx index c5d934b20..343f04b3e 100644 --- a/src/frontend/components/_molecules/data-types/structure/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/index.tsx @@ -21,13 +21,15 @@ const StructureDataType = () => { }, editorActions: { updateModelStructure }, projectActions: { updateDatatype, rearrangeStructureVariables }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() const [tableData, setTableData] = useState([]) - const [editorStructure, setEditorStructure] = useState({ + const [editorStructure, setEditorStructure] = useState>({ + display: 'table', selectedRow: ROWS_NOT_SELECTED.toString(), description: '', }) @@ -46,9 +48,14 @@ const StructureDataType = () => { useEffect(() => { const foundDataType = dataTypes.find((dataType) => dataType?.derivation === 'structure') - if (editor.type === 'plc-datatype' && foundDataType && 'variable' in foundDataType) { + if ( + editor.type === 'plc-datatype' && + editor.structure.display === 'table' && + foundDataType && + 'variable' in foundDataType + ) { const { description, selectedRow } = editor.structure - setEditorStructure({ description: description, selectedRow: selectedRow }) + setEditorStructure({ display: 'table', description: description, selectedRow: selectedRow }) } }, [editor]) @@ -65,6 +72,7 @@ const StructureDataType = () => { const current = dataTypes.find((dt) => dt.name === editor.meta.name) if (!current || current.derivation !== 'structure') return updateDatatype(editor.meta.name, { ...current, variable: newVariables }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleCreateStructureVariable = () => { @@ -178,6 +186,7 @@ const StructureDataType = () => { rowId: row ?? parseInt(editorStructure.selectedRow), newIndex: (row ?? parseInt(editorStructure.selectedRow)) + index, }) + handleFileAndWorkspaceSavedState(editor.meta.name) updateModelStructure({ selectedRow: parseInt(editorStructure.selectedRow) + index, }) diff --git a/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx b/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx index 1f1aba429..d60f57808 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/editable-cell.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import type { PLCStructureVariable } from '../../../../../../middleware/shared/ports/types' import type { ProjectResponse } from '../../../../../store/slices/project/types' import { cn } from '../../../../../utils/cn' +import { isLegalIdentifier } from '../../../../../utils/keywords' import { InputWithRef } from '../../../../_atoms/input' import { useToast } from '../../../../_features/[app]/toast/use-toast' @@ -32,6 +33,14 @@ const EditableNameCell = ({ getValue, row: { index }, column: { id }, table }: I return { valid: false, message: 'The name cannot be empty' } } + // Field names are written into datatypes/.dt as ST text — + // an illegal identifier here would serialize to a file that + // can't be read back (same rule the .dt parser enforces). + const [legal, reason] = isLegalIdentifier(name) + if (!legal) { + return { valid: false, message: `'${name}' ${reason}` } + } + if (name !== currentName && existingVariables.includes(name)) { return { valid: false, message: `The name '${name}' already exists` } } diff --git a/src/frontend/components/_molecules/data-types/structure/table/index.tsx b/src/frontend/components/_molecules/data-types/structure/table/index.tsx index 2838a460d..390d8e365 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/index.tsx @@ -51,6 +51,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -85,6 +86,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure return variable }), }) + handleFileAndWorkspaceSavedState(editor.meta.name) return { ok: true, message: 'Data updated successfully.' } } catch (error) { console.error('Failed to update data:', error) diff --git a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx new file mode 100644 index 000000000..dadd746a4 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx @@ -0,0 +1,81 @@ +/** + * One Connect button serves both target families. These pin the behaviour that + * had drifted between the two hand-written copies: the label, when the button is + * disabled, and whether a connection is confirmed on screen at all. + */ +import { fireEvent, render, screen } from '@testing-library/react' + +import { DeviceConnectButton } from '../index' + +describe('DeviceConnectButton', () => { + it('reads Connect when disconnected and calls onConnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + expect(onConnect).toHaveBeenCalledTimes(1) + expect(onDisconnect).not.toHaveBeenCalled() + }) + + it('reads Disconnect when connected and calls onDisconnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(onDisconnect).toHaveBeenCalledTimes(1) + expect(onConnect).not.toHaveBeenCalled() + }) + + it('says Disconnect when connected, and nothing else', () => { + // The label IS the status: "Disconnect" can only appear while connected, so a + // separate "Connected" badge beside it was saying the same thing twice — and on + // the device screen it sat next to the licence badge, making the row read as + // three separate facts when it is two. + render() + expect(screen.getByRole('button').textContent).toBe('Disconnect') + expect(screen.queryByText(/Connected/)).toBeNull() + }) + + it('reports a failed attempt', () => { + render() + expect(screen.getByText('● Connection failed')).not.toBeNull() + }) + + it('is inert while connecting', () => { + render() + expect((screen.getByRole('button', { name: 'Connecting...' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('explains itself when something blocks connecting', () => { + render( + , + ) + + const button = screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(button.title).toBe('Select a communication port first') + }) + + it('stays live when nothing blocks it, so resolution can report the real reason', () => { + render() + expect((screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('renders caller-supplied detail beside the status', () => { + render( + + | PLC: RUNNING + , + ) + expect(screen.getByText('| PLC: RUNNING')).not.toBeNull() + }) +}) diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx new file mode 100644 index 000000000..dfa3de701 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react' + +import type { ConnectionStatus } from '../../../store/slices/device/types' +import { cn } from '../../../utils/cn' + +type DeviceConnectButtonProps = { + /** Live connection state. Both target families use the same four states. */ + status: ConnectionStatus + /** Establish the connection. Called only when not already connected. */ + onConnect: () => void + /** Tear the connection down. Called only when connected. */ + onDisconnect: () => void + /** + * When set, the button is disabled and this says why (also the tooltip) — e.g. + * no communication port has been picked yet. + */ + blockedReason?: string + /** Detail shown beside the status: PLC state, license badge. */ + children?: ReactNode + /** DOM id kept for the existing onboarding/tour anchors. */ + containerId?: string +} + +/** + * Connect / Disconnect, for every target type. + * + * One component on purpose. A Runtime v4 target and a baremetal target are + * connected in completely different ways — REST login versus a held Modbus link — + * but to the user it is the same action in the same place, and it had drifted: the + * two buttons differed in colour when connected, in when they were disabled, and + * one of them never showed the green "Connected" confirmation at all. Those are + * the kind of differences nobody decides on; they accumulate. The connection + * mechanics stay with each caller, and only the appearance lives here. + */ +const DeviceConnectButton = ({ + status, + onConnect, + onDisconnect, + blockedReason, + children, + containerId, +}: DeviceConnectButtonProps) => { + const isConnected = status === 'connected' + const isConnecting = status === 'connecting' + const disabled = isConnecting || blockedReason !== undefined + + return ( +
+ + + {/* No "Connected" confirmation: the button itself reads "Disconnect" when + connected, so a second label said the same thing twice. The ERROR state + below stays, because there the button reads "Connect" and the failure + would otherwise be invisible. */} + {status === 'error' && ● Connection failed} + {children} +
+ ) +} + +export { DeviceConnectButton } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index e0d859fd0..bb152e3f1 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -594,8 +594,12 @@ const ProjectTreeLeaf = ({ } if (isDatatype) { - const res = renameDatatype(label, newLabel) - if (!res.ok) setNewLabel(label || '') + // Async: a referenced type awaits the impact modal before renaming. + void renameDatatype(label, newLabel) + .then((res) => { + if (!res.ok) setNewLabel(label || '') + }) + .catch(() => setNewLabel(label || '')) return } diff --git a/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx new file mode 100644 index 000000000..bcfd5e02f --- /dev/null +++ b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx @@ -0,0 +1,31 @@ +import { useOpenPLCStore } from '../../../store' +import { RenameImpactModal } from '.' + +/** + * Store-driven host for the data type rename flow: `datatypeActions.rename` + * parks the awaited confirmation in `pendingDatatypeRename`, this renders the + * impact modal for it, and confirm/cancel resolve the pending promise via + * `respondToPendingRename`. + */ +export const DataTypeRenameImpactModal = () => { + const pending = useOpenPLCStore((s) => s.pendingDatatypeRename) + const respondToPendingRename = useOpenPLCStore((s) => s.datatypeActions.respondToPendingRename) + + if (!pending) return null + + return ( + respondToPendingRename(true)} + onCancel={() => respondToPendingRename(false)} + /> + ) +} diff --git a/src/frontend/components/_molecules/rename-impact-modal/index.tsx b/src/frontend/components/_molecules/rename-impact-modal/index.tsx index a089821ef..1871285f7 100644 --- a/src/frontend/components/_molecules/rename-impact-modal/index.tsx +++ b/src/frontend/components/_molecules/rename-impact-modal/index.tsx @@ -6,7 +6,15 @@ type RenameImpactModalProps = { oldName?: string newName?: string changes?: Array<{ oldName: string; newName?: string; oldType?: string; newType?: string }> - impact: ReferenceImpactAnalysis + // The modal only renders the aggregate maps, so any location shape works. + impact: ReferenceImpactAnalysis + // Copy overrides — defaults keep the original variable-rename wording. + title?: string + affectedListLabel?: string + byKindLabel?: string + confirmLabel?: string + cancelLabel?: string + cancelDescription?: string onConfirm: () => void onCancel: () => void } @@ -17,6 +25,12 @@ export const RenameImpactModal = ({ newName, changes, impact, + title = 'Variable Changes: Impact Analysis', + affectedListLabel = 'Affected POUs:', + byKindLabel = 'By Editor Type:', + confirmLabel = 'Yes, rename references', + cancelLabel = 'No, keep references unchanged', + cancelDescription = 'References will remain with the old name and will no longer match the renamed variable, causing them to become unresolved references', onConfirm, onCancel, }: RenameImpactModalProps) => { @@ -32,9 +46,7 @@ export const RenameImpactModal = ({ onClose={onCancel} > - - Variable Changes: Impact Analysis - + {title}
@@ -91,7 +103,9 @@ export const RenameImpactModal = ({ {impact.byPou.size > 0 && (
-
Affected POUs:
+
+ {affectedListLabel} +
    {Array.from(impact.byPou.entries()).map(([pouName, count]) => (
  • @@ -104,7 +118,7 @@ export const RenameImpactModal = ({ {impact.byEditorType.size > 0 && (
    -
    By Editor Type:
    +
    {byKindLabel}
      {Array.from(impact.byEditorType.entries()).map(([editorType, count]) => (
    • @@ -120,12 +134,11 @@ export const RenameImpactModal = ({

      What would you like to do?

      • - Yes, rename references: All references will be updated to use the - new name + {confirmLabel}: All references will be updated to use the new + name
      • - No, keep references unchanged: References will remain with the - old name and will no longer match the renamed variable, causing them to become unresolved references + {cancelLabel}: {cancelDescription}
    @@ -136,10 +149,10 @@ export const RenameImpactModal = ({ onClick={onCancel} className='h-8 w-full rounded bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-1000 dark:bg-neutral-850 dark:text-neutral-100' > - No, keep references unchanged + {cancelLabel} diff --git a/src/frontend/components/_molecules/variables-panel/index.tsx b/src/frontend/components/_molecules/variables-panel/index.tsx index 180781057..dff7b39f5 100644 --- a/src/frontend/components/_molecules/variables-panel/index.tsx +++ b/src/frontend/components/_molecules/variables-panel/index.tsx @@ -5,17 +5,10 @@ import type { DebugTreeNode } from '../../../../middleware/shared/ports/types' import ViewIcon from '../../../assets/icons/interface/View' import ZapIcon from '../../../assets/icons/interface/Zap' import { cn } from '../../../utils/cn' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../../../utils/variable-types' +import { encodeForceValue, isForcedValueHigh } from '../../../utils/variable-sizes' import { TreeNode } from '../../_atoms/debug-tree-node' import { Label } from '../../_atoms/label' +import { toast } from '../../_features/[app]/toast/use-toast' import { Modal, ModalContent, ModalTitle } from '../modal' type Variable = { @@ -117,6 +110,7 @@ const VariablesPanel = ({ compositeKey: string lookupKey: string variableType: string + enumValues?: string[] position: { x: number; y: number } } | null>(null) const [forceValueModalOpen, setForceValueModalOpen] = useState(false) @@ -125,6 +119,7 @@ const VariablesPanel = ({ compositeKey: string lookupKey: string variableType: string + enumValues?: string[] } | null>(null) const getValue = (compositeKey: string): string | undefined => { @@ -199,6 +194,7 @@ const VariablesPanel = ({ compositeKey: node.compositeKey, lookupKey, variableType: node.type, + enumValues: node.enumValues, position, }) }, @@ -260,6 +256,7 @@ const VariablesPanel = ({ compositeKey: contextMenuState.compositeKey, lookupKey: contextMenuState.lookupKey, variableType: contextMenuState.variableType, + enumValues: contextMenuState.enumValues, }) } handleCloseContextMenu() @@ -281,49 +278,24 @@ const VariablesPanel = ({ } const variableType = pendingForceContext.variableType - const typeInfo = getVariableTypeInfo(variableType) - if (!typeInfo) { - closeModal() - return - } - - const normalizedType = variableType.toLowerCase() - const isFloatType = normalizedType === 'real' || normalizedType === 'lreal' - const isStringType = normalizedType === 'string' let valueBuffer: Uint8Array - let forcedValueForState: boolean - - if (isStringType) { - const parsedStringValue: string | null = parseStringValue(forceValue) - if (parsedStringValue === null) { - closeModal() - return - } - valueBuffer = stringToBuffer(parsedStringValue) - forcedValueForState = true - } else if (isFloatType) { - const parsedFloatValue = parseFloatValue(forceValue, typeInfo.byteSize) - if (parsedFloatValue === null) { - closeModal() - return - } - valueBuffer = floatToBuffer(parsedFloatValue, typeInfo.byteSize) - forcedValueForState = parsedFloatValue >= 0 - } else { - const parsedIntValue = parseIntegerValue(forceValue, typeInfo) - if (parsedIntValue === null) { - closeModal() - return - } - valueBuffer = integerToBuffer(parsedIntValue, typeInfo.byteSize, typeInfo.signed) - forcedValueForState = parsedIntValue >= BigInt(0) + try { + valueBuffer = encodeForceValue(forceValue, variableType, pendingForceContext.enumValues) + } catch (error) { + toast({ + title: 'Cannot force value', + description: error instanceof Error ? error.message : String(error), + variant: 'fail', + }) + closeModal() + return } void onForceVariable( pendingForceContext.compositeKey, variableType, - forcedValueForState, + isForcedValueHigh(forceValue), valueBuffer, pendingForceContext.lookupKey, ) diff --git a/src/frontend/components/_organisms/console/index.tsx b/src/frontend/components/_organisms/console/index.tsx index a6bcd49e9..76f45ca58 100644 --- a/src/frontend/components/_organisms/console/index.tsx +++ b/src/frontend/components/_organisms/console/index.tsx @@ -136,6 +136,7 @@ const Console = memo(() => { message={log.message} tstamp={formatTimestamp(log.tstamp ?? new Date(), filters.timestampFormat)} searchTerm={filters.searchTerm} + segments={log.segments} compileError={log.compileError} onCompileErrorClick={navigateToCompileError} /> diff --git a/src/frontend/components/_organisms/console/log.tsx b/src/frontend/components/_organisms/console/log.tsx index 66e2872e2..e0912705c 100644 --- a/src/frontend/components/_organisms/console/log.tsx +++ b/src/frontend/components/_organisms/console/log.tsx @@ -1,4 +1,4 @@ -import type { StructuredCompileError } from '@root/middleware/shared/ports/types' +import type { LogSegment, StructuredCompileError } from '@root/middleware/shared/ports/types' import { Copy } from 'lucide-react' import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react' @@ -18,6 +18,9 @@ type LogComponentProps = ComponentPropsWithoutRef<'p'> & { message: string tstamp: string searchTerm?: string + /** Styled runs when the tool emitted SGR colour; `message` is the same + * text with the escapes stripped. */ + segments?: LogSegment[] /** When set, the bracketed POU prefix on the first line becomes a * click-to-open button that calls {@link onCompileErrorClick}. * Multi-line gcc-style snippet renders as plain pre-wrapped text @@ -63,6 +66,35 @@ const HighlightedText = ({ text, searchTerm }: { text: string; searchTerm?: stri return <>{parts} } +/** + * The message body of a log line. + * + * Applies the tool's SGR colour when the line carried any, and otherwise + * renders exactly as before. Search highlighting runs inside each styled run + * so a match spanning a colour change still highlights correctly. + */ +const MessageBody = ({ + message, + segments, + searchTerm, +}: { + message: string + segments?: LogSegment[] + searchTerm?: string +}) => { + if (!segments?.length) return + + return ( + <> + {segments.map((segment, index) => ( + + + + ))} + + ) +} + /** * Render a compile-error log line: the first line (the bracketed POU * prefix the compiler-module emits, e.g. `[Manual_Override / body @@ -118,6 +150,7 @@ const LogComponent = ({ message, tstamp, searchTerm, + segments, compileError, onCompileErrorClick, ...rest @@ -156,22 +189,13 @@ const LogComponent = ({ {message && (

    - {level && tstamp ? ( + {level && tstamp && ( <> [ ]:{' '} - {compileError ? ( - - ) : ( - - )} - ) : compileError ? ( + )} + {compileError ? ( ) : ( - + )}

    + +
    + + + ) +} + +export { CreateGraphicalVariableModal } diff --git a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx index 472af5cb5..8297151b4 100644 --- a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx @@ -6,7 +6,11 @@ const RuntimeConnectionLostModal = () => { const { modals, modalActions } = useOpenPLCStore() const isOpen = modals['runtime-connection-lost']?.open || false - const modalData = modals['runtime-connection-lost']?.data as { label?: string } | undefined + // `body` lets a caller state WHICH link died and what to do about it, while the + // default keeps the Runtime v4 copy this modal was written for. The serial link + // (baremetal Connect) reuses the same dialog rather than cloning it — the shape + // of the news is identical: a held connection is gone after retries failed. + const modalData = modals['runtime-connection-lost']?.data as { label?: string; body?: string } | undefined const label = modalData?.label ?? 'Unknown' const handleClose = () => { @@ -32,8 +36,12 @@ const RuntimeConnectionLostModal = () => { Connection to runtime lost

    - The connection to {label} has been lost after multiple failed attempts. Please check that - the runtime is running and accessible. + {modalData?.body ?? ( + <> + The connection to {label} has been lost after multiple failed attempts. Please check + that the runtime is running and accessible. + + )}

diff --git a/src/frontend/components/_organisms/variables-code-editor/index.tsx b/src/frontend/components/_organisms/variables-code-editor/index.tsx index 944cea274..c2d1daed6 100644 --- a/src/frontend/components/_organisms/variables-code-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-code-editor/index.tsx @@ -41,6 +41,12 @@ interface VariablesCodeEditorProps { * cross-routing. */ pouName?: string + /** + * Explicit Monaco model URI, for surfaces that route to the LSP but + * aren't POU variables (the data type `.dt` code view). Takes + * precedence over the URI derived from `pouName`. + */ + modelUri?: string /** * Programmatic cursor jump (e.g. compile-error click → vars-text * view, or Go to Definition redirect for a variable declaration). @@ -54,7 +60,7 @@ interface VariablesCodeEditorProps { * (it's meant for the body editor). Undefined or * `target === 'variables'` is honoured here. */ - cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' } + cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' | 'data-type' } } const VariablesCodeEditor = ({ @@ -63,9 +69,11 @@ const VariablesCodeEditor = ({ shouldUseDarkMode, cursorPosition, pouName, + modelUri, language = 'st', readOnly = false, }: VariablesCodeEditorProps) => { + const resolvedModelUri = modelUri ?? (pouName ? pouVarsUri(pouName) : undefined) const editorRef = useRef(null) const containerRef = useRef(null) const [editorMounted, setEditorMounted] = useState(false) @@ -146,7 +154,7 @@ const VariablesCodeEditor = ({ height='100%' width='100%' language={language} - {...(pouName ? { path: pouVarsUri(pouName) } : {})} + {...(resolvedModelUri ? { path: resolvedModelUri } : {})} defaultValue={''} value={code} onMount={handleEditorDidMount} diff --git a/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx b/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx new file mode 100644 index 000000000..510601d1a --- /dev/null +++ b/src/frontend/components/_organisms/variables-editor/__tests__/return-type-visibility.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react' + +// The variables code editor pulls in Monaco, which cannot run in jsdom. +vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({ + VariablesCodeEditor: () =>
, +})) + +import { useOpenPLCStore } from '../../../../store' +import { VariablesEditor } from '../index' + +const createPou = ( + name: string, + type: 'program' | 'function' | 'function-block', + language: 'st' | 'fbd' | 'ld' | 'sfc', +) => { + const result = useOpenPLCStore.getState().pouActions.create({ type, name, language }) + expect(result.ok).toBe(true) +} + +// https://github.com/Autonomy-Logic/openplc-editor/issues/696 +describe('VariablesEditor return type selector', () => { + it('shows the return type selector for a function written in FBD', () => { + createPou('FbdFunction', 'function', 'fbd') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('shows the return type selector for a function written in LD', () => { + createPou('LdFunction', 'function', 'ld') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('shows the return type selector for a function written in SFC', () => { + createPou('SfcFunction', 'function', 'sfc') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('associates the return type label with its select trigger', () => { + createPou('LabeledFunction', 'function', 'fbd') + render() + const trigger = screen.getByLabelText('Return type :') + expect(trigger.id).toBe('return-type') + }) + + it('shows the return type selector for a function written in ST', () => { + createPou('StFunction', 'function', 'st') + render() + expect(screen.getByText('Return type :')).toBeTruthy() + }) + + it('does not show the return type selector for an FBD program', () => { + createPou('FbdProgram', 'program', 'fbd') + render() + expect(screen.queryByText('Return type :')).toBeNull() + }) + + it('does not show the return type selector for an FBD function block', () => { + createPou('FbdBlock', 'function-block', 'fbd') + render() + expect(screen.queryByText('Return type :')).toBeNull() + }) +}) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index e3b650e59..f2a73addb 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -1052,42 +1052,43 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl
{editorVariables.display === 'table' && (
- {editor.type === 'plc-textual' && editor.meta.pouType === 'function' && ( -
- - -
- )} + Return type : + + +
+ )}
=> { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-message', { - type, - title, - message, - buttons, - ...options, - onResponse: (buttonIndex: number) => resolve(buttonIndex), - }) - }) -} - -const showDebuggerIpInput = (title: string, message: string, defaultValue: string): Promise => { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { - title, - message, - defaultValue, - onSubmit: (value: string) => resolve(value), - onCancel: () => resolve(null), - }) - }) -} - const disabledButtonClass = 'cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent' type DefaultWorkspaceActivityBarProps = { @@ -91,6 +59,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const runtime = useRuntime() const simulator = useSimulator() const debuggerPort = useDebugger() + const device = useDevice() const projectPort = useProject() const capabilities = useCapabilities() const debugSession = useDebugSession() @@ -100,9 +69,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const [isDebuggerProcessing, setIsDebuggerProcessing] = useState(false) const [simulatorRunning, setSimulatorRunning] = useState(false) const pendingSimulatorDebugRef = useRef(false) + // True while a debug session is running OVER THE DEVICE CONNECTION (a baremetal + // target, whatever transport that connection uses). Such a session shares the + // connection, so it has to end when the connection does — which the drop handler + // below acts on. A runtime or simulator session owns its own channel and is + // unaffected, so this stays false for them. + const debugSessionRidesDeviceRef = useRef(false) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const plcStatus = useOpenPLCStore((state): RuntimeConnection['plcStatus'] => state.runtimeConnection.plcStatus) + const switchPosition = useOpenPLCStore((state) => state.runtimeConnection.switchPosition) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const canEdit = useOpenPLCStore((state) => state.workspace.canEdit) @@ -110,18 +86,86 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) const isSimulatorBoard = resolveTargetCapabilities(currentBoardInfo).isInProcessSimulator - // Sync simulatorRunning when the simulator stops externally + const deviceConnectionStatus = useOpenPLCStore((state) => state.deviceConnection.status) + + // Run/stop travels over the session's control channel, so the button is live + // exactly when a SESSION exists — one question, asked once, for every target type. + // Every session publishes its status: a device connection, a runtime login, a + // running simulator. Asking the target's kind first (`directUsbUpload ? … : …`) + // meant asking "did the user log in" for a runtime, which is not the same question + // and diverged in practice: logged in, session never opened, every command + // refused. "Can a payload be delivered?" is what the button actually needs. + // + // Deliberately NOT applied to Build & Upload. Uploading is how a blank board stops + // being blank, so it cannot require a connection — see `handleBuild`, where the + // connection is consulted only to hand the serial port over to arduino-cli. + // + // A target that does not implement run/stop at all is blocked for a DIFFERENT + // reason, and says so. `handlePlcControl` refuses such a target anyway, so + // without this the button looked live and the click did nothing at all — no + // command, no error, no log line. + // + // A transition already in flight blocks it for a third reason. TRANSITIONING + // means the runtime has a start or stop underway: it answers COMMAND:BUSY to + // everything except PING and STATUS, and the state it will settle on is not + // decided yet, so the icon is drawn from a state that is about to change. + // Clicking then cannot do what it appears to. + // + // The reason chain runs in the same order as the blocks it explains, most + // fundamental first: a target that cannot do run/stop at all, then no session + // to send over, then a transition in flight. Asking about the transition first + // would answer "PLC is changing state..." to someone who is not connected, + // reporting a state we last saw rather than the reason the button is inert — + // `plcStatus` is polled and survives the drop. Reached only when blocked, so + // the tail needs no test of its own: supported and connected and still blocked + // leaves exactly one reason. + const plcStateControlSupported = resolveTargetCapabilities(currentBoardInfo).plcStateControl + const plcTransitioning = plcStatus === 'TRANSITIONING' + const plcControlBlocked = !plcStateControlSupported || deviceConnectionStatus !== 'connected' || plcTransitioning + const plcControlBlockedReason = !plcStateControlSupported + ? 'This target does not support Start/Stop from the editor' + : deviceConnectionStatus !== 'connected' + ? 'Connect to the target first' + : 'PLC is changing state...' + + // The emulator stopping is a session ending, and a debug session riding it ends + // with it — which the drop handler below already does for every target. This + // only mirrors the emulator's own state into the button. useEffect(() => { const unsub = simulator.onStopped(() => { pendingSimulatorDebugRef.current = false setSimulatorRunning(false) - const { workspace } = useOpenPLCStore.getState() - if (workspace.isDebuggerVisible) { - void debugSession.stopSession() - } }) return unsub - }, [simulator, debugSession]) + }, [simulator]) + + // A serial debug session lives on the device connection: it shares that + // client, so when the link drops (unplug, reset, liveness failure, or the user + // pressing Disconnect) the session has no transport left and must end. Leaving + // it "active" would show a frozen variable table over a dead port and leave the + // debugger unable to reconnect. + // + // Modbus TCP sessions are deliberately untouched — they own their own socket + // and never depended on the serial link. + // Only 'connected' is tolerated. 'connecting' covers RECOVERY too (the + // connection died and the main process is reopening it), and by then the client + // the session was sharing is already closed — waiting for the recovery verdict + // would just keep a dead session on screen for the whole retry window. A session + // can only have started from 'connected', so the initial connect's 'connecting' + // never reaches this: no session is active to stop. + useEffect(() => { + if (deviceConnectionStatus === 'connected') return + if (!debugSessionRidesDeviceRef.current) return + if (!useOpenPLCStore.getState().workspace.isDebuggerVisible) return + + addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Device disconnected — stopping the debug session (serial debugging runs over the device connection).', + }) + debugSessionRidesDeviceRef.current = false + void debugSession.stopSession() + }, [deviceConnectionStatus, debugSession, addLog]) // Stop simulator if the board is switched away while it's running const prevIsSimulatorBoardRef = useRef(isSimulatorBoard) @@ -204,7 +248,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const requiresRuntimeConnection = !resolveTargetCapabilities(boardInfo).directUsbUpload const { connectionStatus: connStatus, plcStatus: runStatus } = state.runtimeConnection if (requiresRuntimeConnection && connStatus === 'connected' && runStatus === 'RUNNING') { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Stop PLC', 'The PLC must be stopped before continuing.', @@ -218,7 +262,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa setIsCompiling(false) return } - const stopResult = await runtime.stopPlc() + // Same unified control path as the Start/Stop button: the session routes + // it, so this works for a runtime and a device alike. + const stopResult = (await debuggerPort.setPlcState?.('STOPPED')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!stopResult.success) { addLog({ id: crypto.randomUUID(), @@ -242,6 +291,26 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // aliases. const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() + // Serial handoff (D72): a held device connection owns the serial port that + // arduino-cli needs for a direct-USB upload. Release it before the build so + // the upload can take the port; reconnect afterwards (auto-reconnect). + const caps = resolveTargetCapabilities(currentBoardInfo) + const willUpload = !isSimulatorBoard && !(overrides?.compileOnly ?? false) && caps.directUsbUpload + // Release ONLY if the held connection is the serial one arduino-cli needs. + // A connection over Modbus TCP is untouched, so debugging and run/stop keep + // working across the upload; disconnecting unconditionally used to throw it + // away. `released` also tells us whether to reconnect afterwards. + let serialWasReleased = false + if (willUpload && useOpenPLCStore.getState().deviceConnection.status === 'connected') { + try { + serialWasReleased = await device.releaseSerialPort( + useOpenPLCStore.getState().deviceDefinitions.configuration.communicationPort ?? null, + ) + } catch { + // best-effort: never block a build on the handoff. + } + } + try { // Track whether the compile stream already surfaced an error so we // don't log a second, generic "Compilation failed" after a failed @@ -290,13 +359,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) if (pendingSimulatorDebugRef.current) { pendingSimulatorDebugRef.current = false - // Simulator's debug spec resolves to the trivial - // `{ connectionType: 'simulator' }` config — see - // the hals.json entry. Pass it explicitly so the - // session's downstream MD5-verification path has - // the right transport instead of falling back to - // `connectAndStart`'s internal default. - void debugSession.connectAndStart({ connectionType: 'simulator', connectionParams: {} }) + // Rides the emulator's session, so it ends when the emulator + // does — through the same handler a pulled cable goes through. + debugSessionRidesDeviceRef.current = true + // No config: starting the emulator opened its session, so the + // connection manager already knows how to reach it. + void debugSession.connectAndStart() } } else { pendingSimulatorDebugRef.current = false @@ -314,6 +382,32 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (!result.success && !streamedError) { addLog({ id: crypto.randomUUID(), level: 'error', message: result.error ?? 'Compilation failed' }) } + + // Serial handoff (D72): if we released a held device connection for this + // upload, reconnect it now that arduino-cli is done with the port. + // Silent (no dialogs) — the user just flashed on purpose. + if (serialWasReleased && result.success) { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const spec = currentBoardInfo?.debug + // Same candidate resolution Connect uses, so the link comes back the way + // the user established it. Only the serial link is ever released for an + // upload, but resolving the full list lets the reconnect land on Modbus + // TCP if that is what now answers. + // `deferPrompts`: this reconnect is silent and automatic (the user just + // flashed), so it must never pop an address dialog behind their back. A + // DHCP-only target simply stays disconnected until they press Connect. + const candidates = resolveDeviceLinkCandidates(spec, buildDeviceResolverContext(boardTarget), { + transports: caps.debuggerTransports, + deferPrompts: true, + }) + if (candidates.kind === 'candidates') { + try { + await device.connect(candidates.candidates.map((candidate) => candidate.config)) + } catch { + // best-effort: the user can press Connect again. + } + } + } } catch (err: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `Build error: ${getErrorMessage(err)}` }) } finally { @@ -322,9 +416,9 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }, [ compiler, - projectData, projectMeta, deviceDefinitions, + currentBoardInfo, isSimulatorBoard, simulator, debugSession, @@ -341,6 +435,15 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handleBuildRef = useRef(handleBuild) handleBuildRef.current = handleBuild + // CONNECT flow (D72): the device screen's "No Firmware Detected" dialog lives + // in board.tsx but Build & Upload lives here. When the user chooses to flash, + // that dialog fires a decoupled event we answer by running the same build. + useEffect(() => { + return onDeviceFlashRequest(() => { + void handleBuildRef.current() + }) + }, []) + // --------------------------------------------------------------------------- // Build Library (.stlib) // --------------------------------------------------------------------------- @@ -423,46 +526,112 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // PLC control (Start/Stop for runtime targets) // --------------------------------------------------------------------------- - const handlePlcControl = useCallback(async (): Promise => { - if (!jwtToken || connectionStatus !== 'connected') return + /** + * Tell the user the hardware mode switch is holding the device in STOP. + * + * Shown both when the editor blocks a start locally (pre-check) and when the + * device refuses one, so the two paths read identically. `switchLabel` comes + * from the VPP manifest's optional `stateControl.modeSwitch.label` when the + * package provides it, so a P1AM says "CPU switch" rather than the generic + * wording. + */ + const warnSwitchInStop = useCallback(async (deviceName: string, switchLabel?: string): Promise => { + await showDeviceDialog( + 'warning', + 'Device is in STOP', + `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + + 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + + 'Flip the switch to RUN and try again.', + ['OK'], + ) + }, []) + const handlePlcControl = useCallback(async (): Promise => { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const boardInfo = availableBoards.get(boardTarget) + const caps = resolveTargetCapabilities(boardInfo) + if (!caps.plcStateControl) return + + const switchLabel = (boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined)?.stateControl + ?.modeSwitch?.label + + // ONE path for every target. "Start the PLC" is the same request whether it + // travels as Modbus FC 0x4b down a cable or as an HTTP POST to a runtime; the + // connection manager routes it over whatever the session's control channel is. + // Branching here on target type is what kept two copies of the switch + // pre-check, the refusal handling and the error reporting in step by hand. + // + // Reads are NOT done here: the session's status poll keeps `plcStatus` and + // `switchPosition` in the store, so the pre-check is a store lookup rather than + // another round trip over a medium the poll is already using. try { - if (plcStatus === 'RUNNING') { - const result = await runtime.stopPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to stop PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } - } else { - const result = await runtime.startPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to start PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } + // The button is disabled while a transition is in flight; this covers the + // window before the next status poll catches up, and any caller that is not + // the click. + if (plcStatus === 'TRANSITIONING') return + + const wantRun = plcStatus !== 'RUNNING' + + // Never send a start to a device whose switch reads STOP. `null` means + // "unknown / no switch", which must NOT block: a board with no physical + // switch, or firmware predating the state machine, would otherwise be + // un-startable. + if (wantRun && switchPosition === 'stop') { + await warnSwitchInStop(boardTarget, switchLabel) + return } - const statusResult = await runtime.getStatus() - if (statusResult.success && statusResult.status) { + const result = await debuggerPort.setPlcState?.(wantRun ? 'RUNNING' : 'STOPPED') + if (!result) return + + if (result.unsupported) { + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: 'This firmware predates run/stop control. Rebuild and upload the program to enable Start/Stop.', + }) + return + } + // Covers the race where the switch moved between the store's last poll and + // this command: the device is authoritative, so its refusal wins. + if (result.refusedBySwitch) { + await warnSwitchInStop(boardTarget, switchLabel) + return + } + if (!result.success) { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Failed to ${wantRun ? 'start' : 'stop'} PLC: ${result.error ?? 'Unknown error'}`, + }) + return + } + + // No re-read: the target settles into the new state on its next scan and the + // status poll picks it up within one tick. Reflecting the acknowledgement + // keeps the button responsive without a round trip that could still read the + // pre-change value. + if (result.state !== undefined) { useOpenPLCStore .getState() - .deviceActions.setPlcRuntimeStatus(statusResult.status as NonNullable) + .deviceActions.setPlcRuntimeStatus( + (result.state === 1 ? 'RUNNING' : result.state === 2 ? 'ERROR' : 'STOPPED') as NonNullable< + RuntimeConnection['plcStatus'] + >, + ) } } catch (error: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `PLC control error: ${getErrorMessage(error)}` }) } - }, [runtime, jwtToken, connectionStatus, plcStatus, addLog]) - - // --------------------------------------------------------------------------- - // Simulator control (Start/Stop simulator + auto-debug) - // --------------------------------------------------------------------------- + }, [ + deviceDefinitions.configuration.deviceBoard, + availableBoards, + plcStatus, + switchPosition, + debuggerPort, + addLog, + warnSwitchInStop, + ]) const handleSimulatorControl = useCallback(async (): Promise => { try { @@ -486,18 +655,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // MD5 verification — runs after debug compilation for non-simulator // --------------------------------------------------------------------------- - const handleMd5Verification = async ( - projectPath: string, - boardTarget: string, - debugConfig: DebugConnectionConfig, - isRuntimeTarget: boolean, - ) => { + const handleMd5Verification = async (projectPath: string, boardTarget: string, isRuntimeTarget: boolean) => { const { consoleActions, runtimeConnection, deviceActions } = useOpenPLCStore.getState() try { // If runtime target + PLC stopped, offer to start if (isRuntimeTarget && runtimeConnection.plcStatus === 'STOPPED' && runtimeConnection.jwtToken) { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'question', 'PLC Stopped', 'The PLC is currently stopped. The debugger requires the PLC to be running. Would you like to start the PLC now?', @@ -510,9 +674,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting PLC...' }) - const startResult = await runtime.startPlc() + const startResult = (await debuggerPort.setPlcState?.('RUNNING')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!startResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Start PLC Failed', `Could not start the PLC: ${startResult.error || 'Unknown error'}`, @@ -529,7 +696,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Verifying program MD5...' }) const md5Result = await debuggerPort.readProgramMd5(projectPath, boardTarget) if (!md5Result.success || !md5Result.md5) { - await showDebuggerMessage('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) + await showDeviceDialog('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) setIsDebuggerProcessing(false) return } @@ -537,22 +704,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Connect debug transport before MD5 verification — the web platform // needs an active transport (WebRTC or HTTP fallback) to query the device. // connect() is idempotent: connectAndStart will reuse this connection. - const preConnectResult = await debuggerPort.connect(debugConfig) + const preConnectResult = await debuggerPort.connect() if (!preConnectResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', - 'Connection Error', - `Could not connect to debug target: ${preConnectResult.error ?? 'Unknown error'}`, + "Can't Start Debugger", + `Can't start the debugger — ${preConnectResult.error ?? 'unknown error'}.`, ['OK'], ) setIsDebuggerProcessing(false) return } - const verifyResult = await debuggerPort.verifyMd5(md5Result.md5, debugConfig) + const verifyResult = await debuggerPort.verifyMd5(md5Result.md5) if (!verifyResult.success) { await debuggerPort.disconnect() - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Connection Error', `Could not verify MD5: ${verifyResult.error ?? 'Unknown error'}`, @@ -564,17 +731,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (verifyResult.match) { consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) - // Surface the active transport in the store so transport-specific - // pollers (useDebugPolling) can size their batches against the - // real frame budget rather than guessing from the board target. - useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(debugConfig.connectionType) // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to // `'le'` when the trailer was missing or malformed (older // runtimes); detectTargetEndian already logged a warning. useOpenPLCStore.getState().workspaceActions.setDebugTargetEndian(verifyResult.targetEndian ?? 'le') - await debugSession.connectAndStart(debugConfig) + await debugSession.connectAndStart() setIsDebuggerProcessing(false) } else { // Disconnect before re-upload; the recursive call will reconnect @@ -585,7 +748,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa level: 'warning', message: `MD5 mismatch. Target: ${verifyResult.targetMd5}, Expected: ${md5Result.md5}`, }) - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Program Mismatch', 'The program on the target does not match. Upload the current project?', @@ -615,7 +778,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa message: 'Upload completed. Re-verifying...', }) await new Promise((resolve) => setTimeout(resolve, 2000)) - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + void handleMd5Verification(projectPath, boardTarget, isRuntimeTarget) } else { consoleActions.addLog({ id: crypto.randomUUID(), @@ -639,111 +802,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } - // --------------------------------------------------------------------------- - // Debug-spec resolver — surface picker / prompt / error dialogs and - // return a connection-ready DebugConnectionConfig, or null if the - // user cancelled or no config could be resolved. - // --------------------------------------------------------------------------- - - // Renderer-local prompt cache for the DHCP-IP-style flows. Keyed - // by `||` (or `builtin||` - // for hals.json entries) so two boards sharing a `cacheKey` value - // don't see each other's last-entered IP. Lives on a ref so it - // survives across re-renders without triggering them. - const promptCacheRef = useRef>>({}) - - const resolveDebugConfigWithUx = useCallback( - async (boardTarget: string, spec: DebugSpec | undefined): Promise => { - if (!spec) { - await showDebuggerMessage( - 'warning', - 'Debugging Not Available', - "This board hasn't declared a debug spec. The VPP package (or hals.json entry) must provide a `debug` block.", - ['OK'], - ) - return null - } - - // Build resolver context from current store state on each call — - // captures the user's freshest screen edits without forcing the - // user to save first. - const buildContext = (): DebugResolverContext => { - const store = useOpenPLCStore.getState() - const cfg = store.deviceDefinitions.configuration - const rtConn = store.runtimeConnection - // `vendorScreenData` is already keyed by section ID (e.g. - // `modbus_rtu`); resolver state's `screens` shape matches - // 1:1 so we pass it straight through. - const screens = (cfg.vendorScreenData ?? {}) as Record> - const cacheBucketKey = `${cfg.deviceBoard}` - const promptCache = promptCacheRef.current[cacheBucketKey] ?? {} - return { - state: { - configuration: { - deviceBoard: cfg.deviceBoard, - ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), - ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), - }, - screens, - runtimeConnection: { - ...(rtConn.connectionStatus ? { connectionStatus: rtConn.connectionStatus } : {}), - ...(rtConn.jwtToken ? { jwtToken: rtConn.jwtToken } : {}), - }, - promptCache, - }, - capabilities: { - runtimeConnected: runtime.isReadyForDebug?.() === true && rtConn.connectionStatus === 'connected', - jwtToken: Boolean(rtConn.jwtToken), - }, - } - } - - let selectedChannelIndex: number | undefined - // Loop: pickers/prompts re-invoke the resolver with extra state - // until it returns config or error/unsupported/cancelled. - // Capped at 8 iterations as a defensive guard against spec - // bugs that could otherwise loop forever. - for (let iteration = 0; iteration < 8; iteration += 1) { - const outcome = resolveDebugConnection(spec, buildContext(), selectedChannelIndex) - if (outcome.kind === 'config') { - return outcome.config - } - if (outcome.kind === 'error') { - await showDebuggerMessage('warning', outcome.title, outcome.body, ['OK']) - return null - } - if (outcome.kind === 'unsupported') { - // Defensive — buildContext already errored at top-level on - // missing spec, so we shouldn't reach here normally. - return null - } - if (outcome.kind === 'pick') { - const buttons = outcome.channels.map((c) => c.label) - const choice = await showDebuggerMessage('question', outcome.title, outcome.body, buttons) - if (choice < 0 || choice >= outcome.channels.length) return null - selectedChannelIndex = outcome.channels[choice].index - continue - } - if (outcome.kind === 'prompt') { - const bucketKey = boardTarget - const bucket = (promptCacheRef.current[bucketKey] ??= {}) - for (const field of outcome.fields) { - const previous = field.cacheKey ? bucket[field.cacheKey] : undefined - const result = await showDebuggerIpInput(field.title, field.message, previous ?? field.defaultValue ?? '') - if (result === null) return null - const trimmed = result.trim() - if (!trimmed) return null - if (field.cacheKey) bucket[field.cacheKey] = trimmed - } - selectedChannelIndex = outcome.channelIndex - continue - } - } - return null - }, - [runtime], - ) - // --------------------------------------------------------------------------- // Debugger click — full orchestration for non-simulator targets // --------------------------------------------------------------------------- @@ -759,11 +817,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Toggle off if (workspace.isDebuggerVisible) { + debugSessionRidesDeviceRef.current = false await debugSession.stopSession() return } if (isDebuggerProcessing) return + setIsDebuggerProcessing(true) try { @@ -785,8 +845,38 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const projectPath = project.meta.path const boardInfo = availableBoards.get(boardTarget) - const debugConfig = await resolveDebugConfigWithUx(boardTarget, boardInfo?.debug) - if (!debugConfig) { + // No resolution here at all. Every target's session is established before a + // debug session can start — a device by Connect, a runtime by logging in, the + // simulator by pressing Start — so the only question left is whether that + // session exists. Which medium it uses is the connection manager's to know. + const isRuntime = isOpenPLCRuntimeTarget(boardInfo) + + // A session the manager holds (a device or the simulator) also OWNS the debug + // channel, so the session ending ends the debug session — see the drop handler + // above. A runtime's debug channel is its own and outlives nothing. + debugSessionRidesDeviceRef.current = !isRuntime + + // One question for every target: does the manager hold a session? A simulator's + // session is its running emulator, a device's is Connect, a runtime's is the + // login — all three publish the same status. + const sessionStatus = useOpenPLCStore.getState().deviceConnection.status + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] debug session requested for ${boardTarget}; session is "${sessionStatus}"`, + }) + + // Connect first. Starting a debug session must never establish the connection + // itself: connecting is the user's explicit action and reports what it found. + if (sessionStatus !== 'connected') { + await showDeviceDialog( + 'warning', + 'Connection Required', + isRuntime + ? 'Connect to the runtime first. The debugger runs over that connection, so it must be established before a debug session can start.' + : 'Connect to the device first. The debugger runs over the device connection, so the device must be connected before a debug session can start.', + ['OK'], + ) setIsDebuggerProcessing(false) return } @@ -810,12 +900,10 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } - // `isRuntimeTarget` here only gates the "PLC stopped, start it?" - // dialog inside MD5 verification. Tied to whether the active - // channel needs the runtime alive — websocket/tcp targets do, - // rtu/simulator targets don't. - const isRuntimeTarget = debugConfig.connectionType === 'websocket' || debugConfig.connectionType === 'tcp' - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + // Only gates the "PLC stopped, start it?" dialog inside MD5 verification, + // which applies to an OpenPLC runtime (v3/v4) — a fact about the TARGET, not + // about which transport happens to carry the session. + void handleMd5Verification(projectPath, boardTarget, isRuntime) } catch (error: unknown) { consoleActions.addLog({ id: crypto.randomUUID(), @@ -838,7 +926,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - resolveDebugConfigWithUx, + currentBoardInfo, ]) // --------------------------------------------------------------------------- @@ -902,8 +990,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ? simulatorRunning ? 'Stop Simulator' : 'Start Simulator' - : connectionStatus !== 'connected' - ? 'Connect to runtime first' + : plcControlBlocked + ? plcControlBlockedReason : plcStatus === 'RUNNING' ? 'Stop PLC' : 'Start PLC' @@ -911,13 +999,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'} + disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : plcControlBlocked} className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing ? disabledButtonClass : '' - : connectionStatus !== 'connected' + : plcControlBlocked ? disabledButtonClass : '', )} diff --git a/src/frontend/components/_templates/accelerator-handler.tsx b/src/frontend/components/_templates/accelerator-handler.tsx index 1d5136a1e..a28bd40d5 100644 --- a/src/frontend/components/_templates/accelerator-handler.tsx +++ b/src/frontend/components/_templates/accelerator-handler.tsx @@ -288,21 +288,31 @@ const AcceleratorHandler = () => { /** * Undo / Redo */ + // An invalid graphical body blocks history changes outright, so say so rather + // than letting the shortcut look broken (DOPE-495). + const notifyStaleBody = useCallback((pouName: string) => { + toast({ + title: 'History unavailable', + description: `The graphical body of "${pouName}" is invalid, so its history was not changed.`, + variant: 'fail', + }) + }, []) + useEffect(() => { const unsub = accelerator.onUndo(() => { if (!meta?.name) return - undo(meta.name) + if (!undo(meta.name)) notifyStaleBody(meta.name) }) return unsub - }, [meta.name, isMonacoFocused, accelerator, undo]) + }, [meta.name, isMonacoFocused, accelerator, undo, notifyStaleBody]) useEffect(() => { const unsub = accelerator.onRedo(() => { if (!meta?.name) return - redo(meta.name) + if (!redo(meta.name)) notifyStaleBody(meta.name) }) return unsub - }, [meta.name, isMonacoFocused, accelerator, redo]) + }, [meta.name, isMonacoFocused, accelerator, redo, notifyStaleBody]) /** * Quit app (Ctrl+Q on Windows/Linux) diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 1f696ed8f..47b097e84 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -3,16 +3,20 @@ import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useState } import { useCapabilities, useProject, useSystem, useTheme } from '../../../middleware/shared/providers' import { useOpenPLCStore } from '../../store' import type { RungLadderState } from '../../store/slices/ladder' +import type { CreateGraphicalVariableModalData } from '../../store/slices/modal/types' import { cn } from '../../utils/cn' import { ResolutionWarning } from '../_atoms/resolution-warning-message' import Toaster from '../_features/[app]/toast/toaster' import { ProjectModal } from '../_features/[start]/new-project/project-modal' import { AIConsentModal } from '../_features/[workspace]/editor/monaco/ai-consent-modal' +import { DataTypeRenameImpactModal } from '../_molecules/rename-impact-modal/data-type-rename-impact-modal' import AboutModal from '../_organisms/about-modal' import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal } from '../_organisms/modals' import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' import { ConfirmInstallLibrariesModal } from '../_organisms/modals/confirm-install-libraries-modal' import { ConfirmPlcopenImportModal } from '../_organisms/modals/confirm-plcopen-import-modal' +import { CreateGraphicalVariableModal } from '../_organisms/modals/create-graphical-variable-modal' +import { DebuggerIpInputModal } from '../_organisms/modals/debugger-ip-input-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -35,6 +39,8 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { const caps = useCapabilities() const [showComponent, setShowComponent] = useState(true) const modals = useOpenPLCStore(useCallback((s) => s.modals, [])) + const dataTypes = useOpenPLCStore(useCallback((s) => s.project.data.dataTypes, [])) + const { closeModal, onOpenChange } = useOpenPLCStore(useCallback((s) => s.modalActions, [])) const OS = useOpenPLCStore(useCallback((s) => s.workspace.systemConfigs.OS, [])) const { setSystemConfigs, setRecent } = useOpenPLCStore(useCallback((s) => s.workspaceActions, [])) @@ -127,9 +133,19 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-delete-project']?.open === true && ( )} + {modals?.['confirm-plcopen-import']?.open === true && ( )} + {modals?.['create-graphical-variable']?.open === true && ( + dataType.name)} + onOpenChange={(open) => onOpenChange('create-graphical-variable', open)} + onClose={closeModal} + /> + )} {modals?.['quit-application']?.open === true && ( )} @@ -138,6 +154,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { )} {modals?.['runtime-connection-lost']?.open === true && } {modals?.['debugger-message']?.open === true && } + {modals?.['debugger-ip-input']?.open === true && } {modals?.['missing-libraries']?.open === true && } {modals?.['public-catalog-browser']?.open === true && } {modals?.['confirm-install-libraries']?.open === true && } diff --git a/src/frontend/data/constants/app-version.ts b/src/frontend/data/constants/app-version.ts index 3b99e5528..79ed6c0ac 100644 --- a/src/frontend/data/constants/app-version.ts +++ b/src/frontend/data/constants/app-version.ts @@ -18,4 +18,4 @@ * compares a per-deploy `BUILD_ID` (git commit SHA), not this version, so a * stale tab is detected on every deploy even without a version bump. */ -export const APP_VERSION = '4.2.10' +export const APP_VERSION = '4.2.11' diff --git a/src/frontend/hooks/__tests__/debug-medium-profile.test.ts b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts new file mode 100644 index 000000000..cd3013ba3 --- /dev/null +++ b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts @@ -0,0 +1,77 @@ +/** + * How the debug poll is paced and sized, per medium. + * + * This table is the whole reason the poller no longer asks which platform it is + * running on. It replaced two independent sources — a copy of the spec's channel + * kind (for batch size) and a WebRTC-slice flag behind an `isNativeApplication` + * check (for cadence) — which could and did disagree. + * + * The invariants below are the ones that were violated in practice, so they are + * asserted as properties rather than as a snapshot of the numbers. + */ +import type { DebugMedium } from '@root/middleware/shared/ports/types' + +import { DEBUG_MEDIUM_PROFILE, debugProfileFor } from '../useDebugPolling' + +/** Every medium the type admits. A new one must be added here deliberately. */ +const ALL_MEDIA: DebugMedium[] = ['rtu', 'simulator', 'tcp', 'websocket', 'webrtc', 'http-relay'] + +describe('DEBUG_MEDIUM_PROFILE', () => { + it('covers every medium, with no extras', () => { + // A medium with no row would fall through to `undefined` and crash the poller + // on `profile.batchSize`. + expect(Object.keys(DEBUG_MEDIUM_PROFILE).sort()).toEqual([...ALL_MEDIA].sort()) + }) + + it.each(ALL_MEDIA)('%s has a usable batch size and cadence', (medium) => { + const { batchSize, pollIntervalMs } = DEBUG_MEDIUM_PROFILE[medium] + expect(batchSize).toBeGreaterThan(1) + expect(pollIntervalMs).toBeGreaterThan(0) + }) + + it('sizes serial-framed media to one USB-CDC packet', () => { + // 6 + 3·19 = 63 ≤ 64. A 20th variable splits the request across two packets, + // which older serial framers drop. + for (const medium of ['rtu', 'simulator'] as const) { + expect(DEBUG_MEDIUM_PROFILE[medium].batchSize).toBe(19) + expect(6 + 3 * DEBUG_MEDIUM_PROFILE[medium].batchSize).toBeLessThanOrEqual(64) + } + }) + + it('gives every runtime medium the same batch size', () => { + // websocket / webrtc / http-relay all terminate at the SAME debug socket on the + // runtime, so they share its frame budget. Only the hops in front of it differ. + const runtimeMedia = ['websocket', 'webrtc', 'http-relay'] as const + const sizes = new Set(runtimeMedia.map((m) => DEBUG_MEDIUM_PROFILE[m].batchSize)) + expect(sizes.size).toBe(1) + expect(DEBUG_MEDIUM_PROFILE.websocket.batchSize).toBe(500) + }) + + it('backs the relay cadence off well below the direct media', () => { + // The regression this guards: a v4 web session fell through to the simulator + // row, polling the Edge relay every 50ms instead of every 1000ms. + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBeGreaterThan( + DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs, + ) + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBe(1000) + }) + + it('paces a peer-to-peer data channel like any other direct link', () => { + // WebRTC reaches the agent directly, so it is not the relay's problem. + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(DEBUG_MEDIUM_PROFILE.websocket.pollIntervalMs) + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(200) + }) +}) + +describe('debugProfileFor', () => { + it.each(ALL_MEDIA)('returns the %s row', (medium) => { + expect(debugProfileFor(medium)).toBe(DEBUG_MEDIUM_PROFILE[medium]) + }) + + it('falls back to tcp when the manager has published no medium yet', () => { + // Middle of the range, and what the poller defaulted to before the media were + // named. Deliberately NOT the simulator row, which is the fastest cadence and + // the smallest batch — the worst possible guess for an unknown remote link. + expect(debugProfileFor(null)).toBe(DEBUG_MEDIUM_PROFILE.tcp) + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts new file mode 100644 index 000000000..0d6d51c49 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -0,0 +1,459 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockOpenModal = jest.fn() +const mockAddLog = jest.fn() + +/** + * Writes through to `mockState`, like the real action does. The hook reads the + * live status back to decide whether a settled state still needs publishing, so a + * write-only spy would make that branch untestable. + */ +const mockSetDeviceConnectionStatus = jest.fn((status: string, port: string | null = null) => { + mockState.deviceConnection = { status, port } +}) + +/** The status the store ended up in — what the Connect button actually reads. */ +const currentStatus = (): string => (mockState.deviceConnection as { status: string }).status + +const mockStartLicenseCheck = jest.fn() +const mockSetLicenseReport = jest.fn() +const mockClearDeviceLicense = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, + deviceConnection: { status: 'disconnected', port: null }, + deviceLicense: { phase: 'idle', report: null }, + runtimeConnection: { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' }, + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + startDeviceLicenseCheck: mockStartLicenseCheck, + setDeviceLicenseReport: mockSetLicenseReport, + clearDeviceLicense: mockClearDeviceLicense, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockConnect = jest.fn() +const mockDisconnect = jest.fn().mockResolvedValue({ success: true }) +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockResolveDeviceLinkWithUx = jest.fn((..._args: unknown[]) => Promise.resolve(mockResolution)) +const mockRequestDeviceFlash = jest.fn() + +/** What the shared resolution returns: ordered ways to reach the device. */ +const serialCandidate = { + channelLabel: 'Modbus RTU', + channelIndex: 0, + config: { connectionType: 'rtu', connectionParams: { port: 'COM5', baudRate: 115200, slaveId: 1 } }, +} +/** Shape the hook consumes: what can be tried now, and what needs input first. */ +let mockResolution: unknown = { candidates: [serialCandidate], awaitingInput: [] } + +const mockReadLicense = jest.fn() +const mockRefreshLicense = jest.fn() +const mockOpenExternalLink = jest.fn().mockResolvedValue({ success: true }) + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('@root/middleware/shared/providers/platform-context', () => ({ + useDevice: () => ({ + connect: mockConnect, + disconnect: mockDisconnect, + onConnectionStatus: mockOnConnectionStatus, + readLicense: mockReadLicense, + refreshLicense: mockRefreshLicense, + }), + useSystem: () => ({ + getEdgeFrontendUrl: () => 'https://edge.example.com', + openExternalLink: mockOpenExternalLink, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveDeviceLinkWithUx: (...args: unknown[]) => mockResolveDeviceLinkWithUx(...args), +})) +jest.mock('../../utils/device-connect-events', () => ({ requestDeviceFlash: mockRequestDeviceFlash })) + +import type { BoardInfo } from '@root/middleware/shared/ports/types' + +import { useDeviceConnect } from '../use-device-connect' + +const board = { debug: {} } as unknown as BoardInfo + +function latestOnResponse(): (index: number) => void { + const [, props] = mockOpenModal.mock.calls[mockOpenModal.mock.calls.length - 1] + return (props as { onResponse: (i: number) => void }).onResponse +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.deviceConnection = { status: 'disconnected', port: null } + mockState.runtimeConnection = { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' } + mockResolution = { candidates: [serialCandidate], awaitingInput: [] } + mockDisconnect.mockResolvedValue({ success: true }) + mockOnConnectionStatus.mockReturnValue(() => undefined) +}) + +describe('useDeviceConnect', () => { + // Mirroring pushed link status is NOT this hook's job: the link outlives the + // device screen, so that subscription lives in `useDeviceConnectionMonitor` + // (mounted at workspace level) and is tested there. + + const tcpCandidate = { + channelLabel: 'Modbus TCP', + channelIndex: 0, + config: { connectionType: 'tcp', connectionParams: { ipAddress: '192.168.0.50' } }, + } + + it('hands the connection EVERY resolved candidate, in order', async () => { + // Connect does not choose a transport: the main process tries the list and + // keeps the first that answers, which is what lets a stale Modbus TCP address + // fall through to the cable. Choosing here is what previously stranded a + // Modbus-TCP-only project on "select a communication port". + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config, tcpCandidate.config]) + }) + + it('does nothing when resolution was cancelled or impossible', async () => { + // The shared resolution has already told the user why (a cancelled prompt is + // the user's answer), so this must not stack a second dialog on top. + mockResolution = null + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('never asks for a DHCP address when a silent candidate connects', async () => { + // The user's report: with DHCP on, Connect asked for an address before trying + // anything. With a cable attached that question is pure interruption, so the + // deferred channel must stay unasked when the cable works. + mockResolution = { candidates: [serialCandidate], awaitingInput: [1] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(1) + expect(mockResolveDeviceLinkWithUx.mock.calls[0][2]).toMatchObject({ deferPrompts: true }) + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('asks for the deferred address only after the silent candidates fail', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [serialCandidate], awaitingInput: [1] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [tcpCandidate], awaitingInput: [] })) + mockConnect + .mockResolvedValueOnce({ status: 'no-response' }) + .mockResolvedValueOnce({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + // Second resolve targets ONLY the channel that needed input. + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(2) + expect(mockResolveDeviceLinkWithUx.mock.calls[1][2]).toMatchObject({ onlyChannels: [1] }) + expect(mockConnect).toHaveBeenNthCalledWith(2, [tcpCandidate.config]) + // It connected on the second pass, so no failure dialog. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('names every endpoint it tried when nothing answers', async () => { + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'no-response' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect((props as { message: string }).message).toContain('192.168.0.50') + expect((props as { message: string }).message).toContain('COM5') + }) + + it('marks the link as connecting before handing the candidates over', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', null) + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config]) + }) + + it('opens no dialog when a firmware answered', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('shows a no-response error dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + }) + + it('surfaces a connection error', async () => { + mockConnect.mockResolvedValue({ status: 'error', error: 'boom' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'Connection Error', message: 'boom' }) + }) + + it('offers to flash on no-firmware and requests a build when accepted', async () => { + mockConnect.mockResolvedValue({ status: 'no-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Firmware Detected' }) + latestOnResponse()(0) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + latestOnResponse()(1) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + }) + + it('disconnect closes the held link', async () => { + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.disconnect() + expect(mockDisconnect).toHaveBeenCalledTimes(1) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('disconnected', null) + }) + + it('derives isConnecting / isConnected from the store status', () => { + mockState.deviceConnection = { status: 'connected', port: 'COM5' } + const { result } = renderHook(() => useDeviceConnect(board)) + expect(result.current.isConnected).toBe(true) + expect(result.current.isConnecting).toBe(false) + expect(result.current.status).toBe('connected') + }) + + /** + * The Connect button is disabled while the status reads 'connecting', and + * Disconnect only fires when it reads 'connected'. So a status left at + * 'connecting' is a dead button with no way back short of reopening the project. + * Every path out of `connect()` must therefore leave a settled status — including + * the ones that never reach the main process, which is where the wedge was. + */ + describe('never leaves the button stuck on "connecting"', () => { + it('settles when the user cancels the address prompt and nothing else was tried', async () => { + // A DHCP-only target: no silent candidate at all, one channel awaiting input. + // Cancelling the prompt used to leave 'connecting' set forever, because + // device.connect() was never called and so nothing ever pushed a status. + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve(null)) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(currentStatus()).toBe('disconnected') + // Nothing was attempted, so there is no failure to report either. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('settles when the prompted pass resolves no usable candidate', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [] })) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(currentStatus()).toBe('disconnected') + }) + + it('settles after a failure dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + expect(currentStatus()).toBe('disconnected') + }) + + it('settles when the connect IPC call rejects outright', async () => { + mockConnect.mockRejectedValue(new Error('bridge is gone')) + const { result } = renderHook(() => useDeviceConnect(board)) + await expect(result.current.connect()).rejects.toThrow('bridge is gone') + expect(currentStatus()).toBe('disconnected') + }) + + it('leaves a successful connection alone for the main process to publish', async () => { + // The status push and this reply travel separate IPC channels, so settling on + // success too would risk overwriting 'connected' with a spurious flicker. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(currentStatus()).toBe('connecting') + expect(mockSetDeviceConnectionStatus).not.toHaveBeenCalledWith('disconnected', null) + }) + }) + + // ----------------------------------------------------------------------- + // VPP licensing + // ----------------------------------------------------------------------- + describe('licensing', () => { + /** A board whose VPP is sold licensed. `board` above deliberately is not. */ + const licensedBoard = { + compiler: 'arduino-cli', + core: 'esp32:esp32', + preview: '', + specs: {}, + debug: {}, + capabilities: { isLicensable: true }, + vpp: { + packageId: 'com.openplc.espressif-licensed', + vendor: 'Espressif', + deviceId: 'esp32-generic', + packagePath: '/pkg', + screens: {}, + moduleSystem: { enabled: false, maxSlots: 0, modules: [] }, + }, + } as unknown as BoardInfo + + it('runs NO licensing traffic for a board whose VPP is not sold licensed', async () => { + // The common case, and the reason licensability is the first gate: a free + // board's connect must be exactly what it was before licensing existed. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + expect(mockReadLicense).not.toHaveBeenCalled() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('settles the licence over the held link after a successful connect', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).toHaveBeenCalledWith({ packageId: 'com.openplc.espressif-licensed' }) + expect(mockSetLicenseReport).toHaveBeenCalledWith({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + // A licensed device is a silent success — no dialog on every connect. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('does not touch licensing when no firmware answered', async () => { + // There is nothing to ask: the flash dialog is the whole message, and a + // licence dialog stacked on top of it would bury it. + mockConnect.mockResolvedValue({ status: 'no-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Firmware Detected' }) + }) + + it('does not touch licensing when the device never answered at all', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockRefreshLicense).not.toHaveBeenCalled() + }) + + it('prompts about demo mode and offers a purchase when the backend reports no entitlement', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect(props).toMatchObject({ title: 'No Licence For This Device' }) + expect((props as { buttons: string[] }).buttons).toEqual(['Buy Licence', 'Continue in Demo Mode']) + + // Buying opens the device-BOUND purchase page: the id derived main-side is + // what makes the purchase attach to this board rather than to nothing. + latestOnResponse()(0) + await Promise.resolve() + expect(mockOpenExternalLink).toHaveBeenCalledWith( + 'https://edge.example.com/buy?vppId=com.openplc.espressif-licensed&deviceId=659a3520540f803625ddc34081e893d3', + ) + }) + + it('reports a failed check as a failure, never as "not licensed"', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockResolvedValue({ + outcome: { state: 'check-failed', error: 'Activation request failed: 429' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect(props).toMatchObject({ title: 'Licence Check Failed' }) + expect((props as { buttons: string[] }).buttons).not.toContain('Buy Licence') + }) + + it('re-runs the flow when the user retries, and explains the NEW outcome', async () => { + // What makes a purchase completed in the browser land without a reconnect. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense + .mockResolvedValueOnce({ outcome: { state: 'check-failed', error: 'Request timeout' } }) + .mockResolvedValueOnce({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'activated' }, + }) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + expect(mockOpenModal).toHaveBeenCalledTimes(1) + + latestOnResponse()(0) + await Promise.resolve() + await Promise.resolve() + + expect(mockRefreshLicense).toHaveBeenCalledTimes(2) + // The retry succeeded, and success is silent — no second dialog. + expect(mockOpenModal).toHaveBeenCalledTimes(1) + }) + + it('turns a rejected licensing IPC call into check-failed rather than losing it', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + mockRefreshLicense.mockRejectedValue(new Error('bridge is gone')) + + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.connect() + + expect(mockSetLicenseReport).toHaveBeenCalledWith({ + outcome: { state: 'check-failed', error: 'bridge is gone' }, + }) + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'Licence Check Failed' }) + }) + + it('drops the licence on a DELIBERATE disconnect', async () => { + // The user is done with this device; a badge left behind would assert + // possession for hardware nothing is talking to. A link that merely DROPS + // keeps it — that is the device slice's job, not this hook's. + const { result } = renderHook(() => useDeviceConnect(licensedBoard)) + await result.current.disconnect() + + expect(mockClearDeviceLicense).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts new file mode 100644 index 000000000..0f7516fd7 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts @@ -0,0 +1,177 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockSetDeviceConnectionStatus = jest.fn() +const mockOpenModal = jest.fn() +const mockAddLog = jest.fn() + +const mockOpenRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockCloseRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockResolveRuntimeDebugChannel = jest.fn(() => null as unknown) + +const mockState: Record = { + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + runtimeConnection: { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null }, + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4' } }, + deviceAvailableOptions: { availableBoards: new Map() }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockOnLinkLog = jest.fn().mockReturnValue(() => undefined) + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ + onConnectionStatus: mockOnConnectionStatus, + onLinkLog: mockOnLinkLog, + openRuntimeSession: mockOpenRuntimeSession, + closeRuntimeSession: mockCloseRuntimeSession, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveRuntimeDebugChannel: (...args: unknown[]) => mockResolveRuntimeDebugChannel(...(args as [])), +})) + +import { useDeviceConnectionMonitor } from '../use-device-connection-monitor' + +type Payload = { + status: string + descriptor?: string + transport?: 'rtu' | 'tcp' + debugTransport?: 'rtu' | 'tcp' | 'websocket' + reason?: 'lost' +} + +/** Mount the hook and hand back the main-process push callback. */ +function mountAndPush(): (payload: Payload) => void { + renderHook(() => useDeviceConnectionMonitor()) + return mockOnConnectionStatus.mock.calls[0][0] as (payload: Payload) => void +} + +beforeEach(() => { + jest.clearAllMocks() + mockOnConnectionStatus.mockReturnValue(() => undefined) + mockOnLinkLog.mockReturnValue(() => undefined) + mockResolveRuntimeDebugChannel.mockReturnValue(null) + mockState.runtimeConnection = { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null } +}) + +describe('useDeviceConnectionMonitor', () => { + describe('runtime sessions', () => { + it('opens a session when a runtime login comes up', () => { + // A runtime is controlled over REST, which is connectionless — logging in IS + // what establishes its session. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt', ipAddress: '10.0.0.5' } + mockState.deviceAvailableOptions = { + availableBoards: new Map([['OpenPLC Runtime v4', { debug: { channels: [] } }]]), + } + const debugChannel = { connectionType: 'websocket', connectionParams: { ipAddress: '10.0.0.5' } } + mockResolveRuntimeDebugChannel.mockReturnValue(debugChannel) + + renderHook(() => useDeviceConnectionMonitor()) + + expect(mockOpenRuntimeSession).toHaveBeenCalledWith({ address: '10.0.0.5', debug: debugChannel }) + }) + + it('closes the session when the runtime connection goes down', () => { + renderHook(() => useDeviceConnectionMonitor()) + expect(mockCloseRuntimeSession).toHaveBeenCalledTimes(1) + expect(mockOpenRuntimeSession).not.toHaveBeenCalled() + }) + }) + + it('mirrors the main-process connection trace into the console', () => { + // The decisions worth reading happen in the main process; the console is where + // a user can actually see and copy them while reproducing a problem. + renderHook(() => useDeviceConnectionMonitor()) + const emit = mockOnLinkLog.mock.calls[0][0] as (message: string) => void + + emit('open: 2 candidate(s) in order: tcp 192.168.2.20, rtu /dev/ttyACM0') + + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: expect.stringContaining('tcp 192.168.2.20') }), + ) + }) + + it('subscribes once on mount and unsubscribes on unmount', () => { + const unsubscribe = jest.fn() + mockOnConnectionStatus.mockReturnValue(unsubscribe) + + const { unmount } = renderHook(() => useDeviceConnectionMonitor()) + expect(mockOnConnectionStatus).toHaveBeenCalledTimes(1) + + unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('mirrors every pushed status into the store', () => { + const push = mountAndPush() + + for (const status of ['connecting', 'connected', 'disconnected', 'error'] as const) { + push({ status, descriptor: 'COM5', transport: 'rtu', debugTransport: 'rtu' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith(status, 'COM5', 'rtu', 'rtu') + } + }) + + it('mirrors a recovery attempt as connecting, with no transport claimed yet', () => { + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', 'COM5', null, null) + }) + + it('warns the user only when recovery gave up', () => { + const push = mountAndPush() + + // An 'error' from something the user just clicked already has its own dialog. + push({ status: 'error', descriptor: 'COM5' }) + expect(mockOpenModal).not.toHaveBeenCalled() + + push({ status: 'error', descriptor: 'COM5', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledTimes(1) + const [modalId, data] = mockOpenModal.mock.calls[0] + expect(modalId).toBe('runtime-connection-lost') + expect(data).toMatchObject({ label: 'COM5' }) + expect(String((data as { body: string }).body)).toContain('COM5') + }) + + it('does not warn while the link is merely reconnecting', () => { + // The whole point of recovery: a cable pulled and plugged back in must not + // interrupt the user with a dialog. + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + push({ status: 'connected', descriptor: 'COM5' }) + + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('still names the device when the endpoint is unknown', () => { + const push = mountAndPush() + push({ status: 'error', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledWith('runtime-connection-lost', { + label: 'the device', + body: expect.stringContaining('the device'), + }) + }) + + it('advises the right thing to check for the transport that dropped', () => { + // "Check the cable" is useless advice for a link that ran over ethernet. + const push = mountAndPush() + push({ status: 'error', descriptor: '192.168.0.50', transport: 'tcp', reason: 'lost' }) + + const [, data] = mockOpenModal.mock.calls[0] + expect((data as { body: string }).body).toContain('192.168.0.50') + expect((data as { body: string }).body).toContain('network') + expect((data as { body: string }).body).not.toContain('cable') + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-plc-state.test.ts b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts new file mode 100644 index 000000000..9d784b603 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts @@ -0,0 +1,92 @@ +/** + * useDevicePlcState — mirrors the held device link's run/stop state into the + * store. The hook owns no timer; it only translates what the main process + * already pushes on each liveness tick. + */ +import { renderHook } from '@testing-library/react' + +const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() + +/** Captures the callback the hook subscribes with, so tests can drive it. */ +let pushed: ((payload: { port: string; plcState?: number; switchPosition?: number }) => void) | null = null +const mockUnsubscribe = jest.fn() +let onPlcStateImpl: unknown = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe +} + +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ onPlcState: onPlcStateImpl }), +})) + +jest.mock('../../store', () => ({ + useOpenPLCStore: (selector: (s: unknown) => unknown) => + selector({ + deviceActions: { + setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, + }, + }), +})) + +import { useDevicePlcState } from '../use-device-plc-state' + +describe('useDevicePlcState', () => { + beforeEach(() => { + jest.clearAllMocks() + pushed = null + onPlcStateImpl = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe + } + }) + + it('maps a RUNNING push with the switch in RUN', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 1, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('RUNNING') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('run') + }) + + it('maps STOPPED with the switch in STOP', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 0, switchPosition: 0 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('STOPPED') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('stop') + }) + + it('maps the ERROR state', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 2, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('ERROR') + }) + + it('leaves the status untouched when the firmware reports no state', () => { + // Firmware predating the run/stop state machine omits the field. Inventing a + // status would make the button lie, so the hook writes nothing. + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x' }) + + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + // ...and the switch reads as "unknown", which the start pre-check must treat + // as "no gating" rather than blocking. + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith(null) + }) + + it('is inert on a platform whose DevicePort has no held link', () => { + // The web platform has no serial link, so the optional method is absent. + onPlcStateImpl = undefined + expect(() => renderHook(() => useDevicePlcState())).not.toThrow() + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useDevicePlcState()) + unmount() + expect(mockUnsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts new file mode 100644 index 000000000..95c80713a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' + +import { useOpenPLCStore } from '../../store' +import { usePouSnapshot } from '../use-pou-snapshot' + +describe('usePouSnapshot', () => { + describe('captureAndPush', () => { + it('captures a data type snapshot keyed by the data type name', () => { + const created = useOpenPLCStore.getState().datatypeActions.create({ + name: 'CaptureColors', + derivation: 'enumerated', + }) + expect(created.ok).toBe(true) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureColors') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureColors'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].variables).toEqual([]) + expect(bucket.past[0].body).toBeNull() + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureColors', derivation: 'enumerated' }), + ]) + }) + + it('captures the current data type state, not the creation-time state', () => { + useOpenPLCStore.getState().datatypeActions.create({ name: 'CaptureDims', derivation: 'array' }) + const current = useOpenPLCStore.getState().project.data.dataTypes.find((d) => d.name === 'CaptureDims') + if (!current || current.derivation !== 'array') throw new Error('CaptureDims array data type missing') + useOpenPLCStore.getState().projectActions.updateDatatype('CaptureDims', { + ...current, + dimensions: [{ dimension: '0..7' }], + }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureDims') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureDims'] + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureDims', dimensions: [{ dimension: '0..7' }] }), + ]) + }) + + it('captures a POU snapshot for POU names', () => { + useOpenPLCStore.getState().pouActions.create({ type: 'program', name: 'CaptureMain', language: 'st' }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureMain') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureMain'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].dataTypes).toBeUndefined() + const pou = useOpenPLCStore.getState().project.data.pous.find((p) => p.name === 'CaptureMain') + if (!pou) throw new Error('CaptureMain POU missing') + expect(bucket.past[0].body).toBe(pou.body.value) + }) + + it('is a no-op for names matching neither a POU nor a data type', () => { + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureGhost') + + expect(useOpenPLCStore.getState().undoRedo['CaptureGhost']).toBeUndefined() + }) + }) +}) diff --git a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts index 8f956442e..8c482650a 100644 --- a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts +++ b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts @@ -5,6 +5,7 @@ import { renderHook } from '@testing-library/react' // rule Vitest's `vi.hoisted` was originally written against. Spelled out // long-hand (no Vitest API) so the suite runs under plain Jest. const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() const mockSetTimingStats = jest.fn() const mockSetEthercatStatus = jest.fn() const mockSetRuntimeJwtToken = jest.fn() @@ -28,6 +29,7 @@ const mockState: Record = { workspace: { plcLogs: '', plcLogsLastId: null }, deviceActions: { setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, setTimingStats: mockSetTimingStats, setEthercatStatus: mockSetEthercatStatus, setRuntimeJwtToken: mockSetRuntimeJwtToken, diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts new file mode 100644 index 000000000..f957467f2 --- /dev/null +++ b/src/frontend/hooks/use-device-connect.ts @@ -0,0 +1,192 @@ +/** + * useDeviceConnect (D72) — the persistent CONNECT for USB device screens. + * + * "Connect" opens the device channel and — unlike the earlier transient probe — + * the main process HOLDS the link open (a liveness poll keeps it honest, the + * port yields to upload/debug and reconnects afterwards). This hook drives that + * toggle and the follow-up UX from the initial classification: + * + * - no-response → channel wouldn't open (wrong port / busy) → error dialog. + * - no-firmware → opened, but nothing spoke the debug protocol → offer to + * Build & Upload (flash) the firmware, then reconnect. + * - connected-with-firmware → link held. + * + * Live link state (`deviceConnection.status`) is pushed from the main process. + */ +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { useDevice } from '@root/middleware/shared/providers/platform-context' +import { describeDebugEndpoint } from '@root/middleware/shared/utils/debug-endpoint' +import { useCallback } from 'react' + +import { resolveDeviceLinkWithUx } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' +import { requestDeviceFlash } from '../utils/device-connect-events' +import { explainLicenseOutcome } from '../utils/license-outcome-dialog' +import { useDeviceLicense } from './use-device-license' + +export interface UseDeviceConnectResult { + /** Open + hold the link for the given board. Never throws. */ + connect: () => Promise + /** Close the held link. */ + disconnect: () => Promise + /** Live link status, mirrored from the main process. */ + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** Convenience flags derived from `status`. */ + isConnecting: boolean + isConnected: boolean +} + +export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceConnectResult { + const device = useDevice() + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) + const setDeviceConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setDeviceConnectionStatus) + const clearDeviceLicense = useOpenPLCStore((s) => s.deviceActions.clearDeviceLicense) + const status = useOpenPLCStore((s) => s.deviceConnection.status) + const licensing = useDeviceLicense(boardInfo) + + const connect = useCallback(async (): Promise => { + const deviceBoard = useOpenPLCStore.getState().deviceDefinitions.configuration.deviceBoard + + // FIRST PASS: everything that needs nothing from the user — serial, then + // Modbus TCP on a static address. `deferPrompts` means a DHCP channel is set + // aside rather than interrupting with an address dialog, because with a cable + // attached the user should never be asked for one. + const silent = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { deferPrompts: true }) + if (!silent) return + if (silent.candidates.length === 0 && silent.awaitingInput.length === 0) return + + const tried: string[] = [] + setDeviceConnectionStatus('connecting', null) + + // Declared out here so the `finally` can tell "we never got a connection" from + // "we did, and the main process has already published it". + let result: { status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error'; error?: string } = { + status: 'no-response', + } + + try { + if (silent.candidates.length > 0) { + tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(silent.candidates.map((candidate) => candidate.config)) + } + + // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving + // only the deferred channels surfaces the address dialog, and a cancel here + // ends the attempt rather than looping. + if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { + const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { + onlyChannels: silent.awaitingInput, + }) + if (prompted && prompted.candidates.length > 0) { + tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + } else if (tried.length === 0) { + // The user declined to supply the address and there was nothing else to + // try, so nothing was attempted at all. Saying "could not reach the + // device" would be reporting a failure that never happened — they + // cancelled. The `finally` below clears the button. + return + } + } + + const endpoints = tried.join(' or ') || 'this device' + + if (result.status === 'no-response') { + openModal('debugger-message', { + type: 'error', + title: 'No Response', + message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'error') { + openModal('debugger-message', { + type: 'error', + title: 'Connection Error', + message: result.error ?? 'An unexpected error occurred while connecting to the device.', + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'no-firmware') { + openModal('debugger-message', { + type: 'question', + title: 'No Firmware Detected', + message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, + buttons: ['Build & Upload', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) requestDeviceFlash() + }, + }) + return + } + + // The link is up and a firmware answered. If this board's VPP is sold + // licensed, settle its licence now — over the link that is already open. + // + // AWAITED, not fired and forgotten: the whole flow runs on one held Modbus + // link, and letting the connect return first invites the user to click + // Upload or Debug into the middle of a read/write sequence. It is also why + // this is the LAST thing connect does — a non-licensable board (the common + // case) never reaches it, and pays nothing. + if (licensing.isLicensable) { + const report = await licensing.refresh() + if (report) { + explainLicenseOutcome(report, { + openModal, + buy: licensing.buy, + // A retry re-runs the flow and explains the NEW outcome, so a + // transient failure or a purchase completed in the browser resolves + // without disconnecting. + retry: async () => { + const next = await licensing.refresh() + if (next) explainLicenseOutcome(next, { openModal, buy: licensing.buy }) + }, + }) + } + } + } finally { + // 'connecting' is set OPTIMISTICALLY above, and normally only the main process + // clears it — every settled state is pushed from there. But a path that + // returns without ever reaching `deviceSession.open()` leaves nothing to push: + // a cancelled address prompt, a config that built no usable candidate, or an + // IPC rejection. The button is disabled while 'connecting' and Disconnect only + // fires when 'connected', so a stuck 'connecting' is not recoverable from the + // UI at all — the user has to close and reopen the project. Settle it here. + // + // Only on a NON-success outcome. On success the main process has published + // 'connected', but that push and this invoke's reply travel separate IPC + // channels with no ordering guarantee between them, so settling here as well + // would risk a visible flicker for no reason. + if ( + result.status !== 'connected-with-firmware' && + useOpenPLCStore.getState().deviceConnection.status === 'connecting' + ) { + setDeviceConnectionStatus('disconnected', null) + } + } + }, [boardInfo, device, licensing, openModal, setDeviceConnectionStatus]) + + const disconnect = useCallback(async (): Promise => { + await device.disconnect() + setDeviceConnectionStatus('disconnected', null) + // A DELIBERATE disconnect is the one link event that should drop the licence + // too: the user is done with this device, and leaving a badge behind would + // assert possession for hardware nothing is talking to. A link that merely + // DROPS keeps it — see `clearDeviceConnection` in the device slice. + clearDeviceLicense() + }, [clearDeviceLicense, device, setDeviceConnectionStatus]) + + return { + connect, + disconnect, + status, + isConnecting: status === 'connecting', + isConnected: status === 'connected', + } +} diff --git a/src/frontend/hooks/use-device-connection-monitor.ts b/src/frontend/hooks/use-device-connection-monitor.ts new file mode 100644 index 000000000..4916a2c67 --- /dev/null +++ b/src/frontend/hooks/use-device-connection-monitor.ts @@ -0,0 +1,128 @@ +/** + * useDeviceConnectionMonitor — mirrors the held baremetal serial link into the + * store, and warns when it is lost for good. + * + * This is a WORKSPACE-level concern, not a device-screen one. The link outlives + * the screen that opened it: an upload, a debug session and the Start/Stop button + * all depend on `deviceConnection.status` being true. Subscribing inside the + * device screen left the store reading 'connected' after the cable was pulled + * whenever the user happened to be editing a POU, so every request timed out + * against a link the UI still advertised as up. + * + * The main process owns the state machine (liveness poll -> reopen attempts -> + * give up); this hook only reflects it. A cable pulled and plugged back in shows + * up as connected -> connecting -> connected with nothing to click. The warning + * fires only on `reason: 'lost'`, i.e. recovery gave up — an 'error' raised by + * something the user just clicked already has its own dialog, and warning twice + * for one click is worse than not warning at all. + * + * Mount once at the workspace level, next to `useDevicePlcState`. + */ +import { useEffect } from 'react' + +import { useDevice } from '../../middleware/shared/providers' +import { resolveRuntimeDebugChannel } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' + +/** + * Keep the main process's session in step with a Runtime v3/v4 login. + * + * A runtime target is CONTROLLED over REST, which is connectionless — logging in + * is what establishes its session. This mirrors that: when the runtime connection + * comes up, tell the manager where control lives and how this target debugs; when + * it goes down, close the session. The debug channel itself is not opened here — + * the debugger asks for it when it needs it. + * + * The debug channel is DESCRIBED by resolving the board's spec (in the resolution + * service, which owns spec interpretation) — legitimate at session-establishment + * time. No command ever resolves anything. + */ +const useRuntimeSession = (): void => { + const device = useDevice() + const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) + const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) + + useEffect(() => { + if (!device.openRuntimeSession) return + + if (connectionStatus !== 'connected') { + void device.closeRuntimeSession?.() + return + } + + const store = useOpenPLCStore.getState() + const boardTarget = store.deviceDefinitions.configuration.deviceBoard + const boardInfo = store.deviceAvailableOptions.availableBoards.get(boardTarget) + const address = store.runtimeConnection.ipAddress + + // Every early return says why. Returning quietly is what let a runtime target + // end up with no session at all while the UI showed it connected, so that every + // command answered "not connected" on a target the user had just uploaded to. + if (!address) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: '[connection] runtime is connected but has no address recorded; no session opened', + }) + return + } + const debugChannel = resolveRuntimeDebugChannel(boardTarget, boardInfo) + if (!debugChannel) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `[connection] no debug channel could be described for ${boardTarget}; debugging will not be available`, + }) + return + } + + void device.openRuntimeSession({ address, debug: debugChannel }).then((result) => { + if (!result.success) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`, + }) + } + }) + }, [device, connectionStatus, jwtToken]) +} + +export const useDeviceConnectionMonitor = (): void => { + useRuntimeSession() + const device = useDevice() + const addLog = useOpenPLCStore((state) => state.consoleActions.addLog) + const setDeviceConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setDeviceConnectionStatus) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + + // Mirror the main process's connection trace into the console. The interesting + // decisions (which candidate was tried, what each poll concluded, which + // connection served a command) happen in main; without this the user watching + // the UI sees only "connecting..." and then a failure. + useEffect(() => { + if (!device.onLinkLog) return + return device.onLinkLog((message) => { + addLog({ id: crypto.randomUUID(), level: 'info', message: `[connection] ${message}` }) + }) + }, [device, addLog]) + + useEffect(() => { + return device.onConnectionStatus(({ status, descriptor, transport, debugTransport, reason }) => { + setDeviceConnectionStatus(status, descriptor ?? null, transport ?? null, debugTransport ?? null) + + if (status === 'error' && reason === 'lost') { + const endpoint = descriptor ?? 'the device' + // Name the endpoint AND what to check for that transport: "the cable" is + // useless advice for a link that was running over ethernet. + const advice = + transport === 'tcp' + ? 'Check that the device is powered and reachable on the network, then Connect again.' + : 'Check that the cable is plugged in and the port is not in use, then Connect again.' + openModal('runtime-connection-lost', { + label: endpoint, + body: `The connection to ${endpoint} was lost and could not be restored. ${advice}`, + }) + } + }) + }, [device, setDeviceConnectionStatus, openModal]) +} diff --git a/src/frontend/hooks/use-device-license.ts b/src/frontend/hooks/use-device-license.ts new file mode 100644 index 000000000..88e30a9b2 --- /dev/null +++ b/src/frontend/hooks/use-device-license.ts @@ -0,0 +1,161 @@ +/** + * useDeviceLicense — the renderer side of the VPP licensing flow. + * + * Owns the four things the UI needs and nothing else: + * - whether licensing applies to this board at all (`isLicensable`); + * - the last landed report, from the store; + * - `check()` (read + verify, local) and `refresh()` (full flow, may reach the + * network and write); + * - `buy()`, which opens the device-bound purchase page. + * + * Deliberately NOT folded into `useDeviceConnect`: that hook is about resolving + * and holding a link, this one about what the device is entitled to run. The only + * coupling is one call after a successful connect. + */ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { useDevice, useSystem } from '@root/middleware/shared/providers/platform-context' +import { resolveLicensingTarget } from '@root/middleware/shared/utils/licensing' +import { useCallback, useMemo } from 'react' + +import { useOpenPLCStore } from '../store' +import { buildLicenseBuyUrl } from '../utils/license-buy-url' + +export interface UseDeviceLicenseResult { + /** Whether the selected board's VPP participates in licensing at all. When + * false every other member here is inert and the UI shows nothing. */ + isLicensable: boolean + /** Set when the manifest is broken: declares licensable with no package id. */ + configurationError: string | null + /** The last landed report, or null before anything has run. */ + report: DeviceLicenseReport | null + /** True while a call is in flight. */ + isChecking: boolean + /** + * Read + verify what the device holds. Local only, never the network. + * + * Returns the report as well as storing it, so a caller that must ACT on the + * outcome (the connect flow, which prompts about demo mode) does not have to + * read it back out of the store — a read that races the very set it follows. + * `null` when licensing does not apply to this board. + */ + check: () => Promise + /** Full flow: read, verify, and recover from the backend when needed. */ + refresh: () => Promise + /** + * The purchase link for the report currently in the store — what the badge's + * button uses. Null when no valid link can be built. + */ + buyUrl: string | null + /** + * Open the device-bound purchase page. + * + * `deviceId` is a parameter, not read from the store, and that is load-bearing. + * A caller reached straight out of a licensing call — the demo dialog the connect + * flow opens — is holding a report the store has only just been told about, and + * the closure it captured still sees the PREVIOUS value (null, on a first + * connect). Taking the id explicitly is what stops "Buy Licence" from silently + * doing nothing on the one path where it matters most. + */ + buy: (deviceId?: string) => Promise +} + +export function useDeviceLicense(boardInfo: BoardInfo | undefined): UseDeviceLicenseResult { + const device = useDevice() + const system = useSystem() + const startCheck = useOpenPLCStore((s) => s.deviceActions.startDeviceLicenseCheck) + const setReport = useOpenPLCStore((s) => s.deviceActions.setDeviceLicenseReport) + const phase = useOpenPLCStore((s) => s.deviceLicense.phase) + const report = useOpenPLCStore((s) => s.deviceLicense.report) + + const target = useMemo(() => resolveLicensingTarget(boardInfo), [boardInfo]) + + /** + * Run one licensing call, whichever it is. + * + * `startCheck` before and `setReport` after, unconditionally — an early return + * on a non-licensable board would leave `phase: 'checking'` set forever, and the + * UI disables its actions on that. + */ + const run = useCallback( + async (which: 'check' | 'refresh'): Promise => { + if (!target.licensable) return null + + const call = which === 'check' ? device.readLicense : device.refreshLicense + if (!call) { + // A platform that holds no device link (the port declares both optional). + // Reported rather than ignored: silence here reads as "no license". + const report: DeviceLicenseReport = { + outcome: { state: 'check-failed', error: 'This platform cannot check device licenses.' }, + } + setReport(report) + return report + } + + startCheck() + const request = { packageId: target.packageId } + try { + const report = await call(request) + setReport(report) + return report + } catch (error) { + // The IPC call itself failed. Still a report, and still `check-failed`: + // the badge must never fall back to "not licensed" because a channel died. + const report: DeviceLicenseReport = { + outcome: { state: 'check-failed', error: error instanceof Error ? error.message : String(error) }, + } + setReport(report) + return report + } + }, + [device.readLicense, device.refreshLicense, setReport, startCheck, target], + ) + + const check = useCallback(() => run('check'), [run]) + const refresh = useCallback(() => run('refresh'), [run]) + + /** + * Build the purchase link for a given device id. + * + * A purchase must be BOUND to a device, and the id is derived main-side, so + * there is nothing to bind to before a report has landed — hence null. The + * builder also refuses an id the `/buy` page would reject, so a malformed one + * yields no button rather than a dead end. + */ + const urlFor = useCallback( + (deviceId: string | undefined): string | null => { + if (!target.licensable) return null + return buildLicenseBuyUrl({ baseUrl: system.getEdgeFrontendUrl(), vppId: target.packageId, deviceId }) + }, + [system, target], + ) + + /** The link for whatever is in the store — what the badge's button uses. */ + const buyUrl = useMemo(() => urlFor(report?.deviceId), [report?.deviceId, urlFor]) + + const buy = useCallback( + async (deviceId?: string): Promise => { + // Prefer the id the CALLER is holding. See the docstring on `buy` above: + // the dialog opened right after a licensing call has the fresh report, while + // this hook's closure still sees the previous one. + const url = urlFor(deviceId ?? report?.deviceId) + if (!url) return + await system.openExternalLink(url) + }, + [report?.deviceId, system, urlFor], + ) + + return { + isLicensable: target.licensable, + configurationError: + !target.licensable && target.reason === 'no-package-id' + ? 'This board declares a licensed VPP but its package is missing an id. The VPP package needs fixing.' + : null, + report, + isChecking: phase === 'checking', + check, + refresh, + buyUrl, + buy, + } +} diff --git a/src/frontend/hooks/use-device-plc-state.ts b/src/frontend/hooks/use-device-plc-state.ts new file mode 100644 index 000000000..9ce1ffa88 --- /dev/null +++ b/src/frontend/hooks/use-device-plc-state.ts @@ -0,0 +1,61 @@ +/** + * useDevicePlcState — mirrors a baremetal target's run/stop state into the store. + * + * There is no timer here. The main process already polls the held device link to + * keep it honest, and that liveness read is the status frame (FC 0x46), which + * carries the run/stop state and the mode-switch position. This hook only + * subscribes to what that tick already pushes, so the Start/Stop button tracks + * the device — including a switch flipped by hand at the panel — without any + * extra traffic, a second timer, or a transient connection. + * + * Writes the SAME `runtimeConnection.plcStatus` the Runtime v4 poll writes, so + * every consumer (the button icon, its tooltip, the debugger's "PLC is stopped" + * prompt) works unchanged regardless of target type. + * + * Mount once at the workspace level, next to `useRuntimePolling`. + */ +import { useEffect } from 'react' + +import { PlcRuntimeState, PlcSwitchPosition } from '../../backend/shared/simulator/types' +import type { PlcStatus } from '../../middleware/shared/ports/types' +import { useDevice } from '../../middleware/shared/providers' +import { useOpenPLCStore } from '../store' + +/** Map the wire value to the store's PlcStatus union. */ +function toPlcStatus(state: number | undefined): PlcStatus | null { + switch (state) { + case PlcRuntimeState.RUNNING: + return 'RUNNING' + case PlcRuntimeState.STOPPED: + return 'STOPPED' + case PlcRuntimeState.ERROR: + return 'ERROR' + default: + // Firmware predating the run/stop state machine omits the field. Leave the + // status untouched rather than inventing one — the button then behaves as + // it did before, and the Start path reports `unsupported` if used. + return null + } +} + +export const useDevicePlcState = (): void => { + const device = useDevice() + const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) + + useEffect(() => { + // Optional on the port: the web platform has no held serial link. + if (!device.onPlcState) return + return device.onPlcState(({ plcState, switchPosition }) => { + const status = toPlcStatus(plcState) + if (status !== null) setPlcRuntimeStatus(status) + + // Absent on older firmware, which means "no switch gating" — null rather + // than a guessed 'run', so the pre-check can tell "no switch" from + // "switch says RUN". + setPlcSwitchPosition( + switchPosition === PlcSwitchPosition.STOP ? 'stop' : switchPosition === PlcSwitchPosition.RUN ? 'run' : null, + ) + }) + }, [device, setPlcRuntimeStatus, setPlcSwitchPosition]) +} diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index 1ba92a930..aeaa86cb5 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -7,6 +7,8 @@ import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' * Convenience hook wrapping snapshotActions.pushToHistory(). * Captures the current POU state (variables, body, globalVariables, and * graphical flow state for LD/FBD) and pushes it to the undo history. + * Names that resolve to a data type instead of a POU capture the data + * type entry (`dataTypes`) so datatype editors share the same history. * * State is read via getState() at capture time (not subscribed): the hook * never re-renders its consumers and `captureAndPush` keeps a stable identity. @@ -24,11 +26,17 @@ export function usePouSnapshot() { const captureAndPush = useCallback( (pouName: string) => { // A debounced graphical write-back may still be pending — flush it so - // the snapshot can't pair a stale body with a fresh flow. - flushFlowWriteBacks(useOpenPLCStore.getState, pouName) + // the snapshot can't pair a stale body with a fresh flow. A failed flush + // leaves the body stale, so there is nothing coherent to capture. + if (flushFlowWriteBacks(useOpenPLCStore.getState, pouName).length > 0) return const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) - if (!pou) return + if (!pou) { + const dataType = project.data.dataTypes.find((d) => d.name === pouName) + if (!dataType) return + pushToHistory(pouName, { variables: [], body: null, dataTypes: [dataType] }) + return + } pushToHistory(pouName, { variables: pou.interface?.variables ?? [], diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 294722135..c7986612a 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -22,6 +22,7 @@ export const useRuntimePolling = () => { const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) const setTimingStats = useOpenPLCStore((state) => state.deviceActions.setTimingStats) const setEthercatStatus = useOpenPLCStore((state) => state.deviceActions.setEthercatStatus) const openModal = useOpenPLCStore((state) => state.modalActions.openModal) @@ -36,6 +37,7 @@ export const useRuntimePolling = () => { deviceActions.setRuntimeJwtToken(null) deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) + deviceActions.setPlcSwitchPosition(null) deviceActions.setTimingStats(null) deviceActions.setEthercatStatus(null) }, []) @@ -116,6 +118,9 @@ export const useRuntimePolling = () => { ? (rawStatus as PlcStatus) : 'UNKNOWN' setPlcRuntimeStatus(plcStatus) + // Runtime v4 reports the mode-switch position alongside the state; + // absent on older runtimes, which means "no gating". + setPlcSwitchPosition(statusResult.switchPosition ?? null) if (includeTimingStatsInPolling && statusResult.timingStats) { setTimingStats(statusResult.timingStats) @@ -169,7 +174,7 @@ export const useRuntimePolling = () => { } finally { isPollingRef.current = false } - }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) + }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setPlcSwitchPosition, setTimingStats, setEthercatStatus]) // Keep the store's connection token in lock-step with the platform's token // authority. When the authority transparently refreshes an expired token diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 08f263b81..c5d699fd0 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -17,74 +17,69 @@ * - Diagram/source scan results are cached per {pouName, language, fbContext} * since the editor is read-only during debug * - * Polling intervals: - * - Modbus RTU / simulator: 50ms (no network; keep the UI snappy) - * - Web HTTP fallback: 1000ms (WebRTC failed; each poll is a slow - * orchestrator round-trip, so back off) - * - Everything else: 200ms (general purpose — TCP / WebSocket / - * web WebRTC data channel) + * Batch size and poll interval both come from the session's medium — see + * `DEBUG_MEDIUM_PROFILE`. */ import { useCallback, useEffect, useRef } from 'react' -import type { DebugConnectionType, DebugTreeNode } from '../../middleware/shared/ports/types' +import type { DebugMedium, DebugTreeNode } from '../../middleware/shared/ports/types' import { useCapabilities, useDebugger } from '../../middleware/shared/providers' import { openPLCStoreBase, useOpenPLCStore } from '../store' import { buildActiveIndexSet } from '../utils/debug-polling-filter' import { applySwapToVariableBytes } from '../utils/endian' import { getTypeSizeByName, parseValueByTypeName } from '../utils/variable-sizes' -/** Polling interval for transports with serial framing (RTU / simulator). */ -const RTU_POLL_INTERVAL_MS = 50 -/** Polling interval for higher-bandwidth transports (TCP / WebSocket). */ -const DEFAULT_POLL_INTERVAL_MS = 200 -/** Polling interval for the web HTTP fallback (WebRTC unavailable): each - * poll is a full orchestrator `run_command` round-trip, so we slow right - * down to avoid hammering the edge API / runtime. */ -const HTTP_FALLBACK_POLL_INTERVAL_MS = 1000 - -// Batch size is transport-dependent. The wire request packs 3 bytes per -// variable (arr:u8 + elem:u16); the response packs raw type-sized values -// after a small header. The right ceiling is set by the transport's -// frame budget and the runtime's MAX_DEBUG_FRAME — never the target -// board, since the same board can run over RTU or TCP depending on the -// user's communication preferences. -// -// Modbus RTU : capped at 19 so the REQUEST stays ≤63 bytes and -// fits in a single 64-byte USB-CDC packet. A 20-var -// request is 6 + 3·20 = 66 bytes, which a USB-CDC -// target (e.g. SAMD21 / P1AM-100) receives split -// across two USB packets; older firmware whose serial -// framer can't reassemble a multi-packet request then -// drops it. Newer firmware (length-aware handle_serial) -// handles any size, but 19 keeps us compatible with -// field devices on both. 19·3 + 6 = 63 ≤ 64. -// Modbus TCP : Arduino sketch's MAX_MB_FRAME caps it; 60 is -// well within the headroom. -// WebSocket (Runtime v4) : Linux runtime's MAX_DEBUG_FRAME=4096; ~500 -// vars fits comfortably with room for value -// bytes. Anything bigger is unusual and the -// ERROR_OUT_OF_MEMORY fallback halves us back -// down to a safe size. -// Simulator : virtual serial port mirrors the RTU framing, -// so it shares the RTU ceiling. -const RTU_BATCH_SIZE = 19 -const TCP_BATCH_SIZE = 60 -const WEBSOCKET_BATCH_SIZE = 500 +/** + * How to pace and size the debug poll, per medium. + * + * These are two INDEPENDENT physical limits, which is why they live in one table + * rather than being derived from each other: + * + * `batchSize` — the frame budget at the far end. The request packs 3 bytes per + * variable (arr:u8 + elem:u16) and the response packs raw type-sized values after + * a small header. It is a property of the TARGET, never of the board the user + * picked, since the same board can be reached over RTU or TCP. + * rtu / simulator : 19, so the request stays ≤63 bytes and fits one 64-byte + * USB-CDC packet (6 + 3·19 = 63). A 20-variable request is 66 + * bytes, which a SAMD21 / P1AM-100 receives split across two + * packets — older firmware whose serial framer cannot + * reassemble then drops it. The simulator's virtual serial + * port mirrors the same framing. + * tcp : the Arduino sketch's MAX_MB_FRAME caps it; 60 has headroom. + * websocket / : the Linux runtime's MAX_DEBUG_FRAME=4096 — ~500 variables + * webrtc / with room for value bytes. All three reach the SAME debug + * http-relay socket on the runtime, so they share its budget; only the + * number of hops in front of it differs. + * + * `pollIntervalMs` — round-trip latency of the link. + * rtu / simulator : 50ms, no network in the way; keep the UI responsive. + * tcp / websocket : 200ms, one network hop. + * webrtc : 200ms, peer-to-peer to the agent — as direct as it gets. + * http-relay : 1000ms. Every poll is browser -> Edge -> agent websocket -> + * runtime and back. Polling this at the direct rate buries the + * relay in requests for data that cannot arrive any faster. + * Overridable per deployment via + * `capabilities.debugRelayPollIntervalMs`. + * + * A medium the caller has not published yet reads as `tcp` — the middle of the + * range, and what this defaulted to before the media were named. + */ +export const DEBUG_MEDIUM_PROFILE: Record = { + rtu: { batchSize: 19, pollIntervalMs: 50 }, + simulator: { batchSize: 19, pollIntervalMs: 50 }, + tcp: { batchSize: 60, pollIntervalMs: 200 }, + websocket: { batchSize: 500, pollIntervalMs: 200 }, + webrtc: { batchSize: 500, pollIntervalMs: 200 }, + 'http-relay': { batchSize: 500, pollIntervalMs: 1000 }, +} + +const DEFAULT_MEDIUM: DebugMedium = 'tcp' const MIN_BATCH_SIZE = 2 -function batchSizeForTransport(transport: DebugConnectionType | null): number { - switch (transport) { - case 'websocket': - return WEBSOCKET_BATCH_SIZE - case 'rtu': - case 'simulator': - return RTU_BATCH_SIZE - case 'tcp': - case null: - default: - return TCP_BATCH_SIZE - } +/** The profile for a medium, tolerating one not yet published. */ +export function debugProfileFor(medium: DebugMedium | null): { batchSize: number; pollIntervalMs: number } { + return DEBUG_MEDIUM_PROFILE[medium ?? DEFAULT_MEDIUM] } interface LeafMeta { @@ -139,13 +134,6 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const capabilities = useCapabilities() const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const { workspaceActions, consoleActions } = useOpenPLCStore() - // Web-only: which transport the debug session is actually running over. - // 'http' means WebRTC is unavailable and every poll is a slow - // orchestrator round-trip — so we back the cadence off (see below). - // On the desktop editor this is the unused 'http' default; the - // `!isNativeApplication` guard keeps the editor's real TCP/WebSocket - // transports on the standard 200ms regardless. - const sessionDebugTransport = useOpenPLCStore((state) => state.session.debugTransport) // Targeted selectors for active-index cache invalidation. // These only change on user interaction (not every poll cycle). @@ -162,10 +150,9 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const batchOffsetRef = useRef(0) const isPollingRef = useRef(false) - // Dynamic batch size — overwritten with the transport-specific - // ceiling on session start; halves on ERROR_OUT_OF_MEMORY and - // resets on the next session start. - const batchSizeRef = useRef(TCP_BATCH_SIZE) + // Dynamic batch size — overwritten with the medium's ceiling on session start; + // halves on ERROR_OUT_OF_MEMORY and resets on the next session start. + const batchSizeRef = useRef(DEBUG_MEDIUM_PROFILE[DEFAULT_MEDIUM].batchSize) // Full leaf index→metadata map — computed once when debugger starts. // One index → many leaves (a shared global appears under each POU's key). @@ -406,36 +393,29 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const pollRef = useRef(pollVariables) pollRef.current = pollVariables - // The transport in use for the current debug session. This drives both - // the batch size and the poll interval — neither should be conditioned - // on board target since the same board can speak RTU, TCP, etc. The - // workspace activity bar sets this when the debug session connects. - const debugConnectionType = useOpenPLCStore((state) => state.workspace.debugConnectionType) + // The medium this session is actually riding, published by the connection + // manager — the one place that knows. Read LIVE rather than latched at session + // start, because on web it can change mid-session: a WebRTC data channel that + // drops falls back to the Edge relay, and the cadence has to follow it down. + const debugMedium = useOpenPLCStore((state) => state.deviceConnection.debugTransport) // Set up polling interval when debugger becomes visible. useEffect(() => { if (isDebuggerVisible) { + const profile = debugProfileFor(debugMedium) + // Reset state on session start - batchSizeRef.current = batchSizeForTransport(debugConnectionType) + batchSizeRef.current = profile.batchSize batchOffsetRef.current = 0 lastResponseTimestampRef.current = 0 activeIndexesRef.current = null visibleVarsCacheRef.current = null - // RTU framing also covers the simulator's virtual serial port — - // both need the tighter cadence to keep up with toggling state. - const usesRtuFraming = debugConnectionType === 'rtu' || debugConnectionType === 'simulator' - // Web HTTP fallback: WebRTC unavailable, so reads go over the - // orchestrator proxy (high latency) — slow the cadence right down. - // Gated on `!isNativeApplication` so the desktop editor's real - // TCP/WebSocket transports never hit this branch (their - // `session.debugTransport` is an unused 'http' default). - const usesHttpFallback = !capabilities.isNativeApplication && sessionDebugTransport === 'http' - const pollIntervalMs = usesRtuFraming - ? RTU_POLL_INTERVAL_MS - : usesHttpFallback - ? HTTP_FALLBACK_POLL_INTERVAL_MS - : DEFAULT_POLL_INTERVAL_MS + // One lookup, both axes. The medium already distinguishes a peer-to-peer + // data channel from the Edge relay, so nothing here needs to ask which + // platform it is running on. + const pollIntervalMs = + debugMedium === 'http-relay' ? capabilities.debugRelayPollIntervalMs : profile.pollIntervalMs // Fire first poll immediately, then schedule at fixed rate // Skip tick if previous poll is still in progress (isPolling guard) @@ -483,17 +463,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void visibleVarsCacheRef.current = null batchOffsetRef.current = 0 } - // `sessionDebugTransport` + `capabilities.isNativeApplication` are - // in the deps so the cadence re-evaluates if WebRTC drops to the HTTP - // fallback (or recovers) mid-session — the effect tears down the old - // interval and restarts at the new rate. - }, [ - isDebuggerVisible, - debugConnectionType, - sessionDebugTransport, - capabilities.isNativeApplication, - workspaceActions, - ]) + // `debugMedium` is in the deps so the cadence re-evaluates when a WebRTC data + // channel drops to the Edge relay (or recovers) mid-session — the effect tears + // down the old interval and restarts at the new rate. + }, [isDebuggerVisible, debugMedium, capabilities.debugRelayPollIntervalMs, workspaceActions]) // Clean up on unmount useEffect(() => { diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index f309f5fa1..aff9c47ea 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -12,8 +12,8 @@ import { useCallback, useRef } from 'react' -import type { DebugConnectionConfig, DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' -import { useDebugger, useSimulator } from '../../middleware/shared/providers' +import type { DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' +import { useDebugger } from '../../middleware/shared/providers' import { useOpenPLCStore } from '../store' import { parseDebugMap } from '../utils/debug-parser' import { @@ -32,10 +32,10 @@ export interface UseDebugSessionReturn { * connects via the debugger port, stores all artifacts in workspace, * and activates the debugger UI. * - * @param config — Connection target (simulator, TCP, RTU, WebSocket). - * If omitted, defaults to simulator. + * Takes nothing: the connection manager holds the session for every target by the + * time a debug session can start, so there is no medium for a caller to name. */ - connectAndStart: (config?: DebugConnectionConfig) => Promise<{ success: boolean; error?: string }> + connectAndStart: () => Promise<{ success: boolean; error?: string }> /** Disconnect from the debug target and clear all debug state. */ stopSession: () => Promise @@ -49,7 +49,6 @@ export interface UseDebugSessionReturn { export function useDebugSession(): UseDebugSessionReturn { const debuggerPort = useDebugger() - const simulator = useSimulator() const { project: { data: projectData, meta: projectMeta }, @@ -60,164 +59,157 @@ export function useDebugSession(): UseDebugSessionReturn { const debugTreesRef = useRef>({}) - const connectAndStart = useCallback( - async (config?: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> => { - const debugConfig = config ?? ({ connectionType: 'simulator', connectionParams: {} } as DebugConnectionConfig) - const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() - const boardTarget = deviceDefinitions.configuration.deviceBoard - const projectPath = project.meta.path + const connectAndStart = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() + const boardTarget = deviceDefinitions.configuration.deviceBoard + const projectPath = project.meta.path - logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) + logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) - try { - const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) - if (!debugFileResult.success || !debugFileResult.content) { - const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } + try { + const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) + if (!debugFileResult.success || !debugFileResult.content) { + const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + + wsActions.setDebugCContent(debugFileResult.content) - wsActions.setDebugCContent(debugFileResult.content) + const instances = project.data.configurations.resource.instances - const instances = project.data.configurations.resource.instances + const debugMap = parseDebugMap(debugFileResult.content) + if (!debugMap) { + const error = 'Invalid debug-map.json (expected schema version 2)' + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + + const entriesForTree = debugMapToEntries(debugMap) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + }) + + // Build the debug variable tree — the single enumeration walk. The + // composite-key → index map (used by the LD/FBD editors and the poller) + // is derived from this same tree, so every consumer resolves a + // variable's address identically. + let treeMap = new Map() + const pouTrees: Record = {} + try { + const treeResult = buildDebugVariableTreeMap( + project.data.pous, + instances, + entriesForTree, + project.data, + useOpenPLCStore.getState().libraries.system, + ) + treeMap = treeResult.treeMap + + // Group trees by POU name for polling hook + for (const node of treeResult.trees) { + const pouName = node.compositeKey.split(':')[0] + if (!pouTrees[pouName]) pouTrees[pouName] = [] + pouTrees[pouName].push(node) + } - const debugMap = parseDebugMap(debugFileResult.content) - if (!debugMap) { - const error = 'Invalid debug-map.json (expected schema version 2)' - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } + for (const w of treeResult.warnings) { + logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) } - const entriesForTree = debugMapToEntries(debugMap) logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, }) + } catch { + logActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Debug tree builder encountered errors.', + }) + } - // Build the debug variable tree — the single enumeration walk. The - // composite-key → index map (used by the LD/FBD editors and the poller) - // is derived from this same tree, so every consumer resolves a - // variable's address identically. - let treeMap = new Map() - const pouTrees: Record = {} - try { - const treeResult = buildDebugVariableTreeMap( - project.data.pous, - instances, - entriesForTree, - project.data, - useOpenPLCStore.getState().libraries.system, - ) - treeMap = treeResult.treeMap - - // Group trees by POU name for polling hook - for (const node of treeResult.trees) { - const pouName = node.compositeKey.split(':')[0] - if (!pouTrees[pouName]) pouTrees[pouName] = [] - pouTrees[pouName].push(node) - } - - for (const w of treeResult.warnings) { - logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) - } - - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, - }) - } catch { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'warning', - message: 'Debug tree builder encountered errors.', - }) - } - - debugTreesRef.current = pouTrees - - // Derive the composite-key → packed-address map from the tree leaves. - const indexMap = deriveVariableIndexMap(treeMap, debugMap) - - // Build FB instance map - const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - - const fbTypesCount = fbDebugInstancesMap.size - const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) - if (fbTypesCount > 0) { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, - }) - } - - // Connect debugger via port - const connectResult = await debuggerPort.connect(debugConfig) - if (!connectResult.success) { - const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } - - // Store debug artifacts in workspace - wsActions.setDebugVariableIndexes(indexMap) - wsActions.setDebugVariableTree(treeMap) - wsActions.setFbDebugInstances(fbDebugInstancesMap) + debugTreesRef.current = pouTrees - // Set default selected instance for each FB type - fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { - if (instanceList.length > 0) { - wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) - } - }) + // Derive the composite-key → packed-address map from the tree leaves. + const indexMap = deriveVariableIndexMap(treeMap, debugMap) - // Set target IP for non-simulator connections - if (debugConfig.connectionType !== 'simulator' && debugConfig.connectionParams.ipAddress) { - wsActions.setDebuggerTargetIp(debugConfig.connectionParams.ipAddress) - } + // Build FB instance map + const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - // Record the active transport so useDebugPolling picks the right - // poll cadence + batch size. Set on EVERY start path (runtime - // targets also set it earlier in handleMd5Verification; this - // additionally covers the simulator path, which doesn't go - // through MD5 verification — without it the simulator stayed at - // the default 200ms instead of its intended 50ms). Must be set - // before `setDebuggerVisible(true)`, which is what triggers the - // polling effect. - wsActions.setDebugConnectionType(debugConfig.connectionType) - - wsActions.setDebuggerVisible(true) + const fbTypesCount = fbDebugInstancesMap.size + const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) + if (fbTypesCount > 0) { logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debugger connected. Found ${indexMap.size} debug variables.`, + message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, }) + } - return { success: true } - } catch (err: unknown) { - const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + // Connect debugger via port + const connectResult = await debuggerPort.connect() + if (!connectResult.success) { + const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) return { success: false, error } } - }, - [debuggerPort, deviceDefinitions, projectData, projectMeta], - ) - const stopSession = useCallback(async () => { - // If simulator is running, stop it - if (simulator.isRunning()) { - await simulator.stop() + // Store debug artifacts in workspace + wsActions.setDebugVariableIndexes(indexMap) + wsActions.setDebugVariableTree(treeMap) + wsActions.setFbDebugInstances(fbDebugInstancesMap) + + // Set default selected instance for each FB type + fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { + if (instanceList.length > 0) { + wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) + } + }) + + // Set target IP for non-simulator connections + // The target's address, for the debugger's own display. Comes from the + // session the manager holds, not from a config the caller chose. + const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port + if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) + + // Nothing to record about the transport: `useDebugPolling` reads the medium + // the connection manager published (`deviceConnection.debugTransport`) and + // derives both its batch size and its cadence from it. Copying that into a + // second store field is what let the two disagree — and made a session whose + // medium was not yet known silently poll as if it were the simulator. + wsActions.setDebuggerVisible(true) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debugger connected. Found ${indexMap.size} debug variables.`, + }) + + return { success: true } + } catch (err: unknown) { + const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } } + }, [debuggerPort, deviceDefinitions, projectData, projectMeta]) - // Disconnect debugger + /** + * End the debug session — and ONLY the debug session. + * + * It used to stop the simulator too, which had the ownership backwards: a debug + * session is a consumer of a connection, not the owner of the thing on the other + * end. Stopping the simulator is the Stop button's job (`handleSimulatorControl`), + * and closing that session is the connection manager's. + */ + const stopSession = useCallback(async () => { await debuggerPort.disconnect() - // Clear all debug state workspaceActions.clearDebugState() debugTreesRef.current = {} - }, [simulator, debuggerPort, workspaceActions]) + }, [debuggerPort, workspaceActions]) const forceVariable = useCallback( async (index: number, force: boolean, value?: string, type?: string, enumValues?: string[]): Promise => { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 8bad1b9f9..fc73bacab 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -52,6 +52,8 @@ import { useDebugNonBoolValuesMap, useIsDebuggerVisible, } from '../hooks/use-debug-value' +import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monitor' +import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' @@ -151,6 +153,10 @@ const WorkspaceScreen = () => { // Start global runtime polling for status and logs useRuntimePolling() + // Mirrors a baremetal target's run/stop state from the held device link's + // existing liveness tick (no timer of its own). + useDevicePlcState() + useDeviceConnectionMonitor() // Build debug variables from POUs with debug=true const allDebugVariables = useMemo(() => { diff --git a/src/frontend/services/__tests__/device-link-resolution.test.ts b/src/frontend/services/__tests__/device-link-resolution.test.ts new file mode 100644 index 000000000..566a67c8a --- /dev/null +++ b/src/frontend/services/__tests__/device-link-resolution.test.ts @@ -0,0 +1,111 @@ +/** + * Describing a Runtime v3/v4 target's debug channel — through the SAME resolver + * Connect uses, with the target's declared transports deciding what is eligible. + * + * The regression these pin: eligibility used to be a hardcoded serial-then-TCP list + * inside the resolver, so a `websocket` channel was never a candidate. No runtime + * session was ever opened, and every command then answered "not connected" on a + * target the user had connected to and uploaded a program to. The fix is not a + * second code path for runtimes — it is asking the target which media it speaks. + */ +const mockAddLog = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' } }, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt-token' }, + consoleActions: { addLog: mockAddLog }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) + +import type { DebugSpec } from '../../../backend/shared/hardware/debug-spec' +import type { BoardInfo } from '../../../middleware/shared/ports/types' +import { resolveRuntimeDebugChannel } from '../device-link-resolution' + +/** A board carries BOTH halves: the spec says how a channel is built, the + * capability matrix says which channels the target can actually speak. */ +const boardWith = (spec: DebugSpec, transports: string[]): BoardInfo => + ({ debug: spec, capabilities: { debuggerTransports: transports } }) as unknown as BoardInfo + +/** The shape a Runtime v4 board declares — an SLM-RP4's, verbatim. */ +const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing. Reconnect to the runtime.' }, + }, + }, + ], +} + +/** Runtime v3: same shape, debugged over Modbus TCP instead. */ +const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt-token' } +}) + +describe('resolveRuntimeDebugChannel', () => { + it('describes a v4 target as its WebSocket channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + + expect(config).not.toBeNull() + expect(config?.connectionType).toBe('websocket') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + expect(config?.connectionParams.jwtToken).toBe('jwt-token') + }) + + it('describes a v3 target as its Modbus TCP channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v3', boardWith(v3Spec, ['modbus-tcp'])) + + expect(config?.connectionType).toBe('tcp') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + }) + + it('returns null and SAYS SO when a board declares no debug spec', () => { + // Failing quietly is what hid the bug above until it reached hardware. + expect(resolveRuntimeDebugChannel('Some Board', undefined)).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('no debug spec') }), + ) + }) + + it('returns null and says why when the spec cannot be satisfied', () => { + // v4 requires a JWT; without one the resolver refuses, and the user should be + // able to see that rather than meet "not connected" later. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: null } + + expect(resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket']))).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('could NOT describe a debug channel') }), + ) + }) + + it('traces the channel it settled on', () => { + resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('debug channel is websocket') }), + ) + }) +}) diff --git a/src/frontend/services/__tests__/save-actions.test.ts b/src/frontend/services/__tests__/save-actions.test.ts index dac639efe..f1f67823a 100644 --- a/src/frontend/services/__tests__/save-actions.test.ts +++ b/src/frontend/services/__tests__/save-actions.test.ts @@ -1,22 +1,162 @@ /** * save-actions.ts test file * - * All exported functions (executeSaveProject, executeSaveFile, executeSaveActiveFile) - * depend on `openPLCStoreBase.getState()` to read the Zustand store and on - * `projectPort` (an async interface) to persist data. They also call `toast()` - * which mutates module-level state. - * - * Because these functions are NOT pure (they read/mutate external state), - * they cannot be tested without mocking. The pure helpers they delegate to - * (sanitizePou, collectDebugVariables, serializePouToText, etc.) are covered - * by their own dedicated test suites. - * - * SKIPPED: requires jest.mock / vi.mock for openPLCStoreBase and projectPort. + * The pure helpers these functions delegate to (sanitizePou, + * collectDebugVariables, serializePouToText, …) are covered by their own + * suites. The cases below drive the real store singleton to pin the DOPE-495 + * contract: a graphical flow that fails schema validation keeps a stale + * `pou.body.value`, so it must never be reported as saved. */ +import type { PlatformCapabilities } from '../../../middleware/shared/ports/platform-capabilities' +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' +import { openPLCStoreBase } from '../../store' +import type { LadderFlowType } from '../../store/slices/ladder' +import { getMemoryState } from '../../utils/toast' +import { executeSaveFile, executeSaveProject } from '../save-actions' + +const capabilities = { isNativeApplication: true } as PlatformCapabilities + +const lastToast = () => getMemoryState().toasts[0] + +function makeProjectPort(): ProjectPort { + return { + saveProject: vi.fn().mockResolvedValue({ success: true }), + saveFile: vi.fn().mockResolvedValue({ success: true }), + } as unknown as ProjectPort +} + +function createLadderPou(name: string) { + const state = openPLCStoreBase.getState() + state.pouActions.create({ type: 'program', name, language: 'ld' }) + state.ladderFlowActions.startLadderRung({ + editorName: name, + rungId: `rung_${name}_1`, + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + state.ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) +} + +/** Drop `defaultBounds` / `reactFlowViewport` so the flow fails the zod guard. */ +function corruptFlow(name: string) { + const flow = openPLCStoreBase.getState().ladderFlows.find((f) => f.name === name) + openPLCStoreBase.getState().ladderFlowActions.addLadderFlow({ + name, + updated: true, + rungs: (flow?.rungs ?? []).map((rung) => ({ id: rung.id, comment: '', nodes: [], edges: [] })), + } as unknown as LadderFlowType) + openPLCStoreBase.getState().ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) +} + +const flowUpdated = (name: string) => openPLCStoreBase.getState().ladderFlows.find((f) => f.name === name)?.updated +const fileSaved = (name: string) => openPLCStoreBase.getState().files[name]?.saved + describe('save-actions', () => { - it('is skipped because all exported functions require store mocking', () => { - // Intentionally empty — see file-level comment. - expect(true).toBe(true) + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + openPLCStoreBase.getState().ladderFlowActions.clearLadderFlows() + }) + + afterEach(() => { + warn.mockRestore() + }) + + describe('executeSaveProject', () => { + it('reports success and clears the updated flag for a valid flow', async () => { + createLadderPou('ValidPou') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(true) + expect(flowUpdated('ValidPou')).toBe(false) + }) + + it('does not report a POU as saved when its flow fails validation', async () => { + createLadderPou('BrokenPou') + corruptFlow('BrokenPou') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(false) + // Keeping `updated` set is what lets a later edit retry the write-back. + expect(flowUpdated('BrokenPou')).toBe(true) + expect(fileSaved('BrokenPou')).toBe(false) + expect(lastToast()).toMatchObject({ title: 'Some changes were not saved', variant: 'fail' }) + }) + + it('still saves the valid POUs alongside a failing one', async () => { + createLadderPou('GoodPou') + createLadderPou('BadPou') + corruptFlow('BadPou') + + const projectPort = makeProjectPort() + const result = await executeSaveProject(projectPort, capabilities) + + expect(result.success).toBe(false) + expect(projectPort.saveProject).toHaveBeenCalled() + expect(flowUpdated('GoodPou')).toBe(false) + expect(fileSaved('GoodPou')).toBe(true) + }) + + it('stops blocking saves once the POU behind an invalid flow is deleted', async () => { + createLadderPou('Doomed') + corruptFlow('Doomed') + createLadderPou('Healthy') + + expect((await executeSaveProject(makeProjectPort(), capabilities)).success).toBe(false) + + // Deleting the POU is the user's only escape hatch, and it leaves the + // flow behind — the save must stop reporting it. + openPLCStoreBase.getState().pouActions.delete('Doomed') + + const result = await executeSaveProject(makeProjectPort(), capabilities) + + expect(result.success).toBe(true) + expect(fileSaved('Healthy')).toBe(true) + }) + }) + + describe('executeSaveFile', () => { + it('refuses to write the stale body of a failing flow', async () => { + createLadderPou('BrokenFile') + corruptFlow('BrokenFile') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('BrokenFile', projectPort, capabilities) + + expect(result.success).toBe(false) + expect(projectPort.saveFile).not.toHaveBeenCalled() + expect(flowUpdated('BrokenFile')).toBe(true) + }) + + it('writes a valid flow normally', async () => { + createLadderPou('ValidFile') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('ValidFile', projectPort, capabilities) + + expect(result.success).toBe(true) + expect(projectPort.saveFile).toHaveBeenCalled() + expect(flowUpdated('ValidFile')).toBe(false) + }) + + it('leaves an unrelated failing POU untouched', async () => { + createLadderPou('TargetFile') + createLadderPou('Unrelated') + corruptFlow('Unrelated') + + const projectPort = makeProjectPort() + const result = await executeSaveFile('TargetFile', projectPort, capabilities) + + expect(result.success).toBe(true) + expect(projectPort.saveFile).toHaveBeenCalled() + // The flush is scoped to the target, so the unrelated flow is never + // validated and never warns. + expect(warn).not.toHaveBeenCalled() + expect(flowUpdated('Unrelated')).toBe(true) + }) }) }) diff --git a/src/frontend/services/device-link-resolution.ts b/src/frontend/services/device-link-resolution.ts new file mode 100644 index 000000000..c882b7d17 --- /dev/null +++ b/src/frontend/services/device-link-resolution.ts @@ -0,0 +1,319 @@ +/** + * Turning a board's declarative `debug` spec into something connectable, with the + * dialogs that sometimes takes. + * + * Two flows need this and they used to share nothing: + * + * - Connect (and the reconnect after an upload) needs the ORDERED CANDIDATES for + * the device link — Modbus TCP when the project enables it, serial otherwise. + * - The debugger needs ONE channel for a session (or, for a runtime target, its + * WebSocket). + * + * What they have in common is the interactive part: a spec can ask for input (the + * DHCP address) or offer a choice, which means resolve → ask → resolve again. That + * loop lives here once. When only Connect had it, Connect could not ask for a DHCP + * address at all; when only the debugger had it, Connect silently mis-resolved. + * + * Deliberately not a hook: it is called from click handlers, keeps no React state, + * and the modal helpers below reach the store directly — so the same functions + * serve the activity bar and the device screen without either owning the other. + */ +import { + type DebugResolverContext, + type DeviceLinkCandidateConfig, + resolveDeviceLinkCandidates, +} from '../../backend/shared/hardware/debug-spec' +import type { BoardInfo, DebugConnectionConfig } from '../../middleware/shared/ports/types' +import { describeDebugEndpoint } from '../../middleware/shared/utils/debug-endpoint' +import { resolveTargetCapabilities } from '../../middleware/shared/utils/target-capabilities' +import { useOpenPLCStore } from '../store' + +/** + * Answers the user has already given, keyed by board then by the spec's + * `cacheKey`. Scoped per board so two devices sharing a cache key (`lastDhcpIp`) + * do not inherit each other's address. Module-level: it should outlive any one + * screen, since the same answer serves Connect and the debugger. + */ +const promptCache: Record> = {} + +/** Discard cached answers for a board — used when an entered value stops working. */ +export function forgetPromptAnswers(boardTarget: string): void { + delete promptCache[boardTarget] +} + +/** + * The editor's device dialogs, in one place. Exported because the flows that + * surround resolution — the debug gate, the switch warning, upload prompts — need + * to speak in the same voice, and a second copy of this two-line promise wrapper + * is how two callers end up with subtly different buttons. + */ +export const showDeviceDialog = ( + type: 'info' | 'warning' | 'error' | 'question', + title: string, + message: string, + buttons: string[], + /** Which button is the primary, and which one Escape / click-away chooses. */ + options?: { primaryButtonIndex?: number; dismissButtonIndex?: number }, +): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-message', { + type, + title, + message, + buttons, + ...options, + onResponse: (buttonIndex: number) => resolve(buttonIndex), + }) + }) + +export const showDeviceInput = (title: string, message: string, defaultValue: string): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { + title, + message, + defaultValue, + onSubmit: (value: string) => resolve(value), + onCancel: () => resolve(null), + }) + }) + +/** + * Build resolver context from current store state on every call, so the user's + * freshest screen edits count without saving first. `boardTarget` selects the + * prompt-cache bucket. + * + * `runtimeReadyForDebug` is passed in rather than read here: it comes from the + * runtime port, which only a component can reach, and it is meaningless for the + * baremetal flows. + */ +export function buildDeviceResolverContext( + boardTarget: string, + options: { runtimeReadyForDebug?: boolean } = {}, +): DebugResolverContext { + const store = useOpenPLCStore.getState() + const cfg = store.deviceDefinitions.configuration + const runtimeConnection = store.runtimeConnection + // `vendorScreenData` is already keyed by section id (`modbus_rtu`), which is + // the resolver's `screens` shape 1:1. + const screens = (cfg.vendorScreenData ?? {}) as Record> + + return { + state: { + configuration: { + deviceBoard: cfg.deviceBoard, + ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), + ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), + }, + screens, + runtimeConnection: { + ...(runtimeConnection.connectionStatus ? { connectionStatus: runtimeConnection.connectionStatus } : {}), + ...(runtimeConnection.jwtToken ? { jwtToken: runtimeConnection.jwtToken } : {}), + }, + promptCache: promptCache[boardTarget] ?? {}, + }, + capabilities: { + runtimeConnected: options.runtimeReadyForDebug === true && runtimeConnection.connectionStatus === 'connected', + jwtToken: Boolean(runtimeConnection.jwtToken), + }, + } +} + +/** Outcomes both resolvers can return besides their own success shape. */ +type InteractiveOutcome = + | { kind: 'pick'; channels: Array<{ index: number; label: string }>; title: string; body: string } + | { + kind: 'prompt' + fields: Array<{ field: string; title: string; message: string; cacheKey?: string; defaultValue?: string }> + channelIndex: number + } + | { kind: 'error'; title: string; body: string } + | { kind: 'unsupported' } + +/** What the caller should do next after a non-success outcome. */ +type NextStep = + /** Resolve again; `channelIndex` is the user's pick, if they made one. */ + | { retry: true; channelIndex?: number } + /** Stop: the user cancelled, or there is nothing to connect to. */ + | { retry: false } + +/** + * Surface whatever the resolver asked for, and say whether to resolve again. + * + * A cancelled prompt or picker stops the flow — the user said no, so no dialog is + * repeated and nothing is guessed on their behalf. + */ +async function handleInteractiveOutcome(outcome: InteractiveOutcome, boardTarget: string): Promise { + if (outcome.kind === 'error') { + await showDeviceDialog('warning', outcome.title, outcome.body, ['OK']) + return { retry: false } + } + if (outcome.kind === 'unsupported') return { retry: false } + + if (outcome.kind === 'pick') { + const choice = await showDeviceDialog( + 'question', + outcome.title, + outcome.body, + outcome.channels.map((channel) => channel.label), + ) + if (choice < 0 || choice >= outcome.channels.length) return { retry: false } + return { retry: true, channelIndex: outcome.channels[choice].index } + } + + // prompt: collect every field, caching answers the spec asked to remember. + const bucket = (promptCache[boardTarget] ??= {}) + for (const field of outcome.fields) { + const previous = field.cacheKey ? bucket[field.cacheKey] : undefined + const answer = await showDeviceInput(field.title, field.message, previous ?? field.defaultValue ?? '') + if (answer === null) return { retry: false } + const trimmed = answer.trim() + if (!trimmed) return { retry: false } + if (field.cacheKey) bucket[field.cacheKey] = trimmed + } + return { retry: true, channelIndex: outcome.channelIndex } +} + +/** + * Trace resolution into the console. Resolution happens HERE, in the renderer, + * from the project's screen data — so when a transport is not attempted at all, + * this is the only place that can say why. + */ +function trace(message: string): void { + useOpenPLCStore.getState().consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] ${message}`, + }) +} + +/** Guard against a malformed spec bouncing between prompts forever. */ +const MAX_RESOLVE_ROUNDS = 8 + +/** What resolution found, and what it deliberately left unasked. */ +export interface ResolvedDeviceLink { + /** Ways to reach the device that need nothing from the user, in try-order. */ + candidates: DeviceLinkCandidateConfig[] + /** + * Channel indexes that could also be tried, but only after asking the user + * something. Resolve again with `onlyChannels: awaitingInput` to ask. + */ + awaitingInput: number[] +} + +/** + * Resolve the ways to reach a baremetal device: serial first, then Modbus TCP. + * + * By default this asks the user NOTHING — a channel needing input is reported in + * `awaitingInput` instead. That is what lets Connect try the cable before asking + * for a DHCP address, so a user with a cable attached is never interrupted by a + * dialog about an address they do not need to know. + * + * Pass `onlyChannels` to resolve just those channels, asking whatever they need; + * that is the second pass, run only once everything silent has failed. + * + * Returns null if the user cancelled, or the board declares nothing connectable + * (the dialog explaining why has already been shown). + */ +export async function resolveDeviceLinkWithUx( + boardTarget: string, + boardInfo: BoardInfo | undefined, + options: { runtimeReadyForDebug?: boolean; onlyChannels?: number[]; deferPrompts?: boolean } = {}, +): Promise { + const spec = boardInfo?.debug + const transports = resolveTargetCapabilities(boardInfo).debuggerTransports + if (!spec) { + await showDeviceDialog( + 'warning', + 'Cannot Connect', + 'This board has not declared a debug spec, so the editor has no way to reach it. The VPP package must provide a `debug` block.', + ['OK'], + ) + return null + } + + const resolverOptions = { + transports, + ...(options.onlyChannels ? { onlyChannels: options.onlyChannels } : {}), + ...(options.deferPrompts ? { deferPrompts: true } : {}), + } + + for (let round = 0; round < MAX_RESOLVE_ROUNDS; round += 1) { + const context = buildDeviceResolverContext(boardTarget, options) + const outcome = resolveDeviceLinkCandidates(spec, context, resolverOptions) + if (outcome.kind === 'candidates') { + trace( + `resolved ${outcome.candidates.length} candidate(s) for ${boardTarget}: ${ + outcome.candidates + .map((candidate) => `${candidate.config.connectionType} ${describeDebugEndpoint(candidate.config)}`) + .join(', ') || 'none' + }${outcome.awaitingInput.length ? ` (+${outcome.awaitingInput.length} needing input, not asked yet)` : ''}`, + ) + return { candidates: outcome.candidates, awaitingInput: outcome.awaitingInput } + } + // Say what the spec concluded and what it was reading, so a transport that is + // never attempted can be traced to the screen value that ruled it out. + trace( + `resolution returned "${outcome.kind}"${outcome.kind === 'error' ? `: ${outcome.body}` : ''} — modbus_tcp=${JSON.stringify( + context.state.screens.modbus_tcp ?? null, + )} modbus_rtu=${JSON.stringify(context.state.screens.modbus_rtu ?? null)} port=${String( + context.state.configuration.communicationPort ?? 'none', + )}`, + ) + + const next = await handleInteractiveOutcome(outcome, boardTarget) + if (!next.retry) return null + } + return null +} + +/** + * The channel a Runtime v3/v4 debugs over: the WebSocket for v4, Modbus TCP for v3. + * Used when a runtime login establishes a session, so the manager knows how to open + * that channel later. + * + * Uses the SINGLE-channel resolver, not the candidate one. A runtime declares + * exactly one debug channel and there is nothing to choose between or order — while + * `resolveDeviceLinkCandidates` exists to order a baremetal board's serial and + * Modbus TCP options, and collects only those two kinds. Pointing it at a + * `websocket` channel therefore found nothing eligible, returned an error, and left + * every runtime target without a session: "nothing is connected" for both the + * debugger and run/stop, on a target the user had plainly connected to. + * + * Never prompts (v3/v4 specs declare no prompts) and traces its own failure, because + * a session that cannot be described must not fail silently — that silence is what + * hid this until it reached hardware. + */ +export function resolveRuntimeDebugChannel( + boardTarget: string, + boardInfo: BoardInfo | undefined, +): DebugConnectionConfig | null { + const spec = boardInfo?.debug + if (!spec) { + trace(`${boardTarget}: no debug spec, so no debug channel can be described`) + return null + } + + // The SAME resolver Connect uses. A runtime declares exactly one debug transport + // in its capability matrix (`['websocket']` for v4, `['modbus-tcp']` for v3), so + // the ordered candidate list has one entry — no separate code path, and no + // hardcoded assumption here about what a runtime debugs over. + const outcome = resolveDeviceLinkCandidates( + spec, + buildDeviceResolverContext(boardTarget, { runtimeReadyForDebug: true }), + { transports: resolveTargetCapabilities(boardInfo).debuggerTransports, deferPrompts: true }, + ) + if (outcome.kind === 'candidates' && outcome.candidates.length > 0) { + const [channel] = outcome.candidates + trace(`${boardTarget}: debug channel is ${channel.config.connectionType} (${channel.channelLabel})`) + return channel.config + } + + // Never silently: a session that cannot be described leaves every later command + // answering "not connected" on a target the user believes they are connected to. + trace( + `${boardTarget}: could NOT describe a debug channel — resolver returned "${outcome.kind}"${ + outcome.kind === 'error' ? `: ${outcome.body}` : '' + }`, + ) + return null +} diff --git a/src/frontend/services/graphical-scope.ts b/src/frontend/services/graphical-scope.ts index 1c972717e..e38f2270d 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -20,7 +20,12 @@ */ import type { PLCVariable } from '../../middleware/shared/ports/types' -import { getVariableRestrictionType, validateVariableType } from '../utils/PLC/validate-variable-type' +import type { BoundBlockPin } from '../utils/PLC/validate-variable-type' +import { + getVariableRestrictionType, + resolveNewVariableType, + validateVariableType, +} from '../utils/PLC/validate-variable-type' import { getScopedQueryApi } from './st-lsp' /** @@ -171,19 +176,25 @@ export async function isExpressionValidForType( return validateVariableType(result.type, expectedType).isValid } -/** Concrete `{definition, value}` to type a brand-new variable created from a box's expected type. */ -export function newVariableTypeForExpected(expectedType: string | undefined): { +/** + * Concrete `{definition, value}` to type a brand-new variable created from a + * box's expected type. `boundSiblings` are the pins of the same block instance + * that already have a variable on them — a generic pin (`ANY`, `ANY_NUM`, …) + * adopts the concrete type the block resolved to instead of guessing (#479). + * Decision logic lives in {@link resolveNewVariableType} so it stays testable. + */ +export function newVariableTypeForExpected( + expectedType: string | undefined, + boundSiblings: BoundBlockPin[] = [], +): { definition: PLCVariable['type']['definition'] value: string } { - if (!expectedType) return { definition: 'base-type', value: 'dint' } - const restriction = getVariableRestrictionType(expectedType) - const value = restriction.values - ? Array.isArray(restriction.values) - ? restriction.values[0] - : restriction.values - : 'dint' - return { definition: (restriction.definition as PLCVariable['type']['definition']) ?? 'base-type', value } + const resolved = resolveNewVariableType(expectedType, boundSiblings) + return { + definition: (resolved.definition as PLCVariable['type']['definition']) ?? 'base-type', + value: resolved.value, + } } /** Build the variable reference a graphical node stores when an LSP completion is chosen. */ diff --git a/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts new file mode 100644 index 000000000..447baa6bc --- /dev/null +++ b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ +import { shiftSemanticTokensToBody } from '../internal/semantic-tokens-shift' + +// One token per line of a synthesised datatypes document: +// 0 `TYPE` col 0 +// 1 ` Colors : (RED);` col 2 +// 2 ` Motor : STRUCT` col 2 +// 3 ` speed : INT;` col 4 +// 4 ` END_STRUCT;` col 2 +const AGGREGATE = [0, 0, 4, 0, 0, 1, 2, 6, 0, 0, 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0] + +describe('shiftSemanticTokensToBody', () => { + it('drops tokens before the window and rebases the rest to line 0 by default', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 1, 2))).toEqual([0, 2, 6, 0, 0]) + }) + + it('rebases onto outputStartLine so a view can render its own frame above the window', () => { + // `Motor` occupies lines 2..4; its `.dt` view renders them under a + // local `TYPE` line, so they land on local lines 1..3. + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))).toEqual([ + 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0, + ]) + }) + + it('never emits the line above the window, whose columns can overrun a short frame line', () => { + const out = Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1)) + // The `Colors` token (length 6) would not fit on the 4-character `TYPE` line. + expect(out).not.toContain(6) + }) + + it('keeps everything from startLine when no end is given', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 3))).toEqual([0, 4, 5, 0, 0, 1, 2, 10, 0, 0]) + }) + + it('returns an empty stream when the window selects nothing', () => { + expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 9, 12))).toEqual([]) + }) +}) diff --git a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts index da257c4ca..2b4e042e3 100644 --- a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts +++ b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts @@ -7,10 +7,15 @@ * 1. Decode deltas to absolute (line, col) positions. * 2. Keep tokens whose line is in `[startLine, endLineExclusive)`. * Both ends are LSP coordinates. - * 3. Subtract `startLine` from each surviving token's line so the + * 3. Rebase each surviving token onto `outputStartLine` so the * output is Monaco-relative. * 4. Re-encode as a delta stream Monaco can consume directly. * + * `outputStartLine` exists so a view that renders its own framing + * above the window doesn't have to widen the window to compensate: + * doing that drags in the preceding line's tokens, whose columns can + * overrun the shorter frame line ("end character > line length"). + * * Used in two modes by the ST LSP today: * - **Body view**: `startLine = bodyLineOffset` (preamble line count), * `endLineExclusive = ∞` — drop the preamble, keep everything @@ -29,6 +34,7 @@ export function shiftSemanticTokensToBody( data: number[], startLine: number, endLineExclusive: number = Number.POSITIVE_INFINITY, + outputStartLine: number = 0, ): Uint32Array { // Decode to absolute positions. const abs: Array<{ line: number; col: number; len: number; type: number; mods: number }> = [] @@ -51,7 +57,7 @@ export function shiftSemanticTokensToBody( for (const t of abs) { if (t.line < startLine) continue if (t.line >= endLineExclusive) continue - const shiftedLine = t.line - startLine + const shiftedLine = t.line - startLine + outputStartLine const dLine = shiftedLine - prevLine const dStart = dLine === 0 ? t.col - prevCol : t.col out.push(dLine, dStart, t.len, t.type, t.mods) diff --git a/src/frontend/services/lsp-shared/semantic-tokens.ts b/src/frontend/services/lsp-shared/semantic-tokens.ts index 987d13452..9690ff17a 100644 --- a/src/frontend/services/lsp-shared/semantic-tokens.ts +++ b/src/frontend/services/lsp-shared/semantic-tokens.ts @@ -39,12 +39,14 @@ export interface SemanticTokensRegistration extends monaco.IDisposable { * default keeps everything from `[lineOffset, +∞)` — drop the * preamble, keep the rest. ST's variables-text view overrides this * to clip the end at the body line so only VAR-block tokens render. + * `outputStartLine` rebases the kept window for views that render + * their own framing above it (the data type `.dt` code view). */ export type ResolveSemanticTokensViewport = ( lspUri: string, modelUri: string, lineOffset: number, -) => { startLine: number; endLineExclusive: number } +) => { startLine: number; endLineExclusive: number; outputStartLine?: number } const defaultViewport: ResolveSemanticTokensViewport = (_lspUri, _modelUri, lineOffset) => ({ startLine: lineOffset, @@ -87,10 +89,10 @@ export function registerLspSemanticTokens(opts: RegisterLspSemanticTokensOptions textDocument: { uri: lspUri }, }) if (!result) return null - const { startLine, endLineExclusive } = resolveViewport(lspUri, modelUri, lineOffset) + const { startLine, endLineExclusive, outputStartLine } = resolveViewport(lspUri, modelUri, lineOffset) return { ...(result.resultId ? { resultId: result.resultId } : {}), - data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive), + data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive, outputStartLine), } }, releaseDocumentSemanticTokens() { diff --git a/src/frontend/services/lsp-shared/start-language-service.ts b/src/frontend/services/lsp-shared/start-language-service.ts index 66af71bf7..5d7a5ecd7 100644 --- a/src/frontend/services/lsp-shared/start-language-service.ts +++ b/src/frontend/services/lsp-shared/start-language-service.ts @@ -84,6 +84,14 @@ export interface LanguageService { changeDocument(uri: string, content: string, version?: number): void /** Send `textDocument/didClose`. */ closeDocument(uri: string): void + /** + * Ask Monaco to re-query semantic tokens for every model in this + * language. Needed when a model's tokens derive from a *different* + * document (the datatype `.dt` view reads the aggregate doc), where + * a change to that document leaves the model's text untouched and + * therefore triggers no re-query of its own. + */ + refreshSemanticTokens(): void /** Tear down providers + transport. */ dispose(): void } @@ -232,6 +240,7 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag openDocument: () => undefined, changeDocument: () => undefined, closeDocument: () => undefined, + refreshSemanticTokens: () => undefined, dispose: () => undefined, } } @@ -333,6 +342,10 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag return { ready, + refreshSemanticTokens() { + semanticTokensRegistration?.refresh() + }, + openDocument(uri, content) { if (disposed) return const existing = documents.get(uri) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 5daf9e5e7..ab04b7474 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -13,14 +13,17 @@ import type { PlatformCapabilities } from '../../middleware/shared/ports/platform-capabilities' import type { ProjectPort, RawProjectFile, WriteProjectFiles } from '../../middleware/shared/ports/project-port' -import type { PLCPou } from '../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou } from '../../middleware/shared/ports/types' import { openPLCStoreBase } from '../store' import type { LadderFlowType } from '../store/slices/ladder' import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' +import { isDataTypeFilesEnabled } from '../utils/feature-flags' import { parseIecStringToVariables } from '../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../utils/graphical/sync-nodes-with-variables' import { notifyNoWritePermission } from '../utils/notify-no-write-permission' +import { serializeDataTypeToText } from '../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../utils/PLC/data-type-text-parser' import { getExtensionFromLanguage, getFolderFromPouType } from '../utils/PLC/pou-file-extensions' import { parseGraphicalPouFromString, parseTextualPouFromString } from '../utils/PLC/pou-text-parser' import { serializePouToText } from '../utils/PLC/pou-text-serializer' @@ -46,6 +49,7 @@ type ProjectFileCategory = | 'pin-mapping' | 'project-json' | 'library-manifest' + | 'data-type' type ProjectFileSpec = { path: string @@ -71,7 +75,9 @@ function buildProjectJsonContent(state: StoreState): string { { meta: { name: project.meta.name, type: metaType }, data: { - dataTypes: project.data.dataTypes, + // With .dt persistence on, types live in their own files and + // project.json stops carrying them (same shape as `pous`). + dataTypes: isDataTypeFilesEnabled() ? [] : project.data.dataTypes, pous: [], configuration: project.data.configurations, libraries, @@ -95,6 +101,14 @@ function buildPouSpec(pou: PLCPou, state: StoreState): ProjectFileSpec { } } +function buildDataTypeSpec(dt: PLCDataType): ProjectFileSpec { + return { + path: `datatypes/${dt.name}.dt`, + content: serializeDataTypeToText(dt), + category: 'data-type', + } +} + /** * Yield every file the save flow uploads, in a deterministic order, with the * canonical serialized content for each. Used by `buildAllProjectFileContents*` @@ -124,6 +138,20 @@ function* iterateProjectFiles(state: StoreState): Generator { yield buildPouSpec(pou, state) } + if (isDataTypeFilesEnabled()) { + for (const dt of project.data.dataTypes) { + yield buildDataTypeSpec(dt) + } + // Raw .dt files that failed to parse on load — echoed verbatim + // so an unreadable file is never silently dropped from disk. + // A parsed type claiming the same path wins: guards non-UI entry + // points (e.g. XML import) from yielding duplicate paths. + for (const f of state.unparsedDataTypeFiles) { + if (project.data.dataTypes.some((dt) => `datatypes/${dt.name}.dt` === f.relativePath)) continue + yield { path: f.relativePath, content: f.content, category: 'data-type' } + } + } + if (!isLibrary) { for (const s of project.data.servers ?? []) { yield { @@ -254,7 +282,12 @@ function serializeProjectFile( return [{ path: 'library.json', content, category: 'library-manifest' }] } - // data-type, resource: live in project.json + if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + const dt = project.data.dataTypes.find((d) => d.name === fileName) + return dt ? [buildDataTypeSpec(dt)] : [] + } + + // resource (and data-type while the .dt flag is off): live in project.json return [{ path: 'project.json', content: buildProjectJsonContent(state), category: 'project-json' }] } @@ -324,8 +357,9 @@ export async function executeSaveProject( ): Promise<{ success: boolean }> { // Run any pending debounced graphical write-backs before reading state: // a save landing inside the debounce window must serialize the fresh - // POU bodies, not the pre-edit ones. - flushFlowWriteBacks(openPLCStoreBase.getState) + // POU bodies, not the pre-edit ones. Flows that fail validation keep a + // stale body, so they must not be reported as saved (DOPE-495). + const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState) const state = openPLCStoreBase.getState() // Persist gate. Every save path — Ctrl+S, File → Save, auto-save after // a rename/delete, the AI panel — funnels through here. When the viewer @@ -339,7 +373,7 @@ export async function executeSaveProject( } const { project, pendingDeletions } = state const { setEditingState } = state.workspaceActions - const { setAllToSaved } = state.fileActions + const { setAllToSaved, updateFile } = state.fileActions const { markAllSaved } = state.snapshotActions const deletionsBeforeSave = [...pendingDeletions] @@ -367,6 +401,7 @@ export async function executeSaveProject( const pouFiles: RawProjectFile[] = [] const serverFiles: RawProjectFile[] = [] const remoteDeviceFiles: RawProjectFile[] = [] + const dataTypeFiles: RawProjectFile[] = [] let projectJson = '' // `deviceConfig` / `pinMapping` / `libraryManifest` stay // `undefined` when the iterator doesn't yield them. Library @@ -391,6 +426,9 @@ export async function executeSaveProject( case 'remote-device': remoteDeviceFiles.push({ relativePath: spec.path, content }) break + case 'data-type': + dataTypeFiles.push({ relativePath: spec.path, content }) + break case 'device-config': deviceConfig = content break @@ -415,6 +453,7 @@ export async function executeSaveProject( pouFiles, serverFiles, remoteDeviceFiles, + dataTypeFiles, deletions: [...pendingDeletions], } @@ -432,28 +471,45 @@ export async function executeSaveProject( ...pouFiles.map((f) => ({ path: f.relativePath, content: f.content })), ...serverFiles.map((f) => ({ path: f.relativePath, content: f.content })), ...remoteDeviceFiles.map((f) => ({ path: f.relativePath, content: f.content })), + ...dataTypeFiles.map((f) => ({ path: f.relativePath, content: f.content })), ] state.versionControlActions.recordSavedFiles({ saved: savedRecords, deleted: deletionsBeforeSave, }) + const isStale = new Set(staleFlows) + state.projectActions.clearPendingDeletions() - setEditingState('saved') + setEditingState(staleFlows.length > 0 ? 'unsaved' : 'saved') setAllToSaved() - markAllSaved() + markAllSaved(staleFlows) - // Reset graphical flow state: clear selections and updated flags + // Reset graphical flow state: clear selections and updated flags. + // A stale flow keeps `updated` set and its file dirty — clearing them + // would strand the in-memory edit with no way back to disk. for (const flow of state.ladderFlows) { state.ladderFlowActions.clearSelections({ editorName: flow.name }) + if (isStale.has(flow.name)) continue state.ladderFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } for (const flow of state.fbdFlows) { state.fbdFlowActions.clearSelections({ editorName: flow.name }) + if (isStale.has(flow.name)) continue state.fbdFlowActions.setFlowUpdated({ editorName: flow.name, updated: false }) } + // Must stay after `setAllToSaved()` above, which marks every file saved. + for (const name of staleFlows) { + updateFile({ name, saved: false }) + } - if (!capabilities.isNativeApplication) { + if (staleFlows.length > 0) { + toast({ + title: 'Some changes were not saved', + description: `The graphical body of ${staleFlows.join(', ')} is invalid and could not be written to disk. Every other file was saved.`, + variant: 'fail', + }) + } else if (!capabilities.isNativeApplication) { toast({ title: 'Changes saved!', description: 'The project was saved successfully!', @@ -468,7 +524,9 @@ export async function executeSaveProject( variant: 'fail', }) } - return { success: res.success } + // A stale flow means the user's graphical edit never reached disk, so + // callers that gate on the save (build, close-project) must not proceed. + return { success: res.success && staleFlows.length === 0 } } catch { setEditingState('unsaved') toast({ @@ -496,8 +554,9 @@ export async function executeSaveFile( projectPort: ProjectPort, capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { - // See executeSaveProject — same pending write-back flush requirement. - flushFlowWriteBacks(openPLCStoreBase.getState) + // See executeSaveProject — same pending write-back flush requirement, scoped + // to the target so a single-file save doesn't touch unrelated POUs. + const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState, fileName) const state = openPLCStoreBase.getState() // See executeSaveProject for rationale — same persist gate. if (!state.workspace.canEdit) { @@ -524,6 +583,12 @@ export async function executeSaveFile( return { success: false } } + // Writing the stale body would overwrite disk with pre-edit content and + // then report success — abort instead (DOPE-495). + if (staleFlows.includes(fileName)) { + return fail(`The graphical body of "${fileName}" is invalid, so the file was not written to disk.`) + } + try { // Use the same canonical serializer as the full-project save path so // both flows agree on what bytes hit disk. For POUs and JSON files this @@ -599,12 +664,15 @@ export async function executeSaveFile( if (!spec) return fail('Save failed') const res = await projectPort.saveFile(joinPath(projectPath, 'library.json'), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') + } else if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + const spec = specs[0] + if (!spec) return fail(`Data type "${fileName}" not found.`) + const res = await projectPort.saveFile(joinPath(projectPath, 'datatypes', `${fileName}.dt`), spec.content) + if (!res.success) return fail(res.error ?? 'Save failed') } else { - // data-type, resource: live in project.json (legacy whole-file - // write). These editors don't yet have a surgical save path — - // they still cross-contaminate each other today, which we accept - // until each is migrated to its own read-modify-write branch - // mirroring the library-manager case above. + // resource (and data-type while the .dt flag is off): live in + // project.json (legacy whole-file write) — cross-contamination + // accepted until each is migrated to its own branch. const spec = specs[0] if (!spec) return fail('Save failed') const res = await projectPort.saveFile(joinPath(projectPath, 'project.json'), spec.content) @@ -776,6 +844,34 @@ export async function reloadPouFromDisk(pouName: string, projectPort: ProjectPor } } +/** + * Reload a single data type from its `datatypes/.dt` file, + * discarding in-memory changes ("Don't save" on a datatype tab). + * Same parser the project-open path uses; the name-must-match-file + * rule applies, so a hand-tampered file fails the reload rather than + * silently rekeying the type. + */ +export async function reloadDataTypeFromDisk(name: string, projectPort: ProjectPort): Promise<{ success: boolean }> { + const state = openPLCStoreBase.getState() + const dt = state.project.data.dataTypes.find((d) => d.name === name) + if (!dt) return { success: false } + + try { + const fullPath = joinPath(state.project.meta.path, 'datatypes', `${name}.dt`) + const result = await projectPort.readFileContent(fullPath) + if (!result.success || !result.content) return { success: false } + + const parsed = parseDataTypeFromText(result.content, name) + if (!parsed.dataType) return { success: false } + + state.projectActions.applyDatatypeSnapshot(name, parsed.dataType) + return { success: true } + } catch (error) { + console.error(`Failed to reload data type "${name}" from disk:`, error) + return { success: false } + } +} + /** * Surgical save for the Library Manager tab. * @@ -1050,6 +1146,9 @@ export async function reloadFileFromDisk(fileName: string, projectPort: ProjectP if (file.type === 'library-manifest') { return reloadLibraryManifestFromCleanState(fileName) } + if (file.type === 'data-type' && isDataTypeFilesEnabled()) { + return reloadDataTypeFromDisk(fileName, projectPort) + } // Everything else routes through the POU-specific reload. Non-POU // file types that need a revert path should add a branch above // (mirroring the library-manager / vendor-screen cases) rather diff --git a/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts new file mode 100644 index 000000000..735cd1bbc --- /dev/null +++ b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts @@ -0,0 +1,99 @@ +/** + * @jest-environment jsdom + */ +import type { Diagnostic } from 'vscode-languageserver-protocol' + +import type { PLCDataType } from '../../../../middleware/shared/ports/types' +import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from '../dtview-context' + +const enumType = (name: string): PLCDataType => ({ + name, + derivation: 'enumerated', + values: [{ description: 'RED' }], + initialValue: 'RED', +}) + +const structType = (name: string, fields: string[]): PLCDataType => ({ + name, + derivation: 'structure', + variable: fields.map((field) => ({ + name: field, + type: { definition: 'base-type', value: 'INT' }, + })), +}) + +// Aggregate document, LSP (0-indexed) lines: +// 0 TYPE +// 1 Colors : (RED) := RED; +// 2 Motor : STRUCT +// 3 speed : INT; +// 4 END_STRUCT; +// 5 END_TYPE +const DATA_TYPES: PLCDataType[] = [enumType('Colors'), structType('Motor', ['speed'])] + +const diagnosticAt = (line: number): Diagnostic => ({ + range: { start: { line, character: 0 }, end: { line, character: 4 } }, + message: `line ${line}`, +}) + +describe('dtViewSpan', () => { + it('returns the entry span for a type in the document', () => { + expect(dtViewSpan(DATA_TYPES, 'Motor')).toEqual({ start: 2, length: 3 }) + }) + + it('returns null for a name the document has no entry for', () => { + expect(dtViewSpan(DATA_TYPES, 'Missing')).toBeNull() + }) + + it('returns null for every name when the document is empty', () => { + expect(dtViewSpan([], 'Colors')).toBeNull() + }) +}) + +describe('dtViewLineOffset', () => { + it('is zero for the first entry — both frames open with their own TYPE line', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && dtViewLineOffset(span)).toBe(0) + }) + + it('shifts a later entry by its distance down the document', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && dtViewLineOffset(span)).toBe(1) + }) + + it('shifts the last entry of a longer document', () => { + const dataTypes = [enumType('A'), enumType('B'), structType('C', ['x', 'y'])] + const span = dtViewSpan(dataTypes, 'C') + expect(span && dtViewLineOffset(span)).toBe(2) + }) +}) + +describe('dtViewWindow', () => { + it('covers the entry lines only, never the frame line above them', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && dtViewWindow(span)).toEqual({ startLine: 2, endLineExclusive: 5 }) + }) + + it('is a single line for a one-line entry', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && dtViewWindow(span)).toEqual({ startLine: 1, endLineExclusive: 2 }) + }) +}) + +describe('diagnosticsInSpan', () => { + it('keeps only what falls inside the entry', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + const kept = span ? diagnosticsInSpan([diagnosticAt(1), diagnosticAt(3), diagnosticAt(5)], span) : [] + expect(kept.map((d) => d.message)).toEqual(['line 3']) + }) + + it('excludes the line directly above the entry — that belongs to the previous type', () => { + const span = dtViewSpan(DATA_TYPES, 'Motor') + expect(span && diagnosticsInSpan([diagnosticAt(1)], span)).toEqual([]) + }) + + it('returns nothing when the entry is clean', () => { + const span = dtViewSpan(DATA_TYPES, 'Colors') + expect(span && diagnosticsInSpan([diagnosticAt(3)], span)).toEqual([]) + }) +}) diff --git a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts index 4d06cbd12..0dd2b0aa5 100644 --- a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts +++ b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts @@ -1,8 +1,9 @@ /** * @jest-environment jsdom */ -import type { PLCPou } from '../../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou } from '../../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../../store' +import * as featureFlags from '../../../utils/feature-flags' import { setBodyLineOffset } from '../../lsp-shared/body-offsets' import { redirectDefinitionToStore } from '../goto-definition-redirect' @@ -62,6 +63,87 @@ describe('redirectDefinitionToStore', () => { ).toBe(false) }) + describe('datatypes URI with data types present', () => { + // Aggregate doc: line 0 `TYPE`, 1 `Colors : (...)`, then Motor's + // STRUCT spans 2..4 (declaration, one field, END_STRUCT). + const dataTypes: PLCDataType[] = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + ] + + beforeEach(() => { + setProjectPous([]) + openPLCStoreBase.setState((s) => ({ + ...s, + project: { ...s.project, data: { ...s.project.data, dataTypes } }, + })) + jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(true) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('opens the owning type in code mode with the cursor on its declaration line', () => { + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } }, + }), + ).toBe(true) + + const state = openPLCStoreBase.getState() + expect(state.selectedTab).toBe('Colors') + // The active editor holds the fresh model — `updateModelStructureForName` + // writes there when the name matches, leaving `editors[]` behind. + const model = state.editor + expect(model.type === 'plc-datatype' && model.structure.display).toBe('code') + // Entry line 0 sits below the view's own `TYPE` frame → Monaco line 2. + expect(model.cursorPosition).toEqual({ lineNumber: 2, column: 3, offset: 0, target: 'data-type' }) + }) + + it('lands on a struct field line inside the owning type', () => { + // Aggregate line 3 = Motor's `speed` field (entry starts at 2). + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 3, character: 4 }, end: { line: 3, character: 9 } }, + }), + ).toBe(true) + + const state = openPLCStoreBase.getState() + expect(state.selectedTab).toBe('Motor') + expect(state.editor.cursorPosition?.lineNumber).toBe(3) + }) + + it('returns false for the END_TYPE framing line past the last entry', () => { + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 5, character: 0 }, end: { line: 5, character: 0 } }, + }), + ).toBe(false) + }) + + it('opens the form tab without a cursor when the code view is not built in', () => { + jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(false) + expect( + redirectDefinitionToStore({ + uri: 'inmemory://datatypes/__project__.st', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } }, + }), + ).toBe(true) + + const model = openPLCStoreBase.getState().editor + expect(model.type === 'plc-datatype' && model.structure.display).toBe('table') + expect(model.cursorPosition).toBeUndefined() + }) + }) + it('returns false when the target POU does not exist in the project', () => { setProjectPous([]) expect( diff --git a/src/frontend/services/st-lsp/__tests__/types.test.ts b/src/frontend/services/st-lsp/__tests__/types.test.ts index 63ad17e47..597dd5fdd 100644 --- a/src/frontend/services/st-lsp/__tests__/types.test.ts +++ b/src/frontend/services/st-lsp/__tests__/types.test.ts @@ -1,4 +1,13 @@ -import { parsePouUri, parsePouVarsUri, POU_DECLARATION_LINE_COUNT, pouUri, pouVarsUri, stubUri } from '../types' +import { + dtViewUri, + parseDtViewUri, + parsePouUri, + parsePouVarsUri, + POU_DECLARATION_LINE_COUNT, + pouUri, + pouVarsUri, + stubUri, +} from '../types' describe('pouUri / stubUri', () => { it('produces well-formed in-memory URIs', () => { @@ -71,3 +80,37 @@ describe('POU_DECLARATION_LINE_COUNT', () => { expect(POU_DECLARATION_LINE_COUNT).toBe(1) }) }) + +describe('dtViewUri / parseDtViewUri', () => { + it('round-trips a data type name, encoding included', () => { + expect(dtViewUri('Motor')).toBe('inmemory://dtview/Motor.dt') + expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type') + }) + + it('returns null for the other ST URI shapes', () => { + expect(parseDtViewUri(pouUri('Motor'))).toBeNull() + expect(parseDtViewUri(pouVarsUri('Motor'))).toBeNull() + expect(parseDtViewUri('inmemory://datatypes/__project__.st')).toBeNull() + }) + + it('does not collide with the pouvars parser', () => { + expect(parsePouVarsUri(dtViewUri('Motor'))).toBeNull() + }) +}) + +describe('malformed percent encoding', () => { + // These parsers run on every model URI the providers see, so a throw + // here would take hover / completion down for that model. + it('returns null instead of throwing, for every synthetic URI shape', () => { + expect(parsePouUri('inmemory://pou/%ZZ.st')).toBeNull() + expect(parsePouUri('inmemory://stub/%ZZ.st')).toBeNull() + expect(parsePouVarsUri('inmemory://pouvars/%ZZ.st')).toBeNull() + expect(parseDtViewUri('inmemory://dtview/%ZZ.dt')).toBeNull() + }) + + it('still decodes well-formed encodings', () => { + expect(parsePouUri(pouUri('My POU'))?.name).toBe('My POU') + expect(parsePouVarsUri(pouVarsUri('My POU'))).toBe('My POU') + expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type') + }) +}) diff --git a/src/frontend/services/st-lsp/dtview-context.ts b/src/frontend/services/st-lsp/dtview-context.ts new file mode 100644 index 000000000..22ccfbd10 --- /dev/null +++ b/src/frontend/services/st-lsp/dtview-context.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * Coordinate translation between a `.dt` code view and the aggregate + * datatypes document. + * + * A `.dt` view renders one type under its own `TYPE` frame line, while + * strucpp only ever sees `DATA_TYPES_URI` — every type in one document, + * under one frame. So the two frames differ by the type's position in + * the aggregate, and every request crossing the seam has to be shifted + * by it. + * + * These helpers take `dataTypes` rather than reading the store, so the + * arithmetic is testable on its own — the seam is where the token and + * marker defects in DOPE-537 came from. + */ + +import type { Diagnostic } from 'vscode-languageserver-protocol' + +import type { PLCDataType } from '../../../middleware/shared/ports/types' +import { type DataTypeLineSpan, dataTypeLineSpans } from '../../utils/PLC/data-type-serializer' +import { DT_VIEW_FRAME_LINE_COUNT } from './types' + +/** The aggregate document's line span for `dtName`, or null if it has none. */ +export function dtViewSpan(dataTypes: PLCDataType[], dtName: string): DataTypeLineSpan | null { + return dataTypeLineSpans(dataTypes).get(dtName) ?? null +} + +/** + * Lines to add to a `.dt` view position to reach the aggregate document. + * Both frames open with a `TYPE` line, so the shift is the entry's start + * minus that frame — 0 for the first type. + */ +export function dtViewLineOffset(span: DataTypeLineSpan): number { + return span.start - DT_VIEW_FRAME_LINE_COUNT +} + +/** + * Aggregate-document line window backing the view. Holds the entry's own + * lines only — widening it to cover the view's `TYPE` frame would pull in + * the previous entry's last line, whose columns overrun that 4-character + * frame line. + */ +export function dtViewWindow(span: DataTypeLineSpan): { startLine: number; endLineExclusive: number } { + return { startLine: span.start, endLineExclusive: span.start + span.length } +} + +/** The published diagnostics that fall inside the entry's own lines. */ +export function diagnosticsInSpan(diagnostics: Diagnostic[], span: DataTypeLineSpan): Diagnostic[] { + const { startLine, endLineExclusive } = dtViewWindow(span) + return diagnostics.filter((d) => d.range.start.line >= startLine && d.range.start.line < endLineExclusive) +} diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index 7697f2e9e..675d253fc 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -39,10 +39,17 @@ import type { PLCDataType } from '../../../middleware/shared/ports/types' import { sanitizeAxisName, softMotionAxisNames } from '../../../middleware/shared/utils/ethercat' import { openPLCStoreBase } from '../../store' import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' -import { serializeDataTypesToLines } from '../../utils/PLC/data-type-serializer' +import { isDataTypeFilesEnabled } from '../../utils/feature-flags' +import { dataTypeLineSpans } from '../../utils/PLC/data-type-serializer' import { getBodyLineOffset } from '../lsp-shared/body-offsets' import { normaliseLocation, routeToPou, routeToPouBody, routeToPouPreamble } from '../lsp-shared/definition-redirect' -import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types' +import { + DATA_TYPES_URI, + DT_VIEW_FRAME_LINE_COUNT, + parsePouUri, + RESOURCE_GLOBALS_URI, + SOFTMOTION_GLOBALS_URI, +} from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -56,18 +63,18 @@ import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_U * line counts that drift the moment a new field separator or * derivation lands on disk. */ -function findDataTypeAtLine(lspLine: number, dataTypes: PLCDataType[]): PLCDataType | null { +function findDataTypeAtLine( + lspLine: number, + dataTypes: PLCDataType[], +): { dataType: PLCDataType; lineInEntry: number } | null { // Synthesised doc: line 0 is `TYPE`, entries start at line 1. if (lspLine < 1) return null - const entries = serializeDataTypesToLines(dataTypes) const byName = new Map(dataTypes.map((dt) => [dt.name, dt])) - let cursor = 1 - for (const entry of entries) { - const span = entry.lines.length - if (lspLine >= cursor && lspLine < cursor + span) { - return byName.get(entry.name) ?? null + for (const [name, span] of dataTypeLineSpans(dataTypes)) { + if (lspLine >= span.start && lspLine < span.start + span.length) { + const dataType = byName.get(name) + return dataType ? { dataType, lineInEntry: lspLine - span.start } : null } - cursor += span } return null } @@ -103,6 +110,27 @@ function openDataTypeEditor(dataType: PLCDataType): boolean { return true } +/** + * Open the type's tab in code mode at a Monaco position in its `.dt` + * view. Falls back to the form tab when the code view isn't built into + * this release. + */ +function routeToDataTypeCodeView(dataType: PLCDataType, monacoLine: number, monacoColumn: number): boolean { + if (!openDataTypeEditor(dataType)) return false + if (!isDataTypeFilesEnabled()) return true + const { + editorActions: { setEditorCursor, updateModelStructureForName }, + } = openPLCStoreBase.getState() + updateModelStructureForName(dataType.name, { display: 'code' }) + setEditorCursor(dataType.name, { + lineNumber: monacoLine, + column: monacoColumn, + offset: 0, + target: 'data-type', + }) + return true +} + /** * Open the EtherCAT device (drive) editor for `deviceId` on `busName`, mirroring * the project-tree click path. Used to redirect go-to-definition on a SoftMotion @@ -206,10 +234,15 @@ export function redirectDefinitionToStore(loc: Location | LocationLink): boolean // branch the redirect would dead-end silently. if (target.uri === DATA_TYPES_URI) { const dataTypes = openPLCStoreBase.getState().project.data.dataTypes - const dt = findDataTypeAtLine(target.lineLsp, dataTypes) - if (!dt) return false - openDataTypeEditor(dt) - return true + const hit = findDataTypeAtLine(target.lineLsp, dataTypes) + if (!hit) return false + // Entry-relative line → `.dt` view line (its own `TYPE` frame sits + // above the entry) → Monaco's 1-indexed frame. + return routeToDataTypeCodeView( + hit.dataType, + hit.lineInEntry + DT_VIEW_FRAME_LINE_COUNT + 1, + target.characterLsp + 1, + ) } const parsed = parsePouUri(target.uri) diff --git a/src/frontend/services/st-lsp/index.ts b/src/frontend/services/st-lsp/index.ts index dd74eb89f..0c3ba077d 100644 --- a/src/frontend/services/st-lsp/index.ts +++ b/src/frontend/services/st-lsp/index.ts @@ -25,6 +25,7 @@ * disposed only at shutdown. */ +import type * as monaco from 'monaco-editor' import { type CompletionItem as LspCompletionItem, type CompletionList, @@ -35,6 +36,8 @@ import { } from 'vscode-languageserver-protocol' import { openPLCStoreBase } from '../../store' +import { isDataTypeFilesEnabled } from '../../utils/feature-flags' +import { dataTypeLineSpans, serializeDataTypeToText } from '../../utils/PLC/data-type-serializer' import { serializePouScopeForQuery } from '../../utils/PLC/pou-signature-serializer' import { getBodyLineOffset, @@ -45,10 +48,15 @@ import { suppressNoDefinitionFound, } from '../lsp-shared' import { parseScopedCompletionType } from './completion-type' +import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from './dtview-context' import { redirectDefinitionToStore } from './goto-definition-redirect' import { redirectToGraphicalPou } from './graphical-redirect' import { registerScopedQueryApi, type ScopedCompletionItem } from './scoped-query' import { + DATA_TYPES_URI, + DT_VIEW_FRAME_LINE_COUNT, + dtViewUri, + parseDtViewUri, parsePouUri, parsePouVarsUri, POU_DECLARATION_LINE_COUNT, @@ -85,6 +93,9 @@ interface LoadStlibBufferParams { * that's `pou://`; for graphical/hybrid POUs it's `stub://`. * Either way the declaration is a single line at LSP index 0, * so the offset is a constant 1. + * - `dtview://.dt` (per-type code view): remap to the + * aggregate datatypes document. Both frames open with a `TYPE` + * line, so the shift is the type's span start minus that frame. * - Anything else: pass through unchanged. */ function resolveStLspContext(modelUri: string): LspContext { @@ -95,9 +106,51 @@ function resolveStLspContext(modelUri: string): LspContext { const lspUri = isStLanguage ? pouUri(varsPou) : stubUri(varsPou) return { lspUri, lineOffset: POU_DECLARATION_LINE_COUNT } } + const dtName = parseDtViewUri(modelUri) + if (dtName !== null) { + const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName) + // A name absent from the document (unparseable `.dt` file) has no + // span to shift by. Pass the view's own URI through: the worker + // never indexed it, so every provider answers nothing rather than + // answering for whichever type happens to be first. + if (!span) return { lspUri: modelUri, lineOffset: 0 } + return { lspUri: DATA_TYPES_URI, lineOffset: dtViewLineOffset(span) } + } return { lspUri: modelUri, lineOffset: getBodyLineOffset(modelUri) } } +/** + * True while a `.dt` model's text still matches what the store would + * serialise for that type. An uncommitted edit breaks the match, and + * tokens resolved against the aggregate document would then be painted + * onto text they don't describe — wrong colours, and columns past the + * end of shorter lines. + */ +function dtViewMatchesStore(dtName: string, monacoApi: typeof monaco): boolean { + const dataType = openPLCStoreBase.getState().project.data.dataTypes.find((d) => d.name === dtName) + if (!dataType) return false + const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(dtName)) + if (!model) return false + return model.getValue() === serializeDataTypeToText(dataType) +} + +let lastDataTypeDiagnostics: Diagnostic[] = [] + +/** Fan the aggregate doc's diagnostics out to every mounted `.dt` model. */ +function applyDataTypeDiagnostics(monacoApi: typeof monaco, markerOwner: string, defaultSource: string): void { + for (const [name, span] of dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes)) { + const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(name)) + if (!model) continue + monacoApi.editor.setModelMarkers( + model, + markerOwner, + diagnosticsInSpan(lastDataTypeDiagnostics, span).map((d) => + lspDiagnosticToMonaco(d, monacoApi, dtViewLineOffset(span), defaultSource), + ), + ) + } +} + export function startStLsp(opts: StLspStartOptions): StLspService { const { stlibSource, monaco: monacoApi, workerUrlOverride, onCrash } = opts @@ -151,6 +204,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService { // VAR-block region; body editors keep everything from the // body line onwards. resolveSemanticTokensViewport: (lspUri, modelUri, lineOffset) => { + const dtName = parseDtViewUri(modelUri) + if (dtName !== null) { + const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName) + // Empty window while the buffer is uncommitted: no colours beats + // colours describing the previous text. + if (!span || !monacoApi || !dtViewMatchesStore(dtName, monacoApi)) { + return { startLine: 0, endLineExclusive: 0 } + } + return { ...dtViewWindow(span), outputStartLine: DT_VIEW_FRAME_LINE_COUNT } + } const isVarsView = parsePouVarsUri(modelUri) !== null return { startLine: lineOffset, @@ -162,6 +225,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService { markerOwner: MARKER_OWNER, diagnosticSource: DIAGNOSTIC_SOURCE, diagnosticsMirror: (params, ctx) => { + // Same trick for the aggregate datatypes doc: strucpp publishes + // against one URI, but each type renders in its own `.dt` view. + if (params.uri === DATA_TYPES_URI) { + // Replayed whenever a `.dt` model mounts later — the mirror is + // event-driven, so a model created after the last publish would + // otherwise show no markers at all. + lastDataTypeDiagnostics = params.diagnostics + applyDataTypeDiagnostics(ctx.monacoApi, ctx.markerOwner, ctx.defaultSource) + return + } // Mirror VAR-block diagnostics onto the variables-text editor // for the same POU (if mounted). The variables editor uses a // separate Monaco model under `pouvars://.st`; strucpp @@ -194,6 +267,32 @@ export function startStLsp(opts: StLspStartOptions): StLspService { ...(onCrash ? { onCrash } : {}), }) + // A `.dt` view's colours and markers come from the aggregate document, + // so a change there leaves the model's own text untouched and Monaco + // never re-queries on its own. Re-drive both from the store instead. + const dtViewSyncDisposables: Array<() => void> = [] + if (monacoApi && isDataTypeFilesEnabled()) { + const api = monacoApi + const hasDtViewModel = () => api.editor.getModels().some((m) => parseDtViewUri(m.uri.toString()) !== null) + dtViewSyncDisposables.push( + openPLCStoreBase.subscribe( + (state) => state.project.data.dataTypes, + () => { + // `refresh()` re-tokenises every ST model in the language, so it + // must not fire for a datatype edit made with no `.dt` view open. + if (!hasDtViewModel()) return + sharedService.refreshSemanticTokens() + applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE) + }, + ), + ) + const onModelAdded = api.editor.onDidCreateModel((model) => { + if (parseDtViewUri(model.uri.toString()) === null) return + applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE) + }) + dtViewSyncDisposables.push(() => onModelAdded.dispose()) + } + // --------------------------------------------------------------------------- // Scoped completion for the graphical (LD/FBD) editors. // @@ -435,6 +534,9 @@ export function startStLsp(opts: StLspStartOptions): StLspService { dispose() { registerScopedQueryApi(null) + for (const off of dtViewSyncDisposables) off() + dtViewSyncDisposables.length = 0 + lastDataTypeDiagnostics = [] sharedService.dispose() }, } diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index ee506b6d5..d667f8372 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -31,6 +31,13 @@ export const STUB_URI_AUTHORITY = 'stub' */ export const POUVARS_URI_AUTHORITY = 'pouvars' +/** + * URI scheme for the per-type `.dt` code view. Like `pouvars://`, the + * LSP never indexes it — requests remap onto `DATA_TYPES_URI` with the + * type's line span as the offset. + */ +export const DTVIEW_URI_AUTHORITY = 'dtview' + /** * URI for the synthesized `TYPE…END_TYPE` document carrying every * user-defined `PLCDataType` (structures, enumerations, arrays). @@ -139,7 +146,19 @@ export function pouVarsUri(name: string): string { export function parsePouVarsUri(uri: string): string | null { const match = new RegExp(`^${POU_URI_SCHEME}://${POUVARS_URI_AUTHORITY}/(.+)\\.st$`).exec(uri) if (!match) return null - return decodeURIComponent(match[1]) + return decodeUriSegment(match[1]) +} + +/** Make a synthetic in-memory URI for a data type's `.dt` code view. */ +export function dtViewUri(name: string): string { + return `${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/${encodeURIComponent(name)}.dt` +} + +/** If `uri` is a `dtview://` URI, return the data type name; otherwise null. */ +export function parseDtViewUri(uri: string): string | null { + const match = new RegExp(`^${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/(.+)\\.dt$`).exec(uri) + if (!match) return null + return decodeUriSegment(match[1]) } /** @@ -151,6 +170,28 @@ export function parsePouVarsUri(uri: string): string | null { */ export const POU_DECLARATION_LINE_COUNT = 1 +/** + * Lines the `.dt` code view renders before the type's own declaration — + * its local `TYPE` frame line. The aggregate document has the same + * frame, so a type's shift between the two is `span.start - DT_VIEW_FRAME_LINE_COUNT`. + */ +export const DT_VIEW_FRAME_LINE_COUNT = 1 + +/** + * Decode a name segment out of a synthetic URI, or `null` when the + * encoding is malformed. These parsers run on every model URI the LSP + * providers see, so a bare `decodeURIComponent` would turn a stray + * `%ZZ` into a thrown `URIError` and take hover / completion down with + * it for that model. + */ +function decodeUriSegment(segment: string): string | null { + try { + return decodeURIComponent(segment) + } catch { + return null + } +} + /** * Returns the POU name encoded in a URI minted by `pouUri` or * `stubUri`, or `null` if the URI doesn't match one of those @@ -160,8 +201,7 @@ export const POU_DECLARATION_LINE_COUNT = 1 export function parsePouUri(uri: string): { kind: 'pou' | 'stub'; name: string } | null { const match = new RegExp(`^${POU_URI_SCHEME}://(${POU_URI_AUTHORITY}|${STUB_URI_AUTHORITY})/(.+)\\.st$`).exec(uri) if (!match) return null - return { - kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub', - name: decodeURIComponent(match[2]), - } + const name = decodeUriSegment(match[2]) + if (name === null) return null + return { kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub', name } } diff --git a/src/frontend/store/__tests__/console-slice.test.ts b/src/frontend/store/__tests__/console-slice.test.ts index 5462b1a7f..1fe08ac4c 100644 --- a/src/frontend/store/__tests__/console-slice.test.ts +++ b/src/frontend/store/__tests__/console-slice.test.ts @@ -266,4 +266,81 @@ describe('createConsoleSlice', () => { expect(store.getState().logs).toHaveLength(1) expect(store.getState().filters.searchTerm).toBe('term') }) + + // ------------------------------------------------------------------------- + // Carriage-return redraws — a progress bar must stay on one line. + // ------------------------------------------------------------------------- + describe('addLog with a carriage-return redraw', () => { + const frame = (id: string, message: string, transient = true) => + [{ id, level: 'info' as const, message, transient }, { redraw: true }] as const + + it('overwrites the open line instead of appending', () => { + const { addLog } = store.getState().consoleActions + addLog(...frame('1', 'Downloading 10%')) + addLog(...frame('2', 'Downloading 60%')) + addLog(...frame('3', 'Downloading 100%')) + + expect(store.getState().logs).toHaveLength(1) + expect(store.getState().logs[0].message).toBe('Downloading 100%') + }) + + it('starts a new line once a newline has committed the previous one', () => { + const { addLog } = store.getState().consoleActions + addLog(...frame('1', 'Downloading A 50%')) + // The frame that arrived with a trailing newline: it still overwrites, + // but closes the line behind it. + addLog(...frame('2', 'Downloading A done', false)) + addLog(...frame('3', 'Downloading B 50%')) + + const { logs } = store.getState() + expect(logs).toHaveLength(2) + expect(logs[0].message).toBe('Downloading A done') + expect(logs[1].message).toBe('Downloading B 50%') + }) + + it('never overwrites an ordinary log line', () => { + const { addLog } = store.getState().consoleActions + addLog({ id: '1', level: 'info', message: 'Compiling...' }) + addLog(...frame('2', 'Downloading 10%')) + + expect(store.getState().logs).toHaveLength(2) + expect(store.getState().logs[0].message).toBe('Compiling...') + }) + + it('appends when the write is not a redraw, even above an open line', () => { + const { addLog } = store.getState().consoleActions + addLog(...frame('1', 'Downloading 10%')) + addLog({ id: '2', level: 'info', message: 'Unrelated output' }) + + expect(store.getState().logs).toHaveLength(2) + }) + }) + + // ------------------------------------------------------------------------- + // SGR colour is split off at ingestion so the rest of the app sees clean text. + // ------------------------------------------------------------------------- + describe('addLog with SGR colour', () => { + const ESC = '\u001B' + + it('stores clean text and keeps the styling in segments', () => { + store.getState().consoleActions.addLog({ + id: '1', + level: 'info', + message: `${ESC}[93marduino:avr${ESC}[0m 1.8.8`, + }) + + const [log] = store.getState().logs + expect(log.message).toBe('arduino:avr 1.8.8') + expect(log.segments?.map((s) => s.text).join('')).toBe('arduino:avr 1.8.8') + expect(log.segments?.[0].className).toContain('text-yellow-600') + }) + + it('leaves uncoloured logs exactly as they were — no segments allocated', () => { + store.getState().consoleActions.addLog({ id: '1', level: 'info', message: 'plain' }) + + const [log] = store.getState().logs + expect(log.message).toBe('plain') + expect(log.segments).toBeUndefined() + }) + }) }) diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index d92bc5ee4..92b842c4f 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -155,6 +155,236 @@ describe('createDeviceSlice', () => { expect(store.getState().deviceActions).toBeDefined() expect(typeof store.getState().deviceActions.setAvailableOptions).toBe('function') }) + + it('has a disconnected serial connection', () => { + const store = makeStore() + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) + }) + }) + + // ----------------------------------------------------------------------- + // serial connection (D72 persistent link) + // ----------------------------------------------------------------------- + describe('serial connection', () => { + it('setDeviceConnectionStatus updates status and port', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: 'COM5', + transport: null, + debugTransport: null, + }) + }) + + it('setDeviceConnectionStatus leaves the port unchanged when omitted', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('connected') + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: 'COM5', + transport: null, + debugTransport: null, + }) + }) + + it('setDeviceConnectionStatus can explicitly clear the port with null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('error', null) + expect(store.getState().deviceConnection).toEqual({ + status: 'error', + port: null, + transport: null, + debugTransport: null, + }) + }) + + it('setDeviceConnectionStatus records both media when the manager reports them', () => { + // What `useDeviceConnectionMonitor` actually forwards. `debugTransport` is a + // separate fact from `transport`: the debug poll sizes its batches to the + // debug medium, and a v4 session (control over REST, debug over a WebSocket) + // has no control transport at all. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '192.168.0.9', null, 'websocket') + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: '192.168.0.9', + transport: null, + debugTransport: 'websocket', + }) + }) + + it('setDeviceConnectionStatus records a shared medium on both slots', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + expect(store.getState().deviceConnection).toMatchObject({ transport: 'rtu', debugTransport: 'rtu' }) + }) + + it('setDeviceConnectionStatus leaves the media untouched when they are omitted', () => { + // A status-only update (the optimistic 'connecting') must not wipe what the + // manager last reported. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + store.getState().deviceActions.setDeviceConnectionStatus('connecting') + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: '/dev/ttyACM0', + transport: 'rtu', + debugTransport: 'rtu', + }) + }) + + it('clearDeviceConnection resets to disconnected/null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) + }) + + it('clearDeviceConnection leaves licensing alone — the link and the entitlement are separate facts', () => { + // A link that drops and comes back does not change what the device is + // entitled to run. Clearing the licence here would make the badge blank on + // every reconnect and force a needless round trip to learn nothing new. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceLicense.report?.outcome).toEqual({ state: 'licensed', how: 'already-stored' }) + }) + }) + + // ----------------------------------------------------------------------- + // VPP licensing + // ----------------------------------------------------------------------- + describe('device licensing', () => { + const LICENSED = { + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed' as const, how: 'already-stored' as const }, + } + + it('starts idle with nothing known', () => { + // The state every non-licensable board stays in: nothing runs, so nothing is + // known, and the UI shows no licensing affordance at all. + expect(makeStore().getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('startDeviceLicenseCheck marks the call in flight', () => { + const store = makeStore() + store.getState().deviceActions.startDeviceLicenseCheck() + expect(store.getState().deviceLicense).toEqual({ phase: 'checking', report: null }) + }) + + it('startDeviceLicenseCheck KEEPS the last report instead of blanking it', () => { + // Blanking would make the badge flicker Licensed -> nothing -> Licensed on + // every refresh, and a refresh that failed would leave the UI knowing LESS + // than before it asked. `phase` is what says "asking". + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.startDeviceLicenseCheck() + expect(store.getState().deviceLicense).toEqual({ phase: 'checking', report: LICENSED }) + }) + + it('setDeviceLicenseReport lands the report and settles the phase', () => { + const store = makeStore() + store.getState().deviceActions.startDeviceLicenseCheck() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + expect(store.getState().deviceLicense).toEqual({ phase: 'done', report: LICENSED }) + }) + + it('preserves the outcome union verbatim, including the entitlement distinction', () => { + // The whole point of the union surviving to the store: the UI branches on + // `entitlementChecked` to decide between offering a purchase and offering a + // re-check. A store that flattened this to a boolean would lose it. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: false }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: false, + }) + + store.getState().deviceActions.setDeviceLicenseReport({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'unlicensed', + entitlementChecked: true, + backendReason: 'no active subscription', + }) + }) + + it('keeps a check-failed outcome distinct from unlicensed', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport({ + outcome: { state: 'check-failed', error: 'Activation request failed: 429' }, + }) + expect(store.getState().deviceLicense.report?.outcome).toEqual({ + state: 'check-failed', + error: 'Activation request failed: 429', + }) + expect(store.getState().deviceLicense.report?.deviceId).toBeUndefined() + }) + + it('clearDeviceLicense resets to idle/null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.clearDeviceLicense() + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('clearDeviceBoard change drops the licence — it was verified against the OLD board', () => { + // setDeviceBoard already wipes everything else that is board-specific + // (platform options, the pin-table row, vendor-screen data). A licence + // report is just as board-specific: it was verified against the previous + // board's deviceId and its VPP's productId. Kept across a switch, the badge + // asserts possession for hardware that is no longer selected, and the buy + // link is built from the NEW package id and the OLD device id. + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + + store.getState().deviceActions.setDeviceBoard('Raspberry Pi 4') + + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) + + it('leaves the licence alone when setDeviceBoard is called with the same board', () => { + // The device screen re-sets the board on several paths; only an actual + // change invalidates the licence. + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + + store.getState().deviceActions.setDeviceBoard('ESP8266 NodeMCU') + + expect(store.getState().deviceLicense.report).toEqual(LICENSED) + }) + + it('clearDeviceDefinitions clears licensing — a new project may select another board', () => { + // A "Licensed" badge carried across a project close would be an assertion + // about hardware that is not even connected. + const store = makeStore() + store.getState().deviceActions.setDeviceLicenseReport(LICENSED) + store.getState().deviceActions.clearDeviceDefinitions() + expect(store.getState().deviceLicense).toEqual({ phase: 'idle', report: null }) + }) }) // ----------------------------------------------------------------------- @@ -173,7 +403,7 @@ describe('createDeviceSlice', () => { it('sets available communication ports', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM3', address: '/dev/ttyUSB0' }] + const ports: CommunicationPort[] = [{ address: '/dev/ttyUSB0', manufacturer: 'FTDI' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) expect(store.getState().deviceAvailableOptions.availableCommunicationPorts).toEqual(ports) }) @@ -185,14 +415,14 @@ describe('createDeviceSlice', () => { ]) store.getState().deviceActions.setAvailableOptions({ availableBoards: boards }) store.getState().deviceActions.setAvailableOptions({ - availableCommunicationPorts: [{ name: 'COM1', address: '/dev/tty1' }], + availableCommunicationPorts: [{ address: '/dev/tty1' }], }) expect(store.getState().deviceAvailableOptions.availableBoards.size).toBe(1) }) it('does not overwrite ports when only boards given', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM1', address: '/dev/tty1' }] + const ports: CommunicationPort[] = [{ address: '/dev/tty1' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) store.getState().deviceActions.setAvailableOptions({ availableBoards: new Map(), @@ -304,6 +534,18 @@ describe('createDeviceSlice', () => { expect(rc.ethercatStatus).toBeNull() expect(rc.includeEthercatStatsInPolling).toBe(false) }) + + it('resets the serial connection', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceDefinitions() + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) + }) }) // ----------------------------------------------------------------------- @@ -1255,6 +1497,33 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setPlcSwitchPosition + // ----------------------------------------------------------------------- + describe('setPlcSwitchPosition', () => { + it('defaults to null — unknown, not "no gating"', () => { + // The start pre-check must be able to tell "the switch says RUN" from "this + // target has no switch / firmware too old to report one". Only 'stop' blocks + // a start; null must not, or a board without a switch is un-startable. + expect(makeStore().getState().runtimeConnection.switchPosition).toBeNull() + }) + + it('records the switch reading', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + expect(store.getState().runtimeConnection.switchPosition).toBe('stop') + store.getState().deviceActions.setPlcSwitchPosition('run') + expect(store.getState().runtimeConnection.switchPosition).toBe('run') + }) + + it('clears back to null on disconnect', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + store.getState().deviceActions.setPlcSwitchPosition(null) + expect(store.getState().runtimeConnection.switchPosition).toBeNull() + }) + }) + // ----------------------------------------------------------------------- // setSelectedDevice // ----------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 41de632aa..8aa250908 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -9,6 +9,8 @@ import type { PinUpdateResponse, RuntimeConnection, SelectedDevice, + DeviceConnection, + DeviceConnectionStatus, StoredCredentials, } from '../slices/device' @@ -95,6 +97,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -137,6 +140,7 @@ describe('Device slice types', () => { jwtToken: 'token', connectionStatus: 'connected', plcStatus: 'RUNNING', + switchPosition: 'run', ipAddress: '192.168.1.1', runtimeVersion: 'v4.1.9', selectedDevice: { @@ -178,6 +182,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -187,11 +192,33 @@ describe('Device slice types', () => { ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { status: 'disconnected', port: null, transport: null, debugTransport: null }, + deviceLicense: { phase: 'idle', report: null }, } expect(state.deviceAvailableOptions).toBeDefined() expect(state.deviceDefinitions).toBeDefined() expect(state.deviceUpdated).toBeDefined() expect(state.runtimeConnection).toBeDefined() + expect(state.deviceConnection).toBeDefined() + // Licensing is its own top-level key, not a field on the connection: the + // link being up and the device being entitled change for unrelated reasons. + expect(state.deviceLicense).toBeDefined() + }) + }) + + // ----------------------------------------------------------------------- + // DeviceConnection + // ----------------------------------------------------------------------- + describe('DeviceConnection', () => { + it('accepts every status', () => { + const statuses: DeviceConnectionStatus[] = ['disconnected', 'connecting', 'connected', 'error'] + const conns: DeviceConnection[] = statuses.map((status) => ({ + status, + port: status === 'connected' ? 'COM5' : null, + transport: status === 'connected' ? 'rtu' : null, + debugTransport: status === 'connected' ? 'rtu' : null, + })) + expect(conns).toHaveLength(4) }) }) diff --git a/src/frontend/store/__tests__/editor-slice.test.ts b/src/frontend/store/__tests__/editor-slice.test.ts index 4cf2c9e5f..8facf03dc 100644 --- a/src/frontend/store/__tests__/editor-slice.test.ts +++ b/src/frontend/store/__tests__/editor-slice.test.ts @@ -58,7 +58,7 @@ function makeDatatype(name: string): EditorModel { return { type: 'plc-datatype', meta: { name, derivation: 'structure' }, - structure: { selectedRow: '-1', description: '' }, + structure: { display: 'table', selectedRow: '-1', description: '' }, } } @@ -217,7 +217,11 @@ describe('editor slice', () => { a.setEditor(makeTextual('M')) a.updateModelVariablesForName('DT', { display: 'table', selectedRow: 1 }) const dtEditor = store.getState().editors.find((e) => e.meta.name === 'DT')! - expect(editorAs(dtEditor).structure.selectedRow).toBe('-1') + expect(editorAs(dtEditor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) }) }) @@ -281,12 +285,14 @@ describe('editor slice', () => { a.updateModelStructure({ selectedRow: 3, description: 'desc' }) expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', selectedRow: '3', description: 'desc', }) // undefined selectedRow → keeps; empty description → keeps (falsy) a.updateModelStructure({ description: '' }) expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', selectedRow: '3', description: 'desc', }) @@ -296,6 +302,77 @@ describe('editor slice', () => { store.getState().editorActions.updateModelStructure({ selectedRow: 1, description: 'x' }) expect(store.getState().editor.type).toBe('available') }) + + it('switches to code mode keeping the buffer, and back to table with defaults', () => { + const { editorActions: a } = store.getState() + const dt = makeDatatype('S') + a.addModel(dt) + a.setEditor(dt) + + a.updateModelStructure({ selectedRow: 2, description: 'desc' }) + a.updateModelStructure({ display: 'code', code: 'TYPE\nS : (A);\nEND_TYPE\n' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'code', + code: 'TYPE\nS : (A);\nEND_TYPE\n', + }) + + // no `code` on a code→code update keeps the existing buffer + a.updateModelStructure({ display: 'code' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'code', + code: 'TYPE\nS : (A);\nEND_TYPE\n', + }) + + // the table arm's fields don't survive a round trip through code mode + a.updateModelStructure({ display: 'table' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) + }) + }) + + describe('updateModelStructureForName', () => { + it('updates a non-active model', () => { + const { editorActions: a } = store.getState() + const active = makeDatatype('Active') + const background = makeDatatype('Background') + a.addModel(active) + a.addModel(background) + a.setEditor(active) + + a.updateModelStructureForName('Background', { display: 'code', code: 'raw' }) + + const stored = store.getState().editors.find((e) => e.meta.name === 'Background') + expect(stored && editorAs(stored).structure).toEqual({ display: 'code', code: 'raw' }) + expect(editorAs(store.getState().editor).structure).toEqual({ + display: 'table', + selectedRow: '-1', + description: '', + }) + }) + + it('updates the active model when the name matches', () => { + const { editorActions: a } = store.getState() + const dt = makeDatatype('S') + a.addModel(dt) + a.setEditor(dt) + + a.updateModelStructureForName('S', { display: 'code', code: 'raw' }) + expect(editorAs(store.getState().editor).structure).toEqual({ display: 'code', code: 'raw' }) + }) + + it('no-op for an unknown name or a non-datatype model', () => { + const { editorActions: a } = store.getState() + const textual = makeTextual('P') + a.addModel(textual) + a.setEditor(textual) + + a.updateModelStructureForName('missing', { display: 'code', code: 'raw' }) + a.updateModelStructureForName('P', { display: 'code', code: 'raw' }) + expect(store.getState().editor.type).toBe('plc-textual') + }) }) describe('updateModelLadder', () => { diff --git a/src/frontend/store/__tests__/flow-writeback.test.ts b/src/frontend/store/__tests__/flow-writeback.test.ts index 649595c8c..36c5d60e0 100644 --- a/src/frontend/store/__tests__/flow-writeback.test.ts +++ b/src/frontend/store/__tests__/flow-writeback.test.ts @@ -173,11 +173,13 @@ describe('flow write-back scheduler', () => { } as unknown as LadderFlowType) store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: true }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) scheduleFlowWriteBack(getState, 'Main', 'ld') vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) expect(ladderBody('Main')?.rungs).toHaveLength(0) expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + warn.mockRestore() }) }) @@ -210,13 +212,67 @@ describe('flow write-back scheduler', () => { expect(ladderBody('B')?.rungs).toHaveLength(1) }) - it('is a no-op when nothing is pending', () => { + it('leaves the other language untouched when scoped to one POU', () => { makeDirtyLadderPou('Main') + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.startFBDRung({ editorName: 'FbdMain' }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'FbdMain', updated: true }) + + expect(flushFlowWriteBacks(getState, 'Main')).toEqual([]) + expect(store.getState().fbdFlows.find((f) => f.name === 'FbdMain')?.updated).toBe(true) + }) + + it('writes back an updated flow that has no pending timer', () => { + makeDirtyLadderPou('Main') + + expect(flushFlowWriteBacks(getState)).toEqual([]) + expect(ladderBody('Main')?.rungs).toHaveLength(1) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(false) + }) + + it('is a no-op when no flow is marked updated', () => { + makeDirtyLadderPou('Main') + flushFlowWriteBacks(getState) const bodyBefore = ladderBody('Main') + expect(flushFlowWriteBacks(getState)).toEqual([]) + expect(ladderBody('Main')).toBe(bodyBefore) + }) + + it('reports the POU and leaves the body stale when the flow fails validation', () => { + makeDirtyLadderPou('Main') flushFlowWriteBacks(getState) + const bodyBefore = ladderBody('Main') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const flow = store.getState().ladderFlows.find((f) => f.name === 'Main') + store.getState().ladderFlowActions.setRungs({ + editorName: 'Main', + // `defaultBounds` is required by the schema — dropping it makes the flow invalid. + rungs: (flow?.rungs ?? []).map(({ defaultBounds: _defaultBounds, ...rung }) => rung) as never, + }) + expect(flushFlowWriteBacks(getState)).toEqual(['Main']) expect(ladderBody('Main')).toBe(bodyBefore) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('reports a failing FBD flow', () => { + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.startFBDRung({ editorName: 'FbdMain' }) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // `nodes` is required by the schema — dropping it makes the flow invalid. + store.getState().fbdFlowActions.setRung({ + editorName: 'FbdMain', + rung: { comment: '', edges: [], selectedNodes: [] } as never, + }) + + expect(flushFlowWriteBacks(getState)).toEqual(['FbdMain']) + expect(warn).toHaveBeenCalled() + warn.mockRestore() }) }) diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index c965e69bf..008a520be 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -21,6 +21,7 @@ import type { S7CommDataBlock, } from '../../../middleware/shared/ports/types' import { generateIecVariablesToString } from '../../utils/generate-iec-variables-to-string' +import { serializeDataTypeToText } from '../../utils/PLC/data-type-serializer' import { createConsoleSlice } from '../slices/console' import { createDeviceSlice } from '../slices/device' import { createEditorSlice } from '../slices/editor' @@ -47,6 +48,19 @@ function makeStore() { // Helpers // --------------------------------------------------------------------------- +function openDatatypeInCodeMode(store: ReturnType, name: string, code: string) { + store.getState().editorActions.addModel({ + type: 'plc-datatype', + meta: { name, derivation: 'enumerated' }, + structure: { display: 'code', code }, + }) +} + +function codeOf(store: ReturnType, name: string): string | undefined { + const model = store.getState().editors.find((e) => e.meta.name === name) + return model?.type === 'plc-datatype' && model.structure.display === 'code' ? model.structure.code : undefined +} + function makeVariable(name: string, cls: PLCVariable['class'] = 'local'): PLCVariable { return { name, @@ -1129,6 +1143,13 @@ describe('createProjectSlice', () => { expect(store.getState().project.data.dataTypes).toHaveLength(0) }) + it('queues the datatypes/.dt path for deletion', () => { + const dt: PLCDataType = { name: 'MyStruct', derivation: 'structure', variable: [] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.deleteDatatype('MyStruct') + expect(store.getState().pendingDeletions).toContain('datatypes/MyStruct.dt') + }) + it('does nothing when data type not found', () => { const dt: PLCDataType = { name: 'A', derivation: 'structure', variable: [] } store.getState().projectActions.createDatatype({ data: dt }) @@ -1137,6 +1158,163 @@ describe('createProjectSlice', () => { }) }) + describe('updateDatatypeName', () => { + it('renames the data type and queues the old .dt path for deletion', () => { + const dt: PLCDataType = { name: 'OldName', derivation: 'structure', variable: [] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.updateDatatypeName('OldName', 'NewName') + expect(store.getState().project.data.dataTypes[0].name).toBe('NewName') + expect(store.getState().pendingDeletions).toContain('datatypes/OldName.dt') + }) + + it('does nothing when the data type is not found', () => { + store.getState().projectActions.updateDatatypeName('Ghost', 'NewName') + expect(store.getState().project.data.dataTypes).toHaveLength(0) + expect(store.getState().pendingDeletions).toHaveLength(0) + }) + }) + + describe('propagateDatatypeRename', () => { + const directRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + }) + const arrayRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { + definition: 'array', + value: `ARRAY [0..4] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: [{ dimension: '0..4' }], + }, + }, + location: '', + documentation: '', + }) + + beforeEach(() => { + store.getState().projectActions.createPou({ + type: 'program', + data: { + language: 'st', + name: 'Main', + variables: [directRef('motor', 'MotorDef'), arrayRef('motors', 'motordef'), makeVariable('plain')], + body: makeBody(), + documentation: '', + }, + }) + store.getState().projectActions.setGlobalVariables({ + variables: [{ ...directRef('gMotor', 'MotorDef'), class: 'global' }, makeVariable('gPlain', 'global')], + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'MotorDef', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: { definition: 'user-data-type', value: 'MotorDef' } }, + { name: 'id', type: { definition: 'base-type', value: 'INT' } }, + ], + }, + }) + store.getState().projectActions.createDatatype({ + data: { + name: 'MotorBank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'MotorDef' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }, + }) + }) + + it('rewrites direct and array POU variable references (case-insensitive)', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const variables = store.getState().project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' }) + expect(variables[1].type).toEqual({ + definition: 'array', + value: 'ARRAY [0..4] OF DriveDef', + data: { + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + dimensions: [{ dimension: '0..4' }], + }, + }) + expect(variables[2].type).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('rewrites global variable references', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const globals = store.getState().project.data.configurations.resource.globalVariables + expect(globals[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' }) + expect(globals[1].type).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('rewrites other data types and leaves the renamed type entry itself alone', () => { + store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef') + + const dataTypes = store.getState().project.data.dataTypes + // The type's own entry is updateDatatypeName's job. + expect(dataTypes[0].name).toBe('MotorDef') + expect(dataTypes[1]).toEqual({ + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: { definition: 'user-data-type', value: 'DriveDef' } }, + { name: 'id', type: { definition: 'base-type', value: 'INT' } }, + ], + }) + expect(dataTypes[2]).toEqual({ + name: 'MotorBank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + }) + + it('is a no-op when nothing references the type', () => { + const before = store.getState().project + store.getState().projectActions.propagateDatatypeRename('Ghost', 'Phantom') + const after = store.getState().project + + expect(after.data.pous).toEqual(before.data.pous) + expect(after.data.configurations.resource.globalVariables).toEqual( + before.data.configurations.resource.globalVariables, + ) + expect(after.data.dataTypes).toEqual(before.data.dataTypes) + }) + }) + + describe('setUnparsedDataTypeFiles', () => { + it('replaces the stashed raw .dt files', () => { + const raw = [{ relativePath: 'datatypes/Broken.dt', content: 'TYPE garbage' }] + store.getState().projectActions.setUnparsedDataTypeFiles(raw) + expect(store.getState().unparsedDataTypeFiles).toEqual(raw) + store.getState().projectActions.setUnparsedDataTypeFiles([]) + expect(store.getState().unparsedDataTypeFiles).toEqual([]) + }) + + it('is reset by clearProjects', () => { + store.getState().projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/X.dt', content: 'x' }]) + store.getState().projectActions.clearProjects() + expect(store.getState().unparsedDataTypeFiles).toEqual([]) + }) + }) + describe('updateDatatype', () => { it('replaces data type by name', () => { const dt: PLCDataType = { name: 'MyStruct', derivation: 'structure', variable: [] } @@ -1269,6 +1447,104 @@ describe('createProjectSlice', () => { store.getState().projectActions.applyDatatypeSnapshot('NonExistent', replacement) expect(store.getState().project.data.dataTypes[0].name).toBe('A') }) + + it('regenerates the code buffer of a type shown in code mode', () => { + const dt: PLCDataType = { name: 'A', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + openDatatypeInCodeMode(store, 'A', 'stale text') + + store.getState().projectActions.applyDatatypeSnapshot('A', { + name: 'A', + derivation: 'enumerated', + values: [{ description: 'BLUE' }], + }) + + expect(codeOf(store, 'A')).toContain('BLUE') + }) + }) + + // ------------------------------------------------------------------------- + // Data-type code view + // ------------------------------------------------------------------------- + describe('reconcileDatatypeText', () => { + const enumType: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + + it('is a no-op when the type is not shown in code mode', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + + it('is a no-op when the buffer still matches the serialized type', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', serializeDataTypeToText(enumType)) + + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + + it('is a no-op when the type no longer exists', () => { + openDatatypeInCodeMode(store, 'Ghost', 'TYPE\nGhost : (RED);\nEND_TYPE\n') + expect(store.getState().projectActions.reconcileDatatypeText('Ghost').ok).toBe(true) + expect(store.getState().project.data.dataTypes).toHaveLength(0) + }) + + it('folds a diverged buffer back into the type', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', 'TYPE\nColors : (RED, GREEN);\nEND_TYPE\n') + + expect(store.getState().projectActions.reconcileDatatypeText('Colors').ok).toBe(true) + const updated = store.getState().project.data.dataTypes[0] + expect(updated.derivation === 'enumerated' && updated.values).toEqual([ + { description: 'RED' }, + { description: 'GREEN' }, + ]) + }) + + it('refuses when the buffer does not parse', () => { + store.getState().projectActions.createDatatype({ data: enumType }) + openDatatypeInCodeMode(store, 'Colors', 'TYPE\nnot a declaration\nEND_TYPE\n') + + const response = store.getState().projectActions.reconcileDatatypeText('Colors') + expect(response.ok).toBe(false) + expect(response.title).toBe('Data type text is invalid') + expect(store.getState().project.data.dataTypes[0]).toEqual(enumType) + }) + }) + + describe('regenerateDatatypeText', () => { + it('re-serializes the type into its buffer', () => { + const dt: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + openDatatypeInCodeMode(store, 'Colors', 'stale text') + + store.getState().projectActions.regenerateDatatypeText('Colors') + expect(codeOf(store, 'Colors')).toBe(serializeDataTypeToText(dt)) + }) + + it('does nothing when the type is not shown in code mode', () => { + const dt: PLCDataType = { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }] } + store.getState().projectActions.createDatatype({ data: dt }) + store.getState().projectActions.regenerateDatatypeText('Colors') + expect(store.getState().editors).toHaveLength(0) + }) + + it('does nothing when the type no longer exists', () => { + openDatatypeInCodeMode(store, 'Ghost', 'raw') + store.getState().projectActions.regenerateDatatypeText('Ghost') + expect(codeOf(store, 'Ghost')).toBe('raw') + }) + }) + + describe('removeUnparsedDataTypeFile', () => { + it('drops only the matching path', () => { + store.getState().projectActions.setUnparsedDataTypeFiles([ + { relativePath: 'datatypes/A.dt', content: 'a' }, + { relativePath: 'datatypes/B.dt', content: 'b' }, + ]) + store.getState().projectActions.removeUnparsedDataTypeFile('datatypes/A.dt') + expect(store.getState().unparsedDataTypeFiles).toEqual([{ relativePath: 'datatypes/B.dt', content: 'b' }]) + }) }) // ========================================================================= diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 0b7720d91..3a2779100 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -8,6 +8,7 @@ import { createEditorSlice } from '../slices/editor/slice' import { createFBDFlowSlice } from '../slices/fbd/slice' import { createFileSlice } from '../slices/file/slice' import { createHistorySlice } from '../slices/history/slice' +import type { LadderFlowType } from '../slices/ladder' import { createLadderFlowSlice } from '../slices/ladder/slice' import { createLibrarySlice } from '../slices/library/slice' import { createModalSlice } from '../slices/modal/slice' @@ -468,6 +469,24 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.dataTypes[0].derivation).toBe('structure') }) + it('rejects a name owned by an unreadable .dt file', () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = store.getState().datatypeActions.create({ name: 'Ghost', derivation: 'structure' }) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + expect(store.getState().project.data.dataTypes).toHaveLength(0) + }) + + it('rejects a name differing only by case (one file per name on case-folding disks)', () => { + store.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' }) + const result = store.getState().datatypeActions.create({ name: 'motor', derivation: 'structure' }) + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type already exists') + expect(store.getState().project.data.dataTypes).toHaveLength(1) + }) + it('creates an enumerated data type', () => { const result = store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) expect(result.ok).toBe(true) @@ -542,8 +561,8 @@ describe('createSharedSlice', () => { store.getState().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) }) - it('renames data type across all slices', () => { - const result = store.getState().datatypeActions.rename('OldDT', 'NewDT') + it('renames data type across all slices', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(result).toEqual({ ok: true }) const state = store.getState() @@ -553,28 +572,380 @@ describe('createSharedSlice', () => { expect(state.tabs[0].name).toBe('NewDT') }) - it('returns error when new name already exists', () => { + it('queues the old datatypes/.dt path for deletion', async () => { + await store.getState().datatypeActions.rename('OldDT', 'NewDT') + expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') + }) + + it('folds pending code-view edits in and rewrites the buffer under the new name', async () => { + store.getState().editorActions.updateModelStructureForName('OldDT', { + display: 'code', + code: 'TYPE\nOldDT : STRUCT\nspeed : INT;\nEND_STRUCT;\nEND_TYPE\n', + }) + + expect((await store.getState().datatypeActions.rename('OldDT', 'NewDT')).ok).toBe(true) + + const renamed = store.getState().project.data.dataTypes[0] + expect(renamed.name).toBe('NewDT') + expect(renamed.derivation === 'structure' && renamed.variable.map((v) => v.name)).toEqual(['speed']) + + const model = store.getState().editor + expect(model.type === 'plc-datatype' && model.structure.display === 'code' && model.structure.code).toContain( + 'NewDT : STRUCT', + ) + }) + + it('refuses the rename while the code view holds invalid text', async () => { + store + .getState() + .editorActions.updateModelStructureForName('OldDT', { display: 'code', code: 'TYPE\ngarbage\nEND_TYPE\n' }) + + const result = await store.getState().datatypeActions.rename('OldDT', 'NewDT') + expect(result.ok).toBe(false) + expect(store.getState().project.data.dataTypes[0].name).toBe('OldDT') + expect(store.getState().pendingDeletions).not.toContain('datatypes/OldDT.dt') + }) + + it('rejects a name owned by an unreadable .dt file (case-insensitive)', async () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = await store.getState().datatypeActions.rename('OldDT', 'ghost') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + }) + + it('rejects a rename that collides with another type only by case', async () => { + store.getState().datatypeActions.create({ name: 'Motor', derivation: 'array' }) + const result = await store.getState().datatypeActions.rename('OldDT', 'motor') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Motor']) + }) + + it('rejects a case-only rename of the type itself', async () => { + // Writing olddt.dt then deleting OldDT.dt is the same file + // where the filesystem folds case — the type would vanish. + const result = await store.getState().datatypeActions.rename('OldDT', 'olddt') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + }) + + it('allows a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') + expect(result.ok).toBe(true) + }) + + it('returns error when new name already exists', async () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) - const result = store.getState().datatypeActions.rename('OldDT', 'Existing') + const result = await store.getState().datatypeActions.rename('OldDT', 'Existing') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) - it('returns error when data type not found', () => { - const result = store.getState().datatypeActions.rename('NonExistent', 'NewName') + it('returns error when data type not found', async () => { + const result = await store.getState().datatypeActions.rename('NonExistent', 'NewName') expect(result.ok).toBe(false) expect(result.message).toBe('Data type not found') }) - it('updates editor name when renaming the current editor', () => { + it('updates editor name when renaming the current editor', async () => { // OldDT is the current editor expect(store.getState().editor.meta.name).toBe('OldDT') - const result = store.getState().datatypeActions.rename('OldDT', 'RenamedDT') + const result = await store.getState().datatypeActions.rename('OldDT', 'RenamedDT') expect(result.ok).toBe(true) expect(store.getState().editor.meta.name).toBe('RenamedDT') }) }) + // ----------------------------------------------------------------------- + // rename with references (impact modal) + // ----------------------------------------------------------------------- + describe('rename with references (impact modal)', () => { + const directRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { definition: 'user-data-type', value: typeName }, + location: '', + documentation: '', + }) + const arrayRef = (name: string, typeName: string): PLCVariable => ({ + name, + class: 'local', + type: { + definition: 'array', + value: `ARRAY [0..4] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: [{ dimension: '0..4' }], + }, + }, + location: '', + documentation: '', + }) + + beforeEach(() => { + store.getState().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) + store.getState().datatypeActions.create({ name: 'Chassis', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Chassis', { + name: 'Chassis', + derivation: 'structure', + variable: [{ name: 'front', type: { definition: 'user-data-type', value: 'OldDT' } }], + }) + store.getState().datatypeActions.create({ name: 'Bank', derivation: 'array' }) + store.getState().projectActions.updateDatatype('Bank', { + name: 'Bank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'OldDT' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + store.getState().pouActions.create({ type: 'program', name: 'Main', language: 'st' }) + store.getState().projectActions.setPouVariables({ + pouName: 'Main', + variables: [directRef('motor', 'OldDT'), arrayRef('motors', 'olddt')], + }) + store.getState().projectActions.setGlobalVariables({ + variables: [{ ...directRef('gMotor', 'OldDT'), class: 'global' }], + }) + store.getState().fileActions.addFile({ name: 'Resource', type: 'resource', filePath: 'Resource' }) + store.getState().fileActions.setAllToSaved() + store.getState().workspaceActions.setEditingState('saved') + }) + + // The variables view of a POU model — active editor or stored model, + // same preference order the propagation sync uses. + const getVariableView = (name: string) => { + const state = store.getState() + const model = state.editor.meta.name === name ? state.editor : state.editorActions.getEditorFromEditors(name) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) return undefined + return model.variable + } + const getCodeBuffer = (name: string) => { + const view = getVariableView(name) + return view?.display === 'code' ? view.code : undefined + } + + it('parks a pending rename and leaves the store untouched until answered', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + + const pending = store.getState().pendingDatatypeRename + expect(pending?.oldName).toBe('OldDT') + expect(pending?.newName).toBe('NewDT') + expect(pending?.impact.totalReferences).toBe(5) + expect(Array.from(pending?.impact.byPou.entries() ?? [])).toEqual([ + ['Main', 2], + ['Global Variables', 1], + ['Chassis', 1], + ['Bank', 1], + ]) + // Nothing renamed while the modal is open. + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Chassis', 'Bank']) + + store.getState().datatypeActions.respondToPendingRename(true) + await promise + }) + + it('confirm propagates every reference shape, then renames', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + + const state = store.getState() + const variables = state.project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'NewDT' }) + expect(variables[1].type).toEqual({ + definition: 'array', + value: 'ARRAY [0..4] OF NewDT', + data: { + baseType: { definition: 'user-data-type', value: 'NewDT' }, + dimensions: [{ dimension: '0..4' }], + }, + }) + expect(state.project.data.configurations.resource.globalVariables[0].type).toEqual({ + definition: 'user-data-type', + value: 'NewDT', + }) + const chassis = state.project.data.dataTypes.find((d) => d.name === 'Chassis') + expect(chassis?.derivation === 'structure' && chassis.variable[0].type.value).toBe('NewDT') + const bank = state.project.data.dataTypes.find((d) => d.name === 'Bank') + expect(bank?.derivation === 'array' && bank.baseType.value).toBe('NewDT') + + // The type itself was renamed and the old file queued for deletion. + expect(state.project.data.dataTypes.map((d) => d.name)).toEqual(['NewDT', 'Chassis', 'Bank']) + expect(state.pendingDeletions).toContain('datatypes/OldDT.dt') + }) + + it('confirm flags every affected container file dirty', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const files = store.getState().files + expect(files['Main'].saved).toBe(false) + expect(files['Resource'].saved).toBe(false) + expect(files['Chassis'].saved).toBe(false) + expect(files['Bank'].saved).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('cancel leaves the store completely untouched', async () => { + const before = store.getState() + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(false) + const result = await promise + + expect(result).toEqual({ ok: false, cancelled: true, message: 'Rename cancelled' }) + const after = store.getState() + expect(after.pendingDatatypeRename).toBeNull() + // Same object references — no slice was written at all. + expect(after.project).toBe(before.project) + expect(after.files).toBe(before.files) + expect(after.tabs).toBe(before.tabs) + expect(after.pendingDeletions).toBe(before.pendingDeletions) + }) + + it('skips the modal when nothing references the type', async () => { + store.getState().datatypeActions.create({ name: 'Lonely', derivation: 'enumerated' }) + const result = await store.getState().datatypeActions.rename('Lonely', 'Hermit') + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('Hermit') + }) + + it('skips the reference scan on a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') + + expect(result.ok).toBe(true) + expect(store.getState().pendingDatatypeRename).toBeNull() + // References untouched — there was nothing to propagate. + const variables = store.getState().project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type.value).toBe('OldDT') + }) + + it('rejects an invalid new name before opening the modal', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'bad name') + + expect(result.ok).toBe(false) + expect(store.getState().pendingDatatypeRename).toBeNull() + }) + + it('rejects a second rename while one is awaiting confirmation', async () => { + const first = store.getState().datatypeActions.rename('OldDT', 'NewDT') + const pendingBefore = store.getState().pendingDatatypeRename + + store.getState().datatypeActions.create({ name: 'Other', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Other', { + name: 'Other', + derivation: 'structure', + variable: [{ name: 'f', type: { definition: 'user-data-type', value: 'Chassis' } }], + }) + const second = await store.getState().datatypeActions.rename('Chassis', 'Frame') + + expect(second.ok).toBe(false) + expect(second.message).toBe('Another data type rename is awaiting confirmation') + // The first request's resolver is untouched and still completes. + expect(store.getState().pendingDatatypeRename).toBe(pendingBefore) + store.getState().datatypeActions.respondToPendingRename(true) + const result = await first + expect(result).toEqual({ ok: true }) + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('NewDT') + }) + + it('respondToPendingRename without a pending request is a no-op', () => { + const before = store.getState() + store.getState().datatypeActions.respondToPendingRename(true) + expect(store.getState()).toBe(before) + }) + + it('regenerates the code-mode variables buffer when the affected POU is the active editor', async () => { + // pouActions.create left Main as the active editor. + expect(store.getState().editor.meta.name).toBe('Main') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('regenerates the buffer of a stored (non-active) POU model', async () => { + // Make something else the active editor so Main only lives in editors[]. + store.getState().datatypeActions.create({ name: 'Scratch', derivation: 'structure' }) + expect(store.getState().editor.meta.name).toBe('Scratch') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('leaves table-mode variable views alone', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + expect(getVariableView('Main')?.display).toBe('table') + }) + + it('regenerates the .dt code buffer of an affected data type', async () => { + store.getState().editorActions.updateModelStructureForName('Chassis', { + display: 'code', + code: 'TYPE\n Chassis : STRUCT\n front : OldDT;\n END_STRUCT;\nEND_TYPE\n', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const model = store.getState().editorActions.getEditorFromEditors('Chassis') + const code = + model?.type === 'plc-datatype' && model.structure.display === 'code' ? model.structure.code : undefined + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('tolerates an affected POU without an editor model', async () => { + store.getState().projectActions.createPou({ + type: 'program', + data: { + language: 'st', + name: 'Headless', + variables: [directRef('m', 'OldDT')], + body: { language: 'st', value: '' }, + documentation: '', + }, + }) + store.getState().fileActions.addFile({ name: 'Headless', type: 'program', filePath: 'Headless' }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + const headless = store.getState().project.data.pous.find((p) => p.name === 'Headless') + expect(headless?.interface?.variables[0].type.value).toBe('NewDT') + }) + }) + // ----------------------------------------------------------------------- // duplicate // ----------------------------------------------------------------------- @@ -606,6 +977,21 @@ describe('createSharedSlice', () => { expect(result.message).toBe('Data type not found') }) + it('rejects a duplicate name differing only by case', () => { + const result = store.getState().datatypeActions.duplicate('SourceDT', 'sourcedt') + expect(result.ok).toBe(false) + expect(result.message).toBe('Data type name already exists') + }) + + it('rejects a duplicate name owned by an unreadable .dt file', () => { + store + .getState() + .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) + const result = store.getState().datatypeActions.duplicate('SourceDT', 'Ghost') + expect(result.ok).toBe(false) + expect(result.message).toMatch(/could not be read/) + }) + it('returns error when new name already exists', () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'structure' }) const result = store.getState().datatypeActions.duplicate('SourceDT', 'Existing') @@ -1078,6 +1464,23 @@ describe('createSharedSlice', () => { }) }) + // ----------------------------------------------------------------------- + // renameHistory + // ----------------------------------------------------------------------- + describe('renameHistory', () => { + it('moves the undo/redo bucket to the new key', () => { + store.getState().snapshotActions.pushToHistory('Old', snapshot1) + store.getState().snapshotActions.renameHistory('Old', 'New') + expect(store.getState().undoRedo['Old']).toBeUndefined() + expect(store.getState().undoRedo['New'].past).toEqual([snapshot1]) + }) + + it('does nothing when the old key has no history', () => { + store.getState().snapshotActions.renameHistory('Missing', 'New') + expect(store.getState().undoRedo['New']).toBeUndefined() + }) + }) + // ----------------------------------------------------------------------- // undo // ----------------------------------------------------------------------- @@ -1104,6 +1507,34 @@ describe('createSharedSlice', () => { expect(store.getState().undoRedo['Main'].past).toHaveLength(0) }) + it('does nothing when the POU flow fails its write-back', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.getState().pouActions.create({ type: 'program', name: 'Graphical', language: 'ld' }) + store.getState().snapshotActions.pushToHistory('Graphical', snapshot1) + // `rungs` entries without `defaultBounds` fail the ladder schema, so the + // body stays stale and a snapshot here would pair it with a fresh flow. + store.getState().ladderFlowActions.addLadderFlow({ + name: 'Graphical', + updated: true, + rungs: [{ id: 'r1', comment: '', nodes: [], edges: [] }], + } as unknown as LadderFlowType) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Graphical', updated: true }) + + // `false` is what drives the "History unavailable" toast in the UI. + expect(store.getState().snapshotActions.undo('Graphical')).toBe(false) + expect(store.getState().snapshotActions.redo('Graphical')).toBe(false) + + expect(store.getState().undoRedo['Graphical'].past).toHaveLength(1) + expect(store.getState().undoRedo['Graphical'].future).toHaveLength(0) + warn.mockRestore() + }) + + it('reports success when there is simply nothing to undo', () => { + expect(store.getState().snapshotActions.undo('Main')).toBe(true) + store.getState().snapshotActions.pushToHistory('Main', snapshot1) + expect(store.getState().snapshotActions.undo('Main')).toBe(true) + }) + it('undo falls back to empty array when POU has no interface', () => { // Manually strip the POU interface to test the ?? [] fallback const pous = store.getState().project.data.pous.map((p) => { @@ -1434,6 +1865,147 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.pous.find((p) => p.name === 'Main')!.body.value).toBe('v2') }) }) + + // ----------------------------------------------------------------------- + // undo / redo for data types + // ----------------------------------------------------------------------- + describe('undo/redo for data types', () => { + const edited = { + name: 'Colors', + derivation: 'enumerated' as const, + values: [{ description: 'RED' }], + initialValue: '', + } + + beforeEach(() => { + store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) + }) + + const getColorsDataType = () => { + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors') + if (!dataType) throw new Error('Colors data type missing') + return dataType + } + + it('undo restores the snapshot data type and moves the current entry to future', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(initial) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('redo reapplies the undone data type edit', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.future).toHaveLength(0) + expect(history.past[0].dataTypes).toEqual([initial]) + }) + + it('undo marks the data type file saved when history returns to the saved depth', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().fileActions.updateFile({ name: 'Colors', saved: false }) + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(true) + }) + + it('undo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [edited] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('redo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.undo('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.redo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('undo leaves the data type untouched when the snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null }) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('rename keeps the history and undo restores content under the new name', async () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect((await store.getState().datatypeActions.rename('Colors', 'Palette')).ok).toBe(true) + expect(store.getState().undoRedo['Colors']).toBeUndefined() + expect(store.getState().undoRedo['Palette'].past).toHaveLength(1) + + store.getState().snapshotActions.undo('Palette') + + // Content reverts, but the name stays pinned to the current key so + // tabs/files/editors (already rekeyed by the rename) don't desync. + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Palette') + expect(dataType).toEqual({ ...initial, name: 'Palette' }) + }) + + it('redo leaves the data type untouched when the future snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.setState({ + undoRedo: { + Colors: { + past: [], + future: [{ variables: [], body: null }], + savedAtDepth: null, + }, + }, + }) + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.past[0].dataTypes).toEqual([edited]) + }) + }) }) // ========================================================================= @@ -1715,6 +2287,59 @@ describe('createSharedSlice', () => { expect(state.files['Configuration']).toBeDefined() }) + it('pre-opens an unreadable .dt file as a code-mode tab without stealing focus', () => { + const data = { + ...makeMinimalProjectResponse(), + unparsedDataTypeFiles: [ + { relativePath: 'datatypes/Broken.dt', content: 'TYPE\nBroken : STRUCT\ngarbage\nEND_TYPE\n' }, + // No name to derive — skipped rather than registered under ''. + { relativePath: '', content: 'orphan' }, + ], + } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + + const state = store.getState() + expect(state.unparsedDataTypeFiles).toHaveLength(2) + expect(state.files['Broken']).toEqual({ type: 'data-type', filePath: 'Broken', saved: true }) + expect(state.files['']).toBeUndefined() + expect(state.tabs.map((tab) => tab.name)).toEqual(['main', 'Broken']) + // Focus stays on the auto-opened POU. + expect(state.selectedTab).toBe('main') + + const model = state.editors.find((editor) => editor.meta.name === 'Broken') + expect(model?.type === 'plc-datatype' && model.meta.derivation).toBe('structure') + expect(model?.type === 'plc-datatype' && model.structure).toEqual({ + display: 'code', + code: 'TYPE\nBroken : STRUCT\ngarbage\nEND_TYPE\n', + }) + }) + + it('does not let an unreadable .dt displace a POU or a parsed type of the same name', () => { + const data = makeMinimalProjectResponse() + data.projectData.dataTypes = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + ] as typeof data.projectData.dataTypes + const withCollisions = { + ...data, + unparsedDataTypeFiles: [ + // Case-insensitive: the filesystem folds case, the registry doesn't. + { relativePath: 'datatypes/MAIN.dt', content: 'TYPE\ngarbage\nEND_TYPE\n' }, + { relativePath: 'datatypes/colors.dt', content: 'TYPE\ngarbage\nEND_TYPE\n' }, + ], + } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(withCollisions) + + const state = store.getState() + // The POU keeps its own registry entry, tab and model. + expect(state.files['main'].type).toBe('program') + expect(state.tabs.map((tab) => tab.name)).toEqual(['main']) + expect(state.editors.every((editor) => editor.type !== 'plc-datatype')).toBe(true) + expect(state.files['MAIN']).toBeUndefined() + expect(state.files['colors']).toBeUndefined() + // Still preserved, so the next save echoes both files back verbatim. + expect(state.unparsedDataTypeFiles).toHaveLength(2) + }) + it('logs warnings to console when present', () => { const data = { ...makeMinimalProjectResponse(), @@ -2158,6 +2783,16 @@ describe('createSharedSlice', () => { expect(store.getState().undoRedo['P1'].savedAtDepth).toBe(1) expect(store.getState().undoRedo['P2'].savedAtDepth).toBe(2) }) + + it('skips the excluded POUs', () => { + store.getState().snapshotActions.pushToHistory('P1', { variables: [], body: 'v1' }) + store.getState().snapshotActions.pushToHistory('P2', { variables: [], body: 'v1' }) + + store.getState().snapshotActions.markAllSaved(['P2']) + + expect(store.getState().undoRedo['P1'].savedAtDepth).toBe(1) + expect(store.getState().undoRedo['P2'].savedAtDepth).toBe(0) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/shared-utils.test.ts b/src/frontend/store/__tests__/shared-utils.test.ts index 7a7a8b7de..580daf245 100644 --- a/src/frontend/store/__tests__/shared-utils.test.ts +++ b/src/frontend/store/__tests__/shared-utils.test.ts @@ -6,6 +6,7 @@ import { createEditorObjectForServer, createPouObject, createTabObject, + guessDatatypeDerivation, } from '../slices/shared/utils' describe('shared/utils', () => { @@ -236,7 +237,7 @@ describe('shared/utils', () => { expect(result).toEqual({ type: 'plc-datatype', meta: { name: 'IntArray', derivation: 'array' }, - structure: { description: '', selectedRow: '' }, + structure: { display: 'table', description: '', selectedRow: '-1' }, }) }) @@ -257,6 +258,23 @@ describe('shared/utils', () => { }) }) + // ------------------------------------------------------------------------- + // guessDatatypeDerivation + // ------------------------------------------------------------------------- + describe('guessDatatypeDerivation', () => { + it('detects a structure', () => { + expect(guessDatatypeDerivation('TYPE\nPoint : STRUCT\nx : INT;\nEND_STRUCT;\nEND_TYPE')).toBe('structure') + }) + + it('detects an array', () => { + expect(guessDatatypeDerivation('TYPE\nBuf : ARRAY [0..9] OF INT;\nEND_TYPE')).toBe('array') + }) + + it('falls back to enumerated', () => { + expect(guessDatatypeDerivation('TYPE\nColors : (RED, GREEN);\nEND_TYPE')).toBe('enumerated') + }) + }) + // ------------------------------------------------------------------------- // createEditorObjectForServer // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/tabs-utils.test.ts b/src/frontend/store/__tests__/tabs-utils.test.ts index 085be585b..926d2a6c8 100644 --- a/src/frontend/store/__tests__/tabs-utils.test.ts +++ b/src/frontend/store/__tests__/tabs-utils.test.ts @@ -94,7 +94,7 @@ describe('tabs/utils', () => { expect(result.type).toBe('plc-datatype') if (result.type === 'plc-datatype') { expect(result.meta.derivation).toBe('enumerated') - expect(result.structure).toEqual({ selectedRow: '-1', description: '' }) + expect(result.structure).toEqual({ display: 'table', selectedRow: '-1', description: '' }) } }) diff --git a/src/frontend/store/__tests__/webrtc-slice.test.ts b/src/frontend/store/__tests__/webrtc-slice.test.ts index 5dbfab104..1b0537cf9 100644 --- a/src/frontend/store/__tests__/webrtc-slice.test.ts +++ b/src/frontend/store/__tests__/webrtc-slice.test.ts @@ -28,7 +28,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) @@ -128,16 +128,16 @@ describe('createWebRTCSlice', () => { }) }) - describe('setDebugTransport', () => { + describe('setDebugChannelOpen', () => { it('switches transport to webrtc', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - expect(store.getState().session.debugTransport).toBe('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) + expect(store.getState().session.debugChannelOpen).toBe(true) }) it('switches transport back to http', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - store.getState().webrtcActions.setDebugTransport('http') - expect(store.getState().session.debugTransport).toBe('http') + store.getState().webrtcActions.setDebugChannelOpen(true) + store.getState().webrtcActions.setDebugChannelOpen(false) + expect(store.getState().session.debugChannelOpen).toBe(false) }) }) @@ -167,7 +167,7 @@ describe('createWebRTCSlice', () => { it('does not modify sessionId, reconnectAttempt, or debugTransport', () => { store.getState().webrtcActions.setSessionId('existing-session') store.getState().webrtcActions.setReconnectAttempt(2) - store.getState().webrtcActions.setDebugTransport('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) store.getState().webrtcActions.startSession({ deviceId: 'dev-1', @@ -178,7 +178,7 @@ describe('createWebRTCSlice', () => { const { session } = store.getState() expect(session.sessionId).toBe('existing-session') expect(session.reconnectAttempt).toBe(2) - expect(session.debugTransport).toBe('webrtc') + expect(session.debugChannelOpen).toBe(true) }) }) @@ -193,7 +193,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('some error') webrtcActions.setReconnectAttempt(3) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.endSession() @@ -203,7 +203,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) it('preserves deviceId and deviceName after ending session', () => { @@ -230,7 +230,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('timeout') webrtcActions.setReconnectAttempt(5) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.reset() @@ -242,7 +242,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) }) diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index d8fedc7f0..15e92a432 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -61,7 +61,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -431,19 +430,6 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugMd5Mismatch).toBeNull() }) - it('setDebugConnectionType', () => { - expect(store.getState().workspace.debugConnectionType).toBeNull() - - store.getState().workspaceActions.setDebugConnectionType('websocket') - expect(store.getState().workspace.debugConnectionType).toBe('websocket') - - store.getState().workspaceActions.setDebugConnectionType('rtu') - expect(store.getState().workspace.debugConnectionType).toBe('rtu') - - store.getState().workspaceActions.setDebugConnectionType(null) - expect(store.getState().workspace.debugConnectionType).toBeNull() - }) - // ------------------------------------------------------------------------- // clearDebugState // ------------------------------------------------------------------------- @@ -482,7 +468,6 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebugGraphList(['a']) store.getState().workspaceActions.setDebugDataStale(true) store.getState().workspaceActions.setDebugMd5Mismatch({ runtimeMd5: 'r', localMd5: 'l' }) - store.getState().workspaceActions.setDebugConnectionType('websocket') store.getState().workspaceActions.clearDebugState() @@ -503,7 +488,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -616,7 +600,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() expect(workspace.isPlcLogsVisible).toBe(false) expect(workspace.plcLogs).toBe('') expect(workspace.plcLogsLastId).toBeNull() diff --git a/src/frontend/store/slices/console/slice.ts b/src/frontend/store/slices/console/slice.ts index 34e289634..afaeca186 100644 --- a/src/frontend/store/slices/console/slice.ts +++ b/src/frontend/store/slices/console/slice.ts @@ -1,8 +1,23 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' +import type { LogObject } from '../../../../middleware/shared/ports/types' +import { hasAnsi, parseAnsi, stripAnsi } from '../../../utils/terminal-output' import type { ConsoleSlice } from './types' +/** + * Split any SGR colour off the raw text. + * + * Every log lands here, so this is the one place that has to know escapes + * exist: `message` is always clean text, and `segments` carries the styling + * only when there was any. Uncoloured logs (the overwhelming majority) keep + * the exact shape they had before and allocate nothing extra. + */ +function normalizeLogEntry(log: LogObject): LogObject { + if (!hasAnsi(log.message)) return log + return { ...log, message: stripAnsi(log.message), segments: parseAnsi(log.message) } +} + const createConsoleSlice: StateCreator = (setState) => ({ logs: [], filters: { @@ -17,13 +32,24 @@ const createConsoleSlice: StateCreator = (se }, followRequestId: 0, consoleActions: { - addLog: (log) => { + addLog: (log, options) => { setState( produce((state: ConsoleSlice) => { - state.logs.push({ + const entry = normalizeLogEntry({ ...log, tstamp: log.tstamp ?? new Date(), }) + + // A carriage-return redraw overwrites the in-place line a terminal + // would still have the cursor on, instead of stacking another + // entry. That collapses a whole download's worth of progress + // frames into one live-updating line. + const lastIndex = state.logs.length - 1 + if (options?.redraw && state.logs[lastIndex]?.transient) { + state.logs[lastIndex] = entry + return + } + state.logs.push(entry) }), ) }, diff --git a/src/frontend/store/slices/console/types.ts b/src/frontend/store/slices/console/types.ts index dc5f70c5a..da8d33ca8 100644 --- a/src/frontend/store/slices/console/types.ts +++ b/src/frontend/store/slices/console/types.ts @@ -21,8 +21,19 @@ export type ConsoleState = { followRequestId: number } +/** Per-write directives for {@link ConsoleActions.addLog}. */ +export type AddLogOptions = { + /** + * This line came from a carriage-return redraw, so it overwrites the + * in-place line above it when that line is still open (`transient`). + * A write directive rather than entity state — whether the line stays + * open afterwards is recorded on the entry itself. + */ + redraw?: boolean +} + export type ConsoleActions = { - addLog: (log: LogObject) => void + addLog: (log: LogObject, options?: AddLogOptions) => void removeLog: (id: string) => void clearLogs: () => void setLevelFilter: (level: LogLevel, enabled: boolean) => void diff --git a/src/frontend/store/slices/device/index.ts b/src/frontend/store/slices/device/index.ts index fb1725ef7..ac986a10b 100644 --- a/src/frontend/store/slices/device/index.ts +++ b/src/frontend/store/slices/device/index.ts @@ -3,6 +3,9 @@ export type { ConnectionStatus, DeviceActions, DeviceAvailableOptions, + DeviceConnection, + DeviceConnectionStatus, + DeviceLicenseInfo, DevicePinMapping, DeviceSlice, DeviceState, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 3740ab094..25553bba4 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -50,6 +50,7 @@ const createDeviceSlice: StateCreator = (s jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -59,6 +60,17 @@ const createDeviceSlice: StateCreator = (s ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }, + + deviceLicense: { + phase: 'idle', + report: null, + }, deviceActions: { setAvailableOptions: ({ availableBoards, availableCommunicationPorts }): void => { @@ -109,7 +121,7 @@ const createDeviceSlice: StateCreator = (s }, clearDeviceDefinitions: (): void => { setState( - produce(({ deviceDefinitions, runtimeConnection }: DeviceSlice) => { + produce(({ deviceDefinitions, runtimeConnection, deviceConnection, deviceLicense }: DeviceSlice) => { deviceDefinitions.configuration = defaultDeviceConfiguration deviceDefinitions.pinMapping = { pinsByBoard: {}, @@ -126,6 +138,18 @@ const createDeviceSlice: StateCreator = (s runtimeConnection.includeTimingStatsInPolling = false runtimeConnection.ethercatStatus = null runtimeConnection.includeEthercatStatsInPolling = false + // The held device link is meaningless once the project is closed — + // reset it so a stale connection can't leak into the next one. + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null + // Same for licensing, and for a sharper reason: the next project may + // select a different board entirely, and a "Licensed" badge carried over + // from the previous one would be an assertion about hardware that is not + // even connected. + deviceLicense.phase = 'idle' + deviceLicense.report = null }), ) }, @@ -357,7 +381,7 @@ const createDeviceSlice: StateCreator = (s setDeviceBoard: (deviceBoard): void => { const previousBoard = getState().deviceDefinitions.configuration.deviceBoard setState( - produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { + produce(({ deviceDefinitions, deviceUpdated, deviceLicense }: DeviceSlice) => { deviceUpdated.updated = true // Wipe platformOption selections when the board changes — they're // declared per-board in the VPP manifest, so a `cpu=atmega328old` @@ -383,6 +407,14 @@ const createDeviceSlice: StateCreator = (s const cfg = deviceDefinitions.configuration syncActiveBoardVendorBucket(cfg) cfg.vendorScreenData = { ...(cfg.vendorScreenDataByBoard?.[deviceBoard] ?? {}) } + // A licence report is board-specific for the same reason all of the + // above is: it was verified against the PREVIOUS board's `deviceId` + // and its VPP's `productId`. Carried across a switch, the badge + // asserts possession for hardware that is no longer selected, and + // the buy link gets built from the NEW package id paired with the + // OLD device id — binding a purchase to the wrong board. + deviceLicense.phase = 'idle' + deviceLicense.report = null } deviceDefinitions.configuration.deviceBoard = deviceBoard }), @@ -461,6 +493,13 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setPlcSwitchPosition: (position): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.switchPosition = position + }), + ) + }, setSelectedDevice: (device): void => { setState( produce(({ runtimeConnection }: DeviceSlice) => { @@ -527,6 +566,50 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setDeviceConnectionStatus: (status, port, transport, debugTransport): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = status + if (port !== undefined) deviceConnection.port = port + if (transport !== undefined) deviceConnection.transport = transport + if (debugTransport !== undefined) deviceConnection.debugTransport = debugTransport + }), + ) + }, + clearDeviceConnection: (): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null + }), + ) + }, + startDeviceLicenseCheck: (): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'checking' + // `report` is deliberately left alone — see the action's docstring. + }), + ) + }, + setDeviceLicenseReport: (report): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'done' + deviceLicense.report = report + }), + ) + }, + clearDeviceLicense: (): void => { + setState( + produce(({ deviceLicense }: DeviceSlice) => { + deviceLicense.phase = 'idle' + deviceLicense.report = null + }), + ) + }, setVendorScreenData: (persistenceKey, data): void => { setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 5a035b325..4123852ea 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -1,8 +1,11 @@ +import type { DeviceLicenseReport } from '../../../../middleware/shared/ports/device-port' import type { EtherCATRuntimeStatusResponse } from '../../../../middleware/shared/ports/ethercat-types' import type { BoardInfo, CommunicationPort, + DebugMedium, DeviceConfiguration, + DeviceLinkTransport, DevicePin, PlcStatus, TimingStats, @@ -63,6 +66,13 @@ export type RuntimeConnection = { jwtToken: string | null connectionStatus: ConnectionStatus plcStatus: PlcStatus | null + /** Run/stop mode-switch position of the connected target, or null when + * unknown. Lives next to `plcStatus` so the Start/Stop button, its tooltip + * and the start pre-check all read one value, whatever the target type: + * Runtime v4 fills it from `/api/status`, baremetal from the device status + * poll. `'run'` on any device without a physical switch, so a null-safe + * caller treats absence as "no gating". */ + switchPosition: 'run' | 'stop' | null ipAddress: string | null /** Version string reported by the connected runtime (from * get-users-info / the X-OpenPLC-Runtime-Version header), or null @@ -76,6 +86,67 @@ export type RuntimeConnection = { includeEthercatStatsInPolling: boolean } +// --------------------------------------------------------------------------- +// Persistent serial connection (D72) — baremetal "stay connected" +// --------------------------------------------------------------------------- + +export type DeviceConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** + * Live state of the connection the main process holds to a baremetal target, + * mirroring the connection manager — which remains the source of truth. Purely + * whether the connection is up, and over what. + */ +export type DeviceConnection = { + status: DeviceConnectionStatus + /** Endpoint the connection is on (or was last attempted on): a serial path or an IP. */ + port: string | null + /** + * Medium the CONTROL channel uses. Read-only mirror — nothing in the renderer + * picks a transport. Null for a REST-controlled runtime session, which holds no + * connection. + */ + transport: DeviceLinkTransport | null + /** + * Medium the DEBUG channel uses — the ONE fact the debug poller reads, for both + * its batch size and its cadence (see `DEBUG_MEDIUM_PROFILE`). Published by the + * connection manager, which is the only component that knows: the main process on + * the editor, the WebRTC lifecycle manager in the browser. + * + * Can change mid-session on web, when a WebRTC data channel drops to the Edge + * relay — the poller follows it, so this is read live rather than latched. + */ + debugTransport: DebugMedium | null +} + +// --------------------------------------------------------------------------- +// VPP licensing +// --------------------------------------------------------------------------- + +/** + * What the UI knows about the connected device's VPP license. + * + * Separate from `deviceConnection` on purpose: that is about whether the LINK is + * up, this is about what the device is entitled to run. They change for unrelated + * reasons — a link can drop and come back without the license changing, and a + * license can be recovered without the link ever moving — and merging them made + * every reader of one depend on the other. + * + * `report` is null until a licensing call has landed, which is also the state for + * every non-licensable board: nothing runs, so nothing is known, and the UI shows + * no licensing affordance at all. + */ +export type DeviceLicenseInfo = { + /** In flight, so the UI can show progress and refuse to start a second one. */ + phase: 'idle' | 'checking' | 'done' + /** + * The last landed report from `readLicense` / `refreshLicense`. Carries the + * outcome union and the derived `deviceId` (which the renderer cannot compute — + * it needs `node:crypto` — and which feeds the copy button and the buy link). + */ + report: DeviceLicenseReport | null +} + // --------------------------------------------------------------------------- // Device state // --------------------------------------------------------------------------- @@ -91,6 +162,8 @@ export type DeviceState = { updated: boolean } runtimeConnection: RuntimeConnection + deviceConnection: DeviceConnection + deviceLicense: DeviceLicenseInfo } // --------------------------------------------------------------------------- @@ -150,6 +223,8 @@ export type DeviceActions = { setRuntimeConnectionStatus: (status: ConnectionStatus) => void setRuntimeVersion: (version: string | null) => void setPlcRuntimeStatus: (status: PlcStatus | null) => void + /** Set the mode-switch position (null clears it, e.g. on disconnect). */ + setPlcSwitchPosition: (position: 'run' | 'stop' | null) => void setSelectedDevice: (device: SelectedDevice | null) => void setStoredCredentials: (credentials: StoredCredentials | null) => void setTimingStats: (stats: TimingStats | null) => void @@ -158,6 +233,29 @@ export type DeviceActions = { setIncludeEthercatStatsInPolling: (include: boolean) => void setTemporaryDhcpIp: (ipAddress?: string) => void clearRuntimeConnection: () => void + /** Set the persistent serial link state (optionally the port it's on). */ + setDeviceConnectionStatus: ( + status: DeviceConnectionStatus, + port?: string | null, + transport?: DeviceConnection['transport'], + debugTransport?: DeviceConnection['debugTransport'], + ) => void + /** Reset the serial link to disconnected/null. */ + clearDeviceConnection: () => void + /** + * Mark a licensing call as in flight. + * + * Deliberately KEEPS the last report rather than clearing it. Blanking it would + * make the badge flicker from "Licensed" to nothing and back on every refresh — + * and worse, a refresh that fails would leave the UI with less information than + * it had before asking. The `phase` is what says "asking"; the report stays as + * the last thing actually known. + */ + startDeviceLicenseCheck: () => void + /** Land a finished licensing call: `phase='done'`, store the report. */ + setDeviceLicenseReport: (report: DeviceLicenseReport) => void + /** Reset licensing to `idle`/null — on disconnect, board change, project close. */ + clearDeviceLicense: () => void setVendorScreenData: (persistenceKey: string, data: unknown) => void /** Restore `vendorScreenData[k]` for every k in `ownedKeys`: from * `snapshot[k]` when present, else by deleting the key. Used by diff --git a/src/frontend/store/slices/editor/slice.ts b/src/frontend/store/slices/editor/slice.ts index 029fb66c5..7b71c7536 100644 --- a/src/frontend/store/slices/editor/slice.ts +++ b/src/frontend/store/slices/editor/slice.ts @@ -1,7 +1,25 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { EditorSlice, EditorState } from './types' +import type { EditorSlice, EditorState, StructureTableType } from './types' + +const applyStructureView = ( + current: StructureTableType, + data: { display?: 'code' | 'table'; selectedRow?: number; description?: string; code?: string }, +): StructureTableType => { + const display = data.display ?? current.display + if (display === 'table') { + const prevSelectedRow = current.display === 'table' ? current.selectedRow : '-1' + const prevDescription = current.display === 'table' ? current.description : '' + return { + display: 'table', + selectedRow: data.selectedRow !== undefined ? data.selectedRow.toString() : prevSelectedRow, + description: data.description ? data.description : prevDescription, + } + } + const existingCode = current.display === 'code' ? current.code : undefined + return { display: 'code', code: data.code !== undefined ? data.code : existingCode } +} export const createEditorSlice: StateCreator = (setState, getState) => ({ editors: [], @@ -159,19 +177,26 @@ export const createEditorSlice: StateCreator = }), ), - updateModelStructure: ({ selectedRow, description }) => + updateModelStructure: (data) => setState( produce((state: EditorState) => { const { editor } = state if (editor.type === 'plc-datatype') { - editor.structure = { - selectedRow: selectedRow !== undefined ? selectedRow.toString() : editor.structure.selectedRow, - description: description ? description : editor.structure.description, - } + editor.structure = applyStructureView(editor.structure, data) } }), ), + updateModelStructureForName: (name, data) => + setState( + produce((state: EditorState) => { + const targetEditor = + state.editor.meta.name === name ? state.editor : state.editors.find((e) => e.meta.name === name) + if (targetEditor?.type !== 'plc-datatype') return + targetEditor.structure = applyStructureView(targetEditor.structure, data) + }), + ), + updateModelLadder: ({ openRung }) => setState( produce((state: EditorState) => { diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index 42ad52b75..7857366bd 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -25,10 +25,16 @@ export type GlobalVariablesTableType = code?: string } -export type StructureTableType = { - description: string - selectedRow: string -} +export type StructureTableType = + | { + display: 'table' + description: string + selectedRow: string + } + | { + display: 'code' + code?: string + } export type TaskType = { display: 'table'; selectedRow: string } | { display: 'code' } @@ -70,13 +76,15 @@ export type CursorPosition = { * editor. Triggers a forced switch to text mode if the panel * is currently in table mode, and the body editor ignores * positions tagged this way. + * - `data-type` — targets a data type's `.dt` code view, with the + * same forced switch out of table mode. * * Used by Go to Definition redirects: when the LSP points at a * variable declaration (synthesized header line), we surface that * line inside the variables panel instead of clamping the cursor * to the body's line 1. */ - target?: 'body' | 'variables' + target?: 'body' | 'variables' | 'data-type' } // --------------------------------------------------------------------------- @@ -253,7 +261,17 @@ export type EditorActions = { code?: string }, ) => void - updateModelStructure: (data: { selectedRow?: number; description?: string }) => void + /** `display` is optional so table-mode row/description updates can't flip the view. */ + updateModelStructure: (data: { + display?: 'code' | 'table' + selectedRow?: number + description?: string + code?: string + }) => void + updateModelStructureForName: ( + name: string, + data: { display?: 'code' | 'table'; selectedRow?: number; description?: string; code?: string }, + ) => void updateModelTasks: (tasks: { selectedRow?: number; display: 'code' | 'table' }) => void updateModelInstances: (instances: { selectedRow?: number; display: 'code' | 'table' }) => void updateModelLadder: (data: { openRung?: { rungId: string; open: boolean } }) => void diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index cecff18e4..00567eb2d 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -28,6 +28,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'confirm-install-libraries', 'project-readme', 'confirm-plcopen-import', + 'create-graphical-variable', ] function createDefaultModals() { diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 84a34b138..018cac4e2 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -1,3 +1,5 @@ +import type { PLCVariable, VariableClass } from '../../../../middleware/shared/ports/types' + // --------------------------------------------------------------------------- // Modal types — superset of both editor and web // --------------------------------------------------------------------------- @@ -42,6 +44,41 @@ export type ModalTypes = * item. Acts on whatever project is currently open — no targeted * data payload (unlike `confirm-delete-project`). */ | 'confirm-plcopen-import' + /** "Add variable" on a GENERIC block pin (`ANY`, `ANY_NUM`, …) in a + * graphical editor: the pin doesn't dictate a type, so the user + * confirms name / class / type instead of the editor guessing + * (issue #479). Data shape: `CreateGraphicalVariableModalData` — + * carries the pin type, the suggested values and an `onConfirm` + * the caller uses to create + bind the variable. */ + | 'create-graphical-variable' + +/** + * Payload of the `create-graphical-variable` modal. Lives here so the graphical + * editors (`_atoms`) and the modal (`_organisms`) share one contract without + * importing across the atomic-design grain. + * + * `onConfirm` keeps creation + node binding with the caller: each editor + * already knows how to bind a variable into its own graph. + */ +export type CreateGraphicalVariableModalData = { + /** Declared type of the pin the box sits on (e.g. `ANY`, `ANY_NUM`). */ + pinType: string + /** Name the user typed into the box. */ + name: string + /** Type the editor inferred from the block's bound pins — the pre-selection. */ + suggestedType: { definition: PLCVariable['type']['definition']; value: string } + onConfirm: (choice: { + name: string + class: VariableClass + type: { definition: PLCVariable['type']['definition']; value: string } + }) => void + /** + * Undo the box's provisional state. Opening the dialog blurs the box, which + * binds the typed text as a raw (unresolved) reference — abandoning the + * dialog must not leave that behind. + */ + onCancel?: () => void +} export type ModalsState = Record diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index d81e93b31..a36466dfe 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -32,10 +32,13 @@ import { } from '../../../../middleware/shared/utils/iec-address/registry' import type { TargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' import { resolveTargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' +import { renameDataTypeInDataType, renameDataTypeInVariableType } from '../../../utils/data-type-references' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { isLegalIdentifier } from '../../../utils/keywords' import { DEFAULT_BUFFER_MAPPING } from '../../../utils/modbus/generate-modbus-slave-config' +import { serializeDataTypeToText } from '../../../utils/PLC/data-type-serializer' +import { parseDataTypeFromText } from '../../../utils/PLC/data-type-text-parser' import { getExtensionFromLanguage, getFolderFromPouType } from '../../../utils/PLC/pou-file-extensions' import type { ProjectResponse, ProjectSlice, ProjectSliceRoot } from './types' import { getVariableBasedOnRowIdOrVariableId } from './utils' @@ -504,6 +507,48 @@ const regenerateVariablesText = (pouName: string | undefined, getState: ProjectG state.editorActions.updateModelVariablesForName(pouName, { display: 'code', code: newText }) } +// Same contract as the variables pair above, for the `.dt` code view. + +const findDatatypeEditorCode = (name: string, getState: ProjectGetState): string | undefined => { + const state = getState() + const editorModel = state.editor.meta.name === name ? state.editor : state.editors.find((e) => e.meta.name === name) + if (editorModel?.type !== 'plc-datatype') return undefined + if (editorModel.structure.display !== 'code') return undefined + return editorModel.structure.code +} + +const reconcileDatatypeText = (name: string, getState: ProjectGetState, setState: ProjectSetState): ProjectResponse => { + const code = findDatatypeEditorCode(name, getState) + if (typeof code !== 'string') return ok() + + const current = getState().project.data.dataTypes.find((d) => d.name === name) + if (!current) return ok() + // Buffer is a verbatim serialisation of the current type — nothing to fold in. + if (code === serializeDataTypeToText(current)) return ok() + + const { dataType, error } = parseDataTypeFromText(code, name) + if (!dataType) return fail(error ?? 'Unknown parse error.', 'Data type text is invalid') + + setState( + produce((slice: ProjectSlice) => { + const idx = slice.project.data.dataTypes.findIndex((d) => d.name === name) + if (idx !== -1) slice.project.data.dataTypes[idx] = dataType + }), + ) + return ok() +} + +const regenerateDatatypeText = (name: string, getState: ProjectGetState): void => { + if (findDatatypeEditorCode(name, getState) === undefined) return + const state = getState() + const dataType = state.project.data.dataTypes.find((d) => d.name === name) + if (!dataType) return + state.editorActions.updateModelStructureForName(name, { + display: 'code', + code: serializeDataTypeToText(dataType), + }) +} + const createProjectSlice: StateCreator = (setState, getState) => ({ project: { meta: { name: '', type: 'plc-project', path: '' }, @@ -517,6 +562,7 @@ const createProjectSlice: StateCreator = }, }, pendingDeletions: [], + unparsedDataTypeFiles: [], iecAliasMemory: {}, projectActions: { @@ -552,6 +598,7 @@ const createProjectSlice: StateCreator = }, } slice.pendingDeletions = [] + slice.unparsedDataTypeFiles = [] // Session alias-memory is per-project; drop it on a fresh slate so // one project's remembered aliases can't leak into the next. slice.iecAliasMemory = {} @@ -1063,6 +1110,10 @@ const createProjectSlice: StateCreator = deleteDatatype: (name) => { setState( produce((slice: ProjectSlice) => { + // Harmless while the file doesn't exist yet (flag off): the + // editor's deletion pass is existence-checked, the web's + // relies on delete-by-omission. + slice.pendingDeletions.push(`datatypes/${name}.dt`) slice.project.data.dataTypes = slice.project.data.dataTypes.filter((d) => d.name !== name) }), ) @@ -1078,6 +1129,37 @@ const createProjectSlice: StateCreator = }), ) }, + updateDatatypeName: (oldName, newName) => { + setState( + produce((slice: ProjectSlice) => { + const dt = slice.project.data.dataTypes.find((d) => d.name === oldName) + if (!dt) return + // Queue the OLD path — the next save serializes the type + // under its new name (model: updatePouName). + slice.pendingDeletions.push(`datatypes/${oldName}.dt`) + dt.name = newName + }), + ) + }, + propagateDatatypeRename: (oldName, newName) => { + setState( + produce((slice: ProjectSlice) => { + for (const pou of slice.project.data.pous) { + for (const variable of pou.interface?.variables ?? []) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + } + for (const variable of slice.project.data.configurations.resource.globalVariables) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + slice.project.data.dataTypes = slice.project.data.dataTypes.map( + (dataType) => renameDataTypeInDataType(dataType, oldName, newName) ?? dataType, + ) + }), + ) + }, createArrayDimension: ({ name, derivation: _derivation }) => { setState( produce((slice: ProjectSlice) => { @@ -1106,6 +1188,23 @@ const createProjectSlice: StateCreator = if (idx !== -1) slice.project.data.dataTypes[idx] = data }), ) + regenerateDatatypeText(name, getState) + }, + reconcileDatatypeText: (name) => reconcileDatatypeText(name, getState, setState), + regenerateDatatypeText: (name) => regenerateDatatypeText(name, getState), + setUnparsedDataTypeFiles: (files) => { + setState( + produce((slice: ProjectSlice) => { + slice.unparsedDataTypeFiles = files + }), + ) + }, + removeUnparsedDataTypeFile: (relativePath) => { + setState( + produce((slice: ProjectSlice) => { + slice.unparsedDataTypeFiles = slice.unparsedDataTypeFiles.filter((f) => f.relativePath !== relativePath) + }), + ) }, // ----------------------------------------------------------------------- diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 6c652d245..a29a86c5c 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -1,3 +1,4 @@ +import type { RawProjectFile } from '../../../../middleware/shared/ports/project-port' import type { EthercatConfig, ModbusBufferMapping, @@ -216,9 +217,27 @@ export type ProjectActions = { createDatatype: (dto: DataTypeDTO & { rowToInsert?: number }) => ProjectResponse deleteDatatype: (name: string) => void updateDatatype: (name: string, data?: PLCDataType) => void + /** Rename + queue the old `datatypes/.dt` path for deletion + * (model: `updatePouName`). Reference propagation is + * `propagateDatatypeRename`, driven by `datatypeActions.rename`. */ + updateDatatypeName: (oldName: string, newName: string) => void + /** Rewrite every reference to data type `oldName` (POU variables, global + * variables, other data types' fields / array base types) to `newName`. + * Does not touch the type's own entry — `updateDatatypeName` owns that. */ + propagateDatatypeRename: (oldName: string, newName: string) => void createArrayDimension: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => void rearrangeStructureVariables: (args: { associatedDataType?: string; rowId: number; newIndex: number }) => void applyDatatypeSnapshot: (name: string, data: PLCDataType) => void + /** Fold a diverged `.dt` code buffer back into the type before an + * external mutation; refuses (`ok: false`) when the text is invalid. */ + reconcileDatatypeText: (name: string) => ProjectResponse + /** Re-serialize the type into its code buffer after an external mutation. */ + regenerateDatatypeText: (name: string) => void + /** Stash raw `.dt` files that failed to parse on load so saves echo + * them back verbatim (no silent data loss). */ + setUnparsedDataTypeFiles: (files: RawProjectFile[]) => void + /** Drop a preserved raw file once its text parses and becomes a real type. */ + removeUnparsedDataTypeFile: (relativePath: string) => void // Tasks createTask: (dto: TaskDTO & { rowToInsert?: number }) => ProjectResponse @@ -324,6 +343,10 @@ export type ProjectSlice = { project: ProjectState /** Relative file paths queued for deletion on next full project save. */ pendingDeletions: string[] + /** Raw `datatypes/*.dt` files that failed to parse on load. Echoed + * back verbatim by the save flow until they parse — an unreadable + * file must never be silently dropped from disk. */ + unparsedDataTypeFiles: RawProjectFile[] /** * Session-scoped IEC alias memory: `memoryKey -> alias`, where `memoryKey` * is a channel's stable semantic identity (`vpp:moduleId:slot:channel`, diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts index 07e3cd491..b439232b0 100644 --- a/src/frontend/store/slices/shared/flow-writeback.ts +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -34,13 +34,14 @@ type GetWriteBackState = () => SharedRootState const pendingWriteBacks = new Map }>() -function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { +/** @returns `false` when the flow is invalid and `pou.body.value` is left stale. */ +function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): boolean { const state = getState() const flow = language === 'ld' ? state.ladderFlows.find((f) => f.name === pouName) : state.fbdFlows.find((f) => f.name === pouName) - if (!flow?.updated) return + if (!flow?.updated) return true // Validate with zod but persist the raw object (minus the transient // `updated` flag). Using the parsed result would silently strip every @@ -48,13 +49,20 @@ function runWriteBack(getState: GetWriteBackState, pouName: string, language: Fl // byte-drifting the serialized POU vs. the loaded disk copy — phantom // "Modified" entries in Source Control (see DOPE-477). const schema = language === 'ld' ? zodLadderFlowSchema : zodFBDFlowSchema - if (!schema.safeParse(flow).success) return + const validation = schema.safeParse(flow) + if (!validation.success) { + console.warn(`[flow-writeback] "${pouName}" (${language}) failed validation — body left stale`, { + issues: validation.error.issues, + }) + return false + } const { updated: _updated, ...flowBody } = flow state.projectActions.updatePou({ name: pouName, content: { language, value: flowBody } }) const flowActions = language === 'ld' ? state.ladderFlowActions : state.fbdFlowActions flowActions.setFlowUpdated({ editorName: pouName, updated: false }) + return true } /** @@ -78,14 +86,36 @@ export function scheduleFlowWriteBack(getState: GetWriteBackState, pouName: stri pendingWriteBacks.set(pouName, { language, timer }) } -/** Run pending write-backs immediately — all of them, or a single POU's. */ -export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): void { - for (const [name, pending] of [...pendingWriteBacks]) { - if (pouName !== undefined && name !== pouName) continue - clearTimeout(pending.timer) - pendingWriteBacks.delete(name) - runWriteBack(getState, name, pending.language) +/** + * Run pending write-backs immediately — all of them, or a single POU's. + * + * Every `updated` flow is written back, not just the ones with a live timer: + * a timer that already fired and failed validation leaves no pending entry + * behind, so a pending-only sweep would report success for a POU whose body + * is still stale (DOPE-495). + * + * @returns names of POUs whose body could not be updated. + */ +export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): readonly string[] { + cancelFlowWriteBacks(pouName) + + const state = getState() + // Deleting a POU leaves its flow behind, so an invalid one would be reported + // stale on every future save — blocking saves and builds for good. It has no + // body left to write back, so it isn't a write-back failure. + const livePous = new Set(state.project.data.pous.map((pou) => pou.name)) + const failed: string[] = [] + for (const flow of state.ladderFlows) { + if (pouName !== undefined && flow.name !== pouName) continue + if (!livePous.has(flow.name)) continue + if (flow.updated && !runWriteBack(getState, flow.name, 'ld')) failed.push(flow.name) + } + for (const flow of state.fbdFlows) { + if (pouName !== undefined && flow.name !== pouName) continue + if (!livePous.has(flow.name)) continue + if (flow.updated && !runWriteBack(getState, flow.name, 'fbd')) failed.push(flow.name) } + return failed } /** Drop pending write-backs without running them (project open). */ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 016c920bb..78f9c810b 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -2,6 +2,8 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' +import { findAllReferencesToDataType } from '../../../utils/data-type-references' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' @@ -20,7 +22,13 @@ import { } from '../tabs/utils' import { cancelFlowWriteBacks, flushFlowWriteBacks } from './flow-writeback' import type { PouHistorySnapshot, SharedRootState, SharedSlice } from './types' -import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' +import { + createDatatypeObject, + createEditorObjectForDatatype, + createEditorObjectForPou, + createPouObject, + guessDatatypeDerivation, +} from './utils' const MAX_HISTORY_SIZE = 50 @@ -63,6 +71,77 @@ function validateElementName(name: string): { ok: true } | { ok: false; message: return legal ? { ok: true } : { ok: false, message: `'${name}' ${reason}` } } +/** + * Data type names are compared case-insensitively: each one becomes a + * `datatypes/.dt` path, and macOS/Windows fold filename case, so + * `Foo` and `foo` would silently overwrite each other on save. IEC + * identifiers are case-insensitive anyway. + */ +const nameMatches = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + +/** + * A raw datatypes/.dt file that failed to parse still owns its + * name: letting a new data type take it would make the save emit two + * specs for one path (and the raw echo would win). Case-insensitive — + * the file name is the identity and common filesystems fold case. + */ +function collidesWithUnparsedDataTypeFile(state: SharedRootState, name: string): { ok: boolean; message?: string } { + const collides = state.unparsedDataTypeFiles.some( + (f) => f.relativePath.split('/').pop()?.replace(/\.dt$/i, '').toLowerCase() === name.toLowerCase(), + ) + return collides + ? { + ok: false, + message: `A data type file named "${name}.dt" exists on disk but could not be read — fix or remove it first`, + } + : { ok: true } +} + +/** + * Post-propagation bookkeeping for a confirmed data type rename: + * + * 1. Flag every touched container's file dirty — single-file save and the + * close-project check read these flags, and the propagated content + * would otherwise be silently dropped on disk. + * 2. Regenerate code-mode variable buffers of affected POUs. `sanitizePou` + * persists `editor.variable.code` as the authoritative variables block, + * so a stale buffer would resurrect the old type name on save. + * 3. Regenerate the `.dt` code buffers of affected data types — committing + * a stale buffer (commitCode → updateDatatype) would do the same. + */ +function syncAfterDatatypePropagation(state: SharedRootState, impact: DataTypeReferenceImpactAnalysis): void { + const dirtyFiles = new Set() + const affectedPous = new Set() + const affectedDatatypes = new Set() + for (const ref of impact.references) { + // Global variables persist through the Resource entry in the file slice. + dirtyFiles.add(ref.kind === 'global-variable' ? 'Resource' : ref.container) + if (ref.kind === 'pou-variable') affectedPous.add(ref.container) + if (ref.kind === 'data-type-field' || ref.kind === 'data-type-base-type') affectedDatatypes.add(ref.container) + } + for (const name of dirtyFiles) { + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(name) + } + + // No-op for types whose code view isn't active. + for (const datatypeName of affectedDatatypes) { + state.projectActions.regenerateDatatypeText(datatypeName) + } + + for (const pouName of affectedPous) { + const model = state.editor.meta.name === pouName ? state.editor : state.editors.find((e) => e.meta.name === pouName) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) continue + if (model.variable.display !== 'code') continue + const pou = state.project.data.pous.find((p) => p.name === pouName) + /* istanbul ignore next -- defensive: a pou-variable reference implies the POU exists */ + if (!pou) continue + state.editorActions.updateModelVariablesForName(pouName, { + display: 'code', + code: generateIecVariablesToString(pou.interface?.variables ?? []), + }) + } +} + function renameElement( state: SharedRootState, oldName: string, @@ -91,6 +170,10 @@ function renameElement( state.ladderFlowActions.renameLadderFlow(oldName, newName) state.fbdFlowActions.renameFBDFlow(oldName, newName) + // Follow the undo/redo stacks to the new key — otherwise the history is + // orphaned under the old name and undo becomes a silent no-op after rename. + state.snapshotActions.renameHistory(oldName, newName) + afterRename?.(oldName, newName) // A rename is an unsaved structural change — flag it dirty (the renamed file @@ -103,6 +186,7 @@ function renameElement( const createSharedSlice: StateCreator = (setState, getState) => ({ undoRedo: {}, + pendingDatatypeRename: null, pouActions: { create: ({ type, name, language }) => { @@ -237,9 +321,12 @@ const createSharedSlice: StateCreator = (s datatypeActions: { create: ({ name, derivation }) => { const state = getState() - const existing = state.project.data.dataTypes.find((d) => d.name === name) + const existing = state.project.data.dataTypes.find((d) => nameMatches(d.name, name)) if (existing) return { ok: false, message: 'Data type already exists' } + const fileCollision = collidesWithUnparsedDataTypeFile(state, name) + if (!fileCollision.ok) return fileCollision + const nameCheck = validateElementName(name) if (!nameCheck.ok) return nameCheck @@ -271,19 +358,69 @@ const createSharedSlice: StateCreator = (s delete: (name) => deleteElement(getState(), name, (n) => getState().projectActions.deleteDatatype(n)), - rename: (oldName, newName) => { + rename: async (oldName, newName) => { const state = getState() - const existing = state.project.data.dataTypes.find((d) => d.name === newName) - if (existing) return { ok: false, message: 'Data type name already exists' } + // Includes the type being renamed: a case-only change writes the + // new file and then deletes the old path — the same file where + // the filesystem folds case. + const collides = newName !== oldName && state.project.data.dataTypes.some((d) => nameMatches(d.name, newName)) + if (collides) return { ok: false, message: 'Data type name already exists' } + + const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) + if (!fileCollision.ok) return fileCollision const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } - const updatedDatatype = { ...datatype, name: newName } + // renameElement validates too, but checked up front so the impact + // modal never opens for a rename that would fail afterwards. + const nameCheck = validateElementName(newName) + if (!nameCheck.ok) return nameCheck + + // Fold pending code-view edits in first, so the rename doesn't + // regenerate over them — and so the reference scan sees them. + const reconcile = state.projectActions.reconcileDatatypeText(oldName) + if (!reconcile.ok) return { ok: false, message: reconcile.message } + + if (newName !== oldName) { + const freshState = getState() + const impact = findAllReferencesToDataType( + oldName, + freshState.project.data.pous, + freshState.project.data.configurations.resource.globalVariables, + freshState.project.data.dataTypes, + ) + if (impact.totalReferences > 0) { + // Overwriting a pending request would drop its resolver and strand + // the first caller's await forever (e.g. Enter + blur double-fire). + if (getState().pendingDatatypeRename) { + return { ok: false, message: 'Another data type rename is awaiting confirmation' } + } + const confirmed = await new Promise((resolve) => { + setState({ pendingDatatypeRename: { oldName, newName, impact, resolve } }) + }) + if (!confirmed) return { ok: false, cancelled: true, message: 'Rename cancelled' } + getState().projectActions.propagateDatatypeRename(oldName, newName) + syncAfterDatatypePropagation(getState(), impact) + } + } - return renameElement(state, oldName, newName, () => { - state.projectActions.updateDatatype(oldName, updatedDatatype) + const result = renameElement(getState(), oldName, newName, () => { + // Renames via the dedicated action so the old .dt path gets + // queued for deletion — a plain updateDatatype would strand + // the old file on disk. + getState().projectActions.updateDatatypeName(oldName, newName) }) + // Only after renameElement are the type and its model both keyed by newName. + if (result.ok) getState().projectActions.regenerateDatatypeText(newName) + return result + }, + + respondToPendingRename: (confirmed) => { + const pending = getState().pendingDatatypeRename + if (!pending) return + setState({ pendingDatatypeRename: null }) + pending.resolve(confirmed) }, duplicate: (sourceName, newName) => { @@ -291,9 +428,12 @@ const createSharedSlice: StateCreator = (s const source = state.project.data.dataTypes.find((d) => d.name === sourceName) if (!source) return { ok: false, message: 'Data type not found' } - const existing = state.project.data.dataTypes.find((d) => d.name === newName) + const existing = state.project.data.dataTypes.find((d) => nameMatches(d.name, newName)) if (existing) return { ok: false, message: 'Data type name already exists' } + const fileCollision = collidesWithUnparsedDataTypeFile(state, newName) + if (!fileCollision.ok) return fileCollision + const nameCheck = validateElementName(newName) if (!nameCheck.ok) return nameCheck @@ -615,6 +755,22 @@ const createSharedSlice: StateCreator = (s meta: data.meta, data: data.projectData, }) + // Raw .dt files that failed to parse — stashed so saves echo + // them back verbatim; always set so a reopen clears stale ones. + getState().projectActions.setUnparsedDataTypeFiles(data.unparsedDataTypeFiles ?? []) + + // Unreadable files have no PLCDataType, so no tree leaf to click. + const unparsedDataTypes = (data.unparsedDataTypeFiles ?? []).flatMap((file) => { + const name = file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') + if (!name) return [] + // The file registry is keyed by raw name across both kinds: a + // colliding file would retype the real element and misroute its save. + const taken = [...data.projectData.pous, ...data.projectData.dataTypes].some( + (element) => element.name.toLowerCase() === name.toLowerCase(), + ) + if (taken) return [] + return [{ name, content: file.content, derivation: guessDatatypeDerivation(file.content) }] + }) // Add ladder and FBD flows for graphical POUs. // @@ -824,6 +980,9 @@ const createSharedSlice: StateCreator = (s data.projectData.dataTypes.forEach((dt) => { files[dt.name] = { type: 'data-type', filePath: dt.name, saved: true } }) + unparsedDataTypes.forEach(({ name }) => { + files[name] = { type: 'data-type', filePath: name, saved: true } + }) const servers = data.projectData.servers if (servers) { servers.forEach((s) => { @@ -925,6 +1084,18 @@ const createSharedSlice: StateCreator = (s } }) + // Tab included, and focus stays on the auto-opened POU above. + unparsedDataTypes.forEach(({ name, content, derivation }) => { + const tabToBeCreated: TabsProps = { + name, + path: `/data/data-types/${derivation}/${name}`, + elementType: { type: 'data-type', derivation }, + } + getState().tabsActions.updateTabs(tabToBeCreated) + getState().editorActions.addModel(createEditorObjectForDatatype(name, derivation)) + getState().editorActions.updateModelStructureForName(name, { display: 'code', code: content }) + }) + // Reset all graphical flow updated flags at the very end of project open. // Various operations during load (syncNodesWithVariables, debug flag restoration, // tab opening) call updateNode which sets flow.updated = true as a side effect. @@ -968,6 +1139,17 @@ const createSharedSlice: StateCreator = (s ) }, + renameHistory: (oldName, newName) => { + setState( + produce((state: SharedRootState) => { + const history = state.undoRedo[oldName] + if (!history) return + delete state.undoRedo[oldName] + state.undoRedo[newName] = history + }), + ) + }, + markSaved: (pouName) => { setState( produce((state: SharedRootState) => { @@ -979,10 +1161,11 @@ const createSharedSlice: StateCreator = (s ) }, - markAllSaved: () => { + markAllSaved: (except) => { setState( produce((state: SharedRootState) => { - for (const history of Object.values(state.undoRedo)) { + for (const [pouName, history] of Object.entries(state.undoRedo)) { + if (except?.includes(pouName)) continue history.savedAtDepth = history.past.length } }), @@ -992,24 +1175,33 @@ const createSharedSlice: StateCreator = (s undo: (pouName) => { // A debounced graphical write-back may still be pending — flush it so // the redo snapshot below can't pair a stale body with a fresh flow. - flushFlowWriteBacks(getState, pouName) + // A failed flush leaves the body stale, and capturing it would restore + // the file to "saved" over content that never reached disk (DOPE-495). + if (flushFlowWriteBacks(getState, pouName).length > 0) return false const state = getState() const history = state.undoRedo[pouName] - if (!history || history.past.length === 0) return + if (!history || history.past.length === 0) return true const snapshot = history.past[history.past.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to future. Plain references — the store is // immer-managed (frozen, copy-on-write), so later edits can never // reach a captured snapshot. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1022,49 +1214,69 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state const afterUndo = getState().undoRedo[pouName] if (afterUndo?.savedAtDepth !== null && afterUndo?.savedAtDepth === afterUndo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } + return true }, redo: (pouName) => { // See undo — same pending write-back consistency requirement. - flushFlowWriteBacks(getState, pouName) + if (flushFlowWriteBacks(getState, pouName).length > 0) return false const state = getState() const history = state.undoRedo[pouName] - if (!history || history.future.length === 0) return + if (!history || history.future.length === 0) return true const snapshot = history.future[history.future.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to past. Plain references — see undo. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1077,29 +1289,42 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state const afterRedo = getState().undoRedo[pouName] if (afterRedo?.savedAtDepth !== null && afterRedo?.savedAtDepth === afterRedo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } + return true }, }, }) diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index ba3efb36a..83b96a869 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -1,10 +1,13 @@ +import type { RawProjectFile } from '../../../../middleware/shared/ports/project-port' import type { DeviceConfiguration, DevicePin, + PLCDataType, PLCProjectData, PLCVariable, ProjectMeta, } from '../../../../middleware/shared/ports/types' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import type { AISlice } from '../ai' import type { ConsoleSlice } from '../console' import type { DeviceSlice } from '../device' @@ -58,10 +61,14 @@ export type SharedResponse = { export type PouHistorySnapshot = { variables: PLCVariable[] + /** POU body; `null` for data type snapshots (`dataTypes` carries the state instead). */ body: unknown globalVariables?: PLCVariable[] ladderFlow?: unknown fbdFlow?: unknown + /** Set when the history key is a data type instead of a POU. Always a + * single element today — the array shape mirrors `HistorySnapshot.dataTypes`. */ + dataTypes?: PLCDataType[] } export type PouHistory = { @@ -87,11 +94,30 @@ export type PouActions = { duplicate: (sourceName: string, newName: string) => SharedResponse } +export type DatatypeRenameResponse = SharedResponse & { + /** True when the user declined the reference-impact modal — a user choice, not an error. */ + cancelled?: boolean +} + +/** A rename waiting on the reference-impact modal. `resolve` releases the + * `datatypeActions.rename` await; the modal fires it via + * `datatypeActions.respondToPendingRename`. */ +export type PendingDatatypeRename = { + oldName: string + newName: string + impact: DataTypeReferenceImpactAnalysis + resolve: (confirmed: boolean) => void +} + export type DatatypeActions = { create: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => SharedResponse deleteRequest: (name: string) => void delete: (name: string) => SharedResponse - rename: (oldName: string, newName: string) => SharedResponse + /** Async: a rename of a referenced type awaits the impact modal before + * propagating the new name into every reference. Cancel = no state change. */ + rename: (oldName: string, newName: string) => Promise + /** Confirm (`true`) or cancel (`false`) the pending rename's impact modal. */ + respondToPendingRename: (confirmed: boolean) => void duplicate: (sourceName: string, newName: string) => SharedResponse } @@ -116,10 +142,13 @@ export type EtherCATDeviceActions = { export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void + renameHistory: (oldName: string, newName: string) => void markSaved: (pouName: string) => void - markAllSaved: () => void - undo: (pouName: string) => void - redo: (pouName: string) => void + markAllSaved: (except?: readonly string[]) => void + /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */ + undo: (pouName: string) => boolean + /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */ + redo: (pouName: string) => boolean } export type OpenProjectResponseData = { @@ -132,6 +161,9 @@ export type OpenProjectResponseData = { devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse on load, preserved + * raw so the save flow echoes them back verbatim. */ + unparsedDataTypeFiles?: RawProjectFile[] /** * Edit permission flag forwarded from `ProjectResponse.data.canEdit`. * `false` puts the workspace in read-only mode; `true` / `undefined` @@ -166,6 +198,7 @@ export type SharedWorkspaceActions = { export type SharedSlice = { undoRedo: Record + pendingDatatypeRename: PendingDatatypeRename | null pouActions: PouActions datatypeActions: DatatypeActions serverActions: ServerActions diff --git a/src/frontend/store/slices/shared/utils.ts b/src/frontend/store/slices/shared/utils.ts index d9e7e1bff..5972bbb91 100644 --- a/src/frontend/store/slices/shared/utils.ts +++ b/src/frontend/store/slices/shared/utils.ts @@ -141,10 +141,17 @@ export function createEditorObjectForDatatype(name: string, derivation: string): return { type: 'plc-datatype', meta: { name, derivation: derivation as 'enumerated' | 'structure' | 'array' }, - structure: { description: '', selectedRow: '' }, + structure: { display: 'table', description: '', selectedRow: '-1' }, } } +/** Best-effort derivation for an unparseable `.dt` — only picks the icon. */ +export function guessDatatypeDerivation(content: string): 'enumerated' | 'structure' | 'array' { + if (/\bSTRUCT\b/i.test(content)) return 'structure' + if (/\bARRAY\b/i.test(content)) return 'array' + return 'enumerated' +} + export function createEditorObjectForServer( name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua', diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index bb04f5098..f3e13db48 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -57,7 +57,7 @@ const CreateEditorModelObject = ( return { type: 'plc-datatype', meta: { name, derivation }, - structure: { selectedRow: '-1', description: '' }, + structure: { display: 'table', selectedRow: '-1', description: '' }, } } diff --git a/src/frontend/store/slices/webrtc/index.ts b/src/frontend/store/slices/webrtc/index.ts index 219a7cd37..c17b16159 100644 --- a/src/frontend/store/slices/webrtc/index.ts +++ b/src/frontend/store/slices/webrtc/index.ts @@ -1,9 +1,2 @@ export { createWebRTCSlice } from './slice' -export type { - DebugTransport, - WebRTCActions, - WebRTCConnectionStatus, - WebRTCSession, - WebRTCSlice, - WebRTCState, -} from './types' +export type { WebRTCActions, WebRTCConnectionStatus, WebRTCSession, WebRTCSlice, WebRTCState } from './types' diff --git a/src/frontend/store/slices/webrtc/slice.ts b/src/frontend/store/slices/webrtc/slice.ts index 5878c8e9c..1e0aab35a 100644 --- a/src/frontend/store/slices/webrtc/slice.ts +++ b/src/frontend/store/slices/webrtc/slice.ts @@ -11,7 +11,7 @@ const initialSession: WebRTCSession = { status: 'disconnected', error: null, reconnectAttempt: 0, - debugTransport: 'http', + debugChannelOpen: false, } const createWebRTCSlice: StateCreator = (setState) => ({ @@ -67,10 +67,10 @@ const createWebRTCSlice: StateCreator = (setSt }), ) }, - setDebugTransport: (transport) => { + setDebugChannelOpen: (open) => { setState( produce(({ session }: WebRTCSlice) => { - session.debugTransport = transport + session.debugChannelOpen = open }), ) }, @@ -93,7 +93,7 @@ const createWebRTCSlice: StateCreator = (setSt session.status = 'disconnected' session.error = null session.reconnectAttempt = 0 - session.debugTransport = 'http' + session.debugChannelOpen = false }), ) }, diff --git a/src/frontend/store/slices/webrtc/types.ts b/src/frontend/store/slices/webrtc/types.ts index aab4d88ce..1c516b662 100644 --- a/src/frontend/store/slices/webrtc/types.ts +++ b/src/frontend/store/slices/webrtc/types.ts @@ -2,8 +2,6 @@ import type { WebRTCConnectionStatus } from '../../../../middleware/shared/ports export type { WebRTCConnectionStatus } -export type DebugTransport = 'http' | 'webrtc' - // --------------------------------------------------------------------------- // WebRTC session state // --------------------------------------------------------------------------- @@ -16,7 +14,16 @@ export type WebRTCSession = { status: WebRTCConnectionStatus error: string | null reconnectAttempt: number - debugTransport: DebugTransport + /** + * Is the WebRTC DEBUG data channel open? + * + * A fact about this WebRTC session, deliberately not a medium name: which + * medium the debug poller then rides is derived from this once, by the web + * connection manager, and published as `deviceConnection.debugTransport`. + * Naming a medium here as well gave two fields the same vocabulary and let + * them disagree. + */ + debugChannelOpen: boolean } export type WebRTCState = { @@ -35,7 +42,7 @@ export type WebRTCActions = { setStatus: (status: WebRTCConnectionStatus) => void setError: (error: string | null) => void setReconnectAttempt: (attempt: number) => void - setDebugTransport: (transport: DebugTransport) => void + setDebugChannelOpen: (open: boolean) => void startSession: (params: { deviceId: string; deviceName: string; agentId: string }) => void endSession: () => void reset: () => void diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 86f132c1a..d461bc0af 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -53,7 +53,6 @@ const createWorkspaceSlice: StateCreator debugGraphList: [], debugDataStale: false, debugMd5Mismatch: null, - debugConnectionType: null, debugTargetEndian: 'le', // Project loading state isProjectLoading: false, @@ -173,7 +172,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' workspace.isPlcLogsVisible = false workspace.plcLogs = '' @@ -373,13 +371,6 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugConnectionType: (connectionType) => { - setState( - produce(({ workspace }: WorkspaceSlice) => { - workspace.debugConnectionType = connectionType - }), - ) - }, setDebugTargetEndian: (endian) => { setState( produce(({ workspace }: WorkspaceSlice) => { @@ -406,7 +397,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' }), ) diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 17b0f4c64..09cd89876 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -1,6 +1,5 @@ import type { Architecture, - DebugConnectionType, DebugTreeNode, FbInstanceInfo, Platform, @@ -97,10 +96,6 @@ export type WorkspaceState = { debugGraphList: string[] debugDataStale: boolean debugMd5Mismatch: { runtimeMd5: string; localMd5: string } | null - /** Active transport for the running debug session — drives the - * per-poll batch size and any other transport-specific behaviour. - * Null when no session is active. */ - debugConnectionType: DebugConnectionType | null /** Target's native byte order for multi-byte variable values on * the wire. Detected from the 0xDEAD sentinel in the MD5 * response: LE target writes the trailer as `[0xAD, 0xDE]`, BE @@ -185,7 +180,6 @@ export type WorkspaceActions = { setDebugGraphList: (list: string[]) => void setDebugDataStale: (stale: boolean) => void setDebugMd5Mismatch: (mismatch: { runtimeMd5: string; localMd5: string } | null) => void - setDebugConnectionType: (connectionType: DebugConnectionType | null) => void setDebugTargetEndian: (endian: 'le' | 'be') => void clearDebugState: () => void clearFbDebugContext: () => void diff --git a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts index 1d27d1331..898e8f847 100644 --- a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts +++ b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts @@ -11,7 +11,12 @@ * one of these up. */ import type { PLCDataType } from '../../../../middleware/shared/ports/types' -import { serializeDataTypesToLines, serializeDataTypesToST } from '../data-type-serializer' +import { + dataTypeLineSpans, + serializeDataTypesToLines, + serializeDataTypesToST, + serializeDataTypeToText, +} from '../data-type-serializer' const enumerated = (name: string, values: string[], initialValue?: string): PLCDataType => ({ name, @@ -88,8 +93,79 @@ describe('serializeDataTypesToST', () => { }) it('serialises a multi-dimension array with an initial value', () => { + // IEC 61131-3 multi-dimension syntax: one bracket, comma-separated. const out = serializeDataTypesToST([array('Matrix', 'REAL', ['0..3', '0..3'], '[0,0,0,0]')]) - expect(out).toBe('TYPE\n Matrix : ARRAY [0..3][0..3] OF REAL := [0,0,0,0];\nEND_TYPE\n') + expect(out).toBe('TYPE\n Matrix : ARRAY [0..3, 0..3] OF REAL := [0,0,0,0];\nEND_TYPE\n') + }) + + it('rebuilds structure array fields from their structured shape', () => { + const dt: PLCDataType = { + name: 'Rec', + derivation: 'structure', + variable: [ + { + name: 'samples', + type: { + definition: 'array', + value: 'ARRAY [1..5, 1..3] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '1..5' }, { dimension: '1..3' }], + }, + }, + }, + // Legacy records may carry an array definition without the + // structured `data` — the display value is all we have. + { name: 'legacy', type: { definition: 'array', value: 'ARRAY [0..1] OF BOOL' } }, + ], + } + expect(serializeDataTypesToST([dt])).toBe( + 'TYPE\n' + + ' Rec : STRUCT\n' + + ' samples : ARRAY [1..5, 1..3] OF INT;\n' + + ' legacy : ARRAY [0..1] OF BOOL;\n' + + ' END_STRUCT;\n' + + 'END_TYPE\n', + ) + }) + + it('collapses newlines in structure field documentation', () => { + // A multi-line comment would desync serializeDataTypesToLines' + // line map and be unreadable by the .dt parser. + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + documentation: 'target speed\nin rpm', + }, + ], + } + expect(serializeDataTypesToST([dt])).toContain(' speed : INT; (* target speed in rpm *)\n') + }) + + it('renders structure field documentation as a trailing comment', () => { + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { + name: 'speed', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '100' } }, + documentation: 'target speed in rpm', + }, + ], + } + expect(serializeDataTypesToST([dt])).toBe( + 'TYPE\n' + + ' Motor : STRUCT\n' + + ' speed : INT := 100; (* target speed in rpm *)\n' + + ' END_STRUCT;\n' + + 'END_TYPE\n', + ) }) it('packs multiple entries in one block in declaration order', () => { @@ -145,7 +221,7 @@ describe('serializeDataTypesToLines', () => { it('reports a single line for an array (regardless of dimensions)', () => { const entries = serializeDataTypesToLines([array('Buffer', 'INT', ['0..9', '0..3'])]) - expect(entries).toEqual([{ name: 'Buffer', lines: [' Buffer : ARRAY [0..9][0..3] OF INT;'] }]) + expect(entries).toEqual([{ name: 'Buffer', lines: [' Buffer : ARRAY [0..9, 0..3] OF INT;'] }]) }) it('drops zero-line entries from the result', () => { @@ -154,6 +230,23 @@ describe('serializeDataTypesToLines', () => { expect(entries).toEqual([{ name: 'Color', lines: [' Color : (Red);'] }]) }) + it('serializeDataTypeToText frames a single entry as its own TYPE block', () => { + expect(serializeDataTypeToText(enumerated('Color', ['Red', 'Green'], 'Red'))).toBe( + 'TYPE\n Color : (Red, Green) := Red;\nEND_TYPE\n', + ) + expect(serializeDataTypeToText(structure('Point', [{ name: 'x', type: 'INT' }]))).toBe( + 'TYPE\n Point : STRUCT\n x : INT;\n END_STRUCT;\nEND_TYPE\n', + ) + expect(serializeDataTypeToText(array('Buffer', 'INT', ['0..9']))).toBe( + 'TYPE\n Buffer : ARRAY [0..9] OF INT;\nEND_TYPE\n', + ) + }) + + it('serializeDataTypeToText returns an empty string for an unknown derivation', () => { + const unknown = { name: 'Mystery', derivation: 'pointer' } as unknown as PLCDataType + expect(serializeDataTypeToText(unknown)).toBe('') + }) + it('lines from serializeDataTypesToLines roundtrip into serializeDataTypesToST', () => { // Lock the invariant: the flat ST output is exactly TYPE + // join(lines, '\n') + END_TYPE. goto-definition-redirect @@ -167,3 +260,50 @@ describe('serializeDataTypesToLines', () => { expect(reconstructed).toBe(flat) }) }) + +describe('dataTypeLineSpans', () => { + it('places entries after the TYPE frame and accumulates multi-line spans', () => { + const spans = dataTypeLineSpans([ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [ + { name: 'speed', type: { definition: 'base-type', value: 'INT' } }, + { name: 'torque', type: { definition: 'base-type', value: 'INT' } }, + ], + }, + { + name: 'Buffer', + derivation: 'array', + dimensions: [{ dimension: '0..9' }], + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '', + }, + ]) + + // Line 0 is `TYPE`; the struct occupies declaration + 2 fields + END_STRUCT. + expect(spans.get('Colors')).toEqual({ start: 1, length: 1 }) + expect(spans.get('Motor')).toEqual({ start: 2, length: 4 }) + expect(spans.get('Buffer')).toEqual({ start: 6, length: 1 }) + }) + + it('agrees with the rendered aggregate document', () => { + const dataTypes: PLCDataType[] = [ + { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' }, + { + name: 'Motor', + derivation: 'structure', + variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }], + }, + ] + const lines = serializeDataTypesToST(dataTypes).split('\n') + for (const [name, span] of dataTypeLineSpans(dataTypes)) { + expect(lines[span.start]).toContain(name) + } + }) + + it('is empty for no data types', () => { + expect(dataTypeLineSpans([]).size).toBe(0) + }) +}) diff --git a/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts new file mode 100644 index 000000000..689bfe16c --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/data-type-text-parser.test.ts @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Tests for the `.dt` text parser — the inverse of + * `serializeDataTypeToText`. The core invariant is the round-trip: + * `parse(serialize(x))` must deep-equal `x` for every shape the + * visual editor can build. Everything else is error reporting. + */ +import type { PLCDataType } from '../../../../middleware/shared/ports/types' +import { serializeDataTypeToText } from '../data-type-serializer' +import { parseDataTypeFromText } from '../data-type-text-parser' + +const roundTrip = (dt: PLCDataType) => parseDataTypeFromText(serializeDataTypeToText(dt), dt.name) + +describe('parseDataTypeFromText round-trips', () => { + it('round-trips an enumerated type without an initial value', () => { + const dt: PLCDataType = { + name: 'Color', + derivation: 'enumerated', + values: [{ description: 'Red' }, { description: 'Green' }, { description: 'Blue' }], + initialValue: '', + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an enumerated type with an initial value', () => { + const dt: PLCDataType = { + name: 'Mode', + derivation: 'enumerated', + values: [{ description: 'Auto' }, { description: 'Manual' }], + initialValue: 'Auto', + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an empty enumeration (freshly created in the UI)', () => { + const dt: PLCDataType = { name: 'Empty', derivation: 'enumerated', values: [], initialValue: '' } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a structure with base, user, and array fields', () => { + const dt: PLCDataType = { + name: 'Motor', + derivation: 'structure', + variable: [ + { name: 'speed', type: { definition: 'base-type', value: 'INT' } }, + { name: 'status', type: { definition: 'user-data-type', value: 'MotorState' } }, + { + name: 'samples', + type: { + definition: 'array', + value: 'ARRAY [1..5, 1..3] OF INT', + data: { + baseType: { definition: 'base-type', value: 'INT' }, + dimensions: [{ dimension: '1..5' }, { dimension: '1..3' }], + }, + }, + }, + ], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips structure field initial values and documentation', () => { + const dt: PLCDataType = { + name: 'Config', + derivation: 'structure', + variable: [ + { + name: 'rate', + type: { definition: 'base-type', value: 'INT' }, + initialValue: { simpleValue: { value: '100' } }, + documentation: 'sampling rate in ms', + }, + { + name: 'enabled', + type: { definition: 'base-type', value: 'BOOL' }, + initialValue: { simpleValue: { value: 'TRUE' } }, + }, + ], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips an empty structure (freshly created in the UI)', () => { + const dt: PLCDataType = { name: 'Shell', derivation: 'structure', variable: [] } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a single-dimension array', () => { + const dt: PLCDataType = { + name: 'Buffer', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '', + dimensions: [{ dimension: '0..9' }], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) + + it('round-trips a multi-dimension array of a user type with an initial value', () => { + const dt: PLCDataType = { + name: 'Grid', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'Cell' }, + initialValue: '[c1, c2]', + dimensions: [{ dimension: '0..3' }, { dimension: '0..3' }], + } + expect(roundTrip(dt)).toEqual({ dataType: dt }) + }) +}) + +describe('parseDataTypeFromText tolerance', () => { + it('accepts keyword-case and whitespace variations', () => { + const text = 'type\r\n\r\n color : (Red, Green) ;\r\nend_type\r\n' + const result = parseDataTypeFromText(text) + expect(result.error).toBeUndefined() + expect(result.dataType).toEqual({ + name: 'color', + derivation: 'enumerated', + values: [{ description: 'Red' }, { description: 'Green' }], + initialValue: '', + }) + }) + + it('accepts a case-insensitive name match and normalises to the expected name', () => { + const result = parseDataTypeFromText('TYPE\n color : (Red);\nEND_TYPE\n', 'Color') + expect(result.error).toBeUndefined() + expect(result.dataType?.name).toBe('Color') + }) + + it('accepts END_STRUCT with spaced semicolon and lowercase struct keywords', () => { + const text = 'TYPE\n Point : struct\n x : int;\n end_struct ;\nEND_TYPE\n' + const result = parseDataTypeFromText(text, 'Point') + expect(result.error).toBeUndefined() + expect(result.dataType).toEqual({ + name: 'Point', + derivation: 'structure', + variable: [{ name: 'x', type: { definition: 'base-type', value: 'INT' } }], + }) + }) +}) + +describe('parseDataTypeFromText errors', () => { + it('rejects an empty file', () => { + expect(parseDataTypeFromText('').error).toMatch(/empty file/) + expect(parseDataTypeFromText(' \n \n').error).toMatch(/empty file/) + }) + + it('rejects a missing TYPE frame', () => { + expect(parseDataTypeFromText('Color : (Red);\nEND_TYPE\n').error).toMatch(/must start with a TYPE/) + expect(parseDataTypeFromText('TYPE\n Color : (Red);\n').error).toMatch(/must end with END_TYPE/) + }) + + it('rejects a TYPE block with no declaration', () => { + expect(parseDataTypeFromText('TYPE\nEND_TYPE\n').error).toMatch(/declares no data type/) + }) + + it('rejects more than one declaration per file', () => { + const twoEnums = 'TYPE\n A : (X);\n B : (Y);\nEND_TYPE\n' + expect(parseDataTypeFromText(twoEnums).error).toMatch(/exactly one data type/) + const structPlusEnum = 'TYPE\n P : STRUCT\n x : INT;\n END_STRUCT;\n B : (Y);\nEND_TYPE\n' + expect(parseDataTypeFromText(structPlusEnum).error).toMatch(/exactly one data type/) + }) + + it('rejects a structure without END_STRUCT', () => { + expect(parseDataTypeFromText('TYPE\n P : STRUCT\n x : INT;\nEND_TYPE\n').error).toMatch(/missing END_STRUCT/) + }) + + it('rejects invalid structure fields with a hint', () => { + const missingSemicolon = 'TYPE\n P : STRUCT\n x : INT\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(missingSemicolon).error).toMatch(/missing semicolon/) + const missingColon = 'TYPE\n P : STRUCT\n x INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(missingColon).error).toMatch(/missing colon/) + const badFieldType = 'TYPE\n P : STRUCT\n x : MY TYPE;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(badFieldType).error).toMatch(/invalid structure field/) + }) + + it('rejects invalid enumeration values', () => { + expect(parseDataTypeFromText('TYPE\n Color : (Red, 2bad);\nEND_TYPE\n').error).toMatch(/invalid enumeration value/) + }) + + it('rejects invalid structure field names', () => { + const badName = 'TYPE\n P : STRUCT\n 2bad : INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(badName).error).toMatch(/invalid structure field name: "2bad"/) + // Reserved words are rejected too — same isLegalIdentifier rule + // the structure form enforces at entry. + const keywordName = 'TYPE\n P : STRUCT\n IF : INT;\n END_STRUCT;\nEND_TYPE\n' + expect(parseDataTypeFromText(keywordName).error).toMatch(/invalid structure field name: "IF" — is a reserved word/) + }) + + it('rejects unrecognized declarations with a hint', () => { + expect(parseDataTypeFromText('TYPE\n Foo\nEND_TYPE\n').error).toMatch(/missing semicolon/) + expect(parseDataTypeFromText('TYPE\n Foo Bar;\nEND_TYPE\n').error).toMatch(/missing colon/) + expect(parseDataTypeFromText('TYPE\n Foo : ?!;\nEND_TYPE\n').error).toMatch(/unrecognized declaration format/) + }) + + it('rejects an invalid type name', () => { + expect(parseDataTypeFromText('TYPE\n 2bad : (Red);\nEND_TYPE\n').error).toMatch(/invalid type name/) + expect(parseDataTypeFromText('TYPE\n ARRAY : (Red);\nEND_TYPE\n').error).toMatch( + /invalid type name: "ARRAY" — is a reserved word/, + ) + }) + + it('rejects a declared name that does not match the expected name', () => { + const result = parseDataTypeFromText('TYPE\n Other : (Red);\nEND_TYPE\n', 'Color') + expect(result.error).toMatch(/does not match the expected name "Color"/) + expect(result.error).toMatch(/rename the data type via the project tree/) + }) +}) diff --git a/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts b/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts index a609e96de..4bfef9ce5 100644 --- a/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts +++ b/src/frontend/utils/PLC/__tests__/validate-variable-type.test.ts @@ -1,4 +1,9 @@ -import { getVariableRestrictionType, validateVariableType } from '../validate-variable-type' +import { + getVariableRestrictionType, + isGenericTypeName, + resolveNewVariableType, + validateVariableType, +} from '../validate-variable-type' describe('validateVariableType', () => { it('accepts anything for the bare ANY generic', () => { @@ -128,6 +133,10 @@ describe('getVariableRestrictionType', () => { expect(restriction.values).toContain('TIME') }) + it('keeps the single-entry shape for a singleton generic', () => { + expect(getVariableRestrictionType('ANY_STRING')).toEqual({ values: ['STRING'], definition: 'base-type' }) + }) + it('returns the concrete base-type name uppercased', () => { expect(getVariableRestrictionType('BOOL')).toEqual({ values: 'BOOL', definition: 'base-type' }) }) @@ -139,3 +148,103 @@ describe('getVariableRestrictionType', () => { }) }) }) + +describe('isGenericTypeName', () => { + it('recognises ANY and every ANY_* family, case-insensitively', () => { + expect(isGenericTypeName('ANY')).toBe(true) + expect(isGenericTypeName('any')).toBe(true) + expect(isGenericTypeName('ANY_NUM')).toBe(true) + expect(isGenericTypeName('any_int')).toBe(true) + expect(isGenericTypeName('ANY_ELEMENTARY')).toBe(true) + }) + + it('rejects concrete and user-defined type names', () => { + expect(isGenericTypeName('INT')).toBe(false) + expect(isGenericTypeName('BOOL')).toBe(false) + expect(isGenericTypeName('TIME')).toBe(false) + expect(isGenericTypeName('MyStruct')).toBe(false) + expect(isGenericTypeName('')).toBe(false) + }) +}) + +describe('resolveNewVariableType', () => { + it('falls back to DINT when the box is not wired to any pin', () => { + expect(resolveNewVariableType(undefined)).toEqual({ definition: 'base-type', value: 'dint' }) + }) + + it('mirrors a concrete pin type, ignoring any bound siblings', () => { + expect(resolveNewVariableType('TIME')).toEqual({ definition: 'base-type', value: 'TIME' }) + expect(resolveNewVariableType('BOOL', [{ pinType: 'ANY', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'BOOL', + }) + }) + + it('keeps a derived (user-defined) pin type as-is', () => { + expect(resolveNewVariableType('MyStruct')).toEqual({ definition: 'derived', value: 'MyStruct' }) + }) + + describe('generic pins (issue #479)', () => { + it('adopts the type already bound to another generic pin of the same block', () => { + // MOVE: IN : ANY bound to an INT, so OUT : ANY must be an INT too. + expect(resolveNewVariableType('ANY', [{ pinType: 'ANY', variableType: 'INT' }])).toEqual({ + definition: 'base-type', + value: 'INT', + }) + // ADD: IN1 : ANY_NUM bound to a REAL — used to create a SINT. + expect(resolveNewVariableType('ANY_NUM', [{ pinType: 'ANY_NUM', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'REAL', + }) + }) + + it('accepts a user-defined type bound to an ANY pin', () => { + expect(resolveNewVariableType('ANY', [{ pinType: 'ANY', variableType: 'MyStruct' }])).toEqual({ + definition: 'derived', + value: 'MyStruct', + }) + }) + + it('ignores siblings sitting on concrete pins', () => { + // MOVE's EN : BOOL says nothing about how the ANY pins resolved. + expect(resolveNewVariableType('ANY_NUM', [{ pinType: 'BOOL', variableType: 'BOOL' }])).toEqual({ + definition: 'base-type', + value: 'DINT', + }) + }) + + it('ignores siblings whose type the pin would reject', () => { + expect(resolveNewVariableType('ANY_INT', [{ pinType: 'ANY_NUM', variableType: 'REAL' }])).toEqual({ + definition: 'base-type', + value: 'DINT', + }) + }) + + it('ignores siblings with no bound type, or a still-generic one', () => { + expect( + resolveNewVariableType('ANY_INT', [ + { pinType: 'ANY_INT', variableType: '' }, + { pinType: 'ANY_INT', variableType: 'ANY_INT' }, + { pinType: 'ANY_INT', variableType: 'LINT' }, + ]), + ).toEqual({ definition: 'base-type', value: 'LINT' }) + }) + + it('prefers DINT over the first flattened entry when there is nothing to infer from', () => { + // The flattened ANY_NUM/ANY_INT sets start at SINT — the old default. + expect(resolveNewVariableType('ANY_NUM')).toEqual({ definition: 'base-type', value: 'DINT' }) + expect(resolveNewVariableType('ANY_INT')).toEqual({ definition: 'base-type', value: 'DINT' }) + expect(resolveNewVariableType('ANY')).toEqual({ definition: 'base-type', value: 'DINT' }) + }) + + it('honours the restriction when DINT is not in it', () => { + expect(resolveNewVariableType('ANY_BIT')).toEqual({ definition: 'base-type', value: 'BOOL' }) + expect(resolveNewVariableType('ANY_REAL')).toEqual({ definition: 'base-type', value: 'REAL' }) + expect(resolveNewVariableType('ANY_STRING')).toEqual({ definition: 'base-type', value: 'STRING' }) + }) + + it('falls back to DINT for an unknown generic name', () => { + expect(resolveNewVariableType('ANY_NOT_A_GENERIC')).toEqual({ definition: 'base-type', value: 'dint' }) + }) + }) +}) diff --git a/src/frontend/utils/PLC/data-type-serializer.ts b/src/frontend/utils/PLC/data-type-serializer.ts index a6e7b9305..c9ece49c0 100644 --- a/src/frontend/utils/PLC/data-type-serializer.ts +++ b/src/frontend/utils/PLC/data-type-serializer.ts @@ -35,6 +35,12 @@ import type { PLCDataType, PLCVariableType } from '../../../middleware/shared/ports/types' function renderVariableType(type: PLCVariableType): string { + // Rebuild array types from their structured shape — `value` is a + // display string that legacy records may hold in a lossy form. + if (type.definition === 'array' && type.data) { + const dims = type.data.dimensions.map((d) => d.dimension).join(', ') + return `ARRAY [${dims}] OF ${type.data.baseType.value}` + } return type.value } @@ -47,15 +53,19 @@ function renderEnumeratedLines(dt: Extract): string[] { const fieldLines = dt.variable.map((v) => { const init = v.initialValue?.simpleValue?.value ? ` := ${v.initialValue.simpleValue.value}` : '' - return ` ${v.name} : ${renderVariableType(v.type)}${init};` + // Newlines collapsed: a multi-line comment would desync the + // line map (goto-definition) and be unreadable by the parser. + const doc = v.documentation ? ` (* ${v.documentation.replace(/\s*\r?\n\s*/g, ' ')} *)` : '' + return ` ${v.name} : ${renderVariableType(v.type)}${init};${doc}` }) return [` ${dt.name} : STRUCT`, ...fieldLines, ` END_STRUCT;`] } function renderArrayLines(dt: Extract): string[] { - const dims = dt.dimensions.map((d) => `[${d.dimension}]`).join('') + // IEC 61131-3 multi-dimension syntax: one bracket, comma-separated. + const dims = dt.dimensions.map((d) => d.dimension).join(', ') const initial = dt.initialValue ? ` := ${dt.initialValue}` : '' - return [` ${dt.name} : ARRAY ${dims} OF ${renderVariableType(dt.baseType)}${initial};`] + return [` ${dt.name} : ARRAY [${dims}] OF ${renderVariableType(dt.baseType)}${initial};`] } function renderDataTypeLines(dt: PLCDataType): string[] { @@ -101,6 +111,33 @@ export function serializeDataTypesToLines(dataTypes: PLCDataType[]): SerializedD return out } +/** Where one data type's lines sit inside the aggregate `TYPE…END_TYPE` block. */ +export interface DataTypeLineSpan { + /** 0-indexed first line of the entry in the aggregate document. */ + start: number + /** Line count of the entry. */ + length: number +} + +/** + * Line spans of every entry in the aggregate document, keyed by name. + * Line 0 is the `TYPE` frame, so entries start at 1. + * + * The per-type `.dt` code view renders the same lines under its own + * `TYPE…END_TYPE` frame, so `start - 1` is the shift between the two + * frames — that is what the LSP layer needs to talk to the aggregate + * document on a per-type buffer's behalf. + */ +export function dataTypeLineSpans(dataTypes: PLCDataType[]): Map { + const spans = new Map() + let start = 1 + for (const entry of serializeDataTypesToLines(dataTypes)) { + spans.set(entry.name, { start, length: entry.lines.length }) + start += entry.lines.length + } + return spans +} + /** * Serialise every entry in `dataTypes` to a single ST `TYPE` block. * Returns `''` when there's nothing to emit — the LSP sync layer @@ -115,3 +152,17 @@ export function serializeDataTypesToST(dataTypes: PLCDataType[]): string { const body = entries.flatMap((e) => e.lines).join('\n') return `TYPE\n${body}\nEND_TYPE\n` } + +/** + * Serialise ONE data type to its on-disk `.dt` file content — a + * `TYPE…END_TYPE` block holding a single declaration. This is the + * canonical persistence format (`datatypes/.dt`); its inverse + * is `parseDataTypeFromText` in `data-type-text-parser.ts`, and the + * pair must round-trip. Returns `''` for a derivation that renders + * to no lines (unknown shape). + */ +export function serializeDataTypeToText(dt: PLCDataType): string { + const lines = renderDataTypeLines(dt) + if (lines.length === 0) return '' + return `TYPE\n${lines.join('\n')}\nEND_TYPE\n` +} diff --git a/src/frontend/utils/PLC/data-type-text-parser.ts b/src/frontend/utils/PLC/data-type-text-parser.ts new file mode 100644 index 000000000..3f9113db1 --- /dev/null +++ b/src/frontend/utils/PLC/data-type-text-parser.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Parse a single data-type declaration from its on-disk `.dt` text + * (an ST `TYPE…END_TYPE` block) back into a `PLCDataType`. + * + * Inverse of `serializeDataTypeToText` in `data-type-serializer.ts`. + * The parser accepts exactly the shapes the serializer emits — + * tolerant to whitespace and keyword case — so `parse(serialize(x))` + * round-trips. Anything the visual editor cannot represent is a + * parse error, never a silent drop: the code view keeps the user on + * the text until it is valid, and the project loader falls back to + * preserving the raw file content. + * + * When `expectedName` is given, the declared name must match it + * (case-insensitive) — the file name is the type's identity, so + * renaming happens through the project tree, not by editing text. + */ +import { baseTypeSchema } from '../../../middleware/shared/ports/plc-schemas' +import type { PLCDataType, PLCStructureVariable, PLCVariableType } from '../../../middleware/shared/ports/types' +import { parseArrayType } from '../generate-iec-string-to-variables' +import { isLegalIdentifier } from '../keywords' + +export interface ParseDataTypeResult { + dataType?: PLCDataType + error?: string +} + +const identifierRegex = /^[A-Za-z_]\w*$/ + +const structStartRegex = /^(?\w+)\s*:\s*STRUCT$/i + +const structEndRegex = /^END_STRUCT\s*;$/i + +// Name : (Value1, Value2) := Initial ; +const enumRegex = /^(?\w+)\s*:\s*\((?[^)]*)\)\s*(?::=\s*(?[^;]+?))?\s*;$/ + +// Name : ARRAY [d1, d2] OF Base := Initial ; +const arrayRegex = + /^(?\w+)\s*:\s*(?ARRAY\s*\[[^\]]+\]\s+OF\s+[A-Za-z_][\w.]*)\s*(?::=\s*(?[^;]+?))?\s*;$/i + +// FieldName : Type := Initial ; (* documentation *) +const fieldRegex = + /^(?\w+)\s*:\s*(?[\w\s[\],.]+?)\s*(?::=\s*(?[^;]+?))?\s*;\s*(?:\(\*\s*(?.*?)\s*\*\))?$/ + +const guessErrorReason = (line: string): string => { + if (!line.includes(';')) return 'missing semicolon (;) at the end of the declaration' + if (!line.includes(':')) return 'missing colon (:) between name and type' + return 'unrecognized declaration format' +} + +function buildFieldType(typeStr: string): PLCVariableType | null { + const arrayType = parseArrayType(typeStr) + if (arrayType) return arrayType + const baseCheck = baseTypeSchema.safeParse(typeStr) + if (baseCheck.success) return { definition: 'base-type', value: baseCheck.data } + if (identifierRegex.test(typeStr)) return { definition: 'user-data-type', value: typeStr } + return null +} + +function parseStructure(name: string, body: string[]): ParseDataTypeResult { + const endIndex = body.findIndex((line) => structEndRegex.test(line)) + if (endIndex === -1) return { error: 'missing END_STRUCT; to close the structure' } + if (endIndex !== body.length - 1) return { error: 'a .dt file must declare exactly one data type' } + + const variable: PLCStructureVariable[] = [] + for (const line of body.slice(1, endIndex)) { + const match = fieldRegex.exec(line) + const groups = match?.groups + const type = groups?.type !== undefined ? buildFieldType(groups.type) : null + if (groups?.name === undefined || type === null) { + return { error: `invalid structure field: "${line}". Possible cause: ${guessErrorReason(line)}` } + } + const [fieldNameLegal, fieldNameReason] = isLegalIdentifier(groups.name) + if (!fieldNameLegal) { + return { error: `invalid structure field name: "${groups.name}" — ${fieldNameReason}` } + } + variable.push({ + name: groups.name, + type, + ...(groups.initial !== undefined ? { initialValue: { simpleValue: { value: groups.initial.trim() } } } : {}), + ...(groups.documentation !== undefined && groups.documentation !== '' + ? { documentation: groups.documentation } + : {}), + }) + } + return { dataType: { name, derivation: 'structure', variable } } +} + +function parseSingleLine(line: string): ParseDataTypeResult { + const enumMatch = enumRegex.exec(line) + if (enumMatch?.groups?.name !== undefined) { + const raw = enumMatch.groups.values.trim() + const values = raw === '' ? [] : raw.split(',').map((v) => v.trim()) + const invalid = values.find((v) => !identifierRegex.test(v)) + if (invalid !== undefined) return { error: `invalid enumeration value: "${invalid}"` } + return { + dataType: { + name: enumMatch.groups.name, + derivation: 'enumerated', + values: values.map((description) => ({ description })), + initialValue: enumMatch.groups.initial?.trim() ?? '', + }, + } + } + + const arrayMatch = arrayRegex.exec(line) + if (arrayMatch?.groups?.name !== undefined) { + const arrayType = parseArrayType(arrayMatch.groups.type) + if (arrayType?.data) { + return { + dataType: { + name: arrayMatch.groups.name, + derivation: 'array', + baseType: arrayType.data.baseType, + initialValue: arrayMatch.groups.initial?.trim() ?? '', + dimensions: arrayType.data.dimensions, + }, + } + } + } + + return { error: `invalid declaration: "${line}". Possible cause: ${guessErrorReason(line)}` } +} + +export function parseDataTypeFromText(content: string, expectedName?: string): ParseDataTypeResult { + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== '') + + if (lines.length === 0) return { error: 'empty file — expected a TYPE…END_TYPE declaration' } + if (!/^TYPE$/i.test(lines[0])) return { error: 'the declaration must start with a TYPE block' } + if (!/^END_TYPE$/i.test(lines[lines.length - 1])) return { error: 'the declaration must end with END_TYPE' } + + const body = lines.slice(1, -1) + if (body.length === 0) return { error: 'the TYPE block declares no data type' } + + const structMatch = structStartRegex.exec(body[0]) + const structName = structMatch?.groups?.name + const result = + structName !== undefined + ? parseStructure(structName, body) + : body.length > 1 + ? { error: 'a .dt file must declare exactly one data type' } + : parseSingleLine(body[0]) + + if (result.dataType === undefined) return result + // Same rule the UI enforces at creation/rename (keywords, literals, + // identifier shape) — a hand-written file can't smuggle in a name + // the editor would refuse to work with. + const [nameLegal, nameReason] = isLegalIdentifier(result.dataType.name) + if (!nameLegal) { + return { error: `invalid type name: "${result.dataType.name}" — ${nameReason}` } + } + + if (expectedName !== undefined) { + if (result.dataType.name.toLowerCase() !== expectedName.toLowerCase()) { + return { + error: `declared type name "${result.dataType.name}" does not match the expected name "${expectedName}" — rename the data type via the project tree instead`, + } + } + result.dataType.name = expectedName + } + return result +} diff --git a/src/frontend/utils/PLC/validate-variable-type.ts b/src/frontend/utils/PLC/validate-variable-type.ts index f59c4aec4..fdf2d143d 100644 --- a/src/frontend/utils/PLC/validate-variable-type.ts +++ b/src/frontend/utils/PLC/validate-variable-type.ts @@ -151,3 +151,80 @@ export const getVariableRestrictionType = (variableType: string) => { definition: isABaseType.success ? 'base-type' : 'derived', } } + +/** + * A pin of the same block instance that already has a variable bound to it: + * the pin's DECLARED type (`pinType`, possibly generic) plus the CONCRETE type + * of the variable sitting on it (`variableType`). + */ +export type BoundBlockPin = { + pinType: string + variableType: string +} + +/** + * True for a generic pin type — `ANY` or any `ANY_*` family. Such a pin has no + * type of its own: IEC resolves it from the block's other pins, so it is the + * one case where the editor cannot type a new variable on its own. + * Case-insensitive. + */ +export const isGenericTypeName = (typeName: string): boolean => { + const upper = typeName.toUpperCase() + return upper === 'ANY' || upper.includes('ANY_') +} + +/** Canonical `{definition, value}` for a concrete type name, via the restriction table. */ +const newTypeFromConcrete = (concreteType: string): { definition: string | undefined; value: string } => { + const restriction = getVariableRestrictionType(concreteType) + // Concrete names always come back as a single string — the array shape is + // reserved for `ANY_*` inputs, which never reach here. + const value = Array.isArray(restriction.values) ? restriction.values[0] : restriction.values + return { definition: restriction.definition, value: value ?? 'dint' } +} + +/** + * The `{definition, value}` to type a brand-new variable created from a box's + * expected type. + * + * IEC 61131-3 resolves every generic pin (`ANY`, `ANY_NUM`, …) of one block + * instance to the SAME concrete type. `validateVariableType` only judges a pin + * in isolation — `ANY` accepts anything and `SINT` is a legal `ANY_NUM` — so + * typing a new variable from its own pin alone silently produced a type the + * transpiler then rejects (issue #479: a MOVE fed by an `INT` created a `DINT` + * sink). Feeding the pins already bound on the same block instance lets a + * generic pin adopt the type the block has actually resolved to. + * + * Only siblings sitting on GENERIC pins count: a concrete pin (MOVE's + * `EN : BOOL`) says nothing about how the generic ones resolved. + */ +export const resolveNewVariableType = ( + expectedType: string | undefined, + boundSiblings: BoundBlockPin[] = [], +): { definition: string | undefined; value: string } => { + // Box not wired to any pin — nothing constrains it. + if (!expectedType) return { definition: 'base-type', value: 'dint' } + + const upperExpectedType = expectedType.toUpperCase() + if (!isGenericTypeName(upperExpectedType)) return newTypeFromConcrete(expectedType) + + const inferred = boundSiblings.find( + (sibling) => + isGenericTypeName(sibling.pinType) && + sibling.variableType.length > 0 && + !isGenericTypeName(sibling.variableType) && + validateVariableType(sibling.variableType, upperExpectedType).isValid, + ) + if (inferred) return newTypeFromConcrete(inferred.variableType) + + // Nothing bound yet, so nothing to infer from (first variable on a fresh + // block). Pick a default that at least satisfies the restriction, preferring + // DINT — the IEC default integer, and already what a plain `ANY` fell back to + // — over the first entry of the flattened set, which is SINT for every + // numeric generic. + const flattened = flattenGenericToBaseTypes(upperExpectedType) + if (flattened.includes('DINT')) return { definition: 'base-type', value: 'DINT' } + if (flattened.length > 0) return { definition: 'base-type', value: flattened[0] } + // Unknown generic name (a malformed block definition) — no restriction to + // honour, fall back to the unconstrained default. + return { definition: 'base-type', value: 'dint' } +} diff --git a/src/frontend/utils/__tests__/cn.test.ts b/src/frontend/utils/__tests__/cn.test.ts index 087bbc300..e19d522a6 100644 --- a/src/frontend/utils/__tests__/cn.test.ts +++ b/src/frontend/utils/__tests__/cn.test.ts @@ -1,5 +1,25 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { cn } from '../cn' +/** + * The `cp-*` font scale is declared twice: once in `tailwind.config.ts`, where + * it generates the classes, and once inside `cn()`, where twMerge has to be + * told those names are sizes. Read the scale out of the config rather than + * restating it here, so the "keep this list in step" comment on `cn()` is + * enforced by this suite instead of hoped for — a fourth size added to + * Tailwind and forgotten in `cn()` fails below rather than silently + * evaporating at runtime, which is exactly how the Connect button lost its + * `text-cp-sm` with no warning and no build error. + */ +function tailwindFontSizeNames(): string[] { + const config = readFileSync(join(process.cwd(), 'tailwind.config.ts'), 'utf8') + const block = /fontSize:\s*\{([\s\S]*?)\n\s*\},/.exec(config) + if (!block) throw new Error('No `fontSize` block found in tailwind.config.ts') + return [...block[1].matchAll(/'([\w-]+)':/g)].map(([, name]) => name) +} + describe('cn', () => { it('merges class names', () => { expect(cn('foo', 'bar')).toBe('foo bar') @@ -26,3 +46,38 @@ describe('cn', () => { expect(cn(['foo', 'bar'])).toBe('foo bar') }) }) + +describe('cn — the custom cp-* font scale', () => { + const fontSizes = tailwindFontSizeNames() + + it('reads the scale out of tailwind.config.ts', () => { + expect(fontSizes).toEqual(['cp-xs', 'cp-sm', 'cp-base']) + }) + + it('pins the reported regression: text-cp-sm survives beside text-white', () => { + // Plain twMerge reads both as colours and drops the size, leaving the + // Connect button at the browser default. + expect(cn('text-cp-sm', 'text-white')).toBe('text-cp-sm text-white') + }) + + it('pins the other half: two cp-* sizes conflict, and the last one wins', () => { + expect(cn('text-cp-xs', 'text-cp-base')).toBe('text-cp-base') + }) + + it.each(fontSizes)('keeps text-%s beside a text colour', (size) => { + expect(cn(`text-${size}`, 'text-white')).toBe(`text-${size} text-white`) + }) + + it('resolves any pair of cp-* sizes to the later one', () => { + for (const first of fontSizes) { + for (const second of fontSizes) { + if (first === second) continue + expect(cn(`text-${first}`, `text-${second}`)).toBe(`text-${second}`) + } + } + }) + + it('still lets a cp-* size override a stock Tailwind size', () => { + expect(cn('text-sm', 'text-cp-base')).toBe('text-cp-base') + }) +}) diff --git a/src/frontend/utils/__tests__/data-type-references.test.ts b/src/frontend/utils/__tests__/data-type-references.test.ts new file mode 100644 index 000000000..9a8a58918 --- /dev/null +++ b/src/frontend/utils/__tests__/data-type-references.test.ts @@ -0,0 +1,254 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../../middleware/shared/ports/types' +import { + findAllReferencesToDataType, + GLOBAL_VARIABLES_CONTAINER, + renameDataTypeInDataType, + renameDataTypeInVariableType, + variableTypeReferencesDataType, +} from '../data-type-references' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const directType = (typeName: string): PLCVariableType => ({ definition: 'user-data-type', value: typeName }) + +const arrayType = (typeName: string, dimensions: string[] = ['0..4']): PLCVariableType => ({ + definition: 'array', + value: `ARRAY [${dimensions.join(', ')}] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: dimensions.map((dimension) => ({ dimension })), + }, +}) + +const baseType = (value = 'INT'): PLCVariableType => ({ definition: 'base-type', value }) + +const makeVariable = (name: string, type: PLCVariableType): PLCVariable => ({ + name, + class: 'local', + type, + location: '', + documentation: '', +}) + +const makePou = (name: string, variables: PLCVariable[]): PLCPou => ({ + name, + pouType: 'program', + interface: { variables }, + body: { language: 'st', value: '' }, + documentation: '', +}) + +// --------------------------------------------------------------------------- +// variableTypeReferencesDataType +// --------------------------------------------------------------------------- + +describe('variableTypeReferencesDataType', () => { + it('matches a direct user-data-type reference case-insensitively', () => { + expect(variableTypeReferencesDataType(directType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('motordef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('Other'), 'MotorDef')).toBe(false) + }) + + it('matches an array base type reference', () => { + expect(variableTypeReferencesDataType(arrayType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(arrayType('Other'), 'MotorDef')).toBe(false) + }) + + it('ignores arrays without structured data', () => { + const lossy: PLCVariableType = { definition: 'array', value: 'ARRAY [0..4] OF MotorDef' } + expect(variableTypeReferencesDataType(lossy, 'MotorDef')).toBe(false) + }) + + it('ignores arrays of base types', () => { + const ints: PLCVariableType = { + definition: 'array', + value: 'ARRAY [0..4] OF INT', + data: { baseType: { definition: 'base-type', value: 'INT' }, dimensions: [{ dimension: '0..4' }] }, + } + expect(variableTypeReferencesDataType(ints, 'MotorDef')).toBe(false) + }) + + it('ignores base-type and derived references', () => { + expect(variableTypeReferencesDataType(baseType(), 'MotorDef')).toBe(false) + expect(variableTypeReferencesDataType({ definition: 'derived', value: 'MotorDef' }, 'MotorDef')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// findAllReferencesToDataType +// --------------------------------------------------------------------------- + +describe('findAllReferencesToDataType', () => { + const pous: PLCPou[] = [ + makePou('Main', [ + makeVariable('motor', directType('MotorDef')), + makeVariable('motors', arrayType('motordef')), + makeVariable('plain', baseType()), + ]), + makePou('Aux', [makeVariable('other', directType('Unrelated'))]), + { name: 'NoInterface', pouType: 'program', body: { language: 'st', value: '' }, documentation: '' }, + ] + + const globalVariables: PLCVariable[] = [ + { ...makeVariable('gMotor', directType('MotorDef')), class: 'global' }, + { ...makeVariable('gPlain', baseType()), class: 'global' }, + ] + + const dataTypes: PLCDataType[] = [ + { name: 'MotorDef', derivation: 'structure', variable: [{ name: 'speed', type: baseType() }] }, + { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'rear', type: arrayType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + }, + { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }, + { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] }, + ] + + it('collects references from POU variables, globals, and other data types', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(6) + expect(impact.references).toEqual([ + { kind: 'pou-variable', container: 'Main', variableName: 'motor' }, + { kind: 'pou-variable', container: 'Main', variableName: 'motors' }, + { kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: 'gMotor' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'front' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'rear' }, + { kind: 'data-type-base-type', container: 'MotorBank' }, + ]) + }) + + it('aggregates counts by container and by reference kind', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(Array.from(impact.byPou.entries())).toEqual([ + ['Main', 2], + [GLOBAL_VARIABLES_CONTAINER, 1], + ['Chassis', 2], + ['MotorBank', 1], + ]) + expect(Array.from(impact.byEditorType.entries())).toEqual([ + ['POU variables', 2], + ['global variables', 1], + ['data types', 3], + ]) + }) + + it('returns an empty analysis when nothing references the type', () => { + const impact = findAllReferencesToDataType('Ghost', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(0) + expect(impact.byPou.size).toBe(0) + expect(impact.byEditorType.size).toBe(0) + expect(impact.references).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInVariableType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInVariableType', () => { + it('renames a direct reference and keeps the rest of the type', () => { + expect(renameDataTypeInVariableType(directType('motordef'), 'MotorDef', 'DriveDef')).toEqual({ + definition: 'user-data-type', + value: 'DriveDef', + }) + }) + + it('renames an array base type and rebuilds the display value', () => { + const next = renameDataTypeInVariableType(arrayType('MotorDef', ['0..4', '1..2']), 'MotorDef', 'DriveDef') + expect(next).toEqual({ + definition: 'array', + value: 'ARRAY [0..4, 1..2] OF DriveDef', + data: { + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + dimensions: [{ dimension: '0..4' }, { dimension: '1..2' }], + }, + }) + }) + + it('returns null for types that do not reference the old name', () => { + expect(renameDataTypeInVariableType(directType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(arrayType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(baseType(), 'MotorDef', 'DriveDef')).toBeNull() + expect( + renameDataTypeInVariableType({ definition: 'array', value: 'ARRAY [0..4] OF MotorDef' }, 'MotorDef', 'DriveDef'), + ).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInDataType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInDataType', () => { + it('renames only the structure fields that reference the type', () => { + const chassis: PLCDataType = { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + } + expect(renameDataTypeInDataType(chassis, 'MotorDef', 'DriveDef')).toEqual({ + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('DriveDef') }, + { name: 'id', type: baseType() }, + ], + }) + }) + + it('renames an array data type base type', () => { + const bank: PLCDataType = { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + } + expect(renameDataTypeInDataType(bank, 'MotorDef', 'DriveDef')).toEqual({ + name: 'MotorBank', + derivation: 'array', + baseType: directType('DriveDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + }) + + it('returns null when nothing references the type', () => { + const unrelatedStruct: PLCDataType = { + name: 'Point', + derivation: 'structure', + variable: [{ name: 'x', type: baseType() }], + } + const unrelatedArray: PLCDataType = { + name: 'Ints', + derivation: 'array', + baseType: baseType(), + initialValue: '', + dimensions: [{ dimension: '0..1' }], + } + const mode: PLCDataType = { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] } + + expect(renameDataTypeInDataType(unrelatedStruct, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(unrelatedArray, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(mode, 'MotorDef', 'DriveDef')).toBeNull() + }) +}) diff --git a/src/frontend/utils/__tests__/debugger-session.test.ts b/src/frontend/utils/__tests__/debugger-session.test.ts index bd88262c2..8da79331d 100644 --- a/src/frontend/utils/__tests__/debugger-session.test.ts +++ b/src/frontend/utils/__tests__/debugger-session.test.ts @@ -155,6 +155,103 @@ describe('logCompilerEvent', () => { expect(entries[0].id).not.toBe(entries[1].id) }) + // ------------------------------------------------------------------------- + // Carriage-return progress redraws. + // + // arduino-cli rewrites one line with `\r` while downloading. Each chunk has + // to collapse to the frame a terminal would show, and be flagged so the + // console overwrites the previous frame instead of stacking a new entry. + // ------------------------------------------------------------------------- + describe('carriage-return progress output', () => { + /** Collector that also records the per-write `redraw` directive. */ + function createRedrawCollector() { + const writes: { message: string; transient?: boolean; redraw?: boolean }[] = [] + const log: Parameters[1] = (entry, options) => { + writes.push({ message: entry.message, transient: entry.transient, redraw: options?.redraw }) + } + return { writes, log } + } + + it('collapses a multi-frame chunk to the frame left on screen', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\rDownloading 10%\rDownloading 60%\rDownloading 99%' }, log) + + expect(writes).toHaveLength(1) + expect(writes[0].message).toBe('Downloading 99%') + expect(writes[0].redraw).toBe(true) + }) + + it('marks a chunk that ended mid-line as still open', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\rcore 54.94 MiB / 93.67 MiB 58.65%' }, log) + + expect(writes[0].transient).toBe(true) + }) + + it('commits the line when the redraw arrives with a trailing newline', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\rDownloading index: package_index.tar.bz2 downloaded\n' }, log) + + // Still a redraw (it overwrites the progress frame above it) but no + // longer open, so the next download starts a fresh line. + expect(writes[0].redraw).toBe(true) + expect(writes[0].transient).toBe(false) + }) + + // A `\r` at the end of a line is a CRLF terminator, not a redraw. Counting + // it as one loses log lines on Windows: the ordinary line either overwrites + // the live progress line above it, or is itself overwritten by the next + // redraw. Both were reproduced before this was fixed. + it('does not let a Windows CRLF line overwrite the live progress line', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\rDownloading 50%' }, log) + logCompilerEvent({ message: 'Compiling sketch...\r\n' }, log) + + expect(writes).toHaveLength(2) + expect(writes[1].message).toBe('Compiling sketch...') + expect(writes[1].redraw).toBe(false) + }) + + it('survives a CRLF split across two chunks', () => { + // The `\r` and the `\n` arrive in separate stdout events. + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: 'Windows line\r' }, log) + logCompilerEvent({ message: '\n' }, log) + logCompilerEvent({ message: '\rDownloading 5%' }, log) + + expect(writes.map((w) => w.message)).toEqual(['Windows line', 'Downloading 5%']) + // The ordinary line must be closed, or the redraw replaces it. + expect(writes[0].redraw).toBe(false) + expect(writes[0].transient).toBe(false) + }) + + it('still treats a leading carriage return as a redraw', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\rDownloading 60%' }, log) + expect(writes[0].redraw).toBe(true) + expect(writes[0].transient).toBe(true) + }) + + it('leaves ordinary output untouched — no redraw, never transient', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: 'Linking everything together...\n' }, log) + + expect(writes[0].redraw).toBe(false) + expect(writes[0].transient).toBe(false) + }) + + it('preserves indentation on gcc caret lines while trimming block edges', () => { + const { writes, log } = createRedrawCollector() + logCompilerEvent({ message: '\nsketch.ino:3:3: error: nope\n undefinedFunction(42);\n ^~~~~~~\n' }, log) + + expect(writes.map((w) => w.message)).toEqual([ + 'sketch.ino:3:3: error: nope', + ' undefinedFunction(42);', + ' ^~~~~~~', + ]) + }) + }) + describe('with compileError attached (structured strucpp diagnostic)', () => { const sampleErr = { message: 'Cannot assign WSTRING to BOOL', diff --git a/src/frontend/utils/__tests__/device-connect-events.test.ts b/src/frontend/utils/__tests__/device-connect-events.test.ts new file mode 100644 index 000000000..c3f9f17c3 --- /dev/null +++ b/src/frontend/utils/__tests__/device-connect-events.test.ts @@ -0,0 +1,79 @@ +/** + * The decoupled bridge between the device screen's "No Firmware Detected" dialog + * and Build & Upload, which live in different component trees. + * + * Worth pinning despite being three lines: the event NAME is the contract between + * the two sides, and a typo on either would be silent — the dialog's "Build & + * Upload" button would simply do nothing, with no error to trace. These tests only + * ever go through the public functions, so they cannot drift from that name. + */ +import { onDeviceFlashRequest, requestDeviceFlash } from '../device-connect-events' + +describe('device flash-request bridge', () => { + it('delivers a request to a subscriber', () => { + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + requestDeviceFlash() + + expect(handler).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('delivers synchronously, so the dialog handler can rely on it having run', () => { + const order: string[] = [] + const unsubscribe = onDeviceFlashRequest(() => order.push('handler')) + + requestDeviceFlash() + order.push('after dispatch') + + expect(order).toEqual(['handler', 'after dispatch']) + unsubscribe() + }) + + it('delivers to every subscriber', () => { + const first = jest.fn() + const second = jest.fn() + const unsubFirst = onDeviceFlashRequest(first) + const unsubSecond = onDeviceFlashRequest(second) + + requestDeviceFlash() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + unsubFirst() + unsubSecond() + }) + + it('stops delivering once unsubscribed', () => { + // The returned function is used as a React effect cleanup, so a listener that + // survived it would fire once per remount — the user pressing "Build & Upload" + // once would trigger several builds. + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + unsubscribe() + requestDeviceFlash() + + expect(handler).not.toHaveBeenCalled() + }) + + it('unsubscribing one subscriber leaves the others listening', () => { + const kept = jest.fn() + const dropped = jest.fn() + const unsubKept = onDeviceFlashRequest(kept) + const unsubDropped = onDeviceFlashRequest(dropped) + + unsubDropped() + requestDeviceFlash() + + expect(kept).toHaveBeenCalledTimes(1) + expect(dropped).not.toHaveBeenCalled() + unsubKept() + }) + + it('is safe to call with nobody listening', () => { + // The device screen can be closed between the dialog opening and the response. + expect(() => requestDeviceFlash()).not.toThrow() + }) +}) diff --git a/src/frontend/utils/__tests__/feature-flags.test.ts b/src/frontend/utils/__tests__/feature-flags.test.ts new file mode 100644 index 000000000..d4ec25cda --- /dev/null +++ b/src/frontend/utils/__tests__/feature-flags.test.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +import { isDataTypeFilesEnabled } from '../feature-flags' + +describe('feature flags', () => { + it('ships with the .dt data type persistence switched off', () => { + // Flipped to true by the DOPE-385 release PR; until then every + // build must behave exactly like the legacy project.json format. + expect(isDataTypeFilesEnabled()).toBe(false) + }) +}) diff --git a/src/frontend/utils/__tests__/iec-duration.test.ts b/src/frontend/utils/__tests__/iec-duration.test.ts new file mode 100644 index 000000000..4827d8223 --- /dev/null +++ b/src/frontend/utils/__tests__/iec-duration.test.ts @@ -0,0 +1,100 @@ +import { parseDurationLiteral } from '../iec-duration' + +const NS_PER_MS = 1_000_000n +const NS_PER_SEC = 1_000_000_000n +const NS_PER_MIN = 60n * NS_PER_SEC +const NS_PER_HOUR = 60n * NS_PER_MIN +const NS_PER_DAY = 24n * NS_PER_HOUR + +describe('parseDurationLiteral', () => { + it('parses a single component with every unit', () => { + expect(parseDurationLiteral('2d')).toBe(2n * NS_PER_DAY) + expect(parseDurationLiteral('3h')).toBe(3n * NS_PER_HOUR) + expect(parseDurationLiteral('4m')).toBe(4n * NS_PER_MIN) + expect(parseDurationLiteral('10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('250ms')).toBe(250n * NS_PER_MS) + expect(parseDurationLiteral('7us')).toBe(7_000n) + expect(parseDurationLiteral('9ns')).toBe(9n) + }) + + it('accepts the T# / TIME# / LT# / LTIME# prefixes, case-insensitively', () => { + expect(parseDurationLiteral('T#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('t#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('TIME#10S')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('LT#10s')).toBe(10n * NS_PER_SEC) + expect(parseDurationLiteral('ltime#10s')).toBe(10n * NS_PER_SEC) + }) + + it('sums a chain of descending units', () => { + expect(parseDurationLiteral('1h30m')).toBe(NS_PER_HOUR + 30n * NS_PER_MIN) + expect(parseDurationLiteral('T#1d2h3m4s5ms6us7ns')).toBe( + NS_PER_DAY + 2n * NS_PER_HOUR + 3n * NS_PER_MIN + 4n * NS_PER_SEC + 5n * NS_PER_MS + 6_000n + 7n, + ) + }) + + it('round-trips what the watch panel renders', () => { + // formatTimeValue emits prefix-less, at most two components. + expect(parseDurationLiteral('3s800ms')).toBe(3n * NS_PER_SEC + 800n * NS_PER_MS) + expect(parseDurationLiteral('2d3h')).toBe(2n * NS_PER_DAY + 3n * NS_PER_HOUR) + expect(parseDurationLiteral('0s')).toBe(0n) + expect(parseDurationLiteral('-5s')).toBe(-5n * NS_PER_SEC) + }) + + it('allows overflow units (90s is not clamped to a minute)', () => { + expect(parseDurationLiteral('90s')).toBe(90n * NS_PER_SEC) + expect(parseDurationLiteral('T#5000ms')).toBe(5n * NS_PER_SEC) + }) + + it('accepts a sign on either side of the prefix', () => { + expect(parseDurationLiteral('T#-5s')).toBe(-5n * NS_PER_SEC) + expect(parseDurationLiteral('-T#5s')).toBe(-5n * NS_PER_SEC) + expect(parseDurationLiteral('+T#5s')).toBe(5n * NS_PER_SEC) + expect(parseDurationLiteral('+5s')).toBe(5n * NS_PER_SEC) + // Sign on both sides cancels out. + expect(parseDurationLiteral('-T#-5s')).toBe(5n * NS_PER_SEC) + }) + + it('ignores underscore digit separators and internal whitespace', () => { + expect(parseDurationLiteral('T#1_000ms')).toBe(NS_PER_SEC) + expect(parseDurationLiteral('T#1d 2h')).toBe(NS_PER_DAY + 2n * NS_PER_HOUR) + }) + + it('accepts a fraction on the smallest unit', () => { + expect(parseDurationLiteral('1.5s')).toBe(1_500n * NS_PER_MS) + expect(parseDurationLiteral('T#1m0.25s')).toBe(NS_PER_MIN + 250n * NS_PER_MS) + // Sub-nanosecond fractions round half away from zero. + expect(parseDurationLiteral('0.0000000005s')).toBe(1n) + expect(parseDurationLiteral('0.0000000004s')).toBe(0n) + expect(parseDurationLiteral('-1.5s')).toBe(-1_500n * NS_PER_MS) + }) + + it('rejects an empty or unit-less value', () => { + expect(() => parseDurationLiteral('')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral(' ')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('T#')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('10')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('abc')).toThrow(/Invalid TIME value/) + }) + + it('rejects trailing junk after a valid component', () => { + expect(() => parseDurationLiteral('10s!')).toThrow(/Invalid TIME value/) + expect(() => parseDurationLiteral('10sec')).toThrow(/Invalid TIME value/) + }) + + it('rejects repeated or ascending units', () => { + expect(() => parseDurationLiteral('1s1s')).toThrow(/largest first/) + expect(() => parseDurationLiteral('30m1h')).toThrow(/largest first/) + expect(() => parseDurationLiteral('500ms1s')).toThrow(/largest first/) + }) + + it('rejects a fraction on anything but the smallest unit', () => { + expect(() => parseDurationLiteral('1.5h30m')).toThrow(/smallest unit/) + }) + + it('rejects values outside the int64 nanosecond range', () => { + expect(() => parseDurationLiteral('106752d')).toThrow(/out of range/) + expect(() => parseDurationLiteral('-106752d')).toThrow(/out of range/) + // Just inside the range still parses. + expect(parseDurationLiteral('106751d')).toBe(106751n * NS_PER_DAY) + }) +}) diff --git a/src/frontend/utils/__tests__/license-buy-url.test.ts b/src/frontend/utils/__tests__/license-buy-url.test.ts new file mode 100644 index 000000000..e3cb2a7f0 --- /dev/null +++ b/src/frontend/utils/__tests__/license-buy-url.test.ts @@ -0,0 +1,85 @@ +import { buildLicenseBuyUrl } from '../license-buy-url' + +const DEVICE_ID = '7146518f9842adacfadc731ee7f546e5' +const VPP_ID = 'com.openplc.raspberry-pi-licensed' + +describe('buildLicenseBuyUrl', () => { + it('builds the purchase link the /buy page accepts', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com', vppId: VPP_ID, deviceId: DEVICE_ID }) + expect(url).toBe(`https://edge.autonomylogic.com/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + it('points at a local Edge app when the base URL does', () => { + expect(buildLicenseBuyUrl({ baseUrl: 'http://localhost:5173', vppId: VPP_ID, deviceId: DEVICE_ID })).toBe( + `http://localhost:5173/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`, + ) + }) + + it('tolerates a trailing slash on the base URL', () => { + expect( + buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com//', vppId: VPP_ID, deviceId: DEVICE_ID }), + ).toBe(`https://edge.autonomylogic.com/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + // An Edge app hosted under a sub-path must keep it: `new URL('/buy', base)` + // would silently drop the prefix and 404. + it('keeps a base path instead of resolving to the origin root', () => { + expect(buildLicenseBuyUrl({ baseUrl: 'https://example.com/edge', vppId: VPP_ID, deviceId: DEVICE_ID })).toBe( + `https://example.com/edge/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`, + ) + }) + + // The web build can be configured with a same-origin prefix + // (`VITE_EDGE_FRONTEND_URL=/`), which is not a parseable absolute URL. + it.each([ + ['/', `/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ['', `/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ['/edge', `/edge/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`], + ])('falls back to a root-relative link for the same-origin prefix %p', (baseUrl, expected) => { + expect(buildLicenseBuyUrl({ baseUrl, vppId: VPP_ID, deviceId: DEVICE_ID })).toBe(expected) + }) + + it('percent-encodes a vppId carrying URL-significant characters', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: 'com.a b&c', deviceId: DEVICE_ID }) + expect(url).toBe(`https://e.test/buy?vppId=com.a+b%26c&deviceId=${DEVICE_ID}`) + }) + + // Returning null (instead of a partial link) is the point: the /buy page can + // only answer "Invalid purchase link", so the caller must explain instead. + it.each([ + ['no vppId', { vppId: undefined, deviceId: DEVICE_ID }], + ['blank vppId', { vppId: ' ', deviceId: DEVICE_ID }], + ['no deviceId', { vppId: VPP_ID, deviceId: undefined }], + ['blank deviceId', { vppId: VPP_ID, deviceId: '' }], + ['deviceId too short', { vppId: VPP_ID, deviceId: DEVICE_ID.slice(0, 31) }], + ['deviceId too long', { vppId: VPP_ID, deviceId: `${DEVICE_ID}00` }], + ['deviceId not hex', { vppId: VPP_ID, deviceId: 'z146518f9842adacfadc731ee7f546e5' }], + // The hardware anchor is a different value AND a different length — the + // guard is what keeps a mislabelled field from reaching a purchase. + ['the hardware anchor instead of the deviceId', { vppId: VPP_ID, deviceId: '38363235383037623061383361653764aa' }], + ])('refuses to build a link with %s', (_label, ids) => { + expect(buildLicenseBuyUrl({ baseUrl: 'https://edge.autonomylogic.com', ...ids })).toBeNull() + }) + + it('accepts an uppercase deviceId, matching the page guard', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: VPP_ID, deviceId: DEVICE_ID.toUpperCase() }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID.toUpperCase()}`) + }) + + it('trims surrounding whitespace off both ids', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: ` ${VPP_ID} `, deviceId: ` ${DEVICE_ID} ` }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) + + // The link carries EXACTLY the two ids the /buy page reads. It used to also + // carry `devicePublicKey`, because the purchase was the moment that bound a + // proof-of-possession key to a device (ADR-0002). That key is gone: on bare + // metal the anchor is read inside the closed license-core and the blob is bound + // to `deviceId`, so possession is proven by the silicon, not by a signature. + // Asserted as a WHOLE-STRING equality on purpose — a `toContain` would let a + // third parameter creep back in without failing. + it('carries no query parameter beyond the two ids', () => { + const url = buildLicenseBuyUrl({ baseUrl: 'https://e.test', vppId: VPP_ID, deviceId: DEVICE_ID }) + expect(url).toBe(`https://e.test/buy?vppId=${encodeURIComponent(VPP_ID)}&deviceId=${DEVICE_ID}`) + }) +}) diff --git a/src/frontend/utils/__tests__/license-outcome-dialog.test.ts b/src/frontend/utils/__tests__/license-outcome-dialog.test.ts new file mode 100644 index 000000000..c426193d8 --- /dev/null +++ b/src/frontend/utils/__tests__/license-outcome-dialog.test.ts @@ -0,0 +1,192 @@ +import type { DeviceLicenseReport } from '@root/middleware/shared/ports/device-port' + +import { explainLicenseOutcome, type OpenLicenseDialog } from '../license-outcome-dialog' + +type DialogProps = Parameters[1] + +function harness() { + const opened: DialogProps[] = [] + const openModal: OpenLicenseDialog = (_name, props) => { + opened.push(props) + } + const buy = jest.fn(() => Promise.resolve()) + const retry = jest.fn(() => Promise.resolve()) + return { opened, openModal, buy, retry } +} + +function report(outcome: DeviceLicenseReport['outcome']): DeviceLicenseReport { + return { deviceId: '659a3520540f803625ddc34081e893d3', outcome } +} + +describe('explainLicenseOutcome', () => { + it('says NOTHING when the device is licensed', () => { + // A dialog confirming a licence nobody asked about would fire on every single + // connect to a licensed board. + const { opened, openModal, buy, retry } = harness() + + const shown = explainLicenseOutcome(report({ state: 'licensed', how: 'already-stored' }), { + openModal, + buy, + retry, + }) + + expect(shown).toBe(false) + expect(opened).toHaveLength(0) + }) + + it('is silent for a freshly activated licence too', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'licensed', how: 'activated' }), { openModal, buy }) + + expect(opened).toHaveLength(0) + }) + + describe('unlicensed with the entitlement CHECKED', () => { + it('offers to buy, and buying is what the first button does', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy, retry }) + + expect(opened).toHaveLength(1) + expect(opened[0].buttons).toEqual(['Buy Licence', 'Continue in Demo Mode']) + opened[0].onResponse(0) + expect(buy).toHaveBeenCalledTimes(1) + expect(retry).not.toHaveBeenCalled() + }) + + it('does nothing when the user chooses demo mode', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy }) + opened[0].onResponse(1) + + expect(buy).not.toHaveBeenCalled() + }) + + it("quotes the backend's own wording when it gave one", () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome( + report({ state: 'unlicensed', entitlementChecked: true, backendReason: 'no active subscription' }), + { openModal, buy }, + ) + + expect(opened[0].message).toContain('no active subscription') + }) + }) + + describe('unlicensed with the entitlement NOT checked', () => { + it('offers a re-check and NOT a purchase', () => { + // Nobody asked whether a purchase exists. Offering to buy here is the worst + // thing this flow can do to someone who already paid. + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: false }), { openModal, buy, retry }) + + expect(opened[0].buttons).toEqual(['Check For Licence', 'Continue in Demo Mode']) + expect(opened[0].buttons).not.toContain('Buy Licence') + opened[0].onResponse(0) + expect(retry).toHaveBeenCalledTimes(1) + expect(buy).not.toHaveBeenCalled() + }) + + it('degrades to a plain acknowledgement when no retry is available', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: false }), { openModal, buy }) + + expect(opened[0].buttons).toEqual(['OK']) + // Pressing the only button must not silently trigger anything. + opened[0].onResponse(0) + expect(buy).not.toHaveBeenCalled() + }) + }) + + describe('unsupported', () => { + it('never offers a purchase — buying would not make the device able to store one', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'unsupported' }), { openModal, buy, retry }) + + expect(opened[0].buttons).toEqual(['OK']) + opened[0].onResponse(0) + expect(buy).not.toHaveBeenCalled() + expect(retry).not.toHaveBeenCalled() + }) + + it('names the firmware and tells the user to rebuild, without blaming the hardware', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unsupported' }), { openModal, buy }) + + expect(opened[0].message).toMatch(/rebuild and\s+upload/i) + expect(opened[0].message).toContain('This hardware supports it') + // The old wording ("This Device Cannot Store A Licence") read as a hardware + // limitation and cost a debugging session. + expect(opened[0].title).not.toMatch(/cannot store/i) + }) + }) + + describe('check-failed', () => { + it('says we could not tell, states it is NOT the same as unlicensed, and offers a retry', () => { + const { opened, openModal, buy, retry } = harness() + + explainLicenseOutcome(report({ state: 'check-failed', error: 'Activation request failed: 429' }), { + openModal, + buy, + retry, + }) + + expect(opened[0].type).toBe('error') + expect(opened[0].message).toContain('Activation request failed: 429') + expect(opened[0].message).toContain('NOT the same as having no licence') + expect(opened[0].buttons).toEqual(['Try Again', 'Continue']) + opened[0].onResponse(0) + expect(retry).toHaveBeenCalledTimes(1) + // Never a purchase: we do not know that a purchase is what is missing. + expect(buy).not.toHaveBeenCalled() + }) + + it('never labels the failure as "not licensed"', () => { + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'check-failed', error: 'Request timeout' }), { openModal, buy }) + + expect(opened[0].title).not.toMatch(/not licen/i) + expect(opened[0].buttons).not.toContain('Buy Licence') + }) + }) + + describe('demo-mode wording', () => { + it('is explicit that the DEVICE enforces demo mode, not the editor', () => { + // Saying or implying the editor gates the upload would be false: it uploads + // either way, and the licence-core on the device is what limits actuation. + const { opened, openModal, buy } = harness() + + explainLicenseOutcome(report({ state: 'unlicensed', entitlementChecked: true }), { openModal, buy }) + + expect(opened[0].message).toContain('enforced on the device, not by the editor') + expect(opened[0].message).toContain('build and upload') + }) + + it('never claims to know the execution mode the board is running in', () => { + // The editor verifies POSSESSION, not the signature — it cannot know whether + // the closed core will run FULL. No branch may assert an execution mode. + const { opened, openModal, buy, retry } = harness() + const outcomes: DeviceLicenseReport['outcome'][] = [ + { state: 'unlicensed', entitlementChecked: true }, + { state: 'unlicensed', entitlementChecked: false }, + { state: 'unsupported' }, + { state: 'check-failed', error: 'x' }, + ] + + for (const outcome of outcomes) explainLicenseOutcome(report(outcome), { openModal, buy, retry }) + + for (const props of opened) { + expect(props.title).not.toMatch(/full mode|unlocked/i) + expect(props.message).not.toMatch(/full mode|unlocked/i) + } + }) + }) +}) diff --git a/src/frontend/utils/__tests__/semver.test.ts b/src/frontend/utils/__tests__/semver.test.ts index 97ec6d22f..ff301864d 100644 --- a/src/frontend/utils/__tests__/semver.test.ts +++ b/src/frontend/utils/__tests__/semver.test.ts @@ -1,4 +1,233 @@ -import { compareSemver, isCompatibleEditorVersion } from '../semver' +import { + compareParsedVersions, + compareSemver, + formatVersionForDisplay, + isCompatibleEditorVersion, + isValidVersion, + isVersionAtLeast, + parseVersion, +} from '../semver' + +describe('parseVersion', () => { + it('parses a plain three-part version', () => { + expect(parseVersion('4.1.9')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + }) + + // A `v` prefix is decoration, not meaning: the runtime reports `v4.2.0`, git + // tags carry `v`, and hand-written manifests use both. They must compare the + // same or the same release is two different versions depending on who typed it. + it('treats a v prefix as identical to no prefix', () => { + expect(parseVersion('v4.3.2')).toEqual(parseVersion('4.3.2')) + expect(compareSemver('v4.3.2', '4.3.2')).toBe(0) + expect(isVersionAtLeast('v4.3.2', '4.3.2')).toBe(true) + expect(isVersionAtLeast('4.3.2', 'v4.3.2')).toBe(true) + }) + + // A floor written as `"4.3"` means 4.3.0 and is enforced as 4.3.0. Anything + // else and the same shorthand is honoured by one gate and ignored by another. + it('fills missing components with zero', () => { + expect(parseVersion('4.3')).toEqual(parseVersion('4.3.0')) + expect(parseVersion('4')).toEqual(parseVersion('4.0.0')) + expect(parseVersion('v4')).toEqual(parseVersion('4.0.0')) + expect(parseVersion('4.3')).toEqual({ major: 4, minor: 3, patch: 0, prerelease: undefined }) + expect(parseVersion('4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) + }) + + it('captures pre-release and build suffixes without failing', () => { + expect(parseVersion('4.1.0-rc.3')?.prerelease).toBe('rc.3') + expect(parseVersion('4.1.0+build.5')?.prerelease).toBe('build.5') + expect(parseVersion('4.1-rc.3')).toEqual({ major: 4, minor: 1, patch: 0, prerelease: 'rc.3' }) + }) + + it('tolerates surrounding whitespace', () => { + expect(parseVersion(' 4.1.9 ')).toEqual({ major: 4, minor: 1, patch: 9, prerelease: undefined }) + }) + + // Unknown is not a version. It must not silently become 0.0.0 inside the + // parser, because a caller that cannot tell "unknown" from "0.0.0" cannot + // fail closed on the first and pass on the second. + it.each([ + ['dev', 'a source build with no CI tag'], + ['garbage', 'anything else'], + ['', 'an empty string'], + [' ', 'whitespace only'], + ['4.1 beta', 'trailing garbage after a valid prefix'], + ['abc.def.ghi', 'non-numeric components'], + ['4,3,0', 'the wrong separator'], + ['.1.2', 'a missing major'], + ])('returns null for %p (%s)', (input) => { + expect(parseVersion(input)).toBeNull() + }) + + it('returns null for null and undefined', () => { + expect(parseVersion(null)).toBeNull() + expect(parseVersion(undefined)).toBeNull() + }) + + // `Number.parseInt` answers a finite number here and quietly rounds it — + // 9007199254740993 comes back as …992. A component we can only approximate is + // not readable, and returning a number that no longer matches the string it + // came from would break the one promise the parser makes. + it('rejects a component too large to hold exactly', () => { + expect(parseVersion('9007199254740993.0.0')).toBeNull() + expect(parseVersion('4.9007199254740993.0')).toBeNull() + expect(parseVersion('4.1.9007199254740993')).toBeNull() + // The largest value that IS exact still parses. + expect(parseVersion('9007199254740991.0.0')?.major).toBe(9007199254740991) + }) + + it('leaves an oversized version unable to clear any floor', () => { + // Fails closed as a candidate, declares nothing as a floor — the same + // treatment every other unreadable string gets. + expect(isVersionAtLeast('9007199254740993.0.0', '4.1.0')).toBe(false) + expect(isVersionAtLeast('4.1.0', '9007199254740993.0.0')).toBe(true) + expect(isValidVersion('9007199254740993.0.0')).toBe(false) + }) +}) + +describe('isValidVersion', () => { + it('accepts every shorthand the parser accepts', () => { + for (const raw of ['4.3.2', 'v4.3.2', '4.3', '4', 'v5', '4.1.0-rc.1', '4.1.0+build.5']) { + expect(isValidVersion(raw)).toBe(true) + } + }) + + it('rejects what the parser cannot read', () => { + for (const raw of ['garbage', 'next', '', ' ', '4,3,0', null, undefined]) { + expect(isValidVersion(raw)).toBe(false) + } + }) +}) + +describe('formatVersionForDisplay', () => { + it('trims a readable version', () => { + expect(formatVersionForDisplay(' v4.2.0 ')).toBe('v4.2.0') + }) + + // Every "incompatible versions" message must render an unestablished version + // the same way. A blank leaves a hole in the sentence and tells the user + // nothing about which of the two versions could not be read. + const UNREADABLE: Array<[string | null | undefined, string]> = [ + ['', 'an empty string'], + [' ', 'whitespace only'], + [null, 'null'], + [undefined, 'undefined'], + ] + + it.each(UNREADABLE)('renders %p as "unknown" (%s)', (raw) => { + expect(formatVersionForDisplay(raw)).toBe('unknown') + }) +}) + +describe('compareParsedVersions', () => { + const v = (major: number, minor: number, patch: number) => ({ major, minor, patch }) + + it('orders by major first', () => { + expect(compareParsedVersions(v(5, 0, 0), v(4, 9, 9))).toBe(1) + expect(compareParsedVersions(v(4, 9, 9), v(5, 0, 0))).toBe(-1) + }) + + it('orders by minor when majors match', () => { + expect(compareParsedVersions(v(4, 2, 0), v(4, 1, 99))).toBe(1) + expect(compareParsedVersions(v(4, 1, 99), v(4, 2, 0))).toBe(-1) + }) + + it('orders by patch when major and minor match', () => { + expect(compareParsedVersions(v(4, 1, 10), v(4, 1, 9))).toBe(1) + expect(compareParsedVersions(v(4, 1, 9), v(4, 1, 10))).toBe(-1) + }) + + it('returns 0 for equal triples', () => { + expect(compareParsedVersions(v(4, 1, 9), v(4, 1, 9))).toBe(0) + }) + + it('ignores pre-release when ordering', () => { + // Load-bearing deviation from strict semver: the rc builds on a version + // line ARE the builds shipping that line's features, so treating them as + // "less than" the release would reject runtimes that work. + const rc = { ...v(4, 1, 0), prerelease: 'rc.3' } + expect(compareParsedVersions(rc, v(4, 1, 0))).toBe(0) + expect(compareParsedVersions(v(4, 1, 0), rc)).toBe(0) + }) +}) + +describe('isVersionAtLeast', () => { + it('passes when the candidate is above the floor', () => { + expect(isVersionAtLeast('4.2.10', '4.2.1')).toBe(true) + }) + + it('compares numerically, not lexicographically', () => { + expect(isVersionAtLeast('4.10.0', '4.9.0')).toBe(true) + expect(isVersionAtLeast('4.9.0', '4.10.0')).toBe(false) + }) + + it('passes when the candidate sits exactly on the floor', () => { + expect(isVersionAtLeast('4.2.1', '4.2.1')).toBe(true) + }) + + it('fails when the candidate is below the floor', () => { + expect(isVersionAtLeast('4.2.0', '4.2.1')).toBe(false) + }) + + it('passes a pre-release build of the required version', () => { + expect(isVersionAtLeast('v4.1.9-rc.1', '4.1.9')).toBe(true) + }) + + // The shorthand case. `"4.3"` as a floor must block a 4.2.10 editor exactly + // as `"4.3.0"` would — this is the asymmetry that let a runtime publish a + // floor nobody enforced. + it('enforces a partial floor exactly as its zero-filled equivalent', () => { + expect(isVersionAtLeast('4.2.10', '4.3')).toBe(false) + expect(isVersionAtLeast('4.2.10', '4.3.0')).toBe(false) + expect(isVersionAtLeast('4.3.0', '4.3')).toBe(true) + expect(isVersionAtLeast('4.2.10', '5')).toBe(false) + expect(isVersionAtLeast('5.0.0', '5')).toBe(true) + expect(isVersionAtLeast('4.2.10', 'v4.3')).toBe(false) + }) + + // A peer that asks for nothing gets nothing enforced — this is what keeps + // runtimes predating /api/capabilities working unchanged. + const NOTHING_DECLARED: Array<[string | null | undefined, string]> = [ + [undefined, 'undefined'], + [null, 'null'], + ['', 'an empty string'], + ] + + it.each(NOTHING_DECLARED)('passes when the floor is %p (%s)', (floor) => { + expect(isVersionAtLeast('4.2.0', floor)).toBe(true) + }) + + // An unreadable floor is worth 0.0.0 and everything clears 0.0.0. It is not + // silent, though: the manifest schema refuses it outright and the runtime + // probe logs a warning, so nobody believes a constraint is applying when it + // is not. + it('passes when the floor itself is unreadable, since it declares nothing', () => { + expect(isVersionAtLeast('4.2.0', 'garbage')).toBe(true) + expect(isVersionAtLeast('4.2.0', 'next')).toBe(true) + }) + + // Fails closed: an unidentifiable peer never clears a real floor. + const UNIDENTIFIABLE: Array<[string | null | undefined, string]> = [ + ['dev', 'a source build'], + ['garbage', 'a corrupt value'], + ['', 'a blank answer'], + [null, 'an unreachable peer'], + [undefined, 'a missing value'], + ] + + it.each(UNIDENTIFIABLE)('fails when the candidate is %p (%s) and a real floor exists', (candidate) => { + expect(isVersionAtLeast(candidate, '4.1.0')).toBe(false) + }) + + // The legacy hardcoded header now parses (as 4.0.0) instead of being + // rejected as junk, and still loses — for the honest reason that 4.0.0 + // predates the floor rather than because the string looked odd. + it('reads the legacy "v4" header as 4.0.0, which still fails a 4.1.0 floor', () => { + expect(parseVersion('v4')).toEqual({ major: 4, minor: 0, patch: 0, prerelease: undefined }) + expect(isVersionAtLeast('v4', '4.1.0')).toBe(false) + expect(isVersionAtLeast('v4', '4.0.0')).toBe(true) + }) +}) describe('compareSemver', () => { it('returns 0 when versions are identical', () => { @@ -25,18 +254,28 @@ describe('compareSemver', () => { }) it('strips pre-release suffix before comparing', () => { - // The function intentionally ignores pre-release ordering; both compare - // as the same `4.1.1` triple. If we ever ship pre-releases for real this - // contract needs revisiting, but ignoring is the safer default today. expect(compareSemver('4.1.1-rc.1', '4.1.1')).toBe(0) expect(compareSemver('4.1.1+build.5', '4.1.1-rc.1')).toBe(0) }) - it('treats malformed inputs as 0.0.0 (defensive against corrupt manifests)', () => { + // A v-prefixed catalog version used to parse as 0.0.0 and sort below every + // plain-numbered release, so "update available" was wrong for it. + it('ranks a v-prefixed version by its number, not below everything', () => { + expect(compareSemver('v2.0.0', '1.0.0')).toBe(1) + expect(compareSemver('1.0.0', 'v2.0.0')).toBe(-1) + expect(compareSemver('v1.2.3', '1.2.3')).toBe(0) + }) + + // Sorting needs a total order, so this is the one place unknown becomes + // 0.0.0 — a corrupt `version` in somebody else's manifest sorts to the + // bottom instead of breaking the catalog. Nothing is gated on the result. + it('sorts an unreadable version as the lowest possible one', () => { expect(compareSemver('not-a-version', '0.0.0')).toBe(0) expect(compareSemver('', '0.0.0')).toBe(0) - expect(compareSemver('1.2', '1.2.0')).toBe(0) // missing patch defaults to 0 - expect(compareSemver('abc.def.ghi', '0.0.1')).toBe(-1) // bogus < 0.0.1 + expect(compareSemver('1.2', '1.2.0')).toBe(0) + expect(compareSemver('abc.def.ghi', '0.0.1')).toBe(-1) + expect(compareSemver('0.0.1', 'abc.def.ghi')).toBe(1) + expect(compareSemver('garbage', 'nonsense')).toBe(0) }) }) @@ -59,4 +298,19 @@ describe('isCompatibleEditorVersion', () => { expect(isCompatibleEditorVersion('5.0.0', '4.1.1')).toBe(false) expect(isCompatibleEditorVersion('4.2.0', '4.1.1')).toBe(false) }) + + // The install gate and the runtime gates must answer identically for every + // string, or the same floor is enforced in one place and ignored in the + // other. This is that contract, asserted directly. + it.each([ + ['4.3', '4.2.10'], + ['4', '4.2.10'], + ['v5', '4.2.10'], + ['4.2.10', '4.2.10'], + ['garbage', '4.2.10'], + ['', '4.2.10'], + ['99.0.0', '4.2.10'], + ])('agrees with isVersionAtLeast for floor %p against editor %p', (floor, current) => { + expect(isCompatibleEditorVersion(floor, current)).toBe(isVersionAtLeast(current, floor)) + }) }) diff --git a/src/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts new file mode 100644 index 000000000..0e9e5323a --- /dev/null +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -0,0 +1,99 @@ +import { serialPortDisplay } from '../serial-port-label' + +/** + * This helper is the ONE place a port's label is decided, so the platform + * expectations are asserted here rather than inferred from the producer. + */ +describe('serialPortDisplay', () => { + describe('the path always leads', () => { + it('labels a Windows port by its COM number', () => { + expect(serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' })).toEqual({ + label: 'COM5 (Arduino Uno)', + title: 'COM5 (Arduino Uno)', + }) + }) + + it('labels a macOS port by its /dev/cu path', () => { + expect(serialPortDisplay({ address: '/dev/cu.usbmodem11101', boardName: 'Arduino MKR' })).toEqual({ + label: '/dev/cu.usbmodem11101 (Arduino MKR)', + title: '/dev/cu.usbmodem11101 (Arduino MKR)', + }) + }) + + it('labels a Linux port by its /dev/tty path', () => { + expect(serialPortDisplay({ address: '/dev/ttyACM0', boardName: 'Arduino Mega' })).toEqual({ + label: '/dev/ttyACM0 (Arduino Mega)', + title: '/dev/ttyACM0 (Arduino Mega)', + }) + }) + + it('never replaces the path with the descriptor', () => { + // The original bug: a NodeMCU reading "wch.cn" instead of "COM5". + const { label } = serialPortDisplay({ address: 'COM5', manufacturer: 'wch.cn' }) + expect(label.startsWith('COM5')).toBe(true) + expect(label).not.toBe('wch.cn') + }) + }) + + describe('descriptor precedence', () => { + it('prefers the arduino-cli board name over the manufacturer', () => { + // Both scans found something; the board name is the specific one. + expect(serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' })).toEqual({ + label: 'COM1 (Opta)', + title: 'COM1 (Opta)', + }) + }) + + it('falls back to the manufacturer when arduino-cli identified no board', () => { + expect(serialPortDisplay({ address: 'COM6', manufacturer: 'com0com - serial port emulator' })).toEqual({ + label: 'COM6 (com0com - serial port emulator)', + title: 'COM6 (com0com - serial port emulator)', + }) + }) + + it('shows the bare path when neither scan knew a descriptor', () => { + expect(serialPortDisplay({ address: '/dev/ttyUSB0' })).toEqual({ + label: '/dev/ttyUSB0', + title: undefined, + }) + }) + + it('treats a blank descriptor as absent', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: '' })).toEqual({ + label: 'COM3', + title: undefined, + }) + }) + + it('falls through a blank board name to the manufacturer', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: 'FTDI' })).toEqual({ + label: 'COM3 (FTDI)', + title: 'COM3 (FTDI)', + }) + }) + }) + + describe('degenerate inputs', () => { + it('falls back to the descriptor when the address is empty', () => { + expect(serialPortDisplay({ address: '', boardName: 'Arduino Uno' })).toEqual({ + label: 'Arduino Uno', + title: undefined, + }) + }) + + it('trims surrounding whitespace', () => { + expect(serialPortDisplay({ address: ' COM5 ', manufacturer: ' wch.cn ' })).toEqual({ + label: 'COM5 (wch.cn)', + title: 'COM5 (wch.cn)', + }) + }) + + it('cannot double-wrap, because it never receives a composed string', () => { + // The regression this structure prevents: when the producer pre-composed + // `name`, the renderer had to guess whether it already contained the path. + const { label } = serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' }) + expect(label).toBe('COM5 (Arduino Uno)') + expect(label).not.toContain('COM5 (COM5') + }) + }) +}) diff --git a/src/frontend/utils/__tests__/terminal-output.test.ts b/src/frontend/utils/__tests__/terminal-output.test.ts new file mode 100644 index 000000000..9a39a122e --- /dev/null +++ b/src/frontend/utils/__tests__/terminal-output.test.ts @@ -0,0 +1,144 @@ +import { collapseCarriageReturns, hasAnsi, parseAnsi, stripAnsi } from '../terminal-output' + +const ESC = '\u001B' +const BEL = '\u0007' + +// The literal bytes arduino-cli 1.4.1 writes for its compile summary table. +// Captured from `arduino-cli compile` with colour enabled — this is the exact +// shape the console has to cope with. +const ARDUINO_SUMMARY = + `${ESC}[92mUsed platform${ESC}[0m ${ESC}[92mVersion${ESC}[0m ${ESC}[90mPath${ESC}[0m\n` + + `${ESC}[93marduino:avr${ESC}[0m 1.8.8 ${ESC}[90m/Users/x/Arduino15/packages/arduino/hardware/avr/1.8.8${ESC}[0m` + +describe('hasAnsi', () => { + it('is false for ordinary output and true once an escape appears', () => { + expect(hasAnsi('Sketch uses 924 bytes')).toBe(false) + expect(hasAnsi(`${ESC}[92mgreen${ESC}[0m`)).toBe(true) + }) +}) + +describe('stripAnsi', () => { + it('leaves plain text untouched (identity, not a copy path)', () => { + const plain = 'Compiling core...' + expect(stripAnsi(plain)).toBe(plain) + }) + + it('removes every escape from real arduino-cli output', () => { + const stripped = stripAnsi(ARDUINO_SUMMARY) + expect(stripped).not.toContain(ESC) + expect(stripped).toContain('Used platform Version Path') + expect(stripped).toContain('arduino:avr 1.8.8 /Users/x/Arduino15/packages/arduino/hardware/avr/1.8.8') + }) + + it('removes non-SGR CSI sequences too, so they cannot leak into the text', () => { + expect(stripAnsi(`before${ESC}[2Kafter`)).toBe('beforeafter') + }) +}) + +describe('stripAnsi — non-CSI escapes', () => { + // The module promises stored messages carry no escapes. Matching only CSI + // left OSC payloads and bare ESC bytes in the text, where they reached + // search, copy and the DOM. + it('removes an OSC hyperlink, payload and all', () => { + const link = `${ESC}]8;;https://example.com${BEL}click here${ESC}]8;;${BEL}` + expect(stripAnsi(link)).toBe('click here') + }) + + it('accepts the ST terminator as well as BEL', () => { + expect(stripAnsi(`${ESC}]0;window title${ESC}\\text`)).toBe('text') + }) + + it('removes a two-byte escape', () => { + expect(stripAnsi(`before${ESC}Mafter`)).toBe('beforeafter') + }) + + it('removes a stray ESC with nothing after it', () => { + expect(stripAnsi(`trailing${ESC}`)).toBe('trailing') + }) + + it('leaves no escape byte behind in mixed output', () => { + const mixed = `${ESC}[92mok${ESC}[0m ${ESC}]8;;https://x${BEL}link${ESC}]8;;${BEL} ${ESC}` + expect(stripAnsi(mixed)).not.toContain(ESC) + }) +}) + +describe('parseAnsi', () => { + it('returns a single unstyled segment when there is no colour', () => { + expect(parseAnsi('plain line')).toEqual([{ text: 'plain line' }]) + }) + + it('returns nothing for an empty string', () => { + expect(parseAnsi('')).toEqual([]) + }) + + it('styles each coloured run and leaves the rest unstyled', () => { + const segments = parseAnsi(`${ESC}[93marduino:avr${ESC}[0m 1.8.8`) + expect(segments).toHaveLength(2) + expect(segments[0].text).toBe('arduino:avr') + expect(segments[0].className).toContain('text-yellow-600') + expect(segments[1]).toEqual({ text: ' 1.8.8' }) + }) + + it('round-trips: concatenated segment text equals the stripped message', () => { + const segments = parseAnsi(ARDUINO_SUMMARY) + expect(segments.map((s) => s.text).join('')).toBe(stripAnsi(ARDUINO_SUMMARY)) + }) + + it('merges adjacent runs that share a style instead of emitting duplicate spans', () => { + // reset -> same colour again is common; it must not fragment the output. + const segments = parseAnsi(`${ESC}[92mone${ESC}[0m${ESC}[92mtwo${ESC}[0m`) + expect(segments).toHaveLength(1) + expect(segments[0].text).toBe('onetwo') + }) + + it('treats an empty parameter list as a reset (ECMA-48)', () => { + const segments = parseAnsi(`${ESC}[92mgreen${ESC}[mplain`) + expect(segments[1]).toEqual({ text: 'plain' }) + }) + + it('applies and clears bold independently of colour', () => { + const segments = parseAnsi(`${ESC}[1mbold${ESC}[22mnormal`) + expect(segments[0].className).toContain('font-bold') + expect(segments[1]).toEqual({ text: 'normal' }) + }) + + it('clears only the colour on SGR 39', () => { + const segments = parseAnsi(`${ESC}[1m${ESC}[92mboth${ESC}[39mboldonly`) + expect(segments[0].className).toContain('text-green-600') + expect(segments[0].className).toContain('font-bold') + expect(segments[1].className).toBe('font-bold') + }) + + it('drops unknown codes without losing the text they wrap', () => { + // 48;5;21 (256-colour background) is not mapped; the text must survive. + const segments = parseAnsi(`${ESC}[48;5;21mstill here${ESC}[0m`) + expect(segments.map((s) => s.text).join('')).toBe('still here') + }) +}) + +describe('collapseCarriageReturns', () => { + it('returns the line unchanged when there is no redraw', () => { + expect(collapseCarriageReturns('Linking everything together...')).toBe('Linking everything together...') + }) + + it('keeps only the frame a terminal would leave on screen', () => { + expect(collapseCarriageReturns('\rDownloading 10%\rDownloading 60%\rDownloading 100%')).toBe('Downloading 100%') + }) + + it('keeps the preceding text when the line ends with a bare carriage return', () => { + // A trailing CR moves the cursor but writes nothing. + expect(collapseCarriageReturns('Downloading 100%\r')).toBe('Downloading 100%') + }) + + it('collapses a real arduino-cli download frame pair', () => { + const raw = + '\rDownloading index: package_index.tar.bz2 0 B / 108.96 KiB 0.00%' + + '\rDownloading index: package_index.tar.bz2 downloaded' + expect(collapseCarriageReturns(raw)).toBe('Downloading index: package_index.tar.bz2 downloaded') + }) + + it('handles CRLF without swallowing the line', () => { + // Split on \n happens before this, leaving a trailing \r on Windows output. + expect(collapseCarriageReturns('Windows line\r')).toBe('Windows line') + }) +}) diff --git a/src/frontend/utils/__tests__/variable-sizes.test.ts b/src/frontend/utils/__tests__/variable-sizes.test.ts index 0b281bba0..eec990b9e 100644 --- a/src/frontend/utils/__tests__/variable-sizes.test.ts +++ b/src/frontend/utils/__tests__/variable-sizes.test.ts @@ -3,6 +3,7 @@ import { encodeForceValue, getTypeSizeByName, getVariableSize, + isForcedValueHigh, parseValueByTypeName, parseVariableValue, } from '../variable-sizes' @@ -476,6 +477,11 @@ describe('parseValueByTypeName', () => { const result = parseValueByTypeName(data, 0, 'usint') expect(result).toEqual({ value: '42', bytesRead: 1 }) }) + + it('renders an unknown type as ??? and consumes the 4-byte default', () => { + const result = parseValueByTypeName(u8(1, 2, 3, 4), 0, 'TOTALLY_FAKE_TYPE') + expect(result).toEqual({ value: '???', bytesRead: 4 }) + }) }) // --------------------------------------------------------------------------- @@ -510,9 +516,36 @@ describe('encodeForceValue', () => { ) }) - it('rejects unsupported types cleanly', () => { - expect(() => encodeForceValue('5s', 'TIME')).toThrow(/not supported/) - expect(() => encodeForceValue('"hello"', 'STRING')).toThrow(/not supported/) + it('encodes TIME as int64 nanoseconds, little-endian', () => { + // T#10s → 10e9 ns → 0x00000002540BE400 + expect(Array.from(encodeForceValue('T#10s', 'TIME'))).toEqual([0x00, 0xe4, 0x0b, 0x54, 0x02, 0x00, 0x00, 0x00]) + // Prefix-less input (what the watch panel renders) works too. + expect(Array.from(encodeForceValue('250ms', 'TIME'))).toEqual([0x80, 0xb2, 0xe6, 0x0e, 0x00, 0x00, 0x00, 0x00]) + // Negative durations are two-complement. + expect(Array.from(encodeForceValue('T#-1ns', 'TIME'))).toEqual([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + }) + + it('surfaces the duration parser error for a malformed TIME value', () => { + expect(() => encodeForceValue('10', 'TIME')).toThrow(/Invalid TIME value/) + }) + + it('encodes STRING as a length byte followed by ASCII', () => { + expect(Array.from(encodeForceValue('hi', 'STRING'))).toEqual([2, 0x68, 0x69]) + // IEC literal quotes are unwrapped — and are how spaces survive trimming. + expect(Array.from(encodeForceValue("' hi '", 'STRING'))).toEqual([4, 0x20, 0x68, 0x69, 0x20]) + expect(Array.from(encodeForceValue("''", 'STRING'))).toEqual([0]) + }) + + it('rejects STRING values that are non-ASCII or over the protocol cap', () => { + expect(() => encodeForceValue('café', 'STRING')).toThrow(/must be ASCII/) + expect(() => encodeForceValue('x'.repeat(127), 'STRING')).toThrow(/too long: 127 characters \(max 126\)/) + }) + + it('rejects the wire formats that still lack an encoder', () => { + expect(() => encodeForceValue('D#2026-01-01', 'DATE')).toThrow(/not supported/) + expect(() => encodeForceValue('TOD#12:00:00', 'TOD')).toThrow(/not supported/) + expect(() => encodeForceValue('DT#2026-01-01-12:00:00', 'DT')).toThrow(/not supported/) + expect(() => encodeForceValue('"hello"', 'WSTRING')).toThrow(/not supported/) }) describe('out-of-range truncation', () => { @@ -630,3 +663,25 @@ describe('encodeForceValue', () => { }) }) }) + +// --------------------------------------------------------------------------- +// isForcedValueHigh +// --------------------------------------------------------------------------- + +describe('isForcedValueHigh', () => { + it('treats FALSE / 0 / negative values as forced-low', () => { + expect(isForcedValueHigh('FALSE')).toBe(false) + expect(isForcedValueHigh(' false ')).toBe(false) + expect(isForcedValueHigh('0')).toBe(false) + expect(isForcedValueHigh('-1')).toBe(false) + expect(isForcedValueHigh('T#-5s')).toBe(false) + }) + + it('treats everything else as forced-high', () => { + expect(isForcedValueHigh('TRUE')).toBe(true) + expect(isForcedValueHigh('1')).toBe(true) + expect(isForcedValueHigh('T#10s')).toBe(true) + expect(isForcedValueHigh('0s')).toBe(true) + expect(isForcedValueHigh('hello')).toBe(true) + }) +}) diff --git a/src/frontend/utils/__tests__/variable-types.test.ts b/src/frontend/utils/__tests__/variable-types.test.ts deleted file mode 100644 index 88333fbbc..000000000 --- a/src/frontend/utils/__tests__/variable-types.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { VariableTypeInfo } from '../variable-types' -import { - floatToBuffer, - getVariableTypeInfo, - integerToBuffer, - parseFloatValue, - parseIntegerValue, - parseStringValue, - stringToBuffer, -} from '../variable-types' - -// --------------------------------------------------------------------------- -// getVariableTypeInfo -// --------------------------------------------------------------------------- - -describe('getVariableTypeInfo', () => { - it.each([ - ['BOOL', { byteSize: 1, signed: false }], - ['SINT', { byteSize: 1, signed: true }], - ['USINT', { byteSize: 1, signed: false }], - ['BYTE', { byteSize: 1, signed: false }], - ['INT', { byteSize: 2, signed: true }], - ['UINT', { byteSize: 2, signed: false }], - ['WORD', { byteSize: 2, signed: false }], - ['DINT', { byteSize: 4, signed: true }], - ['UDINT', { byteSize: 4, signed: false }], - ['DWORD', { byteSize: 4, signed: false }], - ['LINT', { byteSize: 8, signed: true }], - ['ULINT', { byteSize: 8, signed: false }], - ['LWORD', { byteSize: 8, signed: false }], - ['REAL', { byteSize: 4, signed: true }], - ['LREAL', { byteSize: 8, signed: true }], - ['STRING', { byteSize: 127, signed: false }], - ])('returns correct info for %s', (type, expected) => { - expect(getVariableTypeInfo(type)).toEqual(expected) - }) - - it('is case-insensitive', () => { - expect(getVariableTypeInfo('bool')).toEqual({ byteSize: 1, signed: false }) - expect(getVariableTypeInfo('Bool')).toEqual({ byteSize: 1, signed: false }) - }) - - it('returns null for unknown type', () => { - expect(getVariableTypeInfo('WSTRING')).toBeNull() - expect(getVariableTypeInfo('TIME')).toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// parseIntegerValue -// --------------------------------------------------------------------------- - -describe('parseIntegerValue', () => { - const sint: VariableTypeInfo = { byteSize: 1, signed: true } - const usint: VariableTypeInfo = { byteSize: 1, signed: false } - const int16: VariableTypeInfo = { byteSize: 2, signed: true } - const uint16: VariableTypeInfo = { byteSize: 2, signed: false } - const dint: VariableTypeInfo = { byteSize: 4, signed: true } - - it('parses decimal string', () => { - expect(parseIntegerValue('42', usint)).toBe(42n) - }) - - it('parses negative decimal for signed type', () => { - expect(parseIntegerValue('-100', sint)).toBe(-100n) - }) - - it('parses 0x hex prefix', () => { - expect(parseIntegerValue('0xFF', usint)).toBe(255n) - }) - - it('parses 0X hex prefix (uppercase)', () => { - expect(parseIntegerValue('0XFF', usint)).toBe(255n) - }) - - it('parses IEC 16# hex prefix', () => { - expect(parseIntegerValue('16#FF', usint)).toBe(255n) - }) - - it('parses IEC 2# binary prefix', () => { - expect(parseIntegerValue('2#11111111', usint)).toBe(255n) - }) - - it('parses IEC 8# octal prefix', () => { - expect(parseIntegerValue('8#377', usint)).toBe(255n) - }) - - it('trims whitespace', () => { - expect(parseIntegerValue(' 42 ', usint)).toBe(42n) - }) - - it('returns null when value exceeds max for signed type', () => { - // SINT max = 127 - expect(parseIntegerValue('128', sint)).toBeNull() - }) - - it('returns null when value is below min for signed type', () => { - // SINT min = -128 - expect(parseIntegerValue('-129', sint)).toBeNull() - }) - - it('returns null when unsigned value is negative', () => { - expect(parseIntegerValue('-1', usint)).toBeNull() - }) - - it('returns null when value exceeds max for unsigned type', () => { - // USINT max = 255 - expect(parseIntegerValue('256', usint)).toBeNull() - }) - - it('returns null for non-numeric input', () => { - expect(parseIntegerValue('abc', sint)).toBeNull() - }) - - it('treats empty string as 0', () => { - // BigInt('') returns 0n, so empty string parses to 0 - expect(parseIntegerValue('', sint)).toBe(0n) - }) - - it('handles INT range correctly', () => { - expect(parseIntegerValue('32767', int16)).toBe(32767n) - expect(parseIntegerValue('-32768', int16)).toBe(-32768n) - expect(parseIntegerValue('32768', int16)).toBeNull() - }) - - it('handles UINT range correctly', () => { - expect(parseIntegerValue('65535', uint16)).toBe(65535n) - expect(parseIntegerValue('65536', uint16)).toBeNull() - }) - - it('handles DINT range', () => { - expect(parseIntegerValue('2147483647', dint)).toBe(2147483647n) - expect(parseIntegerValue('-2147483648', dint)).toBe(-2147483648n) - expect(parseIntegerValue('2147483648', dint)).toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// integerToBuffer -// --------------------------------------------------------------------------- - -describe('integerToBuffer', () => { - it('converts positive unsigned value to buffer', () => { - const result = integerToBuffer(255n, 1, false) - expect(result).toEqual(new Uint8Array([0xff])) - }) - - it('converts positive signed value to buffer', () => { - const result = integerToBuffer(127n, 1, true) - expect(result).toEqual(new Uint8Array([0x7f])) - }) - - it('converts negative signed value using twos complement', () => { - // -1 in 1-byte signed = 0xFF - const result = integerToBuffer(-1n, 1, true) - expect(result).toEqual(new Uint8Array([0xff])) - }) - - it('converts negative signed 2-byte value', () => { - // -1 in 2-byte signed = 0xFF 0xFF - const result = integerToBuffer(-1n, 2, true) - expect(result).toEqual(new Uint8Array([0xff, 0xff])) - }) - - it('converts zero', () => { - const result = integerToBuffer(0n, 2, false) - expect(result).toEqual(new Uint8Array([0x00, 0x00])) - }) - - it('converts multi-byte unsigned value in little-endian order', () => { - // 0x0102 → LE bytes [0x02, 0x01]. Wire format the runtime - // memcpy's straight into IEC ints — every supported target - // (AVR / ARM Cortex-M / x86_64) is little-endian. - const result = integerToBuffer(0x0102n, 2, false) - expect(result).toEqual(new Uint8Array([0x02, 0x01])) - }) - - it('converts 4-byte value in little-endian order', () => { - const result = integerToBuffer(0xdeadbeefn, 4, false) - expect(result).toEqual(new Uint8Array([0xef, 0xbe, 0xad, 0xde])) - }) - - it('converts negative 4-byte signed value', () => { - // -1 in 4-byte signed = 0xFF FF FF FF - const result = integerToBuffer(-1n, 4, true) - expect(result).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff])) - }) -}) - -// --------------------------------------------------------------------------- -// parseFloatValue -// --------------------------------------------------------------------------- - -describe('parseFloatValue', () => { - it('parses valid float string', () => { - expect(parseFloatValue('3.14', 4)).toBeCloseTo(3.14, 2) - }) - - it('parses integer as float', () => { - expect(parseFloatValue('42', 8)).toBe(42) - }) - - it('trims whitespace', () => { - expect(parseFloatValue(' 1.5 ', 4)).toBe(1.5) - }) - - it('returns null for NaN', () => { - expect(parseFloatValue('NaN', 4)).toBeNull() - }) - - it('returns null for Infinity', () => { - expect(parseFloatValue('Infinity', 4)).toBeNull() - }) - - it('returns null for -Infinity', () => { - expect(parseFloatValue('-Infinity', 4)).toBeNull() - }) - - it('returns null for non-numeric string', () => { - expect(parseFloatValue('abc', 4)).toBeNull() - }) - - it('returns null when float32 exceeds max', () => { - expect(parseFloatValue('3.5e38', 4)).toBeNull() - }) - - it('returns null when float32 exceeds negative max', () => { - expect(parseFloatValue('-3.5e38', 4)).toBeNull() - }) - - it('allows large values for 8-byte (double) precision', () => { - expect(parseFloatValue('3.5e38', 8)).toBeCloseTo(3.5e38) - }) - - it('handles negative float', () => { - expect(parseFloatValue('-2.5', 4)).toBe(-2.5) - }) - - it('handles zero', () => { - expect(parseFloatValue('0', 4)).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// floatToBuffer -// --------------------------------------------------------------------------- - -describe('floatToBuffer', () => { - // Wire format: little-endian IEEE 754, matching what the runtime - // memcpy's into the IEC `REAL`/`LREAL` variable on every supported - // target. The read-side decoder (`variable-sizes.ts:decodeWireValue`) - // uses `getFloat32(0, true)` — these tests confirm the writer agrees. - - it('writes float32 as 4 little-endian bytes', () => { - const result = floatToBuffer(1.0, 4) - expect(result.length).toBe(4) - const view = new DataView(result.buffer) - expect(view.getFloat32(0, true)).toBe(1.0) - // 1.0 IEEE 754 = 0x3F800000 → LE bytes [0x00, 0x00, 0x80, 0x3F] - expect(result).toEqual(new Uint8Array([0x00, 0x00, 0x80, 0x3f])) - }) - - it('writes float64 as 8 little-endian bytes', () => { - const result = floatToBuffer(3.141592653589793, 8) - expect(result.length).toBe(8) - const view = new DataView(result.buffer) - expect(view.getFloat64(0, true)).toBeCloseTo(3.141592653589793, 12) - }) - - it('handles zero', () => { - const result = floatToBuffer(0, 4) - expect(result.length).toBe(4) - const view = new DataView(result.buffer) - expect(view.getFloat32(0, true)).toBe(0) - }) - - it('writes 123.4 with the byte order the runtime expects', () => { - // Regression for the byte-swap bug: forcing a REAL to 123.4 - // used to store -429836352 on the runtime side because this - // helper wrote big-endian (0x42 0xF6 0xCC 0xCD) but the runtime - // memcpy'd those bytes into a little-endian float, swapping - // sign / exponent / mantissa. - const result = floatToBuffer(123.4, 4) - expect(result).toEqual(new Uint8Array([0xcd, 0xcc, 0xf6, 0x42])) - }) - - it('returns buffer of requested size for other sizes (no write)', () => { - const result = floatToBuffer(1.0, 2) - expect(result.length).toBe(2) - // No float write happens for non-4/non-8, so all zeros - expect(result).toEqual(new Uint8Array([0, 0])) - }) -}) - -// --------------------------------------------------------------------------- -// parseStringValue -// --------------------------------------------------------------------------- - -describe('parseStringValue', () => { - it('returns string for valid ASCII input', () => { - expect(parseStringValue('Hello')).toBe('Hello') - }) - - it('returns empty string for empty input', () => { - expect(parseStringValue('')).toBe('') - }) - - it('returns string at max length (126 chars)', () => { - const s = 'a'.repeat(126) - expect(parseStringValue(s)).toBe(s) - }) - - it('returns null when string exceeds 126 chars', () => { - const s = 'a'.repeat(127) - expect(parseStringValue(s)).toBeNull() - }) - - it('returns null for non-ASCII character', () => { - expect(parseStringValue('caf\u00e9')).toBeNull() - }) - - it('accepts all printable ASCII', () => { - const s = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' - expect(parseStringValue(s)).toBe(s) - }) - - it('accepts control characters within ASCII range', () => { - expect(parseStringValue('\t\n\r')).toBe('\t\n\r') - }) -}) - -// --------------------------------------------------------------------------- -// stringToBuffer -// --------------------------------------------------------------------------- - -describe('stringToBuffer', () => { - it('creates buffer with length prefix and ASCII bytes', () => { - const result = stringToBuffer('Hi') - expect(result).toEqual(new Uint8Array([2, 72, 105])) // 'H'=72, 'i'=105 - }) - - it('creates buffer for empty string', () => { - const result = stringToBuffer('') - expect(result).toEqual(new Uint8Array([0])) - }) - - it('sets first byte to string length', () => { - const result = stringToBuffer('ABCDE') - expect(result[0]).toBe(5) - expect(result.length).toBe(6) - }) - - it('encodes each character as its char code', () => { - const result = stringToBuffer('A') - expect(result[1]).toBe(65) - }) -}) diff --git a/src/frontend/utils/cn.ts b/src/frontend/utils/cn.ts index c8498c564..7916262b7 100644 --- a/src/frontend/utils/cn.ts +++ b/src/frontend/utils/cn.ts @@ -1,4 +1,26 @@ import { ClassValue, clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { extendTailwindMerge } from 'tailwind-merge' + +/** + * Class merger that knows this project's custom `cp-*` font scale. + * + * Plain `twMerge` does not. Faced with `text-cp-sm text-white` it cannot tell that + * the first is a size, assumes both are text colours, and keeps only the last -- + * silently dropping the size. Anything styled that way rendered at the browser + * default instead: the Connect button came out visibly larger than its + * neighbours, with no warning and no build error. Buttons that escaped it did so + * only by passing a plain string instead of calling this. + * + * Declaring the scale here fixes every call site at once, rather than each caller + * having to know that its size class might evaporate. Keep this list in step with + * `fontSize` in tailwind.config.ts. + */ +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + 'font-size': [{ text: ['cp-xs', 'cp-sm', 'cp-base'] }], + }, + }, +}) export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) diff --git a/src/frontend/utils/data-type-references.ts b/src/frontend/utils/data-type-references.ts new file mode 100644 index 000000000..5596f87bd --- /dev/null +++ b/src/frontend/utils/data-type-references.ts @@ -0,0 +1,139 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../middleware/shared/ports/types' +import type { + DataTypeReferenceImpactAnalysis, + DataTypeReferenceKind, + DataTypeReferenceLocation, +} from './data-type-references/types' + +/** Container label used for references declared in the global variables table. */ +export const GLOBAL_VARIABLES_CONTAINER = 'Global Variables' + +// IEC identifiers are case-insensitive — same rule as the store's data type name checks. +const nameMatches = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + +const KIND_GROUP: Record = { + 'pou-variable': 'POU variables', + 'global-variable': 'global variables', + 'data-type-field': 'data types', + 'data-type-base-type': 'data types', +} + +/** True when `type` references the data type `typeName` — directly or as an array base type. */ +export function variableTypeReferencesDataType(type: PLCVariableType, typeName: string): boolean { + if (type.definition === 'user-data-type') { + return nameMatches(type.value, typeName) + } + if (type.definition === 'array') { + const baseType = type.data?.baseType + return baseType !== undefined && baseType.definition === 'user-data-type' && nameMatches(baseType.value, typeName) + } + return false +} + +/** + * Find every place `typeName` is referenced as a type: POU variables, global + * variables, other structures' fields, and other array data types' base types. + * Mirrors `findAllReferencesToVariable` and returns the same analysis shape so + * the rename impact modal renders it unchanged. + */ +export function findAllReferencesToDataType( + typeName: string, + pous: PLCPou[], + globalVariables: PLCVariable[], + dataTypes: PLCDataType[], +): DataTypeReferenceImpactAnalysis { + const references: DataTypeReferenceLocation[] = [] + + pous.forEach((pou) => { + ;(pou.interface?.variables ?? []).forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'pou-variable', container: pou.name, variableName: variable.name }) + } + }) + }) + + globalVariables.forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: variable.name }) + } + }) + + dataTypes.forEach((dataType) => { + if (dataType.derivation === 'structure') { + dataType.variable.forEach((field) => { + if (variableTypeReferencesDataType(field.type, typeName)) { + references.push({ kind: 'data-type-field', container: dataType.name, variableName: field.name }) + } + }) + } else if (dataType.derivation === 'array') { + if (variableTypeReferencesDataType(dataType.baseType, typeName)) { + references.push({ kind: 'data-type-base-type', container: dataType.name }) + } + } + }) + + const byPou = new Map() + const byEditorType = new Map() + references.forEach((ref) => { + byPou.set(ref.container, (byPou.get(ref.container) ?? 0) + 1) + const group = KIND_GROUP[ref.kind] + byEditorType.set(group, (byEditorType.get(group) ?? 0) + 1) + }) + + return { + totalReferences: references.length, + byPou, + byEditorType, + references, + } +} + +/** + * Rewrite a reference to `oldName` inside a variable type, or return `null` + * when the type doesn't reference it. + */ +export function renameDataTypeInVariableType( + type: PLCVariableType, + oldName: string, + newName: string, +): PLCVariableType | null { + if (type.definition === 'user-data-type' && nameMatches(type.value, oldName)) { + return { ...type, value: newName } + } + if (type.definition === 'array' && type.data) { + const { baseType, dimensions } = type.data + if (baseType.definition === 'user-data-type' && nameMatches(baseType.value, oldName)) { + const dims = dimensions.map((d) => d.dimension).join(', ') + return { + ...type, + // `value` is what variable serialization emits — rebuild it or the + // saved declaration keeps the old base type name. + value: `ARRAY [${dims}] OF ${newName}`, + data: { ...type.data, baseType: { ...baseType, value: newName } }, + } + } + } + return null +} + +/** + * Rewrite references to `oldName` inside another data type (structure fields, + * array base type), or return `null` when nothing references it. + */ +export function renameDataTypeInDataType(dataType: PLCDataType, oldName: string, newName: string): PLCDataType | null { + if (dataType.derivation === 'structure') { + let changed = false + const fields = dataType.variable.map((field) => { + const nextType = renameDataTypeInVariableType(field.type, oldName, newName) + if (!nextType) return field + changed = true + return { ...field, type: nextType } + }) + return changed ? { ...dataType, variable: fields } : null + } + if (dataType.derivation === 'array') { + const nextBaseType = renameDataTypeInVariableType(dataType.baseType, oldName, newName) + return nextBaseType ? { ...dataType, baseType: nextBaseType } : null + } + return null +} diff --git a/src/frontend/utils/data-type-references/types.ts b/src/frontend/utils/data-type-references/types.ts new file mode 100644 index 000000000..e26b5d232 --- /dev/null +++ b/src/frontend/utils/data-type-references/types.ts @@ -0,0 +1,13 @@ +import type { ReferenceImpactAnalysis } from '../variable-references/types' + +export type DataTypeReferenceKind = 'pou-variable' | 'global-variable' | 'data-type-field' | 'data-type-base-type' + +export type DataTypeReferenceLocation = { + kind: DataTypeReferenceKind + /** POU name, referencing data type name, or the global-variables table label. */ + container: string + /** Declaring variable / structure field name; absent for an array data type's base type. */ + variableName?: string +} + +export type DataTypeReferenceImpactAnalysis = ReferenceImpactAnalysis diff --git a/src/frontend/utils/debugger-session.ts b/src/frontend/utils/debugger-session.ts index 505a7f486..9aa8461d2 100644 --- a/src/frontend/utils/debugger-session.ts +++ b/src/frontend/utils/debugger-session.ts @@ -19,6 +19,7 @@ import type { DebugMap, DebugVariableEntry } from './debug-parser' import { packDebugAddr } from './debug-parser' import { buildDebugTree } from './debug-tree-builder' import { buildDebugPathPrefix, findInstanceName, type PLCInstanceMapping } from './debug-variable-finder' +import { collapseCarriageReturns } from './terminal-output' // --------------------------------------------------------------------------- // 0. logCompilerEvent — shared log helper for compile/debug progress @@ -31,6 +32,14 @@ import { buildDebugPathPrefix, findInstanceName, type PLCInstanceMapping } from * per line — keeps the existing scroll/wrap/copy behaviour intact for * the long Arduino-CLI / compiler outputs. * + * Carriage returns are honoured the way a terminal does. arduino-cli draws + * download progress by rewriting one line with `\r`, so each chunk is + * collapsed to the frame that would actually be on screen, and the entry is + * marked `transient` while the line is still open (no terminating newline). + * The console then overwrites that line on the next redraw rather than + * appending, which is what keeps a 200 MB core install to a single live line + * instead of several hundred stacked ones. + * * Events that carry a structured `compileError` are emitted as a * single multi-line entry instead, with the structured field attached. * The renderer's LogComponent uses `whitespace-pre-wrap` so the @@ -44,12 +53,16 @@ export function logCompilerEvent( level?: string compileError?: import('../../middleware/shared/ports/types').StructuredCompileError }, - log: (entry: { - id: string - level: 'error' | 'debug' | 'info' | 'warning' - message: string - compileError?: import('../../middleware/shared/ports/types').StructuredCompileError - }) => void, + log: ( + entry: { + id: string + level: 'error' | 'debug' | 'info' | 'warning' + message: string + compileError?: import('../../middleware/shared/ports/types').StructuredCompileError + transient?: boolean + }, + options?: { redraw?: boolean }, + ) => void, ): void { if (!event.message) return const level = (event.level as 'error' | 'debug' | 'info' | 'warning') ?? 'info' @@ -64,18 +77,44 @@ export function logCompilerEvent( return } - event.message - .trim() - .split('\n') - .forEach((line) => { - if (line) { - log({ - id: crypto.randomUUID(), - level, - message: line, - }) - } - }) + // Whether the chunk ended mid-line decides if its final entry stays open for + // the next redraw, so read that before trimming anything away. + const endsWithNewline = event.message.endsWith('\n') + + // Trim the block edges as before, but leave carriage returns alone: `\r` is + // a redraw marker here, not padding, and `String.trim` would eat it. + const lines = event.message.replace(/^[ \t\n]+|[ \t\n]+$/g, '').split('\n') + + lines.forEach((rawLine, index) => { + // A `\r` at the END of a line is the CR half of a CRLF terminator, not a + // redraw — every line of Windows output carries one, and a chunk can also + // break between the `\r` and the `\n`. arduino-cli always writes a progress + // CR at the START of a frame, so position is what separates the two. + // + // Getting this wrong loses log lines rather than merely misformatting + // them: an ordinary Windows line counted as a redraw overwrites the live + // progress line above it, and one left open is itself overwritten by the + // next redraw. + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + + const redraw = line.includes('\r') + const message = collapseCarriageReturns(line) + if (!message.trim()) return + + const isFinalLine = index === lines.length - 1 + log( + { + id: crypto.randomUUID(), + level, + message, + // Only a redraw leaves an open line. A plain partial line is left + // permanent: appending to it would need real cursor tracking, and + // build output never relies on that. + transient: redraw && isFinalLine && !endsWithNewline, + }, + { redraw }, + ) + }) } // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/device-connect-events.ts b/src/frontend/utils/device-connect-events.ts new file mode 100644 index 000000000..d1f06e2d8 --- /dev/null +++ b/src/frontend/utils/device-connect-events.ts @@ -0,0 +1,23 @@ +/** + * Tiny decoupled bridge for the CONNECT flow (D72). The device screen's + * "no firmware" dialog lives in `board.tsx`, but Build & Upload lives in the + * workspace activity bar (`default.tsx`). Rather than hoist build state up or + * thread refs across the component tree, the dialog fires a DOM CustomEvent the + * activity bar listens for. Same window, synchronous dispatch — no payload. + */ +const FLASH_REQUEST_EVENT = 'openplc:device-flash-request' + +/** Ask the workspace activity bar to run Build & Upload (flash the firmware). */ +export function requestDeviceFlash(): void { + window.dispatchEvent(new CustomEvent(FLASH_REQUEST_EVENT)) +} + +/** + * Subscribe to flash requests. Returns an unsubscribe function suitable for a + * React effect cleanup. + */ +export function onDeviceFlashRequest(handler: () => void): () => void { + const listener = (): void => handler() + window.addEventListener(FLASH_REQUEST_EVENT, listener) + return () => window.removeEventListener(FLASH_REQUEST_EVENT, listener) +} diff --git a/src/frontend/utils/endian.ts b/src/frontend/utils/endian.ts index 62474d483..dc88e0ec2 100644 --- a/src/frontend/utils/endian.ts +++ b/src/frontend/utils/endian.ts @@ -9,7 +9,7 @@ * and code-size-cheap on AVR; the editor (running on a host with * plenty of compute) does the adaptation. * - * - The editor's internal codecs (`floatToBuffer`, `integerToBuffer`, + * - The editor's internal codecs (`encodeForceValue`, * `decodeWireValue`, etc.) always produce / consume **little-endian** * bytes. The swap layer below normalises the wire bytes to that * canonical internal form on reads, and back to target-native on diff --git a/src/frontend/utils/feature-flags.ts b/src/frontend/utils/feature-flags.ts new file mode 100644 index 000000000..5c34ef77b --- /dev/null +++ b/src/frontend/utils/feature-flags.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Compile-time feature switches shared by both IDEs (mirrored file). + * + * `DATATYPES_DT_FILES` gates the per-type data type persistence + * (`datatypes/.dt`, DOPE-385): the write path and the read + * preference both key off it, so a build with the flag off behaves + * exactly like the legacy project.json format in every scenario. + * Flip the constant in the release PR once the whole DOPE-385 series + * has landed. + * + * Exposed as a function rather than a bare constant so call sites + * stay mockable in tests (both flag states need coverage). + */ +const DATATYPES_DT_FILES = false + +export const isDataTypeFilesEnabled = (): boolean => DATATYPES_DT_FILES diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index 53915b241..ac50b424d 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -56,8 +56,9 @@ const hasLibraryPous = (lib: unknown): lib is { pous: Array<{ name: string; type /** * Parse an array type string like "ARRAY[1..10] OF INT" or "ARRAY[1..10, 1..5] OF MyStruct" * Returns null if not an array type, otherwise returns the parsed array type definition. + * Also consumed by the data-type text parser (`PLC/data-type-text-parser.ts`). */ -const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { +export const parseArrayType = (typeStr: string): PLCVariable['type'] | null => { // Match ARRAY[dimensions] OF baseType, where baseType is an identifier (optionally namespaced) const arrayMatch = typeStr.match(/^ARRAY\s*\[([^\]]+)\]\s+OF\s+([A-Za-z_][\w.]*)\s*$/i) if (!arrayMatch) return null diff --git a/src/frontend/utils/iec-duration.ts b/src/frontend/utils/iec-duration.ts new file mode 100644 index 000000000..dcef7401e --- /dev/null +++ b/src/frontend/utils/iec-duration.ts @@ -0,0 +1,97 @@ +/** + * IEC 61131-3 duration literal parser — the inverse of `formatTimeValue` + * in `variable-sizes.ts`. + * + * strucpp stores TIME as int64 nanoseconds, so forcing a TIME variable + * means turning the user's `T#10s` / `1h30m` text into a nanosecond + * count. The watch panel renders durations without the `T#` prefix + * (`3s800ms`), so prefix-less input is accepted too — whatever the panel + * displays can be typed straight back in. + */ + +const NS_PER_UNIT: Record = { + d: 86_400_000_000_000n, + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + ns: 1n, +} + +/** Largest unit first — also the order IEC requires in a literal. */ +const UNIT_ORDER = ['d', 'h', 'm', 's', 'ms', 'us', 'ns'] + +const INT64_MIN = -(2n ** 63n) +const INT64_MAX = 2n ** 63n - 1n + +/** Two-character units come first so `ms` never tokenises as `m` + `s`. */ +const COMPONENT = /^(\d[\d_]*(?:\.\d[\d_]*)?)(ms|us|ns|d|h|m|s)/i + +const HINT = 'Use IEC duration units, e.g. T#10s, 1h30m, 250ms, T#-1s500ms.' + +/** + * Parse an IEC duration literal into signed nanoseconds. + * + * Accepts `T#` / `TIME#` / `LT#` / `LTIME#` or no prefix at all, a sign + * on either side of the prefix, `_` digit separators, unit chains + * (`1h30m`), overflow units (`90s`), and a fraction on the smallest + * unit (`1.5s`). Throws an Error with a user-facing message otherwise — + * callers surface it as-is. + */ +export function parseDurationLiteral(input: string): bigint { + let body = input.trim() + let negative = false + + if (/^[+-]/.test(body)) { + negative = body.startsWith('-') + body = body.slice(1) + } + + const prefix = /^(?:ltime|time|lt|t)#/i.exec(body) + if (prefix) body = body.slice(prefix[0].length) + + // IEC writes the sign after the prefix (`T#-5s`); tolerate either side. + if (/^[+-]/.test(body)) { + if (body.startsWith('-')) negative = !negative + body = body.slice(1) + } + + let rest = body.replace(/\s+/g, '') + if (rest === '') throw new Error(`Invalid TIME value: "${input}". ${HINT}`) + + let totalNs = 0n + let smallestSeen = -1 + let sawFraction = false + + while (rest !== '') { + const match = COMPONENT.exec(rest) + if (!match) throw new Error(`Invalid TIME value: "${input}". ${HINT}`) + + const [component, digits, unitText] = match + const unit = unitText.toLowerCase() + const position = UNIT_ORDER.indexOf(unit) + if (position <= smallestSeen) { + throw new Error(`TIME units must appear once, largest first (d h m s ms us ns): "${input}"`) + } + if (sawFraction) { + throw new Error(`Only the smallest unit of a TIME value may be fractional: "${input}"`) + } + + const [whole, fraction] = digits.replace(/_/g, '').split('.') + totalNs += BigInt(whole) * NS_PER_UNIT[unit] + if (fraction !== undefined) { + sawFraction = true + // Round half away from zero on the magnitude; the sign lands below. + const scale = 10n ** BigInt(fraction.length) + totalNs += (BigInt(fraction) * NS_PER_UNIT[unit] * 2n + scale) / (scale * 2n) + } + + smallestSeen = position + rest = rest.slice(component.length) + } + + const signed = negative ? -totalNs : totalNs + if (signed < INT64_MIN || signed > INT64_MAX) throw new Error(`TIME value out of range: "${input}"`) + return signed +} diff --git a/src/frontend/utils/license-buy-url.ts b/src/frontend/utils/license-buy-url.ts new file mode 100644 index 000000000..83d2fa203 --- /dev/null +++ b/src/frontend/utils/license-buy-url.ts @@ -0,0 +1,57 @@ +/** + * Buy-license deep link (D68a) — carries the device INTO the Edge purchase page. + * + * The page is `/buy` on the Edge **web app** (not the API host), and it needs + * both ids up front: it validates `deviceId` against `/^[0-9a-fA-F]{32}$/` and + * resolves the VPP through `GET vpp-catalog/v1/vpps/:vppId`. That `vppId` is the + * **reverse-domain package id** (`package.id`, e.g. + * `com.openplc.raspberry-pi-licensed`) — NOT a database id — which is exactly + * what the editor already holds as `boardInfo.vpp.packageId`, so no lookup is + * needed to build the link. + * + * A link missing either id lands the buyer on "Invalid purchase link" with no + * way forward, so an incomplete input resolves to `null` here and the caller + * says something useful instead of opening a dead end. + */ + +export interface LicenseBuyLinkInput { + /** Absolute base URL of the Edge web app, or a same-origin prefix on web. */ + baseUrl: string + /** Reverse-domain package id of the licensable VPP (`package.id`). */ + vppId: string | undefined + /** The derived licensing identity (32 hex chars), NOT the hardware anchor. */ + deviceId: string | undefined +} + +/** Mirrors the `/buy` page's own guard — reject before navigating, not after. */ +const DEVICE_ID_RE = /^[0-9a-f]{32}$/i + +function withoutTrailingSlash(base: string): string { + return base.replace(/\/+$/, '') +} + +/** + * Build the `/buy?vppId=…&deviceId=…` URL, or `null` when the ids can't produce + * a link the purchase page will accept. + */ +export function buildLicenseBuyUrl({ baseUrl, vppId, deviceId }: LicenseBuyLinkInput): string | null { + const vpp = vppId?.trim() + const device = deviceId?.trim() + if (!vpp || !device || !DEVICE_ID_RE.test(device)) return null + + const query = new URLSearchParams({ vppId: vpp, deviceId: device }).toString() + const path = `${withoutTrailingSlash(baseUrl)}/buy` + + try { + // Single-argument form on purpose: it preserves a base path, so an Edge app + // hosted under a sub-path still gets `/prefix/buy` instead of `/buy`. + const url = new URL(path) + url.search = query + return url.toString() + } catch { + // Not absolute — the web build can be configured with a same-origin prefix + // (`VITE_EDGE_FRONTEND_URL=/`). A root-relative link is correct there; the + // editor never reaches this branch (its adapter always yields an origin). + return `${path}?${query}` + } +} diff --git a/src/frontend/utils/license-outcome-dialog.ts b/src/frontend/utils/license-outcome-dialog.ts new file mode 100644 index 000000000..4e46fd2df --- /dev/null +++ b/src/frontend/utils/license-outcome-dialog.ts @@ -0,0 +1,137 @@ +/** + * Turn a licensing outcome into what the user is told — and what they are offered. + * + * Pure decision, separate from the hook that calls it, because this is where the + * union's distinctions become promises to a customer and each branch has to be + * defensible on its own: + * + * licensed → say nothing. A silent success is the correct UX; a dialog + * confirming a licence nobody asked about is noise on every + * single connect. + * unlicensed + checked → demo mode, and OFFER TO BUY. The backend was asked + * and said there is no purchase, so this is the one branch + * where pointing at a purchase page is honest. + * unlicensed + NOT checked → demo mode, but offer to CHECK AGAIN, not to buy. + * Nobody asked whether a purchase exists; telling someone + * who already paid to pay again is the worst thing this + * flow can do. + * unsupported → the device cannot store a licence. Buying would not help, + * so no purchase is offered; the detail (when present) names + * a build mismatch, which is actionable. + * check-failed → say we could not tell, and offer a retry. Never present + * this as "not licensed". + * + * The demo-mode wording is deliberate about WHO enforces it: the closed + * license-core inside the VPP does, on the device. The editor uploads either way — + * it does not gate the build on a licence, and saying it might would be false. + */ + +import type { DeviceLicenseReport, DeviceLicenseState } from '@root/middleware/shared/ports/device-port' + +/** The subset of the modal action this needs, so tests need no store. */ +export type OpenLicenseDialog = ( + name: 'debugger-message', + props: { + type: 'error' | 'warning' | 'question' | 'info' + title: string + message: string + buttons: string[] + onResponse: (buttonIndex: number) => void + }, +) => void + +export interface LicenseDialogHandlers { + openModal: OpenLicenseDialog + /** + * Open the device-bound purchase page for `deviceId`. + * + * The id is passed IN rather than looked up by the handler, because this dialog + * is opened straight out of a licensing call and the handler's own view of the + * current report is one render behind — so a lookup would find nothing on a + * first connect and the button would do nothing. + */ + buy: (deviceId?: string) => Promise + /** Re-run the full licensing flow (offered on a failed or unchecked outcome). */ + retry?: () => Promise +} + +const DEMO_EXPLANATION = + 'The device will run in DEMO mode: the VPP stops driving outputs a few minutes after each start. ' + + 'You can still build and upload — the licence is enforced on the device, not by the editor.' + +/** + * Show the dialog this outcome warrants, if any. Returns whether one was opened, + * which is what makes "licensed is silent" testable rather than assumed. + */ +export function explainLicenseOutcome(report: DeviceLicenseReport, handlers: LicenseDialogHandlers): boolean { + const { openModal, buy, retry } = handlers + const outcome: DeviceLicenseState = report.outcome + + switch (outcome.state) { + case 'licensed': + // Silence on purpose. See the module docstring. + return false + + case 'unlicensed': { + if (outcome.entitlementChecked) { + const reason = outcome.backendReason ? `\n\nThe licence server said: ${outcome.backendReason}` : '' + openModal('debugger-message', { + type: 'warning', + title: 'No Licence For This Device', + message: `This VPP is a paid product and no licence is registered for this device.${reason}\n\n${DEMO_EXPLANATION}`, + buttons: ['Buy Licence', 'Continue in Demo Mode'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) void buy(report.deviceId) + }, + }) + return true + } + + // Nobody asked the backend. Offer a check, NOT a purchase. + openModal('debugger-message', { + type: 'warning', + title: 'No Licence Stored On This Device', + message: `This device is not holding a valid licence for this VPP. It may simply not have been activated yet.\n\n${DEMO_EXPLANATION}`, + buttons: retry ? ['Check For Licence', 'Continue in Demo Mode'] : ['OK'], + onResponse: (buttonIndex: number) => { + if (retry && buttonIndex === 0) void retry() + }, + }) + return true + } + + case 'unsupported': + // Unconditional wording, and it names the FIRMWARE. Every licensable VPP + // targets hardware that persists a licence, so a board answering this was + // built without its storage backend — "your hardware cannot do this" would + // be false, and it is the message that sent us looking at a NodeMCU that + // stores licences perfectly well. + openModal('debugger-message', { + type: 'warning', + title: 'Licence Storage Missing From This Firmware', + message: + 'The firmware running on this device reports no licence storage, so a licence cannot be ' + + 'written to it.\n\nThis hardware supports it — every licensed VPP targets hardware that ' + + 'does. The image on the board was built without the storage backend, so rebuild and ' + + `upload it.\n\n${DEMO_EXPLANATION}`, + // No purchase offered: buying would not fix a firmware built wrong. + buttons: ['OK'], + onResponse: () => undefined, + }) + return true + + case 'check-failed': + openModal('debugger-message', { + type: 'error', + title: 'Licence Check Failed', + message: + `The editor could not determine whether this device holds a licence.\n\n${outcome.error}\n\n` + + 'This is NOT the same as having no licence — nothing has changed on the device.', + buttons: retry ? ['Try Again', 'Continue'] : ['OK'], + onResponse: (buttonIndex: number) => { + if (retry && buttonIndex === 0) void retry() + }, + }) + return true + } +} diff --git a/src/frontend/utils/opcua/__tests__/generate-opcua-config.test.ts b/src/frontend/utils/opcua/__tests__/generate-opcua-config.test.ts index c9727abdc..cdedc7d0f 100644 --- a/src/frontend/utils/opcua/__tests__/generate-opcua-config.test.ts +++ b/src/frontend/utils/opcua/__tests__/generate-opcua-config.test.ts @@ -459,6 +459,85 @@ describe('generateOpcUaConfig', () => { expect(parsed[0].config.address_space.arrays.length).toBe(0) }) + // Regression: openplc-editor#745 — an ARRAY OF has + // no leaf of its own in the debug map, and the variable picker only + // pre-expands the elements into `fields` for small 1-D arrays. A bigger + // (or multi-dimensional) UDT array therefore arrived here as a bare + // `array` node and failed the whole build with "Cannot resolve OPC-UA + // array address" on compile+download. + it('publishes an array of a UDT as a structure even without stored fields', () => { + const cfg = baseServerConfig() + cfg.addressSpace.nodes = [ + makeNode({ + nodeType: 'array', + variablePath: 'TANKS', + variableType: 'ARRAY[1..2] OF TANK', + elementType: 'TANK', + arrayLength: 2, + fields: [], // picker skipped expansion → no fields saved + }), + ] + const json = generateOpcUaConfig( + [makePLCServer(cfg)], + debugMapJson([ + { path: 'INSTANCE0.TANKS[1].LEVEL', type: 'INT', arr: 0, elem: 20 }, + { path: 'INSTANCE0.TANKS[1].FLOW', type: 'DINT', arr: 0, elem: 21, size: 4 }, + { path: 'INSTANCE0.TANKS[2].LEVEL', type: 'INT', arr: 0, elem: 22 }, + { path: 'INSTANCE0.TANKS[2].FLOW', type: 'DINT', arr: 0, elem: 23, size: 4 }, + ]), + instances, + )! + const parsed = JSON.parse(json) as Array<{ + config: { + address_space: { + arrays: unknown[] + structures: Array<{ + node_id: string + fields: Array<{ + name: string + fields?: Array<{ name: string; datatype: string; size: number; arr: number; elem: number }> + }> + }> + } + } + }> + const { arrays, structures } = parsed[0].config.address_space + expect(arrays.length).toBe(0) + expect(structures.length).toBe(1) + expect(structures[0].node_id).toBe('ns=1;s=MY_VAR') + expect(structures[0].fields.map((f) => f.name)).toEqual(['[1]', '[2]']) + expect(structures[0].fields[0].fields).toEqual([ + { + name: 'LEVEL', + datatype: 'INT', + size: 2, + arr: 0, + elem: 20, + permissions: { viewer: 'r', operator: 'rw', engineer: 'rw' }, + }, + { + name: 'FLOW', + datatype: 'DINT', + size: 4, + arr: 0, + elem: 21, + permissions: { viewer: 'r', operator: 'rw', engineer: 'rw' }, + }, + ]) + }) + + it('still fails a bare array node whose elements are nowhere in the debug map', () => { + const cfg = baseServerConfig() + cfg.addressSpace.nodes = [makeNode({ nodeType: 'array', variablePath: 'GHOST_ARR', arrayLength: 2 })] + expect(() => + generateOpcUaConfig( + [makePLCServer(cfg)], + debugMapJson([{ path: 'INSTANCE0.SOMETHING_ELSE', type: 'INT', arr: 0, elem: 0 }]), + instances, + ), + ).toThrow('Cannot resolve OPC-UA array address') + }) + it('collects multiple OpcUaConfigErrors and throws combined', () => { const cfg = baseServerConfig() cfg.addressSpace.nodes = [ @@ -564,6 +643,20 @@ describe('validateOpcUaConfig', () => { expect(result.valid).toBe(false) }) + it('a bare array node whose elements are a UDT validates clean', () => { + const cfg = baseServerConfig() + cfg.addressSpace.nodes = [makeNode({ nodeType: 'array', variablePath: 'TANKS', arrayLength: 2 })] + const result = validateOpcUaConfig( + cfg, + debugMapJson([ + { path: 'INSTANCE0.TANKS[1].LEVEL', type: 'INT', arr: 0, elem: 20 }, + { path: 'INSTANCE0.TANKS[2].LEVEL', type: 'INT', arr: 0, elem: 21 }, + ]), + instances, + ) + expect(result).toEqual({ valid: true, errors: [] }) + }) + it('array-with-fields field-level miss is NOT a validation error', () => { const cfg = baseServerConfig() cfg.addressSpace.nodes = [ diff --git a/src/frontend/utils/opcua/__tests__/resolve-indices.test.ts b/src/frontend/utils/opcua/__tests__/resolve-indices.test.ts index 6686d6398..8f195d731 100644 --- a/src/frontend/utils/opcua/__tests__/resolve-indices.test.ts +++ b/src/frontend/utils/opcua/__tests__/resolve-indices.test.ts @@ -4,6 +4,7 @@ import type { DebugLeafInfo } from '../../debug-parser' import { OpcUaConfigError, resolveArrayAddress, + resolveArrayElementFields, resolveStructureAddresses, resolveVariableAddress, } from '../resolve-indices' @@ -348,3 +349,161 @@ describe('resolveArrayAddress', () => { ) }) }) + +// An ARRAY OF has no leaf of its own, so it can only +// be published as a structure whose fields are the elements. The picker +// pre-expands those elements only for small 1-D arrays, so the resolver +// has to be able to rebuild them from the debug map alone. +describe('resolveArrayElementFields', () => { + it('returns [] for a plain array of base types (no sub-element leaves)', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'PROFILE', arrayLength: 2 }) + const leaves = pmap(['INSTANCE0.PROFILE[0]', 0, 10], ['INSTANCE0.PROFILE[1]', 0, 11]) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')])).toEqual([]) + }) + + it('returns [] when the array has no leaves at all', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'GHOST', arrayLength: 2 }) + expect(resolveArrayElementFields(node, pmap(), [inst('INSTANCE0', 'MAIN')])).toEqual([]) + }) + + it('builds one container field per element with canonical leaf type/size/address', () => { + const node = makeNode({ + nodeType: 'array', + variablePath: 'TANKS', + variableType: 'ARRAY[1..2] OF TANK', + elementType: 'TANK', + arrayLength: 2, + }) + const leaves = pmap( + ['INSTANCE0.TANKS[1].LEVEL', 0, 20, 'INT', 2], + ['INSTANCE0.TANKS[1].FLOW', 0, 21, 'DINT', 4], + ['INSTANCE0.TANKS[2].LEVEL', 0, 22, 'INT', 2], + ['INSTANCE0.TANKS[2].FLOW', 0, 23, 'DINT', 4], + ) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')])).toEqual([ + { + name: '[1]', + datatype: 'TANK', + size: null, + arr: null, + elem: null, + permissions: perm, + fields: [ + { name: 'LEVEL', datatype: 'INT', size: 2, arr: 0, elem: 20, permissions: perm }, + { name: 'FLOW', datatype: 'DINT', size: 4, arr: 0, elem: 21, permissions: perm }, + ], + }, + { + name: '[2]', + datatype: 'TANK', + size: null, + arr: null, + elem: null, + permissions: perm, + fields: [ + { name: 'LEVEL', datatype: 'INT', size: 2, arr: 0, elem: 22, permissions: perm }, + { name: 'FLOW', datatype: 'DINT', size: 4, arr: 0, elem: 23, permissions: perm }, + ], + }, + ]) + }) + + it('falls back to UNKNOWN for the element container when elementType is unset', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'A', arrayLength: 1 }) + const fields = resolveArrayElementFields(node, pmap(['INSTANCE0.A[1].X', 0, 1]), [inst('INSTANCE0', 'MAIN')]) + expect(fields[0]).toMatchObject({ name: '[1]', datatype: 'UNKNOWN' }) + }) + + it('falls back to UNKNOWN when a leaf carries an empty type', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'A', arrayLength: 1 }) + const fields = resolveArrayElementFields(node, pmap(['INSTANCE0.A[1].X', 0, 1, '']), [inst('INSTANCE0', 'MAIN')]) + expect(fields[0].fields?.[0]).toMatchObject({ name: 'X', datatype: 'UNKNOWN' }) + }) + + it('orders elements by IEC index, including negative lower bounds', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'SIGNED', arrayLength: 3 }) + // Intentionally unsorted in the map — output must be [-1], [0], [1]. + const leaves = pmap( + ['INSTANCE0.SIGNED[1].V', 0, 3], + ['INSTANCE0.SIGNED[-1].V', 0, 1], + ['INSTANCE0.SIGNED[0].V', 0, 2], + ) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')]).map((f) => f.name)).toEqual([ + '[-1]', + '[0]', + '[1]', + ]) + }) + + it('orders multi-dimensional elements dimension by dimension', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'GRID', arrayLength: 4 }) + const leaves = pmap( + ['INSTANCE0.GRID[2][1].V', 0, 4], + ['INSTANCE0.GRID[1][2].V', 0, 2], + ['INSTANCE0.GRID[1][1].V', 0, 1], + ) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')]).map((f) => f.name)).toEqual([ + '[1][1]', + '[1][2]', + '[2][1]', + ]) + }) + + it('nests deeper members (struct inside the element) as container fields', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'CELLS', elementType: 'CELL', arrayLength: 1 }) + const leaves = pmap( + ['INSTANCE0.CELLS[1].ID', 0, 5, 'INT', 2], + ['INSTANCE0.CELLS[1].LIMITS.MIN', 0, 6, 'INT', 2], + ['INSTANCE0.CELLS[1].LIMITS.MAX', 0, 7, 'INT', 2], + ) + const fields = resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')]) + expect(fields[0].fields).toEqual([ + { name: 'ID', datatype: 'INT', size: 2, arr: 0, elem: 5, permissions: perm }, + { + name: 'LIMITS', + datatype: 'UNKNOWN', + size: null, + arr: null, + elem: null, + permissions: perm, + fields: [ + { name: 'MIN', datatype: 'INT', size: 2, arr: 0, elem: 6, permissions: perm }, + { name: 'MAX', datatype: 'INT', size: 2, arr: 0, elem: 7, permissions: perm }, + ], + }, + ]) + }) + + it('keeps an inner array of base types as one leaf per element', () => { + const node = makeNode({ nodeType: 'array', variablePath: 'ROWS', arrayLength: 1 }) + const leaves = pmap(['INSTANCE0.ROWS[1].SAMPLES[0]', 0, 8], ['INSTANCE0.ROWS[1].SAMPLES[1]', 0, 9]) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')])[0].fields).toEqual([ + { name: 'SAMPLES[0]', datatype: 'INT', size: 2, arr: 0, elem: 8, permissions: perm }, + { name: 'SAMPLES[1]', datatype: 'INT', size: 2, arr: 0, elem: 9, permissions: perm }, + ]) + }) + + it('resolves a global (GVL) array of structs via the bare path', () => { + const node = makeNode({ nodeType: 'array', pouName: 'GVL', variablePath: 'RECIPES', arrayLength: 1 }) + const fields = resolveArrayElementFields(node, pmap(['RECIPES[1].STEP', 0, 40]), []) + expect(fields[0]).toMatchObject({ name: '[1]' }) + expect(fields[0].fields?.[0]).toMatchObject({ name: 'STEP', arr: 0, elem: 40 }) + }) + + it('prefers a member own leaf over its deeper leaves', () => { + // Defensive: a debug map that carries both `A[1].M` and `A[1].M.X` + // must not produce a field that is a leaf and a container at once. + const node = makeNode({ nodeType: 'array', variablePath: 'A', arrayLength: 1 }) + const leaves = pmap(['INSTANCE0.A[1].M.X', 0, 2], ['INSTANCE0.A[1].M', 0, 1]) + expect(resolveArrayElementFields(node, leaves, [inst('INSTANCE0', 'MAIN')])[0].fields).toEqual([ + { name: 'M', datatype: 'INT', size: 2, arr: 0, elem: 1, permissions: perm }, + ]) + }) + + it('throws when the program has no instance in Resources', () => { + const node = makeNode({ nodeType: 'array', pouName: 'NOPE', variablePath: 'A' }) + expect(() => resolveArrayElementFields(node, pmap(['INSTANCE0.A[1].X', 0, 1]), [])).toThrow( + 'Cannot find instance for program', + ) + }) +}) diff --git a/src/frontend/utils/opcua/generate-opcua-config.ts b/src/frontend/utils/opcua/generate-opcua-config.ts index 8d0329e0a..411c52b09 100644 --- a/src/frontend/utils/opcua/generate-opcua-config.ts +++ b/src/frontend/utils/opcua/generate-opcua-config.ts @@ -13,10 +13,11 @@ import { getErrorMessage } from '../get-error-message' import { OpcUaConfigError, resolveArrayAddress, + resolveArrayElementFields, resolveStructureAddresses, resolveVariableAddress, } from './resolve-indices' -import type { PLCInstanceInfo } from './types' +import type { PLCInstanceInfo, ResolvedField } from './types' /** * opcua.json contract version. Bump when the per-variable schema changes @@ -279,15 +280,20 @@ const resolveStructure = ( if (resolvedFields.length === 0) { return null } - return { - node_id: node.nodeId, - browse_name: node.browseName, - display_name: node.displayName, - description: node.description, - fields: resolvedFields.map(convertResolvedFieldToRuntime), - } + return toRuntimeStructure(node, resolvedFields) } +/** + * Wrap resolved fields in the runtime's structure shape. + */ +const toRuntimeStructure = (node: OpcUaNodeConfig, fields: ResolvedField[]): RuntimeStructure => ({ + node_id: node.nodeId, + browse_name: node.browseName, + display_name: node.displayName, + description: node.description, + fields: fields.map(convertResolvedFieldToRuntime), +}) + /** * Resolve an array and build runtime format */ @@ -351,10 +357,19 @@ const buildAddressSpace = ( if (node.fields && node.fields.length > 0) { const struct = resolveStructure(node, pathToAddr, instances, droppedPaths) if (struct) structures.push(struct) - } else { - // Simple arrays of base types - arrays.push(resolveArray(node, pathToAddr, instances)) + break + } + // An array of a UDT / FB has no leaf of its own — only + // `ARR[i].FIELD`. The picker only pre-expands small 1-D ones + // into `fields`, so rebuild the elements from the debug map + // instead of failing the build on a bare `array` node. + const elementFields = resolveArrayElementFields(node, pathToAddr, instances) + if (elementFields.length > 0) { + structures.push(toRuntimeStructure(node, elementFields)) + break } + // Simple arrays of base types + arrays.push(resolveArray(node, pathToAddr, instances)) break } } @@ -523,7 +538,8 @@ export const validateOpcUaConfig = ( case 'array': if (node.fields && node.fields.length > 0) { resolveStructureAddresses(node, pathToAddr, instances) - } else { + } else if (resolveArrayElementFields(node, pathToAddr, instances).length === 0) { + // Not an array of a UDT / FB — must resolve as a plain array. resolveArrayAddress(node, pathToAddr, instances) } break diff --git a/src/frontend/utils/opcua/index.ts b/src/frontend/utils/opcua/index.ts index fc6ba8610..ccd471955 100644 --- a/src/frontend/utils/opcua/index.ts +++ b/src/frontend/utils/opcua/index.ts @@ -25,6 +25,7 @@ export { type LeafAddress, OpcUaConfigError, resolveArrayAddress, + resolveArrayElementFields, resolveStructureAddresses, resolveVariableAddress, } from './resolve-indices' diff --git a/src/frontend/utils/opcua/resolve-indices.ts b/src/frontend/utils/opcua/resolve-indices.ts index d88be32d5..25e45293a 100644 --- a/src/frontend/utils/opcua/resolve-indices.ts +++ b/src/frontend/utils/opcua/resolve-indices.ts @@ -273,3 +273,137 @@ export const resolveArrayAddress = ( ` No array elements with that prefix found in debug-map.json.`, ) } + +/** One debug-map leaf below an array element, split into member segments. */ +interface ElementLeaf { + /** Member path under the element (`['A']`, `['INNER', 'B']`). */ + segments: string[] + info: DebugLeafInfo +} + +/** + * Split the debug-map suffix that follows an array's base path into + * segments, keeping index runs attached to the token they subscript: + * + * `[1].A` → ['[1]', 'A'] + * `[1][2].A.B` → ['[1][2]', 'A', 'B'] + * `[3].INNER[0]` → ['[3]', 'INNER[0]'] + * + * Dots only ever separate members (indices are numeric), so a plain + * split is enough. + */ +const splitSuffixSegments = (suffix: string): string[] => suffix.split('.').filter((part) => part.length > 0) + +/** Numeric sort key for an element token: `[1]` → [1], `[2][3]` → [2, 3]. */ +const elementIndexKey = (elementToken: string): number[] => + [...elementToken.matchAll(/-?\d+/g)].map((match) => Number(match[0])) + +/** Dimension-wise comparison so elements come out in IEC index order. */ +const compareIndexKeys = (a: number[], b: number[]): number => { + const width = Math.max(a.length, b.length) + const perDimension = Array.from({ length: width }, (_, i) => (a[i] ?? 0) - (b[i] ?? 0)) + return perDimension.find((diff) => diff !== 0) ?? 0 +} + +/** + * Group leaves into a field tree by their segment at `depth`. A group + * whose own leaf exists at that depth is a leaf field (address from the + * debug map); otherwise it is a container and recurses. + */ +const fieldsFromLeaves = ( + leaves: ElementLeaf[], + depth: number, + permissions: ResolvedField['permissions'], +): ResolvedField[] => { + const groups = new Map() + for (const leaf of leaves) { + const head = leaf.segments[depth] + const bucket = groups.get(head) + if (bucket) bucket.push(leaf) + else groups.set(head, [leaf]) + } + + const fields: ResolvedField[] = [] + for (const [name, group] of groups) { + const own = group.find((leaf) => leaf.segments.length === depth + 1) + if (own) { + fields.push({ + name, + datatype: own.info.type || 'UNKNOWN', + size: own.info.size, + arr: own.info.arr, + elem: own.info.elem, + permissions, + }) + continue + } + fields.push({ + name, + // Container — the struct/FB type name isn't in the debug map and + // the runtime only needs it for leaves (it creates an Object node). + datatype: 'UNKNOWN', + size: null, + arr: null, + elem: null, + permissions, + fields: fieldsFromLeaves(group, depth + 1, permissions), + }) + } + return fields +} + +/** + * Resolve an array whose elements are a derived type (UDT / FB instance) + * into per-element structure fields, straight from the compiler's debug + * map. + * + * Such an array has no leaf of its own — the debug map only carries + * `ARR[i].FIELD` (and deeper) — so `resolveArrayAddress` cannot address + * it by design. The variable picker pre-expands the elements into the + * node's `fields` only when the array is 1-D and small enough + * (MAX_ARRAY_EXPANSION), so bigger or multi-dimensional UDT arrays reach + * the compiler as a bare `array` node with no fields. Rebuilding the + * element fields from the debug map covers every case and keeps the + * compiler as the single source of truth for type/size/address. + * + * Returns `[]` when the array has no sub-element leaves — i.e. a plain + * array of base types, which `resolveArrayAddress` handles. + */ +export const resolveArrayElementFields = ( + node: OpcUaNodeConfig, + pathToAddr: Map, + instances: PLCInstanceInfo[], +): ResolvedField[] => { + const result = pathForNode(node.pouName, node.variablePath, instances) + if ('error' in result) throw result.error + + const basePath = result.path.toUpperCase() + const prefix = `${basePath}[` + + // element token → its leaves, in debug-map (memory layout) order + const perElement = new Map() + for (const [path, info] of pathToAddr) { + if (!path.startsWith(prefix)) continue + const segments = splitSuffixSegments(path.slice(basePath.length)) + const memberSegments = segments.slice(1) + // A bare `ARR[i]` leaf is a base-type element, not an array of UDT. + if (memberSegments.length === 0) continue + const elementToken = segments[0] + const leaf: ElementLeaf = { segments: memberSegments, info } + const bucket = perElement.get(elementToken) + if (bucket) bucket.push(leaf) + else perElement.set(elementToken, [leaf]) + } + + return [...perElement.entries()] + .sort(([a], [b]) => compareIndexKeys(elementIndexKey(a), elementIndexKey(b))) + .map(([elementToken, leaves]) => ({ + name: elementToken, + datatype: node.elementType || 'UNKNOWN', + size: null, + arr: null, + elem: null, + permissions: node.permissions, + fields: fieldsFromLeaves(leaves, 0, node.permissions), + })) +} diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts index 673a026eb..70e0716de 100644 --- a/src/frontend/utils/semver.ts +++ b/src/frontend/utils/semver.ts @@ -1,38 +1,179 @@ /** - * Tiny semver helpers used by the VPP catalog browser to compare a package - * version's `minEditorVersion` against the running editor's `APP_VERSION`. + * Single source of truth for comparing OpenPLC version strings. * - * Intentionally local — adding the full `semver` npm dependency for two - * comparisons would inflate the renderer bundle for no real gain. Pre-release - * suffixes (`-rc.1`, `+build.5`) are stripped before parsing; this matches - * what arduino-cli does when matching boards.txt menu constraints, and we - * don't currently publish pre-release VPPs. + * Four independent compatibility questions are decided by comparing two + * version strings (DOPE-448): * - * Malformed strings degrade to `0.0.0` so a corrupt manifest in the wild - * doesn't crash the UI — it just compares as the lowest possible version. + * 1. is this runtime new enough for this editor? (`MIN_RUNTIME_VERSION`) + * 2. is this editor new enough for this runtime? (`minEditorVersion` from + * `GET /api/capabilities`) + * 3. is this editor new enough for this VPP? (`package.minEditorVersion`) + * 4. is this runtime new enough for this VPP? (`package.minRuntimeVersion`) + * + * This file used to answer only #3, with `firmware/runtime-version-gate.ts` + * carrying its own parser for the runtime side. The two disagreed on exactly + * the inputs that show up in the field — `"v4"` and `"4.1"` parsed in one and + * were rejected by the other — and a first pass at unifying them kept that + * split alive as a lenient parser and a strict parser chosen by name. + * + * That was still one parser too many. The same string has to mean the same + * thing everywhere, or a floor is enforced in one place and ignored in + * another: `minEditorVersion: "4.3"` refused an install while the identical + * value from a runtime sailed through unnoticed. So there is now exactly + * ONE parser, and it applies one rule: + * + * - a `v` prefix is decoration: `v4.3.2` === `4.3.2` + * - a missing component is zero: `4.3` === `4.3.0`, `4` === `4.0.0` + * - anything else is UNKNOWN: `"dev"`, `"garbage"`, `""` → null + * + * "Readable" means *exactly* readable: a component too large to hold without + * rounding is UNKNOWN too, rather than a number that no longer matches the + * string it came from. + * + * "Unknown" is never a version. It does not become 0.0.0 behind the caller's + * back and it never satisfies a declared floor — a peer that cannot say what + * it is does not get to claim it is new enough. + * + * Pre-release and build suffixes (`-rc.1`, `+build.5`) are parsed but do NOT + * affect ordering: `4.1.0-rc.3` compares equal to `4.1.0`. This is deliberate + * and load-bearing for the runtime gate — the rc tags on a version line ARE + * the builds shipping that line's features, so treating them as "less than" + * the release (strict semver's rule) would reject runtimes that work. + * + * Lives in `frontend/utils/` rather than `backend/shared/` on purpose: the + * architecture rules let `backend-shared` import `utils` but not the reverse, + * and both the VPP surface and the runtime gate need this. No layer exception + * required. */ -type Triple = readonly [number, number, number] +export interface ParsedVersion { + major: number + minor: number + patch: number + /** Pre-release identifier (e.g. `rc.3`) when present. Never affects ordering. */ + prerelease?: string +} + +/** + * `4`, `4.3`, `4.3.2`, `v4.3.2`, `4.3.2-rc.1`, `4.3.2+build.5`. + * + * Anchored at both ends on purpose: a trailing-garbage input like `"4.1 beta"` + * must fail rather than silently parse as `4.1.0`, because a value nobody can + * read is a mistake worth surfacing, not a version worth guessing. + */ +const VERSION_RE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.+))?$/ -function parseSemver(input: string): Triple { - const stripped = input.split(/[-+]/)[0] - const parts = stripped.split('.') - const major = Number.parseInt(parts[0] ?? '', 10) - const minor = Number.parseInt(parts[1] ?? '', 10) - const patch = Number.parseInt(parts[2] ?? '', 10) - return [Number.isFinite(major) ? major : 0, Number.isFinite(minor) ? minor : 0, Number.isFinite(patch) ? patch : 0] +/** Lowest possible version — what an unknown string is worth in a total order. */ +const ZERO: ParsedVersion = { major: 0, minor: 0, patch: 0 } + +/** + * A single numeric component, or null when it is not one we can hold exactly. + * + * `Number.parseInt` answers a *finite* number for a 17-digit component and + * quietly rounds it: `"9007199254740993"` comes back as `…992`. That breaks the + * only promise this parser makes — readable means exact, and anything else is + * UNKNOWN. A component we can only approximate is not readable, so it is null + * rather than a number that no longer matches what was written. + */ +function parseComponent(raw: string | undefined): number | null { + if (raw === undefined) return 0 + const value = Number.parseInt(raw, 10) + return Number.isSafeInteger(value) ? value : null } -export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const [aMajor, aMinor, aPatch] = parseSemver(a) - const [bMajor, bMinor, bPatch] = parseSemver(b) - if (aMajor !== bMajor) return aMajor > bMajor ? 1 : -1 - if (aMinor !== bMinor) return aMinor > bMinor ? 1 : -1 - if (aPatch !== bPatch) return aPatch > bPatch ? 1 : -1 +/** + * Parse a version string, or return null when the string is not a version. + * + * Missing trailing components are zero, so a floor written as `"4.3"` means + * exactly what `"4.3.0"` means and is enforced identically. A leading `v` is + * stripped. Everything else — `"dev"`, `"garbage"`, `""`, `"4.1 beta"`, null — + * is UNKNOWN, and callers decide what unknown costs them. + */ +export function parseVersion(raw: string | null | undefined): ParsedVersion | null { + if (!raw) return null + const match = raw.trim().match(VERSION_RE) + if (!match) return null + const major = parseComponent(match[1]) + const minor = parseComponent(match[2]) + const patch = parseComponent(match[3]) + if (major === null || minor === null || patch === null) return null + return { major, minor, patch, prerelease: match[4] } +} + +/** True when `raw` is a version this codebase can compare. */ +export function isValidVersion(raw: string | null | undefined): boolean { + return parseVersion(raw) !== null +} + +/** + * A version string as it should appear in a message to the user: trimmed, or + * the word `unknown` when there is nothing readable to show. + * + * Exists so that every "incompatible versions" message renders an unreadable + * peer the same way. Printing an empty string leaves a hole in the sentence + * ("Runtime on 10.0.0.1 requires…") and tells the user nothing about which + * of the two versions the editor failed to establish. + */ +export function formatVersionForDisplay(raw: string | null | undefined): string { + const trimmed = raw?.trim() ?? '' + return trimmed.length > 0 ? trimmed : 'unknown' +} + +/** + * Order two parsed versions. Pre-release suffixes are ignored (see the module + * comment) — only the numeric triple decides. + */ +export function compareParsedVersions(a: ParsedVersion, b: ParsedVersion): -1 | 0 | 1 { + if (a.major !== b.major) return a.major > b.major ? 1 : -1 + if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1 + if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1 return 0 } +/** + * `candidate >= minimum` — the one comparison every DOPE-448 gate asks. + * + * Three inputs and what each costs: + * + * - `minimum` absent or empty → **pass**. A peer that declares no floor + * constrains nothing; this is every runtime predating `/api/capabilities` + * and it is what makes shipping the gates safe. + * - `minimum` present but unreadable → **pass**, because an unknown floor is + * worth 0.0.0 and everything clears 0.0.0. Callers that can see the string + * should say so out loud rather than let it vanish — the manifest schema + * rejects such a value when a package is installed and logs it when one + * already installed is read back, and the runtime probe logs a warning. + * - `candidate` unreadable against a real floor → **fail**. An unknown + * version never satisfies a declared minimum. + */ +export function isVersionAtLeast(candidate: string | null | undefined, minimum: string | null | undefined): boolean { + const min = parseVersion(minimum) + if (!min) return true + const version = parseVersion(candidate) + if (!version) return false + return compareParsedVersions(version, min) >= 0 +} + +/** + * Order two version strings for display purposes — sorting catalog rows, + * deciding whether an available version is newer than the installed one. + * + * This is the ONE place an unknown version is coerced to 0.0.0, because a + * sortable list needs a total order and a corrupt `version` field in somebody + * else's manifest should sort to the bottom rather than break the UI. Nothing + * is gated on the result. Every gate uses `isVersionAtLeast`. + */ +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + return compareParsedVersions(parseVersion(a) ?? ZERO, parseVersion(b) ?? ZERO) +} + +/** + * True when `current` satisfies the `minRequired` a package declares. + * + * Delegates to `isVersionAtLeast` so the install gate and the runtime gates + * cannot disagree about what a given string means — the bug this consolidation + * exists to prevent. + */ export function isCompatibleEditorVersion(minRequired: string | undefined, current: string): boolean { - if (!minRequired) return true - return compareSemver(current, minRequired) >= 0 + return isVersionAtLeast(current, minRequired) } diff --git a/src/frontend/utils/serial-port-label.ts b/src/frontend/utils/serial-port-label.ts new file mode 100644 index 000000000..3b697d4e6 --- /dev/null +++ b/src/frontend/utils/serial-port-label.ts @@ -0,0 +1,41 @@ +import type { CommunicationPort } from '../../middleware/shared/ports/types' + +/** + * How a serial port reads in the communication-port picker: + * `/dev/cu.usbmodem11101 (Arduino MKR)`, `COM5 (Arduino Uno)`, `COM5 (wch.cn)`. + * + * Two rules, and they used to pull against each other: + * + * 1. The path always leads. It is what the user recognizes and what we + * actually open — `COM5` on Windows, `/dev/ttyUSB0` on Linux, + * `/dev/cu.usbmodem*` on macOS — so it is never replaced by a descriptor. + * (The bug that motivated this: a NodeMCU reading "wch.cn" instead of + * "COM5", because the label took a manufacturer string over the path.) + * 2. The descriptor survives, in parentheses. It is what distinguishes two + * identical-looking `/dev/cu.usbmodem*` nodes, and dropping it was the + * regression that followed. + * + * Both hold because this composes rather than choosing, and it is the ONE place + * that decides — `CommunicationPort` carries facts (`address`, `boardName`, + * `manufacturer`), never a pre-composed string. That is what makes every + * platform behave identically: there is no second labelling path to drift. + * + * Descriptor precedence: arduino-cli's identified board name first (it is the + * specific, useful one), falling back to the OS vendor/manufacturer string, and + * to nothing at all when neither scan knew anything. + */ +export function serialPortDisplay(port: CommunicationPort): { label: string; title?: string } { + const address = port.address?.trim() ?? '' + const descriptor = port.boardName?.trim() || port.manufacturer?.trim() || '' + + // No path to lead with (shouldn't happen — the enumerator keys on it) — the + // descriptor is all there is. + if (!address) return { label: descriptor, title: undefined } + + if (!descriptor) return { label: address, title: undefined } + + const label = `${address} (${descriptor})` + // Offer the full string on hover as well: a composed label is the one most + // likely to be truncated by the dropdown's width. + return { label, title: label } +} diff --git a/src/frontend/utils/terminal-output.ts b/src/frontend/utils/terminal-output.ts new file mode 100644 index 000000000..a97c44253 --- /dev/null +++ b/src/frontend/utils/terminal-output.ts @@ -0,0 +1,167 @@ +/** + * Interpret the terminal control sequences that show up in captured + * subprocess output (arduino-cli, strucpp, the toolchain) so the console can + * render them the way a terminal would. + * + * Two sequences actually matter in build output: + * + * - **Carriage return.** Progress bars redraw by emitting `\r` and rewriting + * the line in place. Without this, every redraw becomes its own timestamped + * console entry and a single download turns into hundreds of stacked lines. + * - **SGR colour** (`ESC[...m`). arduino-cli colours its compile summary + * table. Without this the raw bytes are printed literally as `ESC[92m`, + * which is what the old editor worked around by forcing `--no-color`. + * + * This is deliberately NOT a terminal emulator. Cursor addressing, scroll + * regions and erase-in-line are not interpreted — build output does not use + * them. Any other escape sequence is stripped rather than acted on, so it can + * never leak into the rendered text. + */ + +import type { LogSegment } from '../../middleware/shared/ports/types' + +/** ESC (0x1B). Written as an escape so an editor cannot silently eat it. */ +const ESC = '\u001B' + +/** + * Every escape sequence we recognise, in precedence order: + * + * 1. **CSI** - `ESC [ `. Group 1 is the parameter list, + * group 2 the final byte (`m` for SGR, the only one that changes styling). + * 2. **OSC** - `ESC ] ... BEL` or `ESC ] ... ESC \\`. Terminal hyperlinks use + * this, and the payload is a URL that must never reach the rendered text. + * 3. Any other **two-byte escape** (`ESC @` through `ESC _`). + * 4. A **stray ESC** with nothing recognisable after it. + * + * The last two alternatives are what make the promise in this file's header + * true. Matching only CSI left OSC payloads and bare ESC bytes in the stored + * message, where they reached search, copy and the DOM. + * + * Shared by {@link parseAnsi} and {@link stripAnsi} so both agree exactly on + * what counts as an escape. Consumers use `String.matchAll` / `String.replace`, + * both of which operate on an internal clone, so the `g` flag carries no + * `lastIndex` state between calls. + */ +// eslint-disable-next-line no-control-regex -- matching the ESC control byte is the entire point +const ANSI_PATTERN = /\u001B(?:\[([0-9;?]*)([@-~])|\][\s\S]*?(?:\u0007|\u001B\\)|[@-_])?/g + +/** + * SGR foreground colours, standard (30-37) and bright (90-97). + * + * Tailwind pairs rather than raw hex: the console renders on both light and + * dark backgrounds, and a literal terminal palette is unreadable on one of + * them. Shades are chosen for contrast against the console background, not + * for fidelity to any particular terminal theme. + */ +const SGR_FOREGROUND: Record = { + 30: 'text-neutral-700 dark:text-neutral-300', + 31: 'text-red-600 dark:text-red-400', + 32: 'text-green-600 dark:text-green-400', + 33: 'text-yellow-600 dark:text-yellow-400', + 34: 'text-blue-600 dark:text-blue-400', + 35: 'text-fuchsia-600 dark:text-fuchsia-400', + 36: 'text-cyan-600 dark:text-cyan-400', + 37: 'text-neutral-600 dark:text-neutral-200', + 90: 'text-neutral-500 dark:text-neutral-400', + 91: 'text-red-500 dark:text-red-300', + 92: 'text-green-600 dark:text-green-400', + 93: 'text-yellow-600 dark:text-yellow-300', + 94: 'text-blue-500 dark:text-blue-300', + 95: 'text-fuchsia-500 dark:text-fuchsia-300', + 96: 'text-cyan-500 dark:text-cyan-300', + 97: 'text-neutral-800 dark:text-neutral-100', +} + +/** True when `text` carries any escape sequence worth parsing. */ +export function hasAnsi(text: string): boolean { + return text.includes(ESC) +} + +/** + * Remove every escape sequence, leaving the human-readable text. + * + * This is what gets stored as the log message, so search, filtering and + * copy-to-clipboard all keep working on clean text without knowing that + * colour exists at all. + */ +export function stripAnsi(text: string): string { + return hasAnsi(text) ? text.replace(ANSI_PATTERN, '') : text +} + +/** + * Split `text` into styled runs. + * + * Returns a single unstyled segment when there is no colour, so callers can + * treat the result uniformly. Only SGR sequences affect styling; other CSI + * sequences are consumed and dropped. + */ +export function parseAnsi(text: string): LogSegment[] { + if (!hasAnsi(text)) return text ? [{ text }] : [] + + const segments: LogSegment[] = [] + let foreground: string | undefined + let bold = false + let cursor = 0 + + const currentClass = () => [foreground, bold ? 'font-bold' : undefined].filter(Boolean).join(' ') || undefined + + const push = (value: string) => { + if (!value) return + const className = currentClass() + // Merge into the previous run when the style is unchanged: a reset + // immediately followed by the same colour is common, and would otherwise + // emit a string of adjacent identical spans. + const previous = segments[segments.length - 1] + if (previous && previous.className === className) { + previous.text += value + return + } + segments.push(className ? { text: value, className } : { text: value }) + } + + for (const match of text.matchAll(ANSI_PATTERN)) { + push(text.slice(cursor, match.index)) + cursor = match.index + match[0].length + + // Only SGR (`m`) changes styling. Every other CSI is dropped above. + if (match[2] !== 'm') continue + + // An empty parameter list means SGR 0 (reset), per ECMA-48. + const params = match[1] === '' ? [0] : match[1].split(';').map(Number) + for (const code of params) { + if (code === 0) { + foreground = undefined + bold = false + } else if (code === 1) { + bold = true + } else if (code === 22) { + bold = false + } else if (code === 39) { + foreground = undefined + } else if (SGR_FOREGROUND[code]) { + foreground = SGR_FOREGROUND[code] + } + // Unknown codes (backgrounds, 256-colour selectors, ...) are ignored + // rather than rendered — the text they wrap is still shown, and the + // sequence itself is already stripped. + } + } + + push(text.slice(cursor)) + return segments +} + +/** + * Collapse a carriage-return redraw to what a terminal would leave on screen. + * + * `\r` returns the cursor to column 0, so only the text written after the + * last one survives. A trailing `\r` moves the cursor but writes nothing, so + * the preceding text stays visible. + */ +export function collapseCarriageReturns(line: string): string { + if (!line.includes('\r')) return line + + const frames = line.split('\r') + while (frames.length > 1 && frames[frames.length - 1] === '') frames.pop() + return frames[frames.length - 1] +} diff --git a/src/frontend/utils/variable-references/types.ts b/src/frontend/utils/variable-references/types.ts index 6c066809c..6f96416e8 100644 --- a/src/frontend/utils/variable-references/types.ts +++ b/src/frontend/utils/variable-references/types.ts @@ -10,9 +10,9 @@ export type VariableReferenceLocation = { columnEnd?: number } -export type ReferenceImpactAnalysis = { +export type ReferenceImpactAnalysis = { totalReferences: number byPou: Map byEditorType: Map - references: VariableReferenceLocation[] + references: Location[] } diff --git a/src/frontend/utils/variable-sizes.ts b/src/frontend/utils/variable-sizes.ts index 9e629a053..8fef9aeed 100644 --- a/src/frontend/utils/variable-sizes.ts +++ b/src/frontend/utils/variable-sizes.ts @@ -1,4 +1,5 @@ import type { PLCVariable } from '../../middleware/shared/ports/types' +import { parseDurationLiteral } from './iec-duration' import { type IECTypeMetadata, type IECWireFormat, lookupBaseType } from './iec-types-registry' /** @@ -325,10 +326,33 @@ export function encodeForceValue(input: string, typeName: string, enumValues?: s } /** - * Encode dispatcher keyed on `wireFormat`. TIME / DATE / TOD / DT / - * STRING / WSTRING force is not yet supported — those need IEC literal - * parsing (`T#…`, `D#…`) and string framing, which the debugger UI - * doesn't currently expose. + * Colour hint stored in the workspace's forced-variables map: `false` + * marks a forced-low value (rendered blue in the watch panel and the + * graphical editors), `true` forced-high. + */ +export function isForcedValueHigh(input: string): boolean { + // Drop a literal prefix so `T#-5s` reads as negative like `-5s` does. + const value = input + .trim() + .toLowerCase() + .replace(/^[a-z_]+#/, '') + return value !== 'false' && value !== '0' && !value.startsWith('-') +} + +/** + * Unwrap an IEC STRING literal (`'text'`) to its content. Input arrives + * trimmed, so quoting is also how a force value keeps leading/trailing + * spaces. + */ +function stripStringLiteral(input: string): string { + const quoted = /^'([\s\S]*)'$/.exec(input) + return quoted ? quoted[1] : input +} + +/** + * Encode dispatcher keyed on `wireFormat`. DATE / TOD / DT / WSTRING + * force is not yet supported — those need calendar-literal parsing + * (`D#…`, `TOD#…`, `DT#…`) and UTF-16 framing. */ function encodeByWireFormat(originalInput: string, numericInput: string, meta: IECTypeMetadata): Uint8Array { switch (meta.wireFormat) { @@ -382,11 +406,28 @@ function encodeByWireFormat(originalInput: string, numericInput: string, meta: I new DataView(buf.buffer).setFloat64(0, n, true) return buf } - case 'duration-ns-i64': + case 'duration-ns-i64': { + const buf = new Uint8Array(8) + new DataView(buf.buffer).setBigInt64(0, parseDurationLiteral(originalInput), true) + return buf + } + case 'len8-utf8': { + const text = stripStringLiteral(originalInput) + if (text.length > DEBUG_STRING_CAP) { + throw new Error(`STRING value too long: ${text.length} characters (max ${DEBUG_STRING_CAP})`) + } + const buf = new Uint8Array(1 + text.length) + buf[0] = text.length + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code > 127) throw new Error(`STRING value must be ASCII: "${originalInput}"`) + buf[i + 1] = code + } + return buf + } case 'datetime-ns-i64': case 'date-ns-i64': case 'tod-ns-i64': - case 'len8-utf8': case 'len8-utf16le': throw new Error(`Forcing ${meta.name} values is not supported yet`) default: { diff --git a/src/frontend/utils/variable-types.ts b/src/frontend/utils/variable-types.ts deleted file mode 100644 index d023c9081..000000000 --- a/src/frontend/utils/variable-types.ts +++ /dev/null @@ -1,162 +0,0 @@ -export interface VariableTypeInfo { - byteSize: number - signed: boolean -} - -export const getVariableTypeInfo = (type: string): VariableTypeInfo | null => { - const normalizedType = type.toLowerCase() - - switch (normalizedType) { - case 'bool': - return { byteSize: 1, signed: false } - case 'sint': - return { byteSize: 1, signed: true } - case 'usint': - case 'byte': - return { byteSize: 1, signed: false } - case 'int': - return { byteSize: 2, signed: true } - case 'uint': - case 'word': - return { byteSize: 2, signed: false } - case 'dint': - return { byteSize: 4, signed: true } - case 'udint': - case 'dword': - return { byteSize: 4, signed: false } - case 'lint': - return { byteSize: 8, signed: true } - case 'ulint': - case 'lword': - return { byteSize: 8, signed: false } - case 'real': - return { byteSize: 4, signed: true } - case 'lreal': - return { byteSize: 8, signed: true } - case 'string': - return { byteSize: 127, signed: false } - default: - return null - } -} - -export const parseIntegerValue = (value: string, typeInfo: VariableTypeInfo): bigint | null => { - try { - let parsedValue: bigint - - const trimmedValue = value.trim() - if (trimmedValue.startsWith('0x') || trimmedValue.startsWith('0X')) { - parsedValue = BigInt(trimmedValue) - } else if (trimmedValue.startsWith('2#')) { - parsedValue = BigInt('0b' + trimmedValue.substring(2)) - } else if (trimmedValue.startsWith('8#')) { - parsedValue = BigInt('0o' + trimmedValue.substring(2)) - } else if (trimmedValue.startsWith('16#')) { - parsedValue = BigInt('0x' + trimmedValue.substring(3)) - } else { - parsedValue = BigInt(trimmedValue) - } - - const maxValue = typeInfo.signed - ? BigInt(2) ** BigInt(typeInfo.byteSize * 8 - 1) - BigInt(1) - : BigInt(2) ** BigInt(typeInfo.byteSize * 8) - BigInt(1) - const minValue = typeInfo.signed ? -(BigInt(2) ** BigInt(typeInfo.byteSize * 8 - 1)) : BigInt(0) - - if (parsedValue < minValue || parsedValue > maxValue) { - return null - } - - return parsedValue - } catch { - return null - } -} - -export const integerToBuffer = (value: bigint, byteSize: number, signed: boolean): Uint8Array => { - const buffer = new Uint8Array(byteSize) - - let workingValue = value - if (signed && value < BigInt(0)) { - const maxUnsigned = BigInt(2) ** BigInt(byteSize * 8) - workingValue = maxUnsigned + value - } - - // Little-endian: matches what the runtime memcpy's into the IEC - // integer variable on every supported target. The previous - // descending-index loop emitted big-endian bytes, which is why - // forcing a DINT to e.g. 0x12345678 ended up stored as 0x78563412. - // BOOL forcing didn't hit this because it bypasses this helper - // (single-byte inline `new Uint8Array([1])`). - for (let i = 0; i < byteSize; i++) { - buffer[i] = Number(workingValue & BigInt(0xff)) - workingValue = workingValue >> BigInt(8) - } - - return buffer -} - -export const parseFloatValue = (value: string, byteSize: number): number | null => { - const trimmedValue = value.trim() - const parsedValue = parseFloat(trimmedValue) - - if (isNaN(parsedValue) || !isFinite(parsedValue)) { - return null - } - - if (byteSize === 4) { - const maxFloat32 = 3.4028235e38 - const minFloat32 = -3.4028235e38 - if (parsedValue > maxFloat32 || parsedValue < minFloat32) { - return null - } - } - - return parsedValue -} - -export const floatToBuffer = (value: number, byteSize: number): Uint8Array => { - const buffer = new Uint8Array(byteSize) - const dataView = new DataView(buffer.buffer) - - // Little-endian: the runtime memcpy's these bytes straight into a - // `float`/`double`, and every supported target (AVR, ARM Cortex-M, - // x86_64) stores IEEE 754 little-endian. The READ decoder in - // `variable-sizes.ts` and the parallel encoder in - // `encodeForceValue` both already use `true` here — this writer - // used to disagree, which made forcing a REAL to 123.4 store the - // byte-swapped value (e.g. -429836352). - if (byteSize === 4) { - dataView.setFloat32(0, value, true) - } else if (byteSize === 8) { - dataView.setFloat64(0, value, true) - } - - return buffer -} - -export const parseStringValue = (value: string): string | null => { - if (value.length > 126) { - return null - } - - for (let i = 0; i < value.length; i++) { - const charCode = value.charCodeAt(i) - if (charCode > 127) { - return null - } - } - - return value -} - -export const stringToBuffer = (value: string): Uint8Array => { - const buffer = new Uint8Array(1 + value.length) - - buffer[0] = value.length - - for (let i = 0; i < value.length; i++) { - buffer[i + 1] = value.charCodeAt(i) - } - - return buffer -} diff --git a/src/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts new file mode 100644 index 000000000..5e84ee136 --- /dev/null +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -0,0 +1,53 @@ +import { resolveFieldOptions } from '../field-options' + +describe('resolveFieldOptions', () => { + it('returns static options when there is no optionsRef', () => { + expect(resolveFieldOptions({ options: ['a', 'b'] }, { board: undefined })).toEqual(['a', 'b']) + }) + + it('returns an empty array when neither options nor optionsRef are set', () => { + expect(resolveFieldOptions({}, { board: undefined })).toEqual([]) + }) + + it('resolves a dynamic optionsRef from board context', () => { + expect( + resolveFieldOptions( + { optionsRef: 'board.serialPorts', options: ['Serial'] }, + { board: { serialPorts: ['Serial', 'Serial1'] } }, + ), + ).toEqual(['Serial', 'Serial1']) + }) + + it('falls back to static options when optionsRef resolves to undefined', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual([ + 'Serial', + ]) + }) + + it('falls back to static options when the board is absent', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual( + ['Serial'], + ) + }) + + it('falls back to static options when optionsRef resolves to an empty array', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: [] } }), + ).toEqual(['Serial']) + }) + + it('preserves object-shaped options ({ value, label })', () => { + const opts = [{ value: 'a', label: 'A' }] + expect(resolveFieldOptions({ options: opts }, { board: undefined })).toEqual(opts) + }) + + it('filters out non-option entries resolved from optionsRef', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: ['Serial', 42, null] } }), + ).toEqual(['Serial']) + }) + + it('returns empty array fallback when optionsRef yields only invalid entries and no static options', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: [42] } })).toEqual([]) + }) +}) diff --git a/src/frontend/utils/vpp/field-options.ts b/src/frontend/utils/vpp/field-options.ts new file mode 100644 index 000000000..2fb38eda2 --- /dev/null +++ b/src/frontend/utils/vpp/field-options.ts @@ -0,0 +1,48 @@ +/** + * Resolve the option list for a VPP screen `select` field. + * + * A field may declare static `options` and/or a dynamic `optionsRef` — a dotted + * path (e.g. `"board.serialPorts"`) resolved against per-board context so the + * same shared screen adapts to each board (the Modbus RTU serial-port picker + * lists only the UARTs the board actually exposes). When `optionsRef` resolves + * to a non-empty array it wins; otherwise the static `options` are the fallback, + * so a board that doesn't declare the referenced data still renders sensibly. + * + * Pure — no store, no I/O. + */ + +export type FieldOption = string | { value: string; label: string } + +export interface FieldOptionSource { + options?: FieldOption[] + optionsRef?: string +} + +/** Walk a dotted path (`a.b.c`) into a context object; undefined on any miss. */ +function lookupPath(path: string, context: Record): unknown { + let cursor: unknown = context + for (const part of path.split('.')) { + if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined + cursor = (cursor as Record)[part] + } + return cursor +} + +function isFieldOption(value: unknown): value is FieldOption { + return typeof value === 'string' || (typeof value === 'object' && value !== null && 'value' in value) +} + +export function resolveFieldOptions( + field: FieldOptionSource, + context: { board?: Record | undefined }, +): FieldOption[] { + if (field.optionsRef) { + const resolved = lookupPath(field.optionsRef, context as Record) + if (Array.isArray(resolved)) { + const opts = resolved.filter(isFieldOption) + if (opts.length > 0) return opts + } + // optionsRef present but unresolved / empty → fall back to static options. + } + return field.options ?? [] +} diff --git a/src/main/modules/ipc/__tests__/device-license.handler.test.ts b/src/main/modules/ipc/__tests__/device-license.handler.test.ts new file mode 100644 index 000000000..93e1052a3 --- /dev/null +++ b/src/main/modules/ipc/__tests__/device-license.handler.test.ts @@ -0,0 +1,263 @@ +/** + * The two licensing IPC handlers, over a fake held link. + * + * What is worth testing HERE (as opposed to in `license-flow`, which owns the + * decision logic) is the wiring: that the handlers take the CONTROL channel + * rather than opening a connection, that a missing link is reported as + * `check-failed` and never as "not licensed", that the anchor is read fresh, that + * the compound sequence is guarded, and that device traffic is noted so the + * liveness poll does not declare a healthy link lost mid-sequence. + */ + +import MainProcessBridge from '../main' + +jest.mock('electron', () => ({ + app: { getPath: jest.fn(() => '/tmp') }, + dialog: {}, + nativeTheme: { shouldUseDarkColors: false, themeSource: 'system' }, + shell: { openExternal: jest.fn() }, +})) + +jest.mock('@root/backend/editor/ethercat', () => ({ ESIService: jest.fn() })) +jest.mock('@root/backend/editor/library-manager/desktop-catalog-transport', () => ({ + createDesktopCatalogTransport: jest.fn(() => ({})), +})) +jest.mock('@root/backend/editor/utils/runtime-https-config', () => ({ getRuntimeHttpsOptions: jest.fn(() => ({})) })) +jest.mock('@root/backend/shared/ethercat/esi-parser-main', () => ({ parseESIDeviceFull: jest.fn() })) +jest.mock('@root/backend/shared/library/public-catalog-client', () => ({ listPublicLibraries: jest.fn() })) +jest.mock('../../../../backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn(() => ({ loadEnabledArchives: jest.fn(() => ({ archives: [], missing: [] })) })), +})) +jest.mock('../../../../backend/editor/package-manager', () => ({ PackageManagerModule: jest.fn(() => ({})) })) +jest.mock('../../../../backend/editor/services', () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})) +jest.mock('../../../../backend/editor/utils', () => ({ getOpenProjectPath: jest.fn(), getProjectPath: jest.fn() })) +jest.mock('../../../../backend/shared/simulator/simulator-module', () => ({ + SimulatorModule: jest.fn(() => ({ stop: jest.fn() })), +})) + +jest.mock('../../../../backend/editor/license/license-flow', () => ({ + inspectDeviceLicense: jest.fn(), + resolveDeviceLicense: jest.fn(), +})) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const licenseFlow = require('../../../../backend/editor/license/license-flow') as { + inspectDeviceLicense: jest.Mock + resolveDeviceLicense: jest.Mock +} + +const REQUEST = { packageId: 'com.openplc.espressif-licensed' } +const ANCHOR = Uint8Array.from([0, 177, 140, 237]) + +function createBridge() { + return new MainProcessBridge({ + ipcMain: {}, + mainWindow: { isDestroyed: jest.fn(() => false), isMaximized: jest.fn(() => false) }, + projectService: {}, + store: { get: jest.fn(() => undefined) }, + menuBuilder: {}, + pouService: {}, + compilerModule: {}, + hardwareModule: { isSerialPortPresent: jest.fn(() => true) }, + } as never) +} + +/** Install a fake held CONTROL client on the bridge's session manager. */ +function holdClient( + bridge: ReturnType, + client: { getBoardId: jest.Mock; readLicense?: jest.Mock; writeLicense?: jest.Mock }, +) { + const session = (bridge as unknown as { deviceSession: { getClient: () => unknown; noteTraffic: () => void } }) + .deviceSession + jest.spyOn(session, 'getClient').mockReturnValue(client) + return jest.spyOn(session, 'noteTraffic') +} + +function boardIdClient(overrides: Partial<{ getBoardId: jest.Mock }> = {}) { + return { + getBoardId: jest.fn(() => Promise.resolve({ success: true, boardId: ANCHOR })), + readLicense: jest.fn(), + writeLicense: jest.fn(), + ...overrides, + } +} + +beforeEach(() => { + licenseFlow.inspectDeviceLicense.mockReset() + licenseFlow.resolveDeviceLicense.mockReset() +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('device:read-license', () => { + it('reads the anchor off the held link and hands it to the inspect flow', async () => { + const bridge = createBridge() + const client = boardIdClient() + const noteTraffic = holdClient(bridge, client) + licenseFlow.inspectDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(client.getBoardId).toHaveBeenCalledTimes(1) + expect(licenseFlow.inspectDeviceLicense).toHaveBeenCalledWith(client, { ...REQUEST, anchor: ANCHOR }) + expect(result).toEqual({ deviceId: 'abc', outcome: { state: 'licensed', how: 'already-stored' } }) + // The board-id read is device traffic; not noting it lets the liveness poll + // fall due behind it and declare a healthy link lost. + expect(noteTraffic).toHaveBeenCalled() + }) + + it('reports check-failed — never "not licensed" — when no link is held', async () => { + const bridge = createBridge() + // No client installed: getClient() returns null. + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome.state).toBe('check-failed') + expect(licenseFlow.inspectDeviceLicense).not.toHaveBeenCalled() + }) + + it('reports check-failed when the device will not answer the board-id read', async () => { + const bridge = createBridge() + const client = boardIdClient({ + getBoardId: jest.fn(() => Promise.resolve({ success: false, error: 'Request timeout' })), + }) + holdClient(bridge, client) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'Request timeout' }) + expect(licenseFlow.inspectDeviceLicense).not.toHaveBeenCalled() + }) + + it('passes an empty anchor through rather than inventing one', async () => { + // A board answering id_len = 0 is a real reply; the flow is the layer that + // knows it cannot be licensed, and it must get the chance to say so. + const bridge = createBridge() + const client = boardIdClient({ getBoardId: jest.fn(() => Promise.resolve({ success: true })) }) + holdClient(bridge, client) + licenseFlow.inspectDeviceLicense.mockResolvedValue({ outcome: { state: 'check-failed', error: 'no unique id' } }) + + await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(licenseFlow.inspectDeviceLicense).toHaveBeenCalledWith(client, { + ...REQUEST, + anchor: new Uint8Array(0), + }) + }) + + it('turns an unexpected throw into check-failed instead of rejecting the IPC call', async () => { + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + licenseFlow.inspectDeviceLicense.mockRejectedValue(new Error('port closed')) + + const result = await bridge.handleDeviceReadLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'check-failed', error: 'port closed' }) + }) +}) + +describe('device:refresh-license', () => { + it('runs the full flow over the held link and notes the traffic', async () => { + const bridge = createBridge() + const client = boardIdClient() + const noteTraffic = holdClient(bridge, client) + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'activated' }, + }) + + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(licenseFlow.resolveDeviceLicense).toHaveBeenCalledWith(client, { ...REQUEST, anchor: ANCHOR }) + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + // Once for the anchor read, once after the sequence: the sequence spans an + // HTTP round trip, which is long enough for the poll to fall due inside it. + expect(noteTraffic.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it('refuses a second concurrent sequence rather than interleaving it', async () => { + // The frame mutex cannot see a SEQUENCE: two refreshes would each send atomic + // frames while interleaving a read and a write of the same license, one + // reading back the other's blob and drawing a conclusion about it. + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + + let release: (() => void) | undefined + let entered = false + licenseFlow.resolveDeviceLicense.mockImplementation(() => { + entered = true + return new Promise((resolve) => { + release = () => resolve({ deviceId: 'abc', outcome: { state: 'licensed', how: 'activated' } }) + }) + }) + + const first = bridge.handleDeviceRefreshLicense({} as never, REQUEST) + // Let the first sequence get past its anchor read and INTO the flow before + // racing it; asserting on a call that has not been reached yet would test the + // scheduler rather than the guard. + while (!entered) await new Promise((resolve) => setTimeout(resolve, 0)) + + const second = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(second.outcome).toEqual({ + state: 'check-failed', + error: 'A license check is already running on this device.', + }) + expect(licenseFlow.resolveDeviceLicense).toHaveBeenCalledTimes(1) + + release?.() + await expect(first).resolves.toMatchObject({ outcome: { state: 'licensed' } }) + }) + + it('clears the sequence guard after a failure, so a retry is possible', async () => { + const bridge = createBridge() + holdClient(bridge, boardIdClient()) + licenseFlow.resolveDeviceLicense.mockRejectedValueOnce(new Error('port closed')) + + const failed = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + expect(failed.outcome).toEqual({ state: 'check-failed', error: 'port closed' }) + + // A guard left set by a throw would strand the device: every later attempt + // would answer "already running" with nothing actually running. + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + const retried = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(retried.outcome).toEqual({ state: 'unlicensed', entitlementChecked: true }) + }) + + it('reports check-failed when no link is held', async () => { + const bridge = createBridge() + + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(result.outcome.state).toBe('check-failed') + expect(licenseFlow.resolveDeviceLicense).not.toHaveBeenCalled() + }) + + it('does not consume the sequence guard when there is no link to run on', async () => { + const bridge = createBridge() + + await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + // The guard is taken AFTER the channel check, so a disconnected device does + // not leave it set for the next attempt. + holdClient(bridge, boardIdClient()) + licenseFlow.resolveDeviceLicense.mockResolvedValue({ + deviceId: 'abc', + outcome: { state: 'licensed', how: 'activated' }, + }) + const result = await bridge.handleDeviceRefreshLicense({} as never, REQUEST) + + expect(result.outcome).toEqual({ state: 'licensed', how: 'activated' }) + }) +}) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index e0f8cc87b..387d3c5ff 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,11 +1,19 @@ import { ESIService } from '@root/backend/editor/ethercat' import { createDesktopCatalogTransport } from '@root/backend/editor/library-manager/desktop-catalog-transport' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import type { + DebugStatusResult, + DeviceDebugChannel, + DeviceModbusTransport, + PlcControlResult, +} from '@root/backend/shared/debug/types' import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' import { listPublicLibraries } from '@root/backend/shared/library/public-catalog-client' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { DeviceLicenseReport, DeviceLicenseRequest } from '@root/middleware/shared/ports/device-port' import type { EtherCATRuntimeStatusResponse, EtherCATScanRequest, @@ -22,6 +30,7 @@ import type { ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' @@ -38,9 +47,27 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + classifyDeviceLink, + type DeviceProbeOutcome, + PATIENT_BOARD_ID_PROBE, + planBaudAttempts, + QUICK_BOARD_ID_PROBE, + SPECULATIVE_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' +import { + describeLinkCandidate, + type DeviceDebugCandidate, + type DeviceLinkCandidate, + type DeviceLinkStatus, + DeviceSessionManager, +} from '../../../backend/editor/hardware/device-session-manager' +import { + buildDeviceModbusTransport, + modbusTransportKind, +} from '../../../backend/editor/hardware/device-transport-factory' import { LibraryManagerModule } from '../../../backend/editor/library-manager' -import { ModbusTcpClient } from '../../../backend/editor/modbus/modbus-client' -import { ModbusRtuClient } from '../../../backend/editor/modbus/modbus-rtu-client' +import { inspectDeviceLicense, resolveDeviceLicense } from '../../../backend/editor/license/license-flow' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' import { @@ -52,6 +79,31 @@ import { import { WebSocketDebugTransport } from '../../../backend/shared/debug/websocket-debug-transport' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' +import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' + +/** Why a channel could not be handed out. */ +interface ChannelUnavailable { + error: string + needsReconnect: true +} + +/** Program-identity comparison, case-insensitively — targets report either case. */ +function matchesMd5(targetMd5: string, expectedMd5: string): boolean { + return targetMd5.toLowerCase() === expectedMd5.toLowerCase() +} + +/** + * What `debugger:verify-md5` answers. Named so the success and unavailable paths + * are typed against ONE shape — inferred separately, the success branch narrowed + * `success` to the literal `true` and the two stopped being assignable. + */ +interface Md5VerifyReply { + success: boolean + match?: boolean + targetMd5?: string + targetEndian?: 'le' | 'be' + error?: string +} class MainProcessBridge implements MainIpcModule { ipcMain @@ -63,15 +115,29 @@ class MainProcessBridge implements MainIpcModule { compilerModule hardwareModule private registeredHandleChannels: string[] = [] - private debuggerModbusClient: ModbusTcpClient | ModbusRtuClient | null = null - private debuggerWebSocketClient: WebSocketDebugTransport | null = null - private debuggerTargetIp: string | null = null - private debuggerReconnecting: boolean = false + // --------------------------------------------------------------------------- + // Talking to a baremetal device + // + // ONE session, owned by `deviceSession`, whatever media it runs over: the + // debugger, run/stop and the status poll all borrow that one client. + // Nothing else here opens a Modbus client — see `device-link-manager.ts` for + // why (in short: three owners meant a run/stop command could open a second + // socket the board would not answer). + // + // The runtime-v4 WebSocket is the one transport that is NOT a device link: it + // is a different protocol to a different kind of target, so it keeps its own + // client and its own session identity. + // --------------------------------------------------------------------------- + private readonly deviceSession = new DeviceSessionManager({ + verify: (client, candidate, context) => this.verifyDeviceCandidate(client, candidate, context), + probe: (client) => this.probeDeviceLink(client), + serialPortPresent: (port) => this.hardwareModule.isSerialPortPresent(port), + emit: (status) => this.emitDeviceLinkStatus(status), + log: (message) => this.traceDeviceLink(message), + }) + /** Classification of the candidate the held link came from. */ + private deviceLinkProbe: DeviceProbeOutcome | null = null private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' | null = null - private debuggerRtuPort: string | null = null - private debuggerRtuBaudRate: number | null = null - private debuggerRtuSlaveId: number | null = null - private debuggerJwtToken: string | null = null // Address of the runtime this session is authenticated against. Captured at // login so the token authority can re-authenticate against the same device. private runtimeIp: string | null = null @@ -133,6 +199,7 @@ class MainProcessBridge implements MainIpcModule { // ===================== RUNTIME API HANDLERS ===================== private readonly RUNTIME_API_PORT = 8443 private readonly RUNTIME_CONNECTION_TIMEOUT_MS = 5000 // 5 seconds (important-comment) + private readonly RUNTIME_LOGIN_TIMEOUT_MS = 15000 // 15 seconds /** * Low-level HTTP helper that handles data accumulation, timeout, and error handling. @@ -143,6 +210,7 @@ class MainProcessBridge implements MainIpcModule { url: string body?: string headers?: Record + timeoutMs?: number }): Promise<{ statusCode: number; data: string; headers: IncomingHttpHeaders }> { return new Promise((resolve, reject) => { const parsedUrl = new URL(options.url) @@ -172,7 +240,7 @@ class MainProcessBridge implements MainIpcModule { resolve({ statusCode: res.statusCode ?? 0, data, headers: res.headers }) }) }) - req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.setTimeout(options.timeoutMs ?? this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { req.destroy() reject(new Error('Connection timeout')) }) @@ -297,6 +365,7 @@ class MainProcessBridge implements MainIpcModule { method: 'POST', url: this.runtimeUrl(ipAddress, '/api/login'), body: JSON.stringify({ username, password }), + timeoutMs: this.RUNTIME_LOGIN_TIMEOUT_MS, }) if (res.statusCode === 200) { try { @@ -636,10 +705,14 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiRequest<{ status: string timing_stats?: TimingStatsResponse + // Run/stop mode-switch position. Absent on runtimes older than the + // run/stop interface — treat undefined as "no gating". + switchPosition?: 'run' | 'stop' }>(ipAddress, endpoint, (data: string) => { const response = JSON.parse(data) as { status: string timing_stats?: TimingStatsResponse + switchPosition?: 'run' | 'stop' } return response }) @@ -664,6 +737,7 @@ class MainProcessBridge implements MainIpcModule { success: true, status: result.data.status, timingStats, + ...(result.data.switchPosition ? { switchPosition: result.data.switchPosition } : {}), } } else { return { success: false, error: !result.success ? result.error : 'Unknown error' } @@ -673,24 +747,7 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { - try { - // Parse the body so the renderer can drive a retry-on-BUSY - // loop around `COMMAND:BUSY` replies (the runtime answers BUSY - // while it's still unloading the previous program after an - // upload). See `backend/shared/library/start-plc-after-build.ts`. - const result = await this.makeRuntimeApiRequest<{ status?: string }>( - ipAddress, - '/api/start-plc', - (data: string) => JSON.parse(data) as { status?: string }, - ) - if (!result.success) return { success: false, error: result.error } - const status = (result.data?.status ?? '').trim() - return { success: true, status } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - } + handleRuntimeStartPlc = (_event: IpcMainInvokeEvent, ipAddress: string) => this.restStartPlc(ipAddress) handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { @@ -1017,11 +1074,21 @@ class MainProcessBridge implements MainIpcModule { // ===================== DEBUGGER ===================== this.registerHandle('debugger:verify-md5', this.handleDebuggerVerifyMd5) + this.registerHandle('debugger:plc-control', this.handleDebuggerPlcControl) this.registerHandle('debugger:read-program-st-md5', this.handleReadProgramStMd5) this.registerHandle('debugger:get-variables-list', this.handleDebuggerGetVariablesList) this.registerHandle('debugger:set-variable', this.handleDebuggerSetVariable) this.registerHandle('debugger:connect', this.handleDebuggerConnect) this.registerHandle('debugger:disconnect', this.handleDebuggerDisconnect) + this.registerHandle('device:connect', this.handleDeviceConnect) + this.registerHandle('device:disconnect', this.handleDeviceDisconnect) + this.registerHandle('device:release-serial-port', this.handleDeviceReleaseSerialPort) + // VPP licensing over the HELD link — callable any time the device is + // connected, deliberately not folded into `device:connect`. See the handlers. + this.registerHandle('device:read-license', this.handleDeviceReadLicense) + this.registerHandle('device:refresh-license', this.handleDeviceRefreshLicense) + this.registerHandle('session:open-runtime', this.handleOpenRuntimeSession) + this.registerHandle('session:close-runtime', this.handleCloseRuntimeSession) // ===================== RUNTIME API ===================== this.registerHandle('runtime:get-users-info', this.handleRuntimeGetUsersInfo) @@ -1629,129 +1696,161 @@ class MainProcessBridge implements MainIpcModule { } } - handleDebuggerVerifyMd5 = async ( - _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - expectedMd5: string, - ): Promise<{ - success: boolean - match?: boolean - targetMd5?: string - targetEndian?: 'le' | 'be' - error?: string - }> => { - let client: ModbusTcpClient | ModbusRtuClient | null = null - let wsClient: WebSocketDebugTransport | null = null + /** + * Confirm the target is running the program that was just built. + * + * Modbus targets (serial, TCP, simulator) read this over the ONE held device + * link, so the check runs on the same connection every later command uses — no + * second client, and nothing to reconnect afterwards. Runtime v4 reads it over + * its own WebSocket, which is a different protocol to a different target. + */ + handleDebuggerVerifyMd5 = async (_event: IpcMainInvokeEvent, expectedMd5: string): Promise => { try { - if (connectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - client = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - - // Keep the client for subsequent debug operations - this.debuggerModbusClient = client - this.debuggerConnectionType = 'simulator' - - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'websocket') { - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } - if (!this.debuggerWebSocketClient) { - wsClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await wsClient.connect() - } else { - wsClient = this.debuggerWebSocketClient - } + return await this.withDebugChannel( + 'verify md5', + async (client) => { + const probe = await client.getMd5Hash() + // `targetMd5` spelled out rather than spread: `Md5ProbeResult` names the + // hash `md5`, so `...probe` silently left the declared `targetMd5` + // undefined — and TypeScript does not apply excess-property checks to a + // spread, so nothing caught it. The mismatch report then read + // "MD5 mismatch. Target: undefined", losing the one value that tells the + // user which program is actually on the board. + return { + success: true, + match: matchesMd5(probe.md5, expectedMd5), + targetMd5: probe.md5, + targetEndian: probe.targetEndian, + } + }, + (reason) => ({ success: false, error: reason.error }), + ) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + } + } + } - const { md5: targetMd5, targetEndian } = await wsClient.getMd5Hash() + /** + * FC 0x4b run/stop for a baremetal target. + * + * Command only — the state is READ from the status poll (FC 0x46), which already + * reports it, so there is no second round trip here. + * + * Goes over the ONE held device link, whatever transport that link runs over. + * The transport the DEBUGGER is using is not consulted, and no client is opened: + * this is a command to the device, and the connection to it already exists. + * + * That is precisely what was broken. The old code only recognised an RTU client + * as reusable, so with a live Modbus TCP session a Stop fell through to opening a + * transient second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. The user saw "Failed to stop PLC: Request + * timeout" while a working connection sat idle. + */ + handleDebuggerPlcControl = async (_event: IpcMainInvokeEvent, action: 'run' | 'stop'): Promise => { + this.traceDeviceLink(`run/stop: ${action} requested`) - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + // Routed by the session's CONTROL channel, which is the whole point: the caller + // said "stop the PLC" and does not know or care whether that means a Modbus + // function code on a cable or an HTTP POST to a runtime. + const restAddress = this.deviceSession.getRestAddress() + if (restAddress !== null) return this.restSetPlcState(restAddress, action) - if (!this.debuggerWebSocketClient) { - this.debuggerWebSocketClient = wsClient - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - this.debuggerConnectionType = 'websocket' - } + const link = this.requireControl('run/stop') + if ('error' in link) return { success: false, error: link.error } - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'tcp') { - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - client = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + const target = action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED + try { + return await link.client.setPlcState(target) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during PLC control request', + } + } + } - // Reuse existing RTU client if already connected to the same port - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port - ) { - const { md5: targetMd5, targetEndian } = await this.debuggerModbusClient.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - return { success: true, match, targetMd5, targetEndian } - } + /** + * Run/stop over a REST control channel, reported in the same shape the Modbus + * path returns — so the caller handles one result type, not two. + * + * `ERROR_SWITCH_STOP` in the runtime's reply is its way of saying the hardware + * mode switch refused a start, which is exactly what `refusedBySwitch` means on + * the Modbus side (FC 0x4b status 0x86). + */ + private async restSetPlcState(address: string, action: 'run' | 'stop'): Promise { + const result = + action === 'run' ? await this.restStartPlc(address) : await this.makeRuntimeApiRequest(address, '/api/stop-plc') + if (!result.success) return { success: false, error: result.error } - client = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) - } + const status = 'status' in result ? (result.status ?? '') : '' + if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() + // The runtime settles into the new state on its next scan; report the state the + // command asked for so the button can reflect it without a second round trip. + return { success: true, state: action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED } + } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + /** The `/api/start-plc` call, shared by the session router and the IPC handler. */ + private async restStartPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + // The body is parsed because the runtime answers `COMMAND:BUSY` while it is + // still unloading a previous program after an upload, and callers drive a + // retry loop on that. See `backend/shared/library/start-plc-after-build.ts`. + const result = await this.makeRuntimeApiRequest<{ status?: string }>( + address, + '/api/start-plc', + (data: string) => JSON.parse(data) as { status?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: (result.data?.status ?? '').trim() } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } - if (connectionType === 'tcp') { - client.disconnect() - } else { - this.debuggerModbusClient = client - this.debuggerConnectionType = 'rtu' - this.debuggerRtuPort = connectionParams.port! - this.debuggerRtuBaudRate = connectionParams.baudRate! - this.debuggerRtuSlaveId = connectionParams.slaveId! - } + handleDebuggerGetVariablesList = async ( + _event: IpcMainInvokeEvent, + variableIndexes: number[], + ): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: number[] + error?: string + needsReconnect?: boolean + }> => { + // A null connection type means the debugger was intentionally disconnected. + // Fail silently so the renderer's poll loop ignores it. + if (this.debuggerConnectionType === null) { + return { success: false, error: 'Debugger not connected' } + } - return { success: true, match, targetMd5, targetEndian } + // Every target reads over its session's DEBUG channel — Modbus for a device or + // a v3 runtime, the WebSocket for v4. There is nothing to reconnect here: if a + // connection dropped, the manager is already reopening it (or has reported it + // lost), and `needsReconnect` tells the renderer to stop the session rather + // than race it for the medium. + try { + return await this.withDebugChannel( + 'read variables', + async (client) => { + const result = await client.getVariablesList(variableIndexes) + if (result.success && result.data) { + // The debug poll is the busiest thing on the link; telling the session + // about it is what stops the liveness read from queueing behind this + // traffic and timing out on a link that is plainly working. + this.deviceSession.noteTraffic() + return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } + } + return { success: false, error: result.error } + }, + (reason) => ({ success: false, error: reason.error, needsReconnect: true }), + ) } catch (error) { - client?.disconnect() - wsClient?.disconnect() - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', - } + return { success: false, error: getErrorMessage(error), needsReconnect: true } } } @@ -1787,289 +1886,638 @@ class MainProcessBridge implements MainIpcModule { } } - handleDebuggerGetVariablesList = async ( - _event: IpcMainInvokeEvent, - variableIndexes: number[], - ): Promise<{ - success: boolean - tick?: number - lastIndex?: number - data?: number[] - error?: string - needsReconnect?: boolean - }> => { - // If connection type is null, the debugger was intentionally disconnected. - // Return a silent failure so the renderer polling ignores it. - if (this.debuggerConnectionType === null) { - return { success: false, error: 'Debugger not connected' } - } + /** + * Read the run/stop state over an already-open client and push it to the + * renderer. Throttled, because its two callers tick at very different rates: + * the device liveness poll (2.5s) and the debugger's variable poll (fast). + * + * Both callers use the ONE held connection, so there is no handoff to survive: + * a debug session shares the link rather than replacing it, and the Start/Stop + * button keeps tracking the device while debugging. + */ + private plcStatePushedAt = 0 + + private async pushPlcState( + client: { getStatus?: () => Promise }, + port: string, + minIntervalMs = 2000, + ): Promise { + if (!client.getStatus) return + const now = Date.now() + if (now - this.plcStatePushedAt < minIntervalMs) return + this.plcStatePushedAt = now + + const r = await client.getStatus() + if (!r.success) return + this.mainWindow?.webContents?.send('device:plc-state', { + port, + plcState: r.plcState, + switchPosition: r.switchPosition, + }) + } - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** + * Start a debug session against a target. + * + * For a Modbus target there is nothing to open: the session runs over the ONE + * held device link, which Connect established and which the status poll keeps + * honest. That is what makes serial debugging work at all (the OS will not lock + * a port twice), and it is equally right for Modbus TCP (an Arduino TCP server + * serves one client). The simulator is the exception only because it is + * in-process, so it can bring its own link up on demand. + * + * Runtime v4 keeps its own WebSocket: different protocol, different target. + */ + handleDebuggerConnect = async (_event: IpcMainInvokeEvent): Promise<{ success: boolean; error?: string }> => { + try { + // For a shared session this opens nothing — it is the connection Connect + // established, already proven. For a runtime target it opens that target's + // own debug channel, which is why the debugger asks for it here rather than + // at login: the channel exists only while a session needs it. + const channel = await this.requireDebug('debug session') + if ('error' in channel) return { success: false, error: channel.error } + + // Session identity comes from the SESSION, not from what the caller guessed: + // a connected target names no transport at all. + this.debuggerConnectionType = this.deviceSession.getLink()?.transport ?? 'tcp' + return { success: true } + } catch (error) { + this.debuggerConnectionType = null + return { success: false, error: getErrorMessage(error) } + } + } - this.debuggerReconnecting = true - try { - if (!this.debuggerTargetIp || !this.debuggerJwtToken) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP or JWT token stored', needsReconnect: true } - } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: this.debuggerTargetIp, - port: 8443, - token: this.debuggerJwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerWebSocketClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } - } + /** + * Stop a debug session: let go of the debug channel, nothing more. + * + * The SESSION is deliberately untouched — it belongs to Connect (or to the + * runtime login), not to the debugger. Closing it here would drop the user's + * connection, and the status poll driving the Start/Stop button with it, just + * because they stopped debugging. Releasing closes a channel of its own once + * nothing holds it, and never closes a channel shared with control. + */ + handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.deviceSession.releaseDebugChannel('debug session') + this.debuggerConnectionType = null + return Promise.resolve({ success: true }) + } - try { - const result = await this.debuggerWebSocketClient.getVariablesList(variableIndexes) + // =================================================================== + // The device link — "stay connected", over serial or Modbus TCP + // =================================================================== + + /** Push a link state change to the renderer. */ + private emitDeviceLinkStatus(status: DeviceLinkStatus): void { + // `connecting` is not traced. It is a transient the user can already see in the + // button, and it repeats once per candidate — on a baud sweep that is five + // identical lines around the one outcome worth reading. Every settled state + // (connected / disconnected / error) still gets its line. + if (status.status !== 'connecting') { + this.traceDeviceLink( + `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ + status.reason ? ` [${status.reason}]` : '' + }`, + ) + } + this.mainWindow?.webContents?.send('device:connection-status', status) + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), - } - } + /** + * Diagnostic trace for the device connection, to BOTH sinks on purpose: the + * main-process log file keeps it after the fact, and the renderer console puts + * it where a user can read and copy it while reproducing something. Connection + * problems span two processes and a piece of hardware; without this the only + * evidence is "it hangs". + */ + private traceDeviceLink(message: string): void { + logger.info(`[link] ${message}`) + this.mainWindow?.webContents?.send('device:link-log', message) + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + /** + * Turn a resolved channel config into something the link manager can try. + * The only transport-specific step left in the flow; a config that names a + * transport this build cannot speak is dropped rather than half-built. + */ + private toDeviceLinkCandidates( + configs: DebugConnectionConfig[], + opts: { probeBaudRates?: boolean } = {}, + ): DeviceLinkCandidate[] { + const declared: DeviceLinkCandidate[] = [] + // Baud guesses go AFTER everything the project declared: a configured Modbus + // TCP address is a better next try than a rate nobody asked for. + const speculative: DeviceLinkCandidate[] = [] + + const build = (config: DebugConnectionConfig, baudRate: number | undefined, isGuess: boolean): void => { + const kind = modbusTransportKind(config.connectionType) + if (kind === null) return + const params = { + connectionType: config.connectionType, + port: config.connectionParams.port, + baudRate, + slaveId: config.connectionParams.slaveId, + host: config.connectionParams.ipAddress, } + // Only the simulator needs an in-process serial port; building one for a real + // transport would allocate a virtual port nobody reads. + const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} + // Probe the params now so a malformed config fails resolution rather than + // becoming a candidate that always throws on `create()`. + if ('error' in buildDeviceModbusTransport(params, options)) return + ;(isGuess ? speculative : declared).push({ + transport: kind, + // The endpoint ONLY. It is matched against the OS port list and against + // the port an upload asks to borrow, so the baud travels beside it rather + // than inside it — decorating this string made every swept candidate match + // no port and be skipped in 1ms. + descriptor: describeDebugEndpoint(config), + baudRate, + speculative: isGuess, + create: () => { + const built = buildDeviceModbusTransport(params, options) + if ('error' in built) throw new Error(built.error) + return built.client + }, + }) } - if (!this.debuggerModbusClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } + for (const config of configs) { + // A wrong baud is the one misconfiguration that looks like healthy silence: + // the port opens, so it is not "no response", and nothing decodes, so it + // reads as "no firmware" — and the user gets told to reflash a board that is + // running fine. Sweeping the rates OpenPLC is ever built with turns that dead + // end into a connection. Serial only; a TCP address is either right or not. + for (const attempt of planBaudAttempts(config.connectionParams.baudRate, { sweep: opts.probeBaudRates })) { + build(config, attempt.baudRate, attempt.speculative) } + } - this.debuggerReconnecting = true - try { - if (this.debuggerConnectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - } else if (this.debuggerConnectionType === 'tcp') { - if (!this.debuggerTargetIp) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP address stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: this.debuggerTargetIp, - port: 502, - timeout: 5000, - }) - } else if (this.debuggerConnectionType === 'rtu') { - if (!this.debuggerRtuPort || !this.debuggerRtuBaudRate || this.debuggerRtuSlaveId === null) { - this.debuggerReconnecting = false - return { success: false, error: 'No RTU connection parameters stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusRtuClient({ - port: this.debuggerRtuPort, - baudRate: this.debuggerRtuBaudRate, - slaveId: this.debuggerRtuSlaveId, - timeout: 5000, - }) - } else { - this.debuggerReconnecting = false - return { success: false, error: 'No connection type stored', needsReconnect: true } - } + // The patient budget belongs to the last DECLARED endpoint, not to the last + // candidate overall. Without this the baud sweep would silently take that + // patience away from the configured endpoint and hand it to a guess — and a + // board that was just flashed, still booting on the right rate, would be ruled + // out in ~10s instead of the ~32s it sometimes needs. + const lastDeclared = declared[declared.length - 1] + if (lastDeclared) lastDeclared.patient = true - await this.debuggerModbusClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerModbusClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } + return [...declared, ...speculative] + } + + /** Consume the classification the last verified candidate produced. */ + private takeDeviceLinkProbe(): DeviceProbeOutcome | null { + const probe = this.deviceLinkProbe + this.deviceLinkProbe = null + return probe + } + + /** + * Establish a session with a Runtime v3/v4: control over REST, debug over the + * channel its board declares (v3: Modbus TCP on the runtime's address; v4: the + * debug WebSocket). Called once the renderer has logged in. + * + * The debug channel is only DESCRIBED here, not opened — see `acquireDebugChannel`. + */ + handleOpenRuntimeSession = ( + _event: IpcMainInvokeEvent, + params: { address: string; debug: DebugConnectionConfig }, + ): Promise<{ success: boolean; error?: string }> => { + const candidate = this.toDebugCandidate(params.debug) + if (!candidate) { + return Promise.resolve({ + success: false, + error: `This target declares a debug channel this build cannot open: ${params.debug.connectionType}`, + }) } + this.deviceSession.openRestSession({ address: params.address, debugChannel: candidate }) + this.debuggerConnectionType = params.debug.connectionType + return Promise.resolve({ success: true }) + } - try { - const result = await this.debuggerModbusClient.getVariablesList(variableIndexes) + /** Close a REST-controlled session (the user logged out / disconnected). */ + handleCloseRuntimeSession = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + if (this.deviceSession.getRestAddress() !== null) { + this.deviceSession.close() + this.debuggerConnectionType = null + } + return Promise.resolve({ success: true }) + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), - } + /** + * Turn a resolved channel config into an openable DEBUG channel. The one place + * that knows a WebSocket is a debug channel too. + */ + private toDebugCandidate(config: DebugConnectionConfig): DeviceDebugCandidate | null { + if (config.connectionType === 'websocket') { + const host = config.connectionParams.ipAddress + const token = config.connectionParams.jwtToken + if (!host || !token) return null + return { + transport: 'websocket', + descriptor: `websocket ${host}`, + create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), } + } + // One config in, one candidate out: this builds the DEBUG channel for a + // session that already exists, so the rate is settled and guessing is wrong. + const [candidate] = this.toDeviceLinkCandidates([config], { probeBaudRates: false }) + if (!candidate) return null + return { + transport: candidate.transport, + descriptor: `${candidate.transport} ${candidate.descriptor}`, + create: candidate.create, + } + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null + /** + * Is this freshly opened candidate a device we can work with? Runs the + * connect-time classification (`classifyDeviceLink`) and keeps its verdict for + * the renderer. + * + * Only `connected-with-firmware` keeps a candidate. A port that opens but has + * no firmware, or an IP that answers something else, therefore falls through to + * the next candidate instead of becoming a link that cannot serve a single + * command. + */ + private async verifyDeviceCandidate( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise { + // The simulator is in-process: there is no hardware to identify, so the + // board-id read is not the right question to ask of it. + // + // Retried because the session is opened the instant the emulator starts, and + // the sketch inside it still has to reach the point where it services Modbus. + // Failing here would stop an emulator that was merely still booting. + if (candidate.transport === 'simulator') { + for (let attempt = 0; attempt < MainProcessBridge.SIMULATOR_PROBE_ATTEMPTS; attempt += 1) { + try { + if (await this.probeDeviceLink(client)) return true + } catch { + // Not up yet — fall through to the wait below. + } + await new Promise((resolve) => setTimeout(resolve, MainProcessBridge.SIMULATOR_PROBE_INTERVAL_MS)) } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + return false + } + + // Be patient only with the LAST candidate. The id read is retried because a + // board that was just flashed may still be booting — worth ~32s when this is + // the only way in, but not while alternatives are waiting: a Modbus TCP + // address that no longer answers should not delay the cable that would have + // worked. (Measured on a real board: 32.5s to rule out one endpoint.) + // + // A speculative candidate never gets that patience, whether or not it happens + // to be last: it is a baud rate NOBODY configured, and there are several of + // them. Spending the patient budget on the final guess would put ~32s at the + // end of a sweep whose whole point is to finish quickly. + const isPatient = !candidate.speculative && (candidate.patient === true || context.isLastCandidate) + const boardIdProbe = candidate.speculative + ? SPECULATIVE_BOARD_ID_PROBE + : isPatient + ? PATIENT_BOARD_ID_PROBE + : QUICK_BOARD_ID_PROBE + const result = await classifyDeviceLink(client, { boardIdProbe }) + this.deviceLinkProbe = result + if (result.status !== 'connected-with-firmware') { + // Traced only when the endpoint is REJECTED, and then with the budget it was + // given: "no firmware after 2 id reads (baud guess)" is a different problem + // from "no firmware after 6" on the port the project configured. Announcing + // the budget up front, as this used to, put the line before the outcome it + // explains and printed it on every success too. + this.traceDeviceLink( + ` ${candidate.descriptor}: "${result.status}" after up to ${boardIdProbe.attempts} id read(s)` + + `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, was patient)' : ''}` + + `${result.error ? ` — ${result.error}` : ''}`, + ) + return false } + // The status frame doubles as the run/stop state source; push it straight + // away so the Start/Stop button is right before the first poll lands. + await this.pushPlcState(client, candidate.descriptor, 0) + return true } - handleDebuggerConnect = async ( + /** + * Per-tick liveness read, and the ONE place baremetal run/stop state is polled. + * + * Prefers the status read (FC 0x46) over the board id (0x48): both prove the + * firmware is answering, but the status frame also carries the run/stop state + * and the mode-switch position — so a switch flipped by hand at the panel shows + * up within one interval, with no second timer and no extra traffic. + */ + private async probeDeviceLink(client: DeviceModbusTransport): Promise { + const descriptor = this.deviceSession.getLink()?.descriptor ?? '' + if (client.getStatus) { + const status = await client.getStatus() + if (!status.success) return false + this.plcStatePushedAt = 0 + await this.pushPlcState(client, descriptor, 0) + return true + } + return (await client.getBoardId()).success + } + + /** + * Release the link if it holds `port` — the handoff before an upload takes the + * same serial port. A Modbus TCP link is left alone: flashing over USB does not + * disturb it, so debugging and run/stop survive an upload. + * + * Returns whether anything was released, so the caller knows to reconnect. + */ + handleDeviceReleaseSerialPort = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => { + port: string | null | undefined, + ): Promise<{ released: boolean }> => { + return { released: this.deviceSession.releaseSerialPort(port) } + } + + /** The CONTROL channel's client (run/stop, status). */ + private deviceClient(): DeviceModbusTransport | null { + return this.deviceSession.getClient() + } + + /** + * The channel for this operation family, or a reason there isn't one. Every + * device command funnels through here, so "not connected" and "reconnecting" + * read the same everywhere instead of each handler inventing its own message — + * or, worse, opening its own connection. + * + * `debug` operations take the debug channel, which for a shared session IS the + * control channel and for a runtime target is one of its own. + */ + private requireControl(what: string): { client: DeviceModbusTransport } | ChannelUnavailable { + const client = this.deviceClient() + if (!client) return this.explainMissingChannel(what) + this.traceChannelUse(what, 'control') + return { client } + } + + /** + * The DEBUG channel, opening it if this session's debug medium is one of its own. + * Every debug caller passes a distinct `what`, which doubles as the holder name — + * so two callers can hold it at once without either closing it on the other. + * + * A holder acquired here MUST be released, or the channel can never close. Only + * the debug session itself is a long-lived holder (acquired by `debugger:connect`, + * released by `debugger:disconnect`); every per-command caller goes through + * `withDebugChannel`, which releases in a `finally`. + */ + private async requireDebug(what: string): Promise<{ client: DeviceDebugChannel } | ChannelUnavailable> { + const acquired = await this.deviceSession.acquireDebugChannel(what) + if ('error' in acquired) { + if (!this.deviceSession.isConnected()) return this.explainMissingChannel(what) + this.traceDeviceLink(`${what}: debug channel unavailable — ${acquired.error}`) + return { error: acquired.error, needsReconnect: true } + } + this.traceChannelUse(what, 'debug') + return acquired + } + + /** + * Run one command over the DEBUG channel, holding it only for the duration. + * + * The holder set is a reference count, and a per-command caller is not a holder + * of the channel's LIFETIME — it just needs the channel to exist while it runs. + * Registering those callers permanently is what kept a Runtime v3/v4 debug channel + * open after the debug session ended: `read variables` is acquired on every poll + * tick, so once one had run, `releaseDebugChannel('debug session')` always found + * the set non-empty and skipped the close. The user stopped debugging and the + * editor held an authenticated debug channel to their PLC until they logged out. + * + * Releasing here is safe for a BAREMETAL target, where control and debug are the + * same channel: `releaseDebugChannel` returns early on `debugCandidate === null` + * before touching any client, so it can never disconnect the device out from + * under run/stop or the status poll. Only a session whose debug medium is its + * own — v3's second Modbus TCP connection, v4's WebSocket — is ever closed. + */ + private async withDebugChannel( + what: string, + run: (client: DeviceDebugChannel) => Promise, + onUnavailable: (reason: ChannelUnavailable) => T, + ): Promise { + const acquired = await this.requireDebug(what) + if ('error' in acquired) return onUnavailable(acquired) try { - if (connectionType === 'simulator') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + return await run(acquired.client) + } finally { + this.deviceSession.releaseDebugChannel(what) + } + } - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await this.debuggerModbusClient.connect() - - // MD5 fetch warms the connection and exercises the - // runtime's endianness-sentinel path. Endianness detection - // itself is handled at the editor's verify-MD5 step (see - // handleDebuggerVerifyMd5) where the result feeds the swap - // layer; here we just need the connection live. - await this.debuggerModbusClient.getMd5Hash() - } else if (connectionType === 'websocket') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** + * Which channel served which command — logged ONCE per distinct combination. + * + * The question this answers ("did run/stop really ride the same connection as + * the debugger?") is answered by the first occurrence. Logging every occurrence + * answered it several times a second: the debug poll reads variables + * continuously, so an unfiltered trace emitted ~8 identical lines per second + * and buried every other message in the console, including the ones explaining + * a disconnect. + */ + private tracedChannelUses = new Set() - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } + private traceChannelUse(what: string, family: 'control' | 'debug'): void { + const link = this.deviceSession.getLink() + const endpoint = `${link?.transport ?? '?'} ${link?.descriptor ?? '?'}` + const key = `${what}|${family}|${endpoint}` + if (this.tracedChannelUses.has(key)) return + this.tracedChannelUses.add(key) + this.traceDeviceLink(`${what}: using the ${family} channel (${endpoint})`) + } - if (!this.debuggerWebSocketClient || this.debuggerConnectionType !== 'websocket') { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } + private explainMissingChannel(what: string): ChannelUnavailable { + if (this.deviceSession.isRecovering()) { + this.traceDeviceLink(`${what}: refused, the connection is mid-recovery`) + return { error: 'the connection dropped and is being restored — try again in a moment', needsReconnect: true } + } + this.traceDeviceLink(`${what}: refused, nothing is connected`) + return { error: MainProcessBridge.DEVICE_NOT_CONNECTED, needsReconnect: true } + } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - } + /** + * The emulator boots in milliseconds, but "milliseconds" is not "instantly", and + * its session is opened the instant it starts. + */ + private static readonly SIMULATOR_PROBE_ATTEMPTS = 10 + private static readonly SIMULATOR_PROBE_INTERVAL_MS = 200 - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - } else if (connectionType === 'tcp') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** + * Reported when a command arrives and no session exists. + * + * Short and neutral on purpose. The caller already says which action failed + * ("Failed to stop PLC: …", "Could not connect to debug target: …"), so this only + * has to supply the reason. It used to explain the reason as well — "the debugger + * and run/stop share the device connection" — which was written for a baremetal + * board and read as nonsense on a Runtime v4, whose debug channel is its own + * WebSocket and shares nothing. Worse, it appeared on a target the user HAD + * connected to, so the explanation was not merely irrelevant but wrong. + */ + private static readonly DEVICE_NOT_CONNECTED = 'not connected to the target' - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerTargetIp = connectionParams.ipAddress - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + /** + * Open and HOLD the link to a baremetal device (D72). + * + * `candidates` is the ordered list the renderer resolved from the board's debug + * spec — Modbus TCP first when the project enables it, then serial. The manager + * tries them in order and keeps the first that both opens and answers, so a + * stale DHCP address or an unplugged ethernet shield falls through to the cable + * instead of stranding the user on a link that cannot serve a command. + * + * The classification that used to be this method's job now happens per candidate + * (`verifyDeviceCandidate`), because it is also what decides whether a candidate + * is worth keeping. + */ + handleDeviceConnect = async ( + _event: IpcMainInvokeEvent, + candidates: DebugConnectionConfig[], + ): Promise => { + this.deviceLinkProbe = null + // A new connection is a new story: let each command say which channel served + // it again, since it may well be a different one this time. + this.tracedChannelUses.clear() + this.traceDeviceLink( + `connect requested with ${candidates.length} candidate(s): ${ + candidates.map((config) => `${config.connectionType} ${describeDebugEndpoint(config)}`).join(', ') || '(none)' + }`, + ) - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port && - this.debuggerRtuBaudRate === connectionParams.baudRate && - this.debuggerRtuSlaveId === connectionParams.slaveId - ) { - this.debuggerReconnecting = false - return { success: true } - } + const linkCandidates = this.toDeviceLinkCandidates(candidates) + if (linkCandidates.length === 0) { + // Publish a settled state before returning: this path never reaches + // `deviceSession.open()`, so nothing else will, and the renderer set + // 'connecting' the moment the user clicked. Left unsaid, its Connect button + // stays disabled for the rest of the project's life. + this.emitDeviceLinkStatus({ status: 'disconnected' }) + return { status: 'error', error: 'No usable serial or Modbus TCP connection was configured for this device.' } + } + + const result = await this.deviceSession.open(linkCandidates) + // Read the verdict out of the field after the open: verification runs inside + // it, one candidate at a time, and this is where its conclusion lands. + const probe = this.takeDeviceLinkProbe() + if (result.ok) return probe ?? { status: 'connected-with-firmware' } + + // Nothing worked. A candidate that got far enough to be classified gives the + // better message ("no firmware" beats "could not connect"); otherwise report + // what was tried. + if (probe && probe.status !== 'connected-with-firmware') return probe + const tried = result.attempts.map((attempt) => `${describeLinkCandidate(attempt)}: ${attempt.error}`).join('; ') + return { status: 'no-response', error: tried || 'No connection could be established.' } + } + + /** Close the held link (user pressed Disconnect). */ + handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { + this.deviceSession.close() + this.deviceLinkProbe = null + this.tracedChannelUses.clear() + return { success: true } + } - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + // ===================== VPP LICENSING OVER THE HELD LINK ===================== + // + // WHY THESE ARE ON-DEMAND, AND NOT PART OF `device:connect`. + // + // Two reasons, both learned the hard way. First, `refreshLicense` reaches the + // NETWORK: folding it into connect makes every connect to a licensed board wait + // on an HTTP round trip that may be rate-limited or time out, and a connect that + // hangs looks like a broken cable. Second, a purchase happens AFTER the user has + // already been told they are in demo mode — with licensing bolted to connect, the + // only way to pick up a licence they just bought is to disconnect and reconnect, + // or reflash. Exposing the step means the buy dialog can simply call it again on + // the link that is already open. + // + // Both ride the CONTROL channel (`requireControl`): the license FCs are ordinary + // frames on the same held Modbus link as run/stop and the status poll, so they + // queue behind the same mutex and need no connection of their own. - this.debuggerModbusClient = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerRtuPort = connectionParams.port - this.debuggerRtuBaudRate = connectionParams.baudRate - this.debuggerRtuSlaveId = connectionParams.slaveId - } + /** + * True while a COMPOUND licensing sequence is running over the held client. + * + * The transports already serialise individual FRAMES, and that is not enough + * here. `refreshLicense` is read -> HTTP -> write -> read, and two of them + * running at once would each see perfectly atomic frames while interleaving a + * read and a write of the SAME license — one sequence reading back the other's + * blob and drawing a conclusion about it. The frame mutex cannot see the + * sequence, so the sequence needs its own guard. + */ + private deviceLicenseSequenceInFlight = false - this.debuggerConnectionType = connectionType - this.debuggerReconnecting = false + /** + * Read the anchor the licensing identity derives from, over the held link. + * + * Read FRESH on every licensing call rather than cached at connect. The anchor + * IS the device identity, and a board swapped on the same serial path would + * otherwise inherit the previous one's — deciding a license question for + * hardware that is no longer there. One extra frame on an operation that already + * spends several is not worth that risk. + */ + private async readLicenseAnchor(client: DeviceModbusTransport): Promise<{ anchor: Uint8Array } | { error: string }> { + const board = await client.getBoardId() + if (!board.success) return { error: board.error ?? 'the device did not answer the board-id read' } + this.deviceSession.noteTraffic() + return { anchor: board.boardId ?? new Uint8Array(0) } + } - return { success: true } + /** + * `device:read-license` — what license is this board holding right now? + * + * Read + verify only; never contacts the backend, so it is cheap enough for a + * screen open. It answers the question the badge asks, which `device:connect` + * deliberately does not. + */ + handleDeviceReadLicense = async ( + _event: IpcMainInvokeEvent, + request: DeviceLicenseRequest, + ): Promise => { + const control = this.requireControl('read license') + if ('error' in control) return { outcome: { state: 'check-failed', error: control.error } } + + try { + const anchor = await this.readLicenseAnchor(control.client) + if ('error' in anchor) return { outcome: { state: 'check-failed', error: anchor.error } } + + return await inspectDeviceLicense(control.client, { ...request, anchor: anchor.anchor }) } catch (error) { - this.debuggerModbusClient = null - this.debuggerWebSocketClient = null - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - return { success: false, error: getErrorMessage(error) } + return { outcome: { state: 'check-failed', error: getErrorMessage(error) } } } } - handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null + /** + * `device:refresh-license` — the full flow, including the backend recovery. + * + * Called after a connect to a licensable board, and again after a purchase so a + * device gets its license without disconnecting or reflashing. + */ + handleDeviceRefreshLicense = async ( + _event: IpcMainInvokeEvent, + request: DeviceLicenseRequest, + ): Promise => { + const control = this.requireControl('refresh license') + if ('error' in control) return { outcome: { state: 'check-failed', error: control.error } } + + if (this.deviceLicenseSequenceInFlight) { + // Reported rather than queued: the caller is a button or a connect, and a + // second answer arriving later for a question already being answered is + // noise at best and a contradictory badge at worst. + return { outcome: { state: 'check-failed', error: 'A license check is already running on this device.' } } } - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null + this.deviceLicenseSequenceInFlight = true + + try { + const anchor = await this.readLicenseAnchor(control.client) + if ('error' in anchor) return { outcome: { state: 'check-failed', error: anchor.error } } + + const report = await resolveDeviceLicense(control.client, { ...request, anchor: anchor.anchor }) + // The license FCs are device traffic like any other: without noting them the + // liveness poll can fall due mid-sequence and declare a healthy link lost. + this.deviceSession.noteTraffic() + return report + } catch (error) { + return { outcome: { state: 'check-failed', error: getErrorMessage(error) } } + } finally { + this.deviceLicenseSequenceInFlight = false } - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - this.debuggerReconnecting = false - return Promise.resolve({ success: true }) } handleDebuggerSetVariable = async ( @@ -2080,35 +2528,20 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; error?: string }> => { const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - logger.info('[IPC Handler] WebSocket client not connected') - return { success: false, error: 'Not connected to debugger' } - } - - try { - // Shared transport takes Uint8Array; convert from the IPC's - // Buffer payload (Buffer is a Uint8Array subclass so the cast - // is a no-op at runtime, but TS wants the explicit step). - const valueBytes = buffer ? new Uint8Array(buffer) : undefined - const result = await this.debuggerWebSocketClient.setVariable(variableIndex, force, valueBytes) - logger.info('[IPC Handler] WebSocket setVariable result: ' + JSON.stringify(result)) - return result - } catch (error) { - logger.error('[IPC Handler] WebSocket setVariable error: ' + getErrorMessage(error)) - return { success: false, error: getErrorMessage(error) } - } - } - - if (!this.debuggerModbusClient) { - logger.info('[IPC Handler] Modbus client not connected') - return { success: false, error: 'Not connected to debugger' } - } - try { - const result = await this.debuggerModbusClient.setVariable(variableIndex, force, buffer) - logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) - return result + return await this.withDebugChannel( + 'write variable', + async (client) => { + const result = await client.setVariable(variableIndex, force, buffer) + // Forcing values is device traffic too: it queues on the same link and + // proves the same thing a read does. Without this, holding a force while + // the poll is due lets the liveness read wait behind it and time out. + if (result.success) this.deviceSession.noteTraffic() + logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) + return result + }, + (reason) => ({ success: false, error: reason.error }), + ) } catch (error) { logger.error('[IPC Handler] Modbus setVariable error: ' + getErrorMessage(error)) return { success: false, error: getErrorMessage(error) } @@ -2140,6 +2573,7 @@ class MainProcessBridge implements MainIpcModule { /** Stops the simulator and notifies the renderer so it can update UI state. */ private stopSimulatorAndNotify(): void { if (this.simulatorModule.isRunning()) { + this.closeSimulatorSession() this.simulatorModule.stop() this.mainWindow?.webContents.send('simulator:stopped') } @@ -2368,6 +2802,16 @@ class MainProcessBridge implements MainIpcModule { handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + /** + * Start the emulator, then open its session. + * + * The running emulator IS the simulator's connection — there is no port to pick + * and no address to configure, so nothing about it is ever resolved from a spec + * or asked of the user. Opening the session here (rather than when the debugger + * asks) is what makes the simulator behave like every other target downstream: + * commands go to a session the manager holds, and when the emulator stops the + * session ends the same way a pulled cable ends a serial one. + */ handleSimulatorLoadFirmware = async ( _event: IpcMainInvokeEvent, hexPath: string, @@ -2376,17 +2820,40 @@ class MainProcessBridge implements MainIpcModule { const fs = await import('fs/promises') const hexContent = await fs.readFile(hexPath, 'utf-8') this.simulatorModule.loadAndRun(hexContent) + + const opened = await this.deviceSession.open( + this.toDeviceLinkCandidates([{ connectionType: 'simulator', connectionParams: {} }]), + ) + if (!opened.ok) { + this.simulatorModule.stop() + const reason = opened.attempts.map((attempt) => attempt.error).join('; ') + return { success: false, error: reason || 'The simulator did not answer its debug protocol' } + } + this.debuggerConnectionType = 'simulator' return { success: true } } catch (error) { return { success: false, error: getErrorMessage(error) } } } + /** + * Stop the emulator entirely — the simulator's Stop button means "stop the + * simulator", not "stop the program it is running". The session closes first so + * the client is dropped before the thing it talks to disappears. + */ handleSimulatorStop = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.closeSimulatorSession() this.simulatorModule.stop() return Promise.resolve({ success: true }) } + /** Close the session if it is the simulator's. No-op for any other target. */ + private closeSimulatorSession(): void { + if (this.deviceSession.getLink()?.transport !== 'simulator') return + this.deviceSession.close() + this.debuggerConnectionType = null + } + handleSimulatorIsRunning = (_event: IpcMainInvokeEvent): Promise => { return Promise.resolve(this.simulatorModule.isRunning()) } diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 3fb915a2f..a159ab263 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,9 @@ import type { DiscoveredRuntimeDevice, RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { + DeviceConnectionStatusPayload, + DeviceLicenseReport, + DeviceLicenseRequest, +} from '@root/middleware/shared/ports/device-port' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, @@ -21,6 +26,7 @@ import type { UpdateUserParams, WhoAmIResult, } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' @@ -347,11 +353,11 @@ const rendererProcessBridge = { } > > => ipcRenderer.invoke('hardware:get-available-boards'), - getAvailableCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + getAvailableCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:get-available-communication-ports'), refreshAvailableBoards: (): Promise<{ board: string; version: string }[]> => ipcRenderer.invoke('hardware:refresh-available-boards'), - refreshCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + refreshCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:refresh-communication-ports'), // ===================== PACKAGE MANAGER METHODS ===================== @@ -404,17 +410,22 @@ const rendererProcessBridge = { ipcRenderer.invoke('util:read-debug-file', projectPath, boardTarget), debuggerVerifyMd5: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, expectedMd5: string, ): Promise<{ success: boolean; match?: boolean; targetMd5?: string; error?: string }> => - ipcRenderer.invoke('debugger:verify-md5', connectionType, connectionParams, expectedMd5), + ipcRenderer.invoke('debugger:verify-md5', expectedMd5), + + /** FC 0x4b run/stop command. Reads come from `onDevicePlcState` (the device + * status poll), not from here. */ + debuggerPlcControl: ( + action: 'run' | 'stop', + ): Promise<{ + success: boolean + state?: number + switchPosition?: number + refusedBySwitch?: boolean + unsupported?: boolean + error?: string + }> => ipcRenderer.invoke('debugger:plc-control', action), debuggerReadProgramStMd5: ( projectPath: string, @@ -440,20 +451,74 @@ const rendererProcessBridge = { ): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:set-variable', variableIndex, force, valueBuffer), - debuggerConnect: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('debugger:connect', connectionType, connectionParams), + debuggerConnect: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:connect'), debuggerDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('debugger:disconnect'), + // Persistent device connection (D72): try the ordered candidates and HOLD the + // first that answers, returning how the kept channel classified. + deviceConnect: ( + candidates: DebugConnectionConfig[], + ): Promise<{ + status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + error?: string + }> => ipcRenderer.invoke('device:connect', candidates), + + // Close the held serial link (Disconnect). + deviceDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('device:disconnect'), + + // A Runtime v3/v4 session: control over REST at `address`, debug over the channel + // the board declares (opened later, on the debugger's request). + openRuntimeSession: (params: { + address: string + debug: DebugConnectionConfig + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('session:open-runtime', params), + + closeRuntimeSession: (): Promise<{ success: boolean }> => ipcRenderer.invoke('session:close-runtime'), + + // Upload handoff: give up the link ONLY if it is the serial one holding `port`. + deviceReleaseSerialPort: (port: string | null | undefined): Promise<{ released: boolean }> => + ipcRenderer.invoke('device:release-serial-port', port), + + // VPP licensing over the HELD link. `read` is local-only (read + verify) and + // cheap; `refresh` may reach the network and write, which is why they are + // separate channels and neither is part of `device:connect`. + deviceReadLicense: (request: DeviceLicenseRequest): Promise => + ipcRenderer.invoke('device:read-license', request), + + deviceRefreshLicense: (request: DeviceLicenseRequest): Promise => + ipcRenderer.invoke('device:refresh-license', request), + + // Diagnostic trace of the device connection (candidate attempts, poll verdicts, + // which connection served each command), mirrored into the editor console so it + // can be read and copied while reproducing a problem. + onDeviceLinkLog: (callback: (message: string) => void): (() => void) => { + const listener = (_event: unknown, message: string) => callback(message) + ipcRenderer.on('device:link-log', listener) + return () => ipcRenderer.removeListener('device:link-log', listener) + }, + + // Main pushes live link status here (liveness failure, upload/debug handoff). + onDeviceConnectionStatus: (callback: (payload: DeviceConnectionStatusPayload) => void): (() => void) => { + const listener = (_event: unknown, payload: DeviceConnectionStatusPayload) => callback(payload) + ipcRenderer.on('device:connection-status', listener) + return () => ipcRenderer.removeListener('device:connection-status', listener) + }, + + /** + * Subscribe to run/stop state pushed from the held device link. Emitted on + * every liveness tick (FC 0x46 carries the state), so a switch flipped by hand + * at the panel surfaces within one interval without any extra traffic. + */ + onDevicePlcState: ( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): (() => void) => { + const listener = (_event: unknown, payload: { port: string; plcState?: number; switchPosition?: number }) => + callback(payload) + ipcRenderer.on('device:plc-state', listener) + return () => ipcRenderer.removeListener('device:plc-state', listener) + }, + // ===================== RUNTIME API METHODS ===================== runtimeGetUsersInfo: (ipAddress: string): Promise<{ hasUsers: boolean; runtimeVersion?: string; error?: string }> => ipcRenderer.invoke('runtime:get-users-info', ipAddress), @@ -503,6 +568,8 @@ const rendererProcessBridge = { overruns: number }> } + /** Run/stop mode-switch position; absent on older runtimes. */ + switchPosition?: 'run' | 'stop' error?: string }> => ipcRenderer.invoke('runtime:get-status', ipAddress, includeStats), runtimeStartPlc: (ipAddress: string): Promise<{ success: boolean; error?: string; status?: string }> => diff --git a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts index 97a36a61a..dca64d16d 100644 --- a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts @@ -35,6 +35,7 @@ beforeEach(() => { success: true, content: 'debug_vars[] = { ... }', }), + debuggerPlcControl: jest.fn().mockResolvedValue({ success: true, state: 0 }), } as unknown as typeof window.bridge adapter = createEditorDebuggerAdapter() @@ -46,18 +47,15 @@ beforeEach(() => { describe('connect', () => { it('delegates to bridge with connection type and params', async () => { - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('tcp', { - ipAddress: '192.168.1.100', - port: '502', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() expect(result).toEqual({ success: true }) }) it('sets connected state on success', async () => { expect(adapter.isConnected()).toBe(false) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) @@ -66,55 +64,33 @@ describe('connect', () => { success: false, error: 'Connection refused', }) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(false) }) it('catches bridge errors', async () => { ;(window.bridge.debuggerConnect as jest.Mock).mockRejectedValue(new Error('IPC failed')) - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() expect(result).toEqual({ success: false, error: 'IPC failed' }) }) it('supports simulator connection type', async () => { - await adapter.connect({ connectionType: 'simulator', connectionParams: {} }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('simulator', {}) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports websocket connection type with JWT', async () => { - await adapter.connect({ - connectionType: 'websocket', - connectionParams: { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('websocket', { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports RTU connection type with serial params', async () => { - await adapter.connect({ - connectionType: 'rtu', - connectionParams: { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('rtu', { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) }) @@ -124,7 +100,7 @@ describe('connect', () => { describe('disconnect', () => { it('delegates to bridge', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(window.bridge.debuggerDisconnect).toHaveBeenCalled() @@ -132,7 +108,7 @@ describe('disconnect', () => { }) it('clears connected state', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) await adapter.disconnect() @@ -145,7 +121,7 @@ describe('disconnect', () => { adapter.onDisconnected(cb1) adapter.onDisconnected(cb2) - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -157,7 +133,7 @@ describe('disconnect', () => { const cb = jest.fn() adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(adapter.isConnected()).toBe(false) @@ -232,19 +208,62 @@ describe('setVariable', () => { }) }) +// --------------------------------------------------------------------------- +// setPlcState — the operation whose transport parameter caused the bug +// --------------------------------------------------------------------------- + +describe('setPlcState', () => { + it('sends the payload and nothing else', () => { + // Naming a medium here is what made Stop resolve the debug spec, which for a + // DHCP-configured project popped an address dialog — over a serial connection + // that then carried the command anyway. Payload only: the connection manager + // routes it. + void adapter.setPlcState?.('STOPPED') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('stop') + }) + + it('maps RUNNING to the run action', () => { + void adapter.setPlcState?.('RUNNING') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('run') + }) + + it('returns the acknowledgement the main process produced', async () => { + // `refusedBySwitch` / `unsupported` drive two distinct dialogs upstream, so the + // adapter must pass the whole shape through rather than reducing it to a boolean. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockResolvedValue({ + success: false, + refusedBySwitch: true, + state: 0, + switchPosition: 0, + }) + + await expect(adapter.setPlcState?.('RUNNING')).resolves.toMatchObject({ + success: false, + refusedBySwitch: true, + }) + }) + + it('reports a rejected IPC call as a failed command instead of throwing', async () => { + // Run/stop is driven straight from a click handler. An escaping rejection would + // surface as an unhandled promise and the button would look like it did nothing. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockRejectedValue(new Error('bridge is gone')) + + await expect(adapter.setPlcState?.('STOPPED')).resolves.toEqual({ + success: false, + error: 'bridge is gone', + }) + }) +}) + // --------------------------------------------------------------------------- // verifyMd5 // --------------------------------------------------------------------------- describe('verifyMd5', () => { it('delegates to bridge with connection config and expected MD5', async () => { - const result = await adapter.verifyMd5('abc123def456abc123def456abc123de', tcpConfig) + const result = await adapter.verifyMd5('abc123def456abc123def456abc123de') - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'abc123def456abc123def456abc123de', - ) + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('abc123def456abc123def456abc123de') expect(result).toEqual({ success: true, match: true, @@ -253,21 +272,24 @@ describe('verifyMd5', () => { }) it('uses different configs per call', async () => { - await adapter.verifyMd5('md5-1', tcpConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'md5-1', - ) + await adapter.verifyMd5('md5-1') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-1') const simConfig: DebugConnectionConfig = { connectionType: 'simulator', connectionParams: {} } - await adapter.verifyMd5('md5-2', simConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('simulator', {}, 'md5-2') + await adapter.verifyMd5('md5-2') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-2') + }) + + it('omits the transport for a target the connection manager already holds', async () => { + // A connected baremetal board: naming a medium here is what made a debug start + // over serial ask for a DHCP address. + await adapter.verifyMd5('md5-held') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-held') }) it('catches bridge errors', async () => { ;(window.bridge.debuggerVerifyMd5 as jest.Mock).mockRejectedValue(new Error('MD5 check failed')) - const result = await adapter.verifyMd5('abc123', tcpConfig) + const result = await adapter.verifyMd5('abc123') expect(result).toEqual({ success: false, error: 'MD5 check failed' }) }) @@ -328,7 +350,7 @@ describe('onDisconnected', () => { const cb = jest.fn() const unsub = adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() unsub() await adapter.disconnect() @@ -344,7 +366,7 @@ describe('onDisconnected', () => { adapter.onDisconnected(cb3) unsub2() - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -372,12 +394,12 @@ describe('isConnected', () => { }) it('returns true after successful connect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) it('returns false after disconnect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(adapter.isConnected()).toBe(false) }) diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index a2a79eb59..19d4ea42f 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -15,10 +15,7 @@ const mockBoards = new Map([ ], ]) -const mockPorts: CommunicationPort[] = [ - { name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0', address: '/dev/ttyACM0' }, -] +const mockPorts: CommunicationPort[] = [{ address: '/dev/ttyUSB0' }, { address: '/dev/ttyACM0' }] const mockRefreshResult = [{ board: 'Arduino Uno', version: '1.8.6' }] @@ -29,6 +26,22 @@ beforeEach(() => { refreshAvailableBoards: jest.fn().mockResolvedValue(mockRefreshResult), refreshCommunicationPorts: jest.fn().mockResolvedValue(mockPorts), getPreviewImage: jest.fn().mockResolvedValue('data:image/png;base64,abc123'), + deviceConnect: jest.fn().mockResolvedValue({ status: 'connected-with-firmware' }), + deviceDisconnect: jest.fn().mockResolvedValue({ success: true }), + onDeviceConnectionStatus: jest.fn().mockReturnValue(() => undefined), + openRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + closeRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + deviceReleaseSerialPort: jest.fn().mockResolvedValue({ released: true }), + deviceReadLicense: jest.fn().mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }), + deviceRefreshLicense: jest.fn().mockResolvedValue({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }), + onDeviceLinkLog: jest.fn().mockReturnValue(() => undefined), + onDevicePlcState: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge }) @@ -73,4 +86,100 @@ describe('createEditorDeviceAdapter', () => { await adapter.getPreviewImage('motor-shield.png', '/path/to/pkg') expect(window.bridge.getPreviewImage).toHaveBeenCalledWith('motor-shield.png', '/path/to/pkg') }) + + it('delegates connect to window.bridge with the candidate list', async () => { + // Connect passes every way to reach the device, in order; the main process + // tries them and keeps the first that answers. + const candidates = [ + { connectionType: 'tcp' as const, connectionParams: { ipAddress: '192.168.0.50' } }, + { connectionType: 'rtu' as const, connectionParams: { port: 'COM5', baudRate: 115200 } }, + ] + const result = await adapter.connect(candidates) + expect(window.bridge.deviceConnect).toHaveBeenCalledWith(candidates) + expect(result).toMatchObject({ status: 'connected-with-firmware' }) + }) + + it('delegates disconnect to window.bridge', async () => { + const result = await adapter.disconnect() + expect(window.bridge.deviceDisconnect).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true }) + }) + + it('delegates onConnectionStatus subscription to window.bridge and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onConnectionStatus(cb) + expect(window.bridge.onDeviceConnectionStatus).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) + + /** + * The session and handoff members. All pure IPC delegation, but each one is a + * name pairing between the port and the preload bridge — the kind of mismatch + * that type-checks on neither side once `window.bridge` is cast, and then fails + * only at runtime as "not a function" in the middle of a connect. + */ + it('delegates openRuntimeSession with the address and debug channel', async () => { + const params = { + address: '192.168.0.9', + debug: { connectionType: 'websocket' as const, connectionParams: { ipAddress: '192.168.0.9' } }, + } + const result = await adapter.openRuntimeSession?.(params) + expect(window.bridge.openRuntimeSession).toHaveBeenCalledWith(params) + expect(result).toMatchObject({ success: true }) + }) + + it('delegates closeRuntimeSession', async () => { + const result = await adapter.closeRuntimeSession?.() + expect(window.bridge.closeRuntimeSession).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ success: true }) + }) + + it('unwraps releaseSerialPort to the bare `released` flag', async () => { + // The caller decides whether to reconnect after an upload from this boolean, + // so the unwrapping is the part worth pinning — not the passthrough. + await expect(adapter.releaseSerialPort('COM5')).resolves.toBe(true) + expect(window.bridge.deviceReleaseSerialPort).toHaveBeenCalledWith('COM5') + }) + + it('reports releaseSerialPort false when nothing was held on that port', async () => { + ;(window.bridge.deviceReleaseSerialPort as jest.Mock).mockResolvedValue({ released: false }) + await expect(adapter.releaseSerialPort(null)).resolves.toBe(false) + }) + + it('delegates onLinkLog subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onLinkLog?.(cb) + expect(window.bridge.onDeviceLinkLog).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) + + it('delegates onPlcState subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onPlcState?.(cb) + expect(window.bridge.onDevicePlcState).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) + + it('delegates readLicense to the local-only license channel', async () => { + const request = { packageId: 'com.openplc.espressif-licensed' } + + await expect(adapter.readLicense?.(request)).resolves.toEqual({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'licensed', how: 'already-stored' }, + }) + expect(window.bridge.deviceReadLicense).toHaveBeenCalledWith(request) + // The cheap channel must not be the one that reaches the network. + expect(window.bridge.deviceRefreshLicense).not.toHaveBeenCalled() + }) + + it('delegates refreshLicense to the recovering license channel', async () => { + const request = { packageId: 'com.openplc.espressif-licensed' } + + await expect(adapter.refreshLicense?.(request)).resolves.toEqual({ + deviceId: '659a3520540f803625ddc34081e893d3', + outcome: { state: 'unlicensed', entitlementChecked: true }, + }) + expect(window.bridge.deviceRefreshLicense).toHaveBeenCalledWith(request) + expect(window.bridge.deviceReadLicense).not.toHaveBeenCalled() + }) }) diff --git a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts index 2e28e163e..b75d7748d 100644 --- a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts @@ -126,6 +126,24 @@ describe('createEditorPackageAdapter', () => { expect(await adapter.getManifest('missing')).toBeNull() }) + it('keeps the manifest usable when its stored compatibility floor is unreadable', async () => { + // Read path, not the install boundary (DOPE-448): a package installed + // before the floor format was checked must still render and still + // provide its boards. The floor is dropped with a log, not the manifest. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + ;(window.bridge.getPackageManifest as jest.Mock).mockResolvedValue({ + ...validManifest, + package: { ...validManifest.package, minEditorVersion: 'nightly' }, + }) + + const result = await adapter.getManifest('acme-controller') + + expect(result?.package.id).toBe('acme-controller') + expect(result?.package).not.toHaveProperty('minEditorVersion') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('minEditorVersion')) + warnSpy.mockRestore() + }) + it('returns null when the bridge returns a malformed manifest (zod rejection)', async () => { // Suppress the validation warning the schema emits; we want to // assert the rejection, not the noise. diff --git a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts index 58e54eebe..ee78d8f25 100644 --- a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts @@ -97,6 +97,7 @@ const mockRawProjectFiles = { ], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], }, } @@ -367,6 +368,7 @@ describe('createEditorProjectAdapter', () => { pouFiles: [{ relativePath: 'pous/programs/main.st', content: 'PROGRAM main\nEND_PROGRAM' }], serverFiles: [], remoteDeviceFiles: [], + dataTypeFiles: [], deletions: [], } diff --git a/src/middleware/adapters/editor/__tests__/system-adapter.test.ts b/src/middleware/adapters/editor/__tests__/system-adapter.test.ts index d24aa126b..488e9dfa0 100644 --- a/src/middleware/adapters/editor/__tests__/system-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/system-adapter.test.ts @@ -69,3 +69,18 @@ describe('log', () => { expect(window.bridge.log).toHaveBeenCalledWith('error', 'error message') }) }) + +describe('getEdgeFrontendUrl', () => { + it('resolves an absolute Edge WEB app URL, distinct from the API host', () => { + // The `/buy` page lives on the web app, not the API. Deriving one host from + // the other by string surgery breaks the moment either moves, so this must be + // its own value — and it must be absolute, or `buildLicenseBuyUrl` falls back + // to a root-relative link that Electron cannot open. + const url = adapter.getEdgeFrontendUrl() + + expect(url).toMatch(/^https?:\/\//) + expect(url).not.toContain('api.autonomylogic.com') + // No trailing slash: the URL builder appends `/buy` itself. + expect(url.endsWith('/')).toBe(false) + }) +}) diff --git a/src/middleware/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 169a0f16f..b4e76c776 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -10,28 +10,25 @@ * auto-reconnection using stored connection parameters. */ +import type { PlcControlResult } from '../../../backend/shared/debug/types' import { getErrorMessage } from '../../../frontend/utils/get-error-message' import type { DebuggerPort } from '../../shared/ports/debugger-port' -import type { - DebugConnectionConfig, - DebugSetResult, - DebugVariableResult, - Md5VerifyResult, - Unsubscribe, -} from '../../shared/ports/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from '../../shared/ports/types' export function createEditorDebuggerAdapter(): DebuggerPort { let connected = false const disconnectCallbacks: Array<() => void> = [] return { - async connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> { + async connect(): Promise<{ success: boolean; error?: string }> { try { - const result = await window.bridge.debuggerConnect(config.connectionType, config.connectionParams) + // Nothing to pass: the connection manager already holds this target's + // session, so there is no medium for the caller to name. + const result = await window.bridge.debuggerConnect() if (result.success) connected = true return result - } catch (err) { - return { success: false, error: getErrorMessage(err) } + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, @@ -64,11 +61,20 @@ export function createEditorDebuggerAdapter(): DebuggerPort { } }, - async verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise { + async verifyMd5(expectedMd5: string): Promise { try { - return await window.bridge.debuggerVerifyMd5(config.connectionType, config.connectionParams, expectedMd5) - } catch (err) { - return { success: false, error: getErrorMessage(err) } + return await window.bridge.debuggerVerifyMd5(expectedMd5) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + }, + + async setPlcState(state: 'RUNNING' | 'STOPPED'): Promise { + try { + // Payload only. Which medium carries it is the connection manager's business. + return await window.bridge.debuggerPlcControl(state === 'RUNNING' ? 'run' : 'stop') + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 9e59afadf..4e30da525 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -12,10 +12,18 @@ * hardware:refresh-available-boards (invoke) * hardware:refresh-communication-ports (invoke) * util:get-preview-image (invoke) + * device:read-license (invoke) + * device:refresh-license (invoke) */ -import type { DevicePort } from '../../shared/ports/device-port' -import type { BoardInfo, CommunicationPort } from '../../shared/ports/types' +import type { + DeviceConnectionStatusPayload, + DeviceConnectResult, + DeviceLicenseReport, + DeviceLicenseRequest, + DevicePort, +} from '../../shared/ports/device-port' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { return { @@ -38,5 +46,49 @@ export function createEditorDeviceAdapter(): DevicePort { getPreviewImage(imageName: string, packagePath?: string): Promise { return window.bridge.getPreviewImage(imageName, packagePath) }, + + connect(candidates: DebugConnectionConfig[]): Promise { + return window.bridge.deviceConnect(candidates) + }, + + openRuntimeSession(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> { + return window.bridge.openRuntimeSession(params) + }, + + closeRuntimeSession(): Promise<{ success: boolean }> { + return window.bridge.closeRuntimeSession() + }, + + async releaseSerialPort(port: string | null | undefined): Promise { + const result = await window.bridge.deviceReleaseSerialPort(port) + return result.released + }, + + disconnect(): Promise<{ success: boolean }> { + return window.bridge.deviceDisconnect() + }, + + readLicense(request: DeviceLicenseRequest): Promise { + return window.bridge.deviceReadLicense(request) + }, + + refreshLicense(request: DeviceLicenseRequest): Promise { + return window.bridge.deviceRefreshLicense(request) + }, + + onLinkLog(callback: (message: string) => void): () => void { + return window.bridge.onDeviceLinkLog(callback) + }, + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void { + return window.bridge.onDeviceConnectionStatus(callback) + }, + + onPlcState(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void { + return window.bridge.onDevicePlcState(callback) + }, } } diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index 7d4672dea..0d3caeded 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -20,7 +20,7 @@ * importFromFile`) can run unchanged. */ -import { parsePackageManifest } from '../../shared/ports/package-manifest-schema' +import { parseInstalledPackageManifest } from '../../shared/ports/package-manifest-schema' import type { PackagePort } from '../../shared/ports/package-port' import type { ImportResult, @@ -92,9 +92,14 @@ export function createEditorPackageAdapter(): PackagePort { // manifest.json. Validate the shape here before handing it to UI // code — drift between port type and on-disk JSON is a real risk // that an unchecked cast would silently absorb. + // + // Same read-path tolerance as the main process applies (DOPE-448): this + // manifest belongs to an installed package, so an unreadable + // compatibility floor is dropped rather than taking the whole manifest — + // and the package's boards — down with it. const raw = await window.bridge.getPackageManifest(packageId) if (raw === null || raw === undefined) return null - return parsePackageManifest(raw) + return parseInstalledPackageManifest(raw) }, async listRemoteCatalog(): Promise { diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index a6caf6c30..6ea829762 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -11,6 +11,7 @@ */ import { parseProjectFiles } from '../../../backend/shared/utils/parse-project-files' +import { isDataTypeFilesEnabled } from '../../../frontend/utils/feature-flags' import type { CreatePouParams, CreateProjectParams, @@ -202,6 +203,11 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.serverFiles, raw.data.remoteDeviceFiles, raw.data.libraryManifest, + // .dt files only feed the parser while the flag is on — + // off keeps legacy project.json as the source of truth. + // Array guard: the IPC payload is a cast, not validated — a + // version-skewed main process must not crash project open. + isDataTypeFilesEnabled() && Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, @@ -221,6 +227,11 @@ export function createEditorProjectAdapter(): ProjectPort { raw.data.serverFiles, raw.data.remoteDeviceFiles, raw.data.libraryManifest, + // .dt files only feed the parser while the flag is on — + // off keeps legacy project.json as the source of truth. + // Array guard: the IPC payload is a cast, not validated — a + // version-skewed main process must not crash project open. + isDataTypeFilesEnabled() && Array.isArray(raw.data.dataTypeFiles) ? raw.data.dataTypeFiles : [], ) return { success: true, data: parsed } }, diff --git a/src/middleware/adapters/editor/system-adapter.ts b/src/middleware/adapters/editor/system-adapter.ts index 03188e578..e58bd3bbb 100644 --- a/src/middleware/adapters/editor/system-adapter.ts +++ b/src/middleware/adapters/editor/system-adapter.ts @@ -16,6 +16,25 @@ import type { SystemPort } from '../../shared/ports/system-port' import type { SystemInfo } from '../../shared/ports/types' +/** + * Edge web app host — where the license `/buy` page lives. Authoritative for + * shipped builds. + */ +const PRODUCTION_EDGE_WEB_URL = 'https://edge.autonomylogic.com' + +/** + * Build-time override, injected by webpack's `EnvironmentPlugin` (renderer dev + + * prod configs) exactly like `VPP_CATALOG_URL`. Electron renderer bundles have no + * live `process.env`, so this is evaluated when the bundle is BUILT: set it in the + * shell before `npm run dev` to aim the buy flow at a local Edge app. + * + * OPENPLC_EDGE_WEB_URL=http://localhost:5173 npm run dev + * + * The release pipeline does not set it, and webpack's empty-string default is + * falsy, so shipped builds always point at production. + */ +const EDGE_WEB_URL = process.env.OPENPLC_EDGE_WEB_URL || PRODUCTION_EDGE_WEB_URL + export function createEditorSystemAdapter(): SystemPort { return { getSystemInfo(): Promise { @@ -37,5 +56,9 @@ export function createEditorSystemAdapter(): SystemPort { log(level: 'info' | 'error', message: string): void { window.bridge.log(level, message) }, + + getEdgeFrontendUrl(): string { + return EDGE_WEB_URL + }, } } diff --git a/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts new file mode 100644 index 000000000..5cbcb540d --- /dev/null +++ b/src/middleware/shared/ports/__tests__/package-manifest-schema.test.ts @@ -0,0 +1,227 @@ +import { PackageManifestSchema, parseInstalledPackageManifest } from '../package-manifest-schema' + +/** A minimal manifest the schema accepts, with room to override `package`. */ +const manifest = (pkg: Record = {}) => ({ + formatVersion: '1.0', + package: { id: 'vendor.board', name: 'Vendor Board', version: '1.0.0', ...pkg }, + devices: [{ id: 'slm-rp4', name: 'SLM-RP4' }], +}) + +describe('PackageManifestSchema — compatibility floors', () => { + it('accepts a manifest that declares no floors at all', () => { + // Packages built before DOPE-448 must keep installing. + expect(PackageManifestSchema.safeParse(manifest()).success).toBe(true) + }) + + // These are the shorthands a human writes by hand. Each means exactly its + // zero-filled equivalent and is enforced as such, so accepting them here is + // not leniency — it is the same rule the comparator applies. + it.each([ + ['4.3.2', 'a full triple'], + ['v4.3.2', 'a tag-style v prefix'], + ['4.3', 'a two-part shorthand'], + ['4', 'a bare major'], + ['v5', 'a v-prefixed bare major'], + ['4.3.2-rc.1', 'a pre-release'], + ['4.3.2+build.5', 'a build suffix'], + ])('accepts minEditorVersion %p (%s)', (minEditorVersion) => { + expect(PackageManifestSchema.safeParse(manifest({ minEditorVersion })).success).toBe(true) + }) + + // The reason this check exists: an unreadable floor is not inert. The + // comparator treats it as "declares nothing", so the package installs + // everywhere while its author believes a constraint is being enforced. + // A sideloaded `.vpp` never passes through openplc-packages' validator, so + // for that entry path this schema is the only boundary there is. + it.each([ + ['garbage', 'a non-version word'], + ['next', 'a channel name mistaken for a version'], + ['4,3,0', 'the wrong separator'], + ['4.3 or newer', 'prose'], + [' ', 'whitespace only'], + ['', 'an empty string'], + ])('rejects minEditorVersion %p (%s)', (minEditorVersion) => { + expect(PackageManifestSchema.safeParse(manifest({ minEditorVersion })).success).toBe(false) + }) + + it('applies the same rule to minRuntimeVersion', () => { + expect(PackageManifestSchema.safeParse(manifest({ minRuntimeVersion: '4.2' })).success).toBe(true) + expect(PackageManifestSchema.safeParse(manifest({ minRuntimeVersion: 'garbage' })).success).toBe(false) + }) + + it('names the offending field so the install error is actionable', () => { + const result = PackageManifestSchema.safeParse(manifest({ minEditorVersion: 'garbage' })) + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.issues[0].path).toEqual(['package', 'minEditorVersion']) + expect(result.error.issues[0].message).toContain('must be a version') + }) + + it('still lets unknown fields through untouched', () => { + // The editor stays agnostic to manifest contents; the floors are the + // deliberate exception, not a new general policy. + const result = PackageManifestSchema.safeParse(manifest({ vendorExtension: { anything: true } })) + expect(result.success).toBe(true) + }) +}) + +// The other half of the same rule: strict where a package ENTERS the editor, +// tolerant where an installed one is READ BACK. Without this split, the format +// check above would retroactively unresolve a package installed by an older +// editor — its boards would vanish from the board lookup with no message, on an +// upgrade where the user did nothing. +describe('parseInstalledPackageManifest — the load path', () => { + let warnSpy: jest.SpyInstance + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('reads a well-formed manifest exactly as the strict parser does', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: '4.3' })) + expect(parsed?.package.minEditorVersion).toBe('4.3') + expect(warnSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['minEditorVersion', 'garbage'], + ['minRuntimeVersion', '4,3,0'], + ])('drops an uncomparable %s and keeps the package readable', (field, value) => { + const parsed = parseInstalledPackageManifest(manifest({ [field]: value })) + + // The package still resolves — this is the whole point — and the floor it + // could never have enforced is simply gone. + expect(parsed?.package.id).toBe('vendor.board') + expect(parsed?.package).not.toHaveProperty(field) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(field)) + }) + + it('drops a floor that is not even a string', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: 43 })) + expect(parsed?.package).not.toHaveProperty('minEditorVersion') + }) + + it('drops only the unreadable floor, leaving the readable one enforced', () => { + const parsed = parseInstalledPackageManifest(manifest({ minEditorVersion: 'garbage', minRuntimeVersion: '4.2' })) + expect(parsed?.package).not.toHaveProperty('minEditorVersion') + expect(parsed?.package.minRuntimeVersion).toBe('4.2') + }) + + it('leaves every other field of the package untouched', () => { + const parsed = parseInstalledPackageManifest( + manifest({ minEditorVersion: 'garbage', vendorExtension: { anything: true } }), + ) + expect(parsed?.package).toMatchObject({ + id: 'vendor.board', + name: 'Vendor Board', + version: '1.0.0', + vendorExtension: { anything: true }, + }) + }) + + it('still rejects a manifest that is malformed for any other reason', () => { + // Tolerance is scoped to the floors. A document missing `devices` is not a + // manifest, and reading it as one would crash deeper in the loader. + expect(parseInstalledPackageManifest({ formatVersion: '1.0', package: { id: 'x' } })).toBeNull() + }) + + it.each([ + ['a non-object', 'not a manifest'], + ['null', null], + ['an array', []], + ['a manifest whose package is not an object', { formatVersion: '1.0', package: 'nope', devices: [{ id: 'a' }] }], + ])('passes %s straight to the schema rather than guessing at it', (_label, value) => { + expect(parseInstalledPackageManifest(value)).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Board-manager URL +// +// The URL becomes an `--additional-urls` argument to arduino-cli, which then +// downloads a board package containing toolchain executables. The manifest +// signature vouches for the URL, not for what it serves — and arduino-cli's +// package checksums live inside the index it fetches, so a plaintext index is +// MITM-able. +// --------------------------------------------------------------------------- + +/** A manifest whose single device carries the given `target`. */ +const withTarget = (target: Record) => ({ + formatVersion: '1.0', + package: { id: 'vendor.board', name: 'Vendor Board', version: '1.0.0' }, + devices: [{ id: 'esp32-plc-21', name: 'ESP32 PLC 21', target }], +}) + +const VENDOR_INDEX = 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json' + +describe('PackageManifestSchema — board manager URL', () => { + it('accepts a device that declares no target at all', () => { + expect(PackageManifestSchema.safeParse(manifest()).success).toBe(true) + }) + + it('accepts a target with no board manager URL', () => { + expect(PackageManifestSchema.safeParse(withTarget({ type: 'arduino-cli', core: 'arduino:avr' })).success).toBe(true) + }) + + it('accepts the real https vendor index', () => { + expect(PackageManifestSchema.safeParse(withTarget({ boardManagerUrl: VENDOR_INDEX })).success).toBe(true) + }) + + it('accepts a compressed index — the scheme is constrained, not the suffix', () => { + // arduino-cli reads .json.gz / .zip / .bz2 indexes too. + expect(PackageManifestSchema.safeParse(withTarget({ boardManagerUrl: 'https://x.dev/i.json.gz' })).success).toBe( + true, + ) + }) + + it.each([ + ['plaintext http', 'http://x.dev/package_index.json'], + ['a local file', 'file:///etc/passwd'], + ['a bare path', '/tmp/package_index.json'], + ['junk', 'not a url'], + ['an empty string', ''], + ])('rejects %s', (_label, url) => { + const result = PackageManifestSchema.safeParse(withTarget({ boardManagerUrl: url })) + expect(result.success).toBe(false) + }) + + it('leaves the rest of target untouched', () => { + const parsed = PackageManifestSchema.safeParse( + withTarget({ type: 'arduino-cli', core: 'industrialshields:esp32', boardManagerUrl: VENDOR_INDEX }), + ) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.devices[0]).toMatchObject({ + target: { type: 'arduino-cli', core: 'industrialshields:esp32', boardManagerUrl: VENDOR_INDEX }, + }) + } + }) +}) + +describe('parseInstalledPackageManifest — board manager URL', () => { + it('keeps a usable URL', () => { + const parsed = parseInstalledPackageManifest(withTarget({ boardManagerUrl: VENDOR_INDEX })) + expect(parsed?.devices[0].target.boardManagerUrl).toBe(VENDOR_INDEX) + }) + + it('drops a non-https URL instead of hiding every board in the package', () => { + // A package installed before this constraint existed must keep loading; + // rejecting it would make its boards vanish on an upgrade the user did + // not ask for. + const parsed = parseInstalledPackageManifest( + withTarget({ type: 'arduino-cli', core: 'x:y', boardManagerUrl: 'http://x.dev/i.json' }), + ) + expect(parsed).not.toBeNull() + expect(parsed?.devices[0].target.boardManagerUrl).toBeUndefined() + // Everything else about the device survives. + expect(parsed?.devices[0]).toMatchObject({ id: 'esp32-plc-21', target: { type: 'arduino-cli', core: 'x:y' } }) + }) + + it('leaves a device with no target alone', () => { + expect(parseInstalledPackageManifest(manifest())).not.toBeNull() + }) +}) diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index c7698ac92..349caa6e0 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -197,6 +197,16 @@ export interface InstallArduinoCoreArgs { * fails if it is unavailable — required for prebuilt arduino-hal boards * whose precompiled `.a` is ABI-locked to that core version. */ coreVersion?: string + /** Optional third-party board-manager index URL (e.g. a vendor's + * `package__index.json`). Sourced from the VPP manifest's + * `target.boardManagerUrl` (or `board_manager_url` in hals.json) and + * forwarded to arduino-cli as `--additional-urls`. + * + * Cores outside arduino-cli's built-in index are invisible without it: + * `core install industrialshields:esp32` fails with "Platform not found" + * unless the vendor index is supplied AND `core update-index` has been + * run against it. The editor does both; web ignores this field. */ + boardManagerUrl?: string } /** Arduino-CLI library install (editor-only. Same no-op @@ -236,6 +246,17 @@ export interface CheckRuntimeVersionResult { * when the runtime is unreachable or doesn't expose the * endpoint (very old v3 runtimes). */ version: string | null + /** + * Oldest editor this runtime accepts programs from, declared at + * `GET /api/capabilities` (DOPE-448). `null` means the runtime + * declares no floor — it predates the endpoint, or this platform + * has no transport for it. + * + * `null` is "no constraint", never "too old": every runtime + * currently deployed answers `null`, and the runtime only + * advertises this value — the editor is what compares and refuses. + */ + minEditorVersion?: string | null } /** VPP (Vendor Plugin Package) runtime-v4 packaging. Boards that @@ -263,6 +284,23 @@ export interface PackageVppPluginResult { * and return an empty record without errors. */ files: Record errors?: StructuredCompileError[] + /** + * `package.minRuntimeVersion` from the manifest of the VPP this + * board came from (DOPE-448) — the oldest runtime whose plugin API + * the package's HAL was built against. + * + * `null`/absent for non-VPP boards, for packages that declare no + * floor, and on platforms without VPP integration. The pipeline + * compares it against the connected runtime's reported version + * right after the version probe, which is the earliest point where + * both halves are known — a VPP plugin is built against a runtime + * API, so an older runtime loads it and fails at scan time, on a + * live PLC. + * + * This cannot be enforced at install time: the target device is + * unknown until the user connects to one. + */ + minRuntimeVersion?: string | null } // --------------------------------------------------------------------------- diff --git a/src/middleware/shared/ports/debugger-port.ts b/src/middleware/shared/ports/debugger-port.ts index 5a1bd8830..530ecfc00 100644 --- a/src/middleware/shared/ports/debugger-port.ts +++ b/src/middleware/shared/ports/debugger-port.ts @@ -28,15 +28,18 @@ * - DebugTransport interface implementations */ -import type { DebugConnectionConfig, DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' +import type { PlcControlResult } from '../../../backend/shared/debug/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' export interface DebuggerPort { /** - * Connect to a debug target. - * @param config — Connection target (TCP host, RTU serial, WebSocket, or simulator). - * The adapter maps this to the platform's transport mechanism. + * Start a debug session over the session the connection manager holds. + * + * Takes nothing: every target's session is established before this — a device by + * Connect, a runtime by logging in, the simulator by starting. Naming a medium + * here is what made a Stop over serial ask for a DHCP address. */ - connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> + connect(): Promise<{ success: boolean; error?: string }> /** Disconnect from the current debug target. */ disconnect(): Promise<{ success: boolean }> @@ -61,7 +64,7 @@ export interface DebuggerPort { * Used to detect program mismatch before starting a debug session. * @param config — Connection target used for the verification request. */ - verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise + verifyMd5(expectedMd5: string): Promise /** * Read the MD5 hash of the compiled ST program from the debug artifacts. @@ -86,6 +89,19 @@ export interface DebuggerPort { */ onDisconnected(callback: () => void): Unsubscribe + /** + * Ask the target to run or stop (Modbus FC 0x4b). + * + * Command only — the state is READ from the device status poll (FC 0x46), + * which already reports it, so there is deliberately no `getPlcState` here. + * + * A RUN request is REFUSED, not queued, while the hardware mode switch reads + * STOP; the result carries `refusedBySwitch` so the caller shows the "flip the + * switch to RUN" warning. `unsupported` means the firmware predates the + * run/stop state machine. + */ + setPlcState?(state: 'RUNNING' | 'STOPPED'): Promise + /** Check if the debugger is currently connected. */ isConnected(): boolean } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index c6606533a..db3f24fbb 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,124 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig, DebugMedium, DeviceLinkTransport } from './types' + +// --------------------------------------------------------------------------- +// Connect-time classification (D72) — platform contract shared by the port and +// its editor adapter. The store can't reach into `backend/`, so the canonical +// shape lives here. +// --------------------------------------------------------------------------- + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +/** + * Result of opening a persistent device link (D72): how the channel the main + * process settled on classified. + */ +export interface DeviceConnectResult { + status: DeviceProbeStatus + /** Transport failure text when `status === 'error'`. */ + error?: string +} + +/** + * Live status of the held baremetal serial link, pushed by the main process. + * + * `reason: 'lost'` distinguishes the one failure the user must be TOLD about — a + * link that was up, died, and could not be recovered — from an 'error' that came + * straight out of something they just clicked (which already has its own dialog). + */ +export interface DeviceConnectionStatusPayload { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * Medium the CONTROL channel uses (or was using, when it dropped). Absent for a + * REST-controlled runtime session: REST holds no connection. + */ + transport?: DeviceLinkTransport + /** + * Medium the DEBUG channel uses — the same as `transport` when one channel serves + * both roles, else `websocket` (editor / v4), `tcp` (v3), or `webrtc` / + * `http-relay` in the browser. Consumers that must pace or size work to the wire + * (the debug poll) read THIS, rather than inferring a medium they have no + * business choosing. + */ + debugTransport?: DebugMedium + /** + * The endpoint, as the user would name it: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. Not called `port`, because for a Modbus TCP link it + * is an address — and a name that implies serial is what led callers to branch + * on the wrong thing in the first place. + */ + descriptor?: string + reason?: 'lost' +} + +// --------------------------------------------------------------------------- +// VPP licensing over the held link +// --------------------------------------------------------------------------- + +/** + * What the licensing step concluded about a connected device. + * + * A discriminated union rather than a status string plus flags, so the three + * things the UI may assert stay three separate variants. In particular + * `unlicensed` (the backend says there is no purchase — demo is correct, offer to + * buy) and `checkFailed` (we could not find out) must never be rendered the same + * way: showing "Not licensed" for a rate-limited request tells someone who + * already paid to buy again. + * + * `licensed` asserts POSSESSION of a well-formed license bound to this device and + * this VPP — not that the closed license-core will run FULL. Only the core can say + * that, so the badge says "Licensed", never "Full mode". + */ +export type DeviceLicenseState = + /** A well-formed license bound to this device and this VPP is stored. */ + | { state: 'licensed'; how: 'already-stored' | 'activated' } + /** + * The device does NOT hold a valid license. + * + * `entitlementChecked` says how far we got, and the UI must branch on it: + * - `true` — the backend was asked and reported no purchase for this device. + * Demo mode is correct and BUYING is the fix. `backendReason` + * carries the backend's own wording when it gave one. + * - `false` — we only know the device is holding nothing usable; nobody has + * asked whether a purchase exists. The fix to OFFER is "check for + * a license" (a refresh), not "buy" — telling someone who already + * paid to pay again is the worst outcome this union exists to + * prevent. + */ + | { state: 'unlicensed'; entitlementChecked: boolean; backendReason?: string } + /** + * The running firmware reports no licence storage. + * + * On a licensable board this is a FIRMWARE fault, not a hardware limitation: + * every licensable VPP targets hardware that persists a licence across a + * reboot, so a board answering this was built without its storage backend. + * The fix is always "rebuild and upload", never "buy a different board" — + * which is why this variant carries no detail to vary the message with. + */ + | { state: 'unsupported' } + /** Possession could not be determined. Never render as "not licensed". */ + | { state: 'check-failed'; error: string } + +/** Result of a licensing operation over the held link. */ +export interface DeviceLicenseReport { + outcome: DeviceLicenseState + /** + * The licensing identity, 32 lowercase hex chars. Derived main-side (it needs + * `node:crypto`), so the renderer cannot compute it and must be handed it: it + * feeds the license popover, the copy button, and the buy deep link. Absent only + * when there was no usable hardware anchor, which is itself a `checkFailed`. + */ + deviceId?: string +} + +/** Arguments both licensing calls take, resolved by `resolveLicensingTarget`. */ +export interface DeviceLicenseRequest { + /** Reverse-domain VPP package id (`package.id`). */ + packageId: string +} export interface DevicePort { /** @@ -59,4 +176,106 @@ export interface DevicePort { * Web: returns URL to bundled image asset. */ getPreviewImage(imageName: string, packagePath?: string): Promise + + /** + * Open and HOLD the connection to a baremetal device (D72). + * + * `candidates` is the ordered list of ways to reach it, resolved from the + * board's debug spec: Modbus TCP first when the project enables it, then serial. + * The main process tries them in order and keeps the first that both opens and + * answers, so a stale DHCP address or an unplugged ethernet shield falls through + * to the cable instead of leaving the editor claiming a connection it does not + * have. It then holds that ONE connection — every command (debug, run/stop, the + * status poll) rides it — polls it, and pushes status changes through + * `onConnectionStatus`. + * + * Editor: `device:connect`. Web: not applicable locally. + */ + connect(candidates: DebugConnectionConfig[]): Promise + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4), after + * the renderer has logged in: `debug` describes the channel that target debugs + * over (v3 Modbus TCP, v4 the WebSocket), which is opened later, only if a debug + * session asks for it. + * + * Editor: `session:open-runtime`. Web: no-op. + */ + openRuntimeSession?(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> + + /** Close a REST-controlled session (logout / disconnect). */ + closeRuntimeSession?(): Promise<{ success: boolean }> + + /** + * Hand the serial port over for an upload: releases the held connection only if + * it IS the serial one occupying `port`, and reports whether it did. + * + * A connection over Modbus TCP is left alone — flashing over USB does not + * disturb it, so debugging and run/stop survive the upload. Disconnecting + * unconditionally (what the upload flow used to do) threw away a working link + * for no reason. + * + * Editor: `device:release-serial-port`. Web: no-op, returns false. + */ + releaseSerialPort(port: string | null | undefined): Promise + + /** Close a held serial link. Editor: `device:disconnect`. */ + disconnect(): Promise<{ success: boolean }> + + /** + * Ask the connected device what license it is holding RIGHT NOW: read FC 0x4A + * and verify the bytes. Never contacts the backend, so it is cheap and safe to + * call on a poll or a screen open. + * + * The verification is the point. A device answering `SUCCESS` does not mean the + * stored license is good — the targets disagree about what they check first — + * so a caller that trusted the status byte would show "Licensed" on a board + * running demo. + * + * Optional: only platforms that hold a device link implement it. Editor: + * `device:read-license`. + */ + readLicense?(request: DeviceLicenseRequest): Promise + + /** + * Run the FULL licensing flow over the held link: read, verify, and when the + * device holds nothing usable, ask the backend and write what it returns + * (re-reading to confirm). + * + * Separate from `readLicense` because this one can take seconds and reaches the + * network — a connect must not block on it, and it is also what the UI calls + * again after a purchase so a device gets its license without disconnecting or + * reflashing. + * + * Optional: only platforms that hold a device link implement it. Editor: + * `device:refresh-license`. + */ + refreshLicense?(request: DeviceLicenseRequest): Promise + + /** + * Subscribe to live serial-link status pushed by the main process (liveness + * failure, upload/debug handoff). Returns an unsubscribe function. Editor: + * `device:connection-status` IPC event. Web: no-op. + */ + /** + * Subscribe to the device connection's diagnostic trace. Returns an unsubscribe + * function. Editor: `device:link-log`. Web: no-op. + */ + onLinkLog?(callback: (message: string) => void): () => void + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void + + /** + * Subscribe to run/stop state from the held device link (baremetal targets). + * + * Pushed on the same liveness tick that keeps the link honest — the status + * frame (FC 0x46) carries the run/stop state and the mode-switch position — so + * this costs no extra round trip and needs no second timer. `plcState` is + * 0/1/2 (STOPPED/RUNNING/ERROR); `switchPosition` is 0/1 (STOP/RUN) and is + * absent on firmware predating the run/stop state machine. + */ + onPlcState?(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void } diff --git a/src/middleware/shared/ports/package-manifest-schema.ts b/src/middleware/shared/ports/package-manifest-schema.ts index 815230e3e..48c438e6b 100644 --- a/src/middleware/shared/ports/package-manifest-schema.ts +++ b/src/middleware/shared/ports/package-manifest-schema.ts @@ -34,8 +34,66 @@ import { z } from 'zod' +import { isValidVersion } from '../../../frontend/utils/semver' import type { PackageManifest } from './types' +/** + * A compatibility floor must be a version this codebase can compare. + * + * This is the exception to the "the editor is agnostic to manifest + * contents" rule above, and it earns it: an unreadable floor is not + * inert, it is a constraint the package author believes is being + * enforced and which silently is not. `"4.3"`, `"4"` and `"v5"` are all + * accepted — they mean 4.3.0 / 4.0.0 / 5.0.0 — so in practice only + * genuine junk (`"garbage"`, `"next"`, `"4,3,0"`) is refused. + * + * It matters most for the path this gate exists for: a `.vpp` added + * from disk never passes through openplc-packages' `scripts/validate.ts`, + * so for sideloaded packages this schema is the only boundary there is. + * + * Refusing applies to the artefact ENTERING the editor. Reading a + * package that is already installed goes through + * `parseInstalledPackageManifest` below, which drops such a floor + * instead of rejecting the manifest around it. + */ +const versionFloor = z + .string() + .min(1) + .refine(isValidVersion, { message: 'must be a version like "4.3.2", "4.3", "4" or "v4.3.2"' }) + +/** + * A vendor board-manager index URL must be one we are willing to hand to + * arduino-cli as `--additional-urls`. + * + * Same reasoning as `versionFloor`: a field a gate reads should not reach it + * as `unknown`. This one goes further than a gate — it becomes an argument to + * a subprocess that downloads a board package, and board packages carry + * toolchain executables that later builds run. + * + * The signature on a VPP vouches for the manifest, not for what the URL + * serves. arduino-cli's package checksums live INSIDE the index it fetches, + * so a plaintext index is MITM-able and the board package that lands is + * whatever the intercepting index points at. Requiring https closes that. + * + * Only the scheme is constrained. arduino-cli accepts compressed indexes + * (`.json.gz`, `.zip`, `.bz2`) as well as plain `.json`, so pinning the path + * suffix would refuse valid vendors for no security gain. + * + * Authoring-side, openplc-packages' `schema/manifest.schema.json` carries the + * same constraint, so a package that would be refused here cannot be built + * there either. + */ +const boardManagerUrl = z.string().refine( + (value) => { + try { + return new URL(value).protocol === 'https:' + } catch { + return false + } + }, + { message: 'must be an https:// URL' }, +) + export const PackageManifestSchema = z .object({ formatVersion: z.string().min(1), @@ -44,9 +102,32 @@ export const PackageManifestSchema = z id: z.string().min(1), name: z.string().min(1), version: z.string().min(1), + // Compatibility floors (DOPE-448). Optional on purpose: packages built + // before these fields existed must keep installing, and a package that + // declares no floor declares no constraint. They are typed here rather + // than left to `.passthrough()` because the install gate compares them + // — a field a gate reads should not reach it as `unknown`. + // + // Authoring-side rules (minRuntimeVersion required iff a device targets + // runtime-v4, rejected otherwise) live in openplc-packages' + // `scripts/validate.ts`, per this file's split of responsibilities. + // The *format* is checked here because a floor nobody can parse is a + // constraint that silently does not apply — see `versionFloor`. + minEditorVersion: versionFloor.optional(), + minRuntimeVersion: versionFloor.optional(), }) .passthrough(), - devices: z.array(z.object({}).passthrough()).min(1), + devices: z + .array( + z + .object({ + // Only `boardManagerUrl` is declared; everything else on a device + // — and on `target` itself — still flows through untouched. + target: z.object({ boardManagerUrl: boardManagerUrl.optional() }).passthrough().optional(), + }) + .passthrough(), + ) + .min(1), }) .passthrough() @@ -69,3 +150,100 @@ export function parsePackageManifest(value: unknown): PackageManifest | null { // validation in openplc-packages for the deeper fields. return parsed.data as unknown as PackageManifest } + +/** The manifest fields `versionFloor` guards, as read by the load path. */ +const FLOOR_FIELDS: readonly string[] = ['minEditorVersion', 'minRuntimeVersion'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A floor that is absent constrains nothing; one that is present must be comparable. */ +function isUsableFloor(value: unknown): boolean { + return value === undefined || (typeof value === 'string' && isValidVersion(value)) +} + +/** + * Return `value` with any compatibility floor this codebase cannot + * compare removed, logging each one. Anything else is passed through + * untouched, including a shape that is not a manifest at all — deciding + * that is the schema's job, not this function's. + */ +function withComparableFloorsOnly(value: unknown): unknown { + if (!isRecord(value) || !isRecord(value.package)) return value + + const kept: Record = {} + let droppedAny = false + for (const [field, fieldValue] of Object.entries(value.package)) { + if (FLOOR_FIELDS.includes(field) && !isUsableFloor(fieldValue)) { + console.warn( + `[package-manifest] installed package declares an unreadable ${field} (${JSON.stringify(fieldValue)}); ` + + `ignoring it — the compatibility floor it intends cannot be enforced`, + ) + droppedAny = true + continue + } + kept[field] = fieldValue + } + + return droppedAny ? { ...value, package: kept } : value +} + +/** True for a board-manager URL this codebase is willing to pass to arduino-cli. */ +function isUsableBoardManagerUrl(value: unknown): boolean { + return value === undefined || (typeof value === 'string' && boardManagerUrl.safeParse(value).success) +} + +/** + * Return `value` with any board-manager URL we would refuse removed, logging + * each one. + * + * Same strict-at-entry / tolerant-on-read split as the floors above, and for + * the same reason: a package installed before this constraint existed must not + * have all of its boards disappear from the board lookup on an upgrade where + * the user did nothing. Dropping the field leaves the package exactly as + * capable as it was before the vendor-index feature existed — the core simply + * is not auto-installed — while the log keeps the cause visible. + */ +function withUsableBoardManagerUrlsOnly(value: unknown): unknown { + if (!isRecord(value) || !Array.isArray(value.devices)) return value + + let droppedAny = false + const devices = value.devices.map((device: unknown) => { + if (!isRecord(device) || !isRecord(device.target)) return device + if (isUsableBoardManagerUrl(device.target.boardManagerUrl)) return device + + console.warn( + `[package-manifest] installed device declares a board manager URL that is not https ` + + `(${JSON.stringify(device.target.boardManagerUrl)}); ignoring it — the vendor core it ` + + `points at will not be installed automatically`, + ) + droppedAny = true + const { boardManagerUrl: _dropped, ...target } = device.target + return { ...device, target } + }) + + return droppedAny ? { ...value, devices } : value +} + +/** + * Validate a manifest read back from a package that is ALREADY + * INSTALLED, dropping a floor this codebase cannot compare rather than + * rejecting the whole document. + * + * Strict where the artefact enters, tolerant where we are only reading + * what is already on disk. `importFromFile` refuses an unreadable floor + * — that is the boundary, and refusing there is what makes the promise + * in `docs/package-format.md` true. But a package installed BEFORE that + * boundary existed can carry such a floor, and rejecting its manifest on + * load would make the boards it provides disappear from the board lookup + * with no message, on an upgrade where the user did nothing. + * + * Dropping the field leaves the package exactly as unconstrained as it + * already was — an unreadable floor never gated anything (see + * `isVersionAtLeast`) — while the log keeps the cause visible instead of + * silently trading one invisible outcome for another. + */ +export function parseInstalledPackageManifest(value: unknown): PackageManifest | null { + return parsePackageManifest(withUsableBoardManagerUrlsOnly(withComparableFloorsOnly(value))) +} diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index 95bd82bd4..c943abc95 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -104,6 +104,17 @@ export interface PlatformCapabilities { /** True if the app supports EtherCAT device configuration and ESI repository. */ hasEthercat: boolean + // --- Debugging --- + + /** + * Polling interval (ms) for the debugger's HTTP fallback transport (used + * when WebRTC is unavailable). Each poll is a full proxied round-trip, so + * this is deployment-tunable rather than a fixed constant — e.g. + * autonomy-node runs no WebRTC signaling relay and wants this to match + * its general-purpose poll rate. + */ + debugRelayPollIntervalMs: number + // --- Environment --- /** True when running in a development build (Vite DEV / webpack development mode). */ @@ -139,6 +150,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: false, hasPackageManager: true, hasEthercat: true, + debugRelayPollIntervalMs: 1000, isDevMode: false, } @@ -182,5 +194,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: true, hasPackageManager: false, hasEthercat: false, + debugRelayPollIntervalMs: 1000, isDevMode: false, } diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index b52eecca3..1925497ad 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -53,6 +53,9 @@ export interface ProjectResponse { devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** `datatypes/*.dt` files that failed to parse on load, preserved + * raw so the save flow echoes them back verbatim. */ + unparsedDataTypeFiles?: RawProjectFile[] /** * Raw file contents as returned by the backend (path → text), captured * before parsing. Used by the save flow to upload byte-identical content @@ -122,6 +125,10 @@ export interface WriteProjectFiles { serverFiles: RawProjectFile[] /** Remote device config files with pre-serialized JSON content */ remoteDeviceFiles: RawProjectFile[] + /** Data type files (`datatypes/.dt`) with pre-serialized ST + * `TYPE…END_TYPE` content, one declaration per file. Empty until + * the `.dt` write path is switched on (DOPE-533). */ + dataTypeFiles: RawProjectFile[] /** Relative paths to delete from disk (e.g. 'pous/programs/OldPou.st') */ deletions: string[] } @@ -172,6 +179,9 @@ export interface RawProjectFiles { serverFiles: RawProjectFile[] /** Raw remote device config files from devices/remote/ */ remoteDeviceFiles: RawProjectFile[] + /** Raw data type files from datatypes/ (`.dt`, one ST `TYPE…END_TYPE` + * declaration each). Empty on projects that predate the format. */ + dataTypeFiles: RawProjectFile[] /** See {@link ProjectResponse.data.canEdit}. Carried through the * raw layer so adapters that build `ProjectResponse` from a raw * fetch don't have to round-trip the details endpoint twice. */ diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 12d3e51b1..dd021eeaf 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -116,6 +116,11 @@ export interface RuntimeStatusResult { success: boolean status?: PlcStatus | (string & {}) timingStats?: TimingStats + /** Run/stop mode-switch position reported by the runtime (`'run'` / + * `'stop'`). Devices with no switch-aware VPP plugin always report + * `'run'`, and runtimes older than this field omit it entirely — treat + * `undefined` as "no gating". */ + switchPosition?: 'run' | 'stop' error?: string } diff --git a/src/middleware/shared/ports/system-port.ts b/src/middleware/shared/ports/system-port.ts index d1eeae688..f1f4010f2 100644 --- a/src/middleware/shared/ports/system-port.ts +++ b/src/middleware/shared/ports/system-port.ts @@ -51,4 +51,19 @@ export interface SystemPort { * Web: logs via console or remote logging service. */ log(level: 'info' | 'error', message: string): void + + /** + * Absolute base URL of the Autonomy Edge **web app** — the host that serves + * user-facing pages such as `/buy`. NOT the API host: those are different + * origins (`edge.autonomylogic.com` vs `api.autonomylogic.com`), and deriving + * one from the other by string surgery would break the moment either moves. + * + * A port and not a shared constant because each platform resolves it + * differently, and both need to be pointable at a local Edge instance for + * end-to-end testing — which a hardcoded production host in shared UI cannot be. + * + * Editor: build-time env override (`OPENPLC_EDGE_WEB_URL`) over the production + * default. Web: its own env var (may be a same-origin prefix). + */ + getEdgeFrontendUrl(): string } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index f795de683..491232510 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -627,6 +627,18 @@ export interface BoardInfo { * declare it. */ platformOptions?: PlatformOption[] + /** + * Hardware serial ports this board exposes (e.g. `['Serial', 'Serial1']`), + * mirrored from the VPP manifest device's `serialPorts`. Consumed by VPP + * screen `select` fields via `optionsRef: 'board.serialPorts'` (the Modbus + * RTU port picker) and by the always-on serial/debugger. Absent → the editor + * assumes a single `Serial`. + */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port) where the + * debugger runs. Mirrors the manifest device's `defaultSerial`. Absent → + * `Serial`. */ + defaultSerial?: string /** * Declarative debug-channel resolver spec carried through from the * source catalog (hals.json or VPP manifest). Consumed by @@ -718,7 +730,19 @@ export interface PackageManifest { } description: string license?: string + /** + * Oldest editor that may install this package. The install gate refuses a + * package whose floor is above `APP_VERSION` — this is how a package + * requires an editor feature it cannot work without (DOPE-448). + */ minEditorVersion?: string + /** + * Oldest runtime this package works with. Declared only by packages with a + * `runtime-v4` target, whose plugin code executes inside the runtime + * process. Checked at compile time, not install time: the target device is + * unknown until the user connects to one. + */ + minRuntimeVersion?: string } devices: Array<{ id: string @@ -767,6 +791,29 @@ export interface PackageManifest { * integration layer (hal.source). */ precompiledLibrary?: string + /** + * On-device license-storage backend: source file(s) injected into the + * Baremetal sketch and recompiled against the editor's `license_blob.h`. + * + * PRESENCE is the signal — it is what resolves + * `TargetCapabilities.licenseStore` and what drives the backend into the + * build, so one declaration serves both and they cannot disagree. Absence + * means the board answers `LIC_UNSUPPORTED` and the licensing flow reports + * that instead of writing. + */ + licenseStore?: string | string[] + /** + * Per-VPP signing key id (e.g. `espressif-licensed-2026`). Names which key + * the backend/KMS signs this VPP's licenses with and which public key the + * closed artifact embeds. + * + * INFORMATIONAL in the editor: the activation request sends only + * `{ deviceId, packageId }` and the backend resolves its own signing key + * from the package id. The real trust root is the public key compiled into + * the VPP, not this string — it is carried for the build side and for + * diagnosing "the board stored the blob and still runs demo". + */ + licenseKeyId?: string compilerFlags?: { c_flags?: string[] cxx_flags?: string[] @@ -786,6 +833,13 @@ export interface PackageManifest { } } screens?: Record + /** Hardware serial ports this device exposes (e.g. `['Serial', 'Serial1']`). + * Surfaced onto `BoardInfo.serialPorts` and consumed by VPP screen + * `select` fields via `optionsRef: 'board.serialPorts'`. */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port). Surfaced onto + * `BoardInfo.defaultSerial`. Absent → `Serial`. */ + defaultSerial?: string /** Declarative debug-channel resolver spec, consumed by * `backend/shared/hardware/debug-spec.ts`. Same shape as * the `debug` field on built-in hals.json entries — the @@ -896,9 +950,25 @@ export interface VendorIoMapping { entries: IoMappingEntry[] } +/** + * A serial port offered in the communication-port picker. + * + * Deliberately NOT a pre-composed display string. The producer reports facts + * and the renderer decides how they read (`serialPortDisplay`) — conflating the + * two is what let the board name get dropped: the label had to guess whether + * `name` held a bare manufacturer or an already-composed `"COM5 (Arduino Uno)"`. + */ export interface CommunicationPort { - name: string + /** OS-canonical port identifier, and the value actually opened: `COM5` on + * Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.usbmodem*` on macOS. Always the + * primary label — never replaced by a descriptor. */ address: string + /** Board name identified by arduino-cli from the connected core's VID/PID + * (e.g. `Arduino MKR`). Absent when no core matched the device. */ + boardName?: string + /** Manufacturer / vendor string from `serialport` (e.g. `wch.cn` for a + * CH340). The fallback descriptor when arduino-cli identified no board. */ + manufacturer?: string } export interface SerialPort { @@ -1018,8 +1088,40 @@ export interface RuntimeLogEntry { // Debugger // --------------------------------------------------------------------------- +/** + * A channel kind a board's `debug` spec can declare. This is the SPEC's + * vocabulary — what a package author writes — not necessarily what a live + * session ends up riding. See `DebugMedium` for that. + */ export type DebugConnectionType = 'tcp' | 'rtu' | 'websocket' | 'simulator' +/** + * What a live debug session actually rides — the one fact the connection manager + * publishes and the debug poller consumes. + * + * Wider than `DebugConnectionType` because the browser reaches a runtime two ways + * that no spec distinguishes, and they behave differently enough that the poller + * must tell them apart: + * + * `webrtc` a data channel straight to the orchestrator agent, which relays + * to the runtime's debug socket. + * `http-relay` the same request, hop by hop: browser -> Autonomy Edge -> + * agent (over its always-on websocket) -> runtime. The fallback + * when a data channel cannot be opened. + * + * Both terminate at the SAME endpoint on the device, so they carry the same frame + * budget and differ only in latency — which is exactly the split + * `DEBUG_MEDIUM_PROFILE` encodes. + */ +export type DebugMedium = DebugConnectionType | 'webrtc' | 'http-relay' + +/** + * Media that can carry a CONTROL channel — one the connection manager physically + * holds open and polls. Narrower than `DebugMedium` on purpose: a REST-controlled + * runtime holds nothing, and the browser's media are debug-only. + */ +export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' + export interface DebugConnectionConfig { connectionType: DebugConnectionType connectionParams: { @@ -1208,6 +1310,16 @@ export function isV3Logs(logs: PlcLogs): logs is string { // Console // --------------------------------------------------------------------------- +/** + * A run of log text sharing one style, produced by the SGR colour a tool + * emitted (see `frontend/utils/terminal-output`). `className` is a Tailwind + * class string, absent when the run is unstyled. + */ +export interface LogSegment { + text: string + className?: string +} + export interface LogObject { id: string level?: 'debug' | 'info' | 'warning' | 'error' @@ -1220,6 +1332,20 @@ export interface LogObject { * progress / informational logs leave this undefined. */ compileError?: StructuredCompileError + /** + * Styled runs, set only when the source emitted SGR colour. `message` + * always holds the same text with the escapes stripped, so search, + * filtering and copy stay on clean text and only the renderer needs to + * know about colour. + */ + segments?: LogSegment[] + /** + * True while this entry is an in-place line a terminal would still be + * overwriting — a progress redraw not yet terminated by a newline. The + * next redraw replaces it instead of appending; a newline clears the flag + * and the line becomes permanent. + */ + transient?: boolean } // --------------------------------------------------------------------------- diff --git a/src/middleware/shared/utils/debug-endpoint.ts b/src/middleware/shared/utils/debug-endpoint.ts new file mode 100644 index 000000000..1916adc06 --- /dev/null +++ b/src/middleware/shared/utils/debug-endpoint.ts @@ -0,0 +1,15 @@ +import type { DebugConnectionConfig } from '../ports/types' + +/** + * How a connection endpoint reads to a user: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. + * + * Shared between the main process (which labels the connection it holds) and the + * renderer (which names endpoints in dialogs), so "could not reach X" and the + * status bar always spell X the same way. + */ +export function describeDebugEndpoint(config: DebugConnectionConfig): string { + if (config.connectionType === 'tcp') return String(config.connectionParams.ipAddress ?? 'the configured IP address') + if (config.connectionType === 'simulator') return 'simulator' + return String(config.connectionParams.port ?? 'the selected port') +} diff --git a/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts b/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts new file mode 100644 index 000000000..c2c05c416 --- /dev/null +++ b/src/middleware/shared/utils/licensing/__tests__/resolve-licensing-target.test.ts @@ -0,0 +1,93 @@ +import type { BoardInfo, VppMetadata } from '../../../ports/types' +import { resolveLicensingTarget } from '../resolve-licensing-target' + +/** A minimally-valid BoardInfo; each test overrides only what it is about. */ +function board(overrides: Partial = {}): BoardInfo { + return { + compiler: 'arduino-cli', + core: 'esp32:esp32', + preview: '', + specs: {}, + ...overrides, + } +} + +/** VPP metadata for a package id; the rest is inert filler this unit ignores. */ +function vpp(packageId: string): VppMetadata { + return { + packageId, + vendor: 'Espressif', + deviceId: 'esp32-generic', + packagePath: '/packages/espressif', + screens: {}, + moduleSystem: { enabled: false, maxSlots: 0, modules: [] }, + } +} + +const LICENSED_VPP = vpp('com.openplc.espressif-licensed') + +describe('resolveLicensingTarget', () => { + it('reports a licensable VPP with just its package id', () => { + const target = resolveLicensingTarget(board({ vpp: LICENSED_VPP, capabilities: { isLicensable: true } })) + + // Deliberately only the package id. There is no "can this board store a + // licence" companion: every licensable VPP targets hardware that persists + // one, so the answer would be constant, and the DEVICE is what gets asked. + expect(target).toEqual({ licensable: true, packageId: 'com.openplc.espressif-licensed' }) + }) + + it('is not licensable for a VPP that does not declare it', () => { + const target = resolveLicensingTarget(board({ vpp: vpp('com.openplc.espressif') })) + + expect(target).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('is not licensable for a plain non-VPP board', () => { + // The common case: every built-in hals.json board, and the reason this + // function has to be cheap — a connect here must carry no licensing traffic. + expect(resolveLicensingTarget(board())).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('is not licensable for the simulator', () => { + expect(resolveLicensingTarget(board({ compiler: 'simulator' }))).toEqual({ + licensable: false, + reason: 'not-licensable', + }) + }) + + it('is not licensable for runtime v4', () => { + expect(resolveLicensingTarget(board({ compiler: 'openplc-compiler' }))).toEqual({ + licensable: false, + reason: 'not-licensable', + }) + }) + + it('never treats an explicit isLicensable:false as licensable, even on a VPP board', () => { + const target = resolveLicensingTarget(board({ vpp: LICENSED_VPP, capabilities: { isLicensable: false } })) + + expect(target).toEqual({ licensable: false, reason: 'not-licensable' }) + }) + + it('reports a broken manifest distinctly when isLicensable is set with no package id', () => { + // Distinct from `not-licensable` on purpose: this is a manifest someone has + // to fix, not a product that is simply free. Treating it as licensable would + // mean calling activate with nothing to identify the product, then telling the + // user to buy something the editor cannot name. + const target = resolveLicensingTarget(board({ capabilities: { isLicensable: true } })) + + expect(target).toEqual({ licensable: false, reason: 'no-package-id' }) + }) + + it('treats a blank package id as missing rather than as an id', () => { + const target = resolveLicensingTarget( + board({ vpp: { ...LICENSED_VPP, packageId: ' ' }, capabilities: { isLicensable: true } }), + ) + + expect(target).toEqual({ licensable: false, reason: 'no-package-id' }) + }) + + it('handles an absent board without a null dance at the call site', () => { + expect(resolveLicensingTarget(null)).toEqual({ licensable: false, reason: 'not-licensable' }) + expect(resolveLicensingTarget(undefined)).toEqual({ licensable: false, reason: 'not-licensable' }) + }) +}) diff --git a/src/middleware/shared/utils/licensing/index.ts b/src/middleware/shared/utils/licensing/index.ts new file mode 100644 index 000000000..08ca4ff6d --- /dev/null +++ b/src/middleware/shared/utils/licensing/index.ts @@ -0,0 +1 @@ +export { type LicensingSkipReason, type LicensingTarget, resolveLicensingTarget } from './resolve-licensing-target' diff --git a/src/middleware/shared/utils/licensing/resolve-licensing-target.ts b/src/middleware/shared/utils/licensing/resolve-licensing-target.ts new file mode 100644 index 000000000..1ed791be0 --- /dev/null +++ b/src/middleware/shared/utils/licensing/resolve-licensing-target.ts @@ -0,0 +1,61 @@ +/** + * THE first node of the VPP licensing flow: does this board participate at all? + * + * Everything downstream — the extra Modbus round trips, the backend call, the + * badge, the demo prompt — hangs off this one answer. When it is "no", a connect + * is an ordinary connect: no license FCs, no HTTP, nothing on screen. That is + * the common case (every built-in board, plain Runtime v3/v4, Arduino, the + * Simulator), so it has to be the cheap, obvious path rather than a branch buried + * three layers into an activation routine. + * + * Pure function over the already-resolved capability block plus the board's VPP + * metadata — no IPC, no filesystem. Lives on the byte-identical shared surface + * because the renderer asks the same question the main process does: the badge + * must not appear on a board the flow will not run for. + */ + +import type { BoardInfo } from '../../ports/types' +import { resolveTargetCapabilities } from '../target-capabilities' + +/** + * Why the licensing flow will not run for a board. Kept as distinct reasons + * rather than a bare `false` because they are NOT interchangeable to a human: + * "this product is not sold licensed" is normal, while "declared licensable with + * no package id" is a broken manifest that someone has to fix. + */ +export type LicensingSkipReason = 'not-licensable' | 'no-package-id' + +export type LicensingTarget = + | { + licensable: true + /** Reverse-domain VPP package id (`package.id`) — the only VPP identifier + * the activation wire accepts, and the one the purchase page resolves. */ + packageId: string + } + | { licensable: false; reason: LicensingSkipReason } + +/** + * Decide whether the licensing flow applies to a board, and gather the two facts + * it needs if so. + * + * `boardInfo` is optional so callers can hand through a not-yet-resolved board + * without a null dance; absent board info is simply not licensable. + * + * A board whose manifest declares `isLicensable: true` but carries no + * `vpp.packageId` resolves to `no-package-id` rather than being treated as + * licensable. Proceeding without a package id would mean calling `/activate` + * with nothing to identify the product, or worse, deciding "no license" from a + * question that was never asked — and then telling the user to buy something the + * editor cannot name. Only a malformed or hand-edited manifest reaches this. + */ +export function resolveLicensingTarget(boardInfo: BoardInfo | null | undefined): LicensingTarget { + if (!boardInfo) return { licensable: false, reason: 'not-licensable' } + + const capabilities = resolveTargetCapabilities(boardInfo) + if (!capabilities.isLicensable) return { licensable: false, reason: 'not-licensable' } + + const packageId = boardInfo.vpp?.packageId?.trim() + if (!packageId) return { licensable: false, reason: 'no-package-id' } + + return { licensable: true, packageId } +} diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 7fe88efb1..bb63ad0b3 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -30,7 +30,13 @@ export const SIMULATOR_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: true, + plcStateControl: false, directUsbUpload: true, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { @@ -46,7 +52,18 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + // v3 exposes the SAME run/stop REST API as v4 (`/api/start-plc`, + // `/api/stop-plc`, JWT-authenticated) — only the debug channel differs + // (v3: Modbus TCP, v4: WebSocket). The main process already routes the + // command over REST for both, so the only thing that ever stopped v3 + // was this flag. + plcStateControl: true, directUsbUpload: false, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { @@ -64,7 +81,13 @@ export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: true, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: false, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { @@ -83,5 +106,11 @@ export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: true, + // Licensing is never a property of a TARGET FAMILY: a VPP is what is + // sold, so only a VPP manifest can turn this on. Every preset leaves it + // off, and a board that does not declare it gets an ordinary connect + // with no licensing traffic at all. + isLicensable: false, } diff --git a/src/middleware/shared/utils/target-capabilities/resolve.ts b/src/middleware/shared/utils/target-capabilities/resolve.ts index 186e1e370..af1a8c52a 100644 --- a/src/middleware/shared/utils/target-capabilities/resolve.ts +++ b/src/middleware/shared/utils/target-capabilities/resolve.ts @@ -49,7 +49,9 @@ const EMPTY_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: false, directUsbUpload: false, + isLicensable: false, } /** @@ -89,7 +91,8 @@ function inferFromCompiler(boardInfo: BoardInfoLike): TargetCapabilities { * 1. If `boardInfo.capabilities` is present, it's authoritative. * Missing fields are filled in from the matching preset (compiler * + vpp hint), so a manifest can declare only the overrides it - * cares about (e.g. SLM-RP4 just sets `vppIo: true`). + * cares about (e.g. SLM-RP4 just sets `vppIo: true`; a licensed VPP + * sets `isLicensable: true`). * 2. Otherwise, the preset matching the legacy `compiler` field. * 3. Otherwise, an empty (everything-disabled) block. * diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index d88f88954..6eda935e3 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -93,9 +93,48 @@ export interface TargetCapabilities { * whether the host *can* run a simulator. */ isInProcessSimulator: boolean + /** Target implements the runtime run/stop state machine, so the + * Start/Stop control is meaningful. Runtime v3 AND v4 drive it over + * the same REST API (`/api/start-plc`, `/api/stop-plc`, both + * JWT-authenticated); arduino-cli targets drive it over the device + * connection (Modbus FC 0x4b). Only the Simulator is excluded, and + * only because it keeps its dedicated start/stop path. + * + * Runtime v3 having its own web UI is NOT a reason to exclude it: the + * editor's REST access is unaffected by that, the editor has shipped + * this button working against v3, and gating it off here made Start / + * Stop a silent no-op on a target where it had always worked. */ + plcStateControl: boolean + /** Upload happens over a local connection (USB / loopback) and * doesn't require a separate "Connect" step. Arduino-CLI + the * in-process Simulator. Runtime v3 / v4 require an established * network connection. */ directUsbUpload: boolean + + /** The selected board's VPP is sold as a licensed product, so the + * licensing flow runs for it. Flows verbatim from the VPP manifest's + * `device.capabilities.isLicensable`, exactly like `vppIo`. + * + * This is THE gate on the whole flow, and the reason it is a + * capability rather than an inference: when it is `false` a connect is + * an ordinary connect — no anchor read beyond the usual + * classification, no license FCs, no backend call. Every board that + * does not declare it is `false`, which is every built-in hals.json + * board, plain Runtime v3/v4, Linux, Arduino, and the Simulator. + * + * There is deliberately NO companion "can this board store a licence" + * capability. Every licensable VPP targets hardware that persists a + * licence across a reboot — that is a product rule, not something a + * manifest gets to vary — so the answer would be `true` wherever + * `isLicensable` is true and irrelevant everywhere else. What the + * build actually needs is the storage SOURCE, which travels as + * `BoardBuildInfo.licenseStoreFiles`; a second derived boolean on top + * of it bought one diagnostic sentence and one more way for two + * representations of one fact to disagree. + * + * A licensable board that answers `LIC_UNSUPPORTED` on the wire is + * therefore a FIRMWARE fault (built without the backend), never a + * hardware limitation — and the flow says exactly that. */ + isLicensable: boolean }