chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,2 @@
[console_scripts]
focas-mock = focas_mock.cli:main

View File

@@ -0,0 +1,5 @@
from .profiles import list_profiles, load_profile
from .server import FocasMockServer
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]

View File

@@ -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()

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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": {},
}

View File

@@ -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

View File

@@ -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}")