FOCAS — retire Tier-C split, inline managed wire client, make read-only
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# FOCAS Docker simulator — focas-mock + shim DLL
|
||||
|
||||
Hardware-free FOCAS fixture for OtOpcUa's integration test matrix. Runs
|
||||
the vendored [`focas-mock`](focas-mock/VENDORED.md) Python server under
|
||||
Docker and pairs it with the [shim DLL](../Shim/VENDORED.md) that
|
||||
masquerades as `Fwlib64.dll` inside the .NET test process.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────┐ cnc_allclibhndl3 / cnc_rdparam / ...
|
||||
│ xunit test process │ (P/Invoke, __stdcall)
|
||||
│ ├── Driver.FOCAS │
|
||||
│ │ └── FwlibNative.cs ─┼─┐
|
||||
│ └── FocasSimFixture │ │ resolves to...
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ Fwlib64.dll (shim) │ JSON over TCP
|
||||
│ tests/.../Shim/focas_ │──────────────────────┐
|
||||
│ shim.c compiled here │ │
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ focas-mock (Docker) │
|
||||
│ python:3.11-slim │
|
||||
│ profile-aware responses │
|
||||
│ mock_load_profile / │
|
||||
│ mock_patch admin methods │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
The shim bridges the binary ABI (C `__stdcall` exports with FOCAS struct
|
||||
shapes) to the mock's newline-delimited JSON protocol. OtOpcUa's
|
||||
`FocasSimFixture` seeds per-test state by sending `mock_load_profile` +
|
||||
`mock_patch` admin calls on the same socket. Tests assert the managed
|
||||
driver sees the seeded values through its normal P/Invoke path.
|
||||
|
||||
## Running
|
||||
|
||||
Pick one compose profile (they all publish 8193 — only one at a time):
|
||||
|
||||
```powershell
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone up -d
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone down
|
||||
```
|
||||
|
||||
Available profiles + their focas-mock target:
|
||||
|
||||
| compose --profile | focas-mock profile | Covers |
|
||||
|---|---|---|
|
||||
| `thirtyone` / `thirty` / `thirtytwo` | `fwlib30i64` | 30i / 31i / 32i series |
|
||||
| `sixteen` | `FWLIB64` | 16i / 18i / 21i legacy family |
|
||||
| `zerod` / `zerof` / `zeromf` / `zerotf` | `fwlib0iD64` | 0i-D / 0i-F / 0i-MF / 0i-TF |
|
||||
| `powermotion` | `fwlib0DN64` | Power Motion i |
|
||||
| `ethernet` | `fwlibe64` | Ethernet-variant DLL |
|
||||
| `ncguide` | `fwlibNCG64` | NC Guide PC simulator |
|
||||
|
||||
## What this covers — and what it doesn't
|
||||
|
||||
**Covered:**
|
||||
|
||||
- All 10 FOCAS functions `FwlibNative.cs` P/Invokes
|
||||
- Read-after-write round-trip for parameters, macros, PMC ranges
|
||||
- PMC bit read-modify-write path (via the `pmc_wrpmcrng` seam)
|
||||
- `IAlarmSource` raise + clear transitions (via `mock_schedule_alarms`)
|
||||
- Per-series profile selection — tests can pin one and assert series-gated
|
||||
behaviour
|
||||
|
||||
**Not covered** (still hardware-gated):
|
||||
|
||||
- Real FOCAS2 TCP wire protocol (this is a JSON mock; the shim hides
|
||||
the real protocol entirely)
|
||||
- CNC-specific firmware quirks (position scaling across power cycles,
|
||||
edit-mode session locks, MTB custom screens)
|
||||
- Concurrent-read behaviour on the real `Fwlib64.dll` — the shim is
|
||||
single-threaded per connection
|
||||
|
||||
See [`docs/drivers/FOCAS-Test-Fixture.md`](../../../docs/drivers/FOCAS-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## Skip behaviour
|
||||
|
||||
`FocasSimFixture` probes the mock at collection init time:
|
||||
|
||||
- Mock unreachable → tests skip with the compose-up command to run
|
||||
- Mock reachable but shim DLL not loaded → tests skip with a pointer
|
||||
at `Shim/build.ps1`
|
||||
- Both available → tests run
|
||||
|
||||
This lets the same test assembly be green on a fresh CI box without
|
||||
docker, green on a dev box with just the docker compose up, and
|
||||
exercise the full wire path when the shim is built.
|
||||
@@ -0,0 +1,34 @@
|
||||
# FOCAS simulator — focas-mock JSON/TCP + native FOCAS2 Ethernet server.
|
||||
#
|
||||
# The image is built from the vendored focas-mock snapshot at ./focas-mock/
|
||||
# (see focas-mock/VENDORED.md for refresh procedure).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f Docker/docker-compose.yml up -d --wait
|
||||
# docker compose -f Docker/docker-compose.yml down
|
||||
#
|
||||
# One service, one container — the mock's native FOCAS Ethernet responder
|
||||
# auto-detects the binary PDU prefix (`a0 a0 a0 a0`) on the same TCP port
|
||||
# that serves JSON admin commands. Tests that need per-series behaviour
|
||||
# call `mock_load_profile` via the fixture's admin API at test start.
|
||||
# The pre-wire-client era had one compose profile per CNC series; that
|
||||
# ceremony is gone because the managed wire client doesn't depend on a
|
||||
# per-series shim DLL.
|
||||
|
||||
services:
|
||||
focas-sim:
|
||||
image: otopcua-focas-sim:latest
|
||||
build:
|
||||
context: ./focas-mock
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-focas-sim
|
||||
ports:
|
||||
- "8193:8193"
|
||||
restart: "no"
|
||||
command: ["--profile", "FWLIB64"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('127.0.0.1',8193),timeout=2); s.close()\" || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8193
|
||||
|
||||
ENTRYPOINT ["focas-mock", "serve", "--host", "0.0.0.0", "--port", "8193"]
|
||||
CMD ["--profile", "FWLIB64"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,191 @@
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, exposes a JSON-over-TCP mock API, and implements the targeted native FOCAS Ethernet wire protocol used by OtOpcUa fixed-tree tests.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
- auto-detected native FOCAS Ethernet PDU handling for the targeted API subset
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server accepts two protocols on the same port:
|
||||
|
||||
- newline-delimited JSON for fixture control and shim tests
|
||||
- native FOCAS Ethernet binary PDUs from the real `fwlibe64.dll`
|
||||
|
||||
JSON requests are one object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
- `mock_schedule_alarms`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Native FOCAS Ethernet clients do not use the JSON request format. Seed profile
|
||||
and fixture state with JSON first, then point `cnc_allclibhndl3` at the same
|
||||
host and port. Wire-level details are documented in
|
||||
`docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
|
||||
For clients that should avoid FANUC DLL loading entirely, `dotnet/Focas.Wire`
|
||||
contains a native C# read-only TCP client for the verified wire subset. It does
|
||||
not expose write APIs; use the JSON control channel to preset fixture state.
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
Native Ethernet wire notes are in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- Native FOCAS Ethernet support is intentionally scoped to the targeted API subset documented in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
@@ -0,0 +1,45 @@
|
||||
# focas-mock — vendored snapshot
|
||||
|
||||
Source: `C:\Users\dohertj2\Desktop\focas` (sibling project in this dev environment).
|
||||
|
||||
**Snapshot date:** 2026-04-24 (second refresh — pulled the native FOCAS2 Ethernet responder work in).
|
||||
|
||||
## Why vendored
|
||||
|
||||
OtOpcUa's FOCAS integration fixture runs against the Python mock server.
|
||||
The upstream lives in its own repo; this directory is a verbatim
|
||||
snapshot so CI can build the Docker image without network access to the
|
||||
source repo and so OtOpcUa's test matrix pins a known-good revision.
|
||||
|
||||
The managed `WireFocasClient` speaks the mock's native FOCAS2 Ethernet
|
||||
binary protocol directly — there's no longer a companion shim DLL.
|
||||
|
||||
## What's here
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/focas_mock/` | Python package — TCP JSON/line-delimited mock server with 6 Fanuc CNC profiles |
|
||||
| `pyproject.toml` | Package metadata; installs `focas-mock` CLI |
|
||||
| `Dockerfile` | `python:3.11-slim` image built by the parent `docker-compose.yml` |
|
||||
| `README.md` | Upstream README |
|
||||
| `LICENSE` | MIT — permissive, vendoring allowed |
|
||||
|
||||
|
||||
## Refreshing the snapshot
|
||||
|
||||
When upstream ships changes worth pulling:
|
||||
|
||||
```powershell
|
||||
$src = "C:\Users\dohertj2\Desktop\focas"
|
||||
$dest = "$PWD"
|
||||
Remove-Item -Recurse -Force "$dest\src" 2>$null
|
||||
Copy-Item -Recurse "$src\src" "$dest\src"
|
||||
Copy-Item "$src\pyproject.toml" "$dest\"
|
||||
Copy-Item "$src\README.md" "$dest\"
|
||||
Copy-Item "$src\LICENSE" "$dest\"
|
||||
Copy-Item "$src\Dockerfile" "$dest\"
|
||||
```
|
||||
|
||||
Update the snapshot date at the top of this file afterward. No other
|
||||
files belong here — the Docker build context is just the Python package
|
||||
and its metadata.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focas-mock"
|
||||
version = "0.1.0"
|
||||
description = "Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
dependencies = ["pefile>=2024.8.26"]
|
||||
|
||||
[project.scripts]
|
||||
focas-mock = "focas_mock.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
focas_mock = ["builtin_profiles/*.json"]
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: focas-mock
|
||||
Version: 0.1.0
|
||||
Summary: Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports.
|
||||
License-Expression: MIT
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: pefile>=2024.8.26
|
||||
Dynamic: license-file
|
||||
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, and exposes a JSON-over-TCP mock API whose method names match common FOCAS entry points such as `cnc_allclibhndl3`, `cnc_sysinfo`, `cnc_statinfo`, and `cnc_rddynamic2`.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server speaks newline-delimited JSON. Each request is one JSON object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- This is not a reverse-engineered implementation of FANUC's wire protocol.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
LICENSE
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/focas_mock/__init__.py
|
||||
src/focas_mock/cli.py
|
||||
src/focas_mock/constants.py
|
||||
src/focas_mock/data_store.py
|
||||
src/focas_mock/defaults.py
|
||||
src/focas_mock/export_introspection.py
|
||||
src/focas_mock/profiles.py
|
||||
src/focas_mock/server.py
|
||||
src/focas_mock.egg-info/PKG-INFO
|
||||
src/focas_mock.egg-info/SOURCES.txt
|
||||
src/focas_mock.egg-info/dependency_links.txt
|
||||
src/focas_mock.egg-info/entry_points.txt
|
||||
src/focas_mock.egg-info/requires.txt
|
||||
src/focas_mock.egg-info/top_level.txt
|
||||
src/focas_mock/builtin_profiles/FWLIB64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0DN64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0iD64.json
|
||||
src/focas_mock/builtin_profiles/fwlib30i64.json
|
||||
src/focas_mock/builtin_profiles/fwlibNCG64.json
|
||||
src/focas_mock/builtin_profiles/fwlibe64.json
|
||||
tests/test_profiles.py
|
||||
tests/test_server.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
focas-mock = focas_mock.cli:main
|
||||
+1
@@ -0,0 +1 @@
|
||||
pefile>=2024.8.26
|
||||
+1
@@ -0,0 +1 @@
|
||||
focas_mock
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]
|
||||
|
||||
+2677
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+1669
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .data_store import MockDataStore
|
||||
from .export_introspection import write_profiles
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
|
||||
def _default_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
root = _default_root()
|
||||
parser = argparse.ArgumentParser(prog="focas-mock")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
serve = sub.add_parser("serve", help="Start the mock server.")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8193)
|
||||
serve.add_argument("--profile", default="fwlib30i64")
|
||||
serve.add_argument("--data", help="Optional JSON patch file.")
|
||||
|
||||
sub.add_parser("list-profiles", help="List built-in profiles.")
|
||||
|
||||
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
|
||||
dump_profile.add_argument("profile")
|
||||
|
||||
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
|
||||
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
|
||||
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
|
||||
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
|
||||
return parser
|
||||
|
||||
|
||||
async def _run_server(args: argparse.Namespace) -> None:
|
||||
profile = load_profile(args.profile)
|
||||
store = MockDataStore(profile)
|
||||
if args.data:
|
||||
store.load_patch_file(args.data)
|
||||
server = FocasMockServer(args.host, args.port, profile, store)
|
||||
await server.start()
|
||||
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
|
||||
try:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "list-profiles":
|
||||
for profile in list_profiles():
|
||||
print(profile)
|
||||
return
|
||||
|
||||
if args.command == "dump-profile":
|
||||
print(json.dumps(load_profile(args.profile), indent=2))
|
||||
return
|
||||
|
||||
if args.command == "extract-profiles":
|
||||
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
|
||||
for path in written:
|
||||
print(path)
|
||||
return
|
||||
|
||||
if args.command == "serve":
|
||||
asyncio.run(_run_server(args))
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
EW_PROTOCOL = -17
|
||||
EW_SOCKET = -16
|
||||
EW_NODLL = -15
|
||||
EW_BUS = -11
|
||||
EW_SYSTEM2 = -10
|
||||
EW_HSSB = -9
|
||||
EW_HANDLE = -8
|
||||
EW_VERSION = -7
|
||||
EW_UNEXP = -6
|
||||
EW_SYSTEM = -5
|
||||
EW_PARITY = -4
|
||||
EW_MMCSYS = -3
|
||||
EW_RESET = -2
|
||||
EW_BUSY = -1
|
||||
EW_OK = 0
|
||||
EW_FUNC = 1
|
||||
EW_LENGTH = 2
|
||||
EW_NUMBER = 3
|
||||
EW_ATTRIB = 4
|
||||
EW_DATA = 5
|
||||
EW_NOOPT = 6
|
||||
EW_PROT = 7
|
||||
EW_OVRFLOW = 8
|
||||
EW_PARAM = 9
|
||||
EW_BUFFER = 10
|
||||
EW_PATH = 11
|
||||
EW_MODE = 12
|
||||
EW_REJECT = 13
|
||||
EW_DTSRVR = 14
|
||||
EW_ALARM = 15
|
||||
EW_STOP = 16
|
||||
EW_PASSWD = 17
|
||||
|
||||
RC_LABELS = {
|
||||
EW_PROTOCOL: "EW_PROTOCOL",
|
||||
EW_SOCKET: "EW_SOCKET",
|
||||
EW_NODLL: "EW_NODLL",
|
||||
EW_BUS: "EW_BUS",
|
||||
EW_SYSTEM2: "EW_SYSTEM2",
|
||||
EW_HSSB: "EW_HSSB",
|
||||
EW_HANDLE: "EW_HANDLE",
|
||||
EW_VERSION: "EW_VERSION",
|
||||
EW_UNEXP: "EW_UNEXP",
|
||||
EW_SYSTEM: "EW_SYSTEM",
|
||||
EW_PARITY: "EW_PARITY",
|
||||
EW_MMCSYS: "EW_MMCSYS",
|
||||
EW_RESET: "EW_RESET",
|
||||
EW_BUSY: "EW_BUSY",
|
||||
EW_OK: "EW_OK",
|
||||
EW_FUNC: "EW_FUNC",
|
||||
EW_LENGTH: "EW_LENGTH",
|
||||
EW_NUMBER: "EW_NUMBER",
|
||||
EW_ATTRIB: "EW_ATTRIB",
|
||||
EW_DATA: "EW_DATA",
|
||||
EW_NOOPT: "EW_NOOPT",
|
||||
EW_PROT: "EW_PROT",
|
||||
EW_OVRFLOW: "EW_OVRFLOW",
|
||||
EW_PARAM: "EW_PARAM",
|
||||
EW_BUFFER: "EW_BUFFER",
|
||||
EW_PATH: "EW_PATH",
|
||||
EW_MODE: "EW_MODE",
|
||||
EW_REJECT: "EW_REJECT",
|
||||
EW_DTSRVR: "EW_DTSRVR",
|
||||
EW_ALARM: "EW_ALARM",
|
||||
EW_STOP: "EW_STOP",
|
||||
EW_PASSWD: "EW_PASSWD",
|
||||
}
|
||||
|
||||
IMPLEMENTED_FOCAS_METHODS = [
|
||||
"cnc_allclibhndl",
|
||||
"cnc_allclibhndl2",
|
||||
"cnc_allclibhndl3",
|
||||
"cnc_freelibhndl",
|
||||
"cnc_sysinfo",
|
||||
"cnc_statinfo",
|
||||
"cnc_rddynamic2",
|
||||
"cnc_actf",
|
||||
"cnc_acts",
|
||||
"cnc_acts2",
|
||||
"cnc_getpath",
|
||||
"cnc_setpath",
|
||||
"cnc_rdaxisname",
|
||||
"cnc_rdspdlname",
|
||||
"cnc_rdparam",
|
||||
"cnc_wrparam",
|
||||
"cnc_rdmacro",
|
||||
"cnc_wrmacro",
|
||||
"cnc_rdalmmsg2",
|
||||
"pmc_rdpmcrng",
|
||||
"pmc_wrpmcrng",
|
||||
"cnc_rdopmsg",
|
||||
"cnc_rdopmode",
|
||||
"cnc_rdprgnum",
|
||||
"cnc_exeprgname2",
|
||||
"cnc_rdexecprog",
|
||||
"cnc_rdseqnum",
|
||||
"cnc_rdblkcount",
|
||||
"cnc_rdproginfo",
|
||||
"cnc_rdprogdir3",
|
||||
"cnc_rdtimer",
|
||||
"cnc_rdspmeter",
|
||||
"cnc_rdsvmeter",
|
||||
"cnc_rdspload",
|
||||
"cnc_rdspgear",
|
||||
"cnc_rdspmaxrpm",
|
||||
"cnc_rddiagnum",
|
||||
"cnc_rddiaginfo",
|
||||
"cnc_diagnoss",
|
||||
]
|
||||
|
||||
ADMIN_METHODS = [
|
||||
"mock_get_state",
|
||||
"mock_patch",
|
||||
"mock_reset",
|
||||
"mock_load_profile",
|
||||
"mock_list_methods",
|
||||
"mock_schedule_alarms",
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .defaults import make_default_state
|
||||
|
||||
|
||||
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
|
||||
_deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
return target
|
||||
|
||||
|
||||
class MockDataStore:
|
||||
def __init__(self, profile: Mapping[str, Any]) -> None:
|
||||
self.profile = dict(profile)
|
||||
self._defaults = make_default_state(profile)
|
||||
self._state = deepcopy(self._defaults)
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
return self._state
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def reset(self) -> dict[str, Any]:
|
||||
self._state = deepcopy(self._defaults)
|
||||
return self.snapshot()
|
||||
|
||||
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_deep_merge(self._state, patch)
|
||||
return self.snapshot()
|
||||
|
||||
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
|
||||
patch = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return self.merge_patch(patch)
|
||||
|
||||
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
|
||||
entry = self._state.get("forced_errors", {}).get(method)
|
||||
if not entry:
|
||||
return None
|
||||
if isinstance(entry, int):
|
||||
return entry, f"forced error for {method}"
|
||||
rc = int(entry.get("rc", 0))
|
||||
count = int(entry.get("count", 1))
|
||||
message = str(entry.get("message", f"forced error for {method}"))
|
||||
if count <= 1:
|
||||
self._state["forced_errors"].pop(method, None)
|
||||
else:
|
||||
entry["count"] = count - 1
|
||||
return rc, message
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
|
||||
lowered = profile_name.lower()
|
||||
if "30i" in lowered or "ncg" in lowered:
|
||||
return ("30i", 32, 2, 4)
|
||||
if "0id" in lowered or "0dn" in lowered:
|
||||
return ("0i-D", 24, 1, 2)
|
||||
if "fwlibe64" in lowered:
|
||||
return ("e64", 8, 1, 2)
|
||||
return ("generic", 8, 1, 2)
|
||||
|
||||
|
||||
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
|
||||
profile_name = str(profile.get("profile_name", "FWLIB64"))
|
||||
family, default_axes, max_path, max_spindles = _family_config(profile_name)
|
||||
max_axis = int(profile.get("max_axis_hint") or default_axes)
|
||||
axis_names = [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
]
|
||||
while len(axis_names) < max_axis:
|
||||
axis_names.append(f"A{len(axis_names) + 1}")
|
||||
|
||||
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
|
||||
programs = [
|
||||
{"number": 1, "comment": "MAIN", "length": 128},
|
||||
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
|
||||
]
|
||||
|
||||
return {
|
||||
"sysinfo": {
|
||||
"addinfo": 0,
|
||||
"max_axis": max_axis,
|
||||
"cnc_type": family[:2].ljust(2),
|
||||
"mt_type": "M ",
|
||||
"series": family[:4].ljust(4),
|
||||
"version": "A1.0",
|
||||
"axes": str(max_axis).rjust(2, "0"),
|
||||
},
|
||||
"statinfo": {
|
||||
"aut": 1,
|
||||
"run": 3,
|
||||
"motion": 1,
|
||||
"mstb": 0,
|
||||
"emergency": 0,
|
||||
"alarm": 0,
|
||||
"edit": 0,
|
||||
},
|
||||
"paths": {"current": 1, "max": max_path},
|
||||
"dynamic": {
|
||||
"alarm": 0,
|
||||
"prgnum": 1,
|
||||
"prgmnum": 1,
|
||||
"seqnum": 120,
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"axes": {
|
||||
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
|
||||
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
|
||||
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
|
||||
},
|
||||
},
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"acts2": [3200] + [0] * (max_spindles - 1),
|
||||
"axis_names": axis_names[:max_axis],
|
||||
"spindle_names": spindle_names,
|
||||
"parameters": {
|
||||
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
|
||||
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
|
||||
},
|
||||
"pmc": {
|
||||
"R": {
|
||||
"100": {"type": "byte", "value": 0},
|
||||
"101": {"type": "byte", "value": 1},
|
||||
"102": {"type": "byte", "value": 0},
|
||||
},
|
||||
},
|
||||
"macros": {
|
||||
"100": {"value": 12345, "decimal": 3},
|
||||
"101": {"value": 98765, "decimal": 3},
|
||||
},
|
||||
"alarms": [
|
||||
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
|
||||
],
|
||||
"operator_messages": [
|
||||
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
|
||||
],
|
||||
"program": {
|
||||
"current": 1,
|
||||
"main": 1,
|
||||
"sequence": 120,
|
||||
"block_count": 42,
|
||||
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
|
||||
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
|
||||
"directory": programs,
|
||||
},
|
||||
"spindle": {
|
||||
"meter": [
|
||||
{"name": spindle_names[0], "value": 56, "unit": "%"},
|
||||
{"name": spindle_names[1], "value": 0, "unit": "%"},
|
||||
],
|
||||
"servo_meter": [
|
||||
{"name": axis_names[0], "value": 14, "unit": "%"},
|
||||
{"name": axis_names[1], "value": 8, "unit": "%"},
|
||||
{"name": axis_names[2], "value": 5, "unit": "%"},
|
||||
],
|
||||
"load": [
|
||||
{"name": spindle_names[0], "load": 56, "speed": 3200},
|
||||
{"name": spindle_names[1], "load": 0, "speed": 0},
|
||||
],
|
||||
"gear": [1] * max_spindles,
|
||||
"max_rpm": [6000] * max_spindles,
|
||||
},
|
||||
"timers": {
|
||||
"power_on": 86400,
|
||||
"operating": 7200,
|
||||
"cutting": 3600,
|
||||
"cycle": 95,
|
||||
},
|
||||
"operation_mode": {"mode": 1, "name": "MEM"},
|
||||
"diagnostics": {
|
||||
"300": {"type": "long", "value": 14, "description": "servo load X"},
|
||||
"301": {"type": "long", "value": 8, "description": "servo load Y"},
|
||||
"302": {"type": "long", "value": 5, "description": "servo load Z"},
|
||||
},
|
||||
"forced_errors": {},
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .constants import IMPLEMENTED_FOCAS_METHODS
|
||||
|
||||
|
||||
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
|
||||
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
|
||||
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
|
||||
return sorted(set(imports))
|
||||
|
||||
|
||||
def extract_dll_exports(dll_path: str | Path) -> list[str]:
|
||||
import pefile
|
||||
|
||||
pe = pefile.PE(str(dll_path))
|
||||
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
|
||||
return sorted(set(exports))
|
||||
|
||||
|
||||
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
|
||||
lowered = dll_name.lower()
|
||||
if lowered == "fwlib64.dll":
|
||||
return ("generic-ethernet", 8, 1)
|
||||
if lowered == "fwlibe64.dll":
|
||||
return ("embedded-ethernet", 8, 1)
|
||||
if "30i" in lowered:
|
||||
return ("30i/31i/32i", 32, 2)
|
||||
if "ncg" in lowered:
|
||||
return ("ncguide-family", 32, 2)
|
||||
if "0id" in lowered:
|
||||
return ("0i-d-family", 24, 1)
|
||||
if "0dn" in lowered:
|
||||
return ("0-dn-family", 24, 1)
|
||||
return ("unknown", 8, 1)
|
||||
|
||||
|
||||
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
|
||||
dll_path = Path(dll_path)
|
||||
exports = extract_dll_exports(dll_path)
|
||||
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
|
||||
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
|
||||
export_set = set(exports)
|
||||
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
|
||||
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
|
||||
wrapper_supported = sorted(wrapper_imports & export_set)
|
||||
return {
|
||||
"profile_name": dll_path.stem,
|
||||
"dll_name": dll_path.name,
|
||||
"series_hint": series_hint,
|
||||
"max_axis_hint": max_axis_hint,
|
||||
"max_path_hint": max_path_hint,
|
||||
"export_count": len(exports),
|
||||
"connection_methods": connection_methods,
|
||||
"mock_methods": mock_methods,
|
||||
"wrapper_supported_count": len(wrapper_supported),
|
||||
"wrapper_supported_methods": wrapper_supported,
|
||||
"exports": exports,
|
||||
"notes": [
|
||||
"Exports extracted directly from the 64-bit DLL PE export directory.",
|
||||
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
|
||||
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
|
||||
dll_dir = Path(dll_dir)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written: list[Path] = []
|
||||
for dll_path in sorted(dll_dir.glob("*.dll")):
|
||||
profile = build_profile(dll_path, fwlib_cs_path)
|
||||
out_path = out_dir / f"{dll_path.stem}.json"
|
||||
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
||||
written.append(out_path)
|
||||
return written
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
|
||||
|
||||
PROFILE_ALIASES = {
|
||||
"ZeroI_D": "fwlib0iD64",
|
||||
"ZeroI_F": "fwlib0iD64",
|
||||
"ZeroI_MF": "fwlib0iD64",
|
||||
"ZeroI_TF": "fwlib0iD64",
|
||||
"Sixteen_i": "FWLIB64",
|
||||
"Thirty_i": "fwlib30i64",
|
||||
"ThirtyOne_i": "fwlib30i64",
|
||||
"ThirtyTwo_i": "fwlib30i64",
|
||||
"PowerMotion_i": "fwlib0DN64",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_profile_name(profile_name: str) -> str:
|
||||
return PROFILE_ALIASES.get(profile_name, profile_name)
|
||||
|
||||
|
||||
def load_profile(profile_name: str) -> dict[str, Any]:
|
||||
profile_name = resolve_profile_name(profile_name)
|
||||
candidates = [
|
||||
PROFILE_DIR / f"{profile_name}.json",
|
||||
PROFILE_DIR / f"{Path(profile_name).stem}.json",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return json.loads(candidate.read_text(encoding="utf-8"))
|
||||
available = ", ".join(list_profiles())
|
||||
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")
|
||||
+1018
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user