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:
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
focas-mock = focas_mock.cli:main
|
||||
@@ -0,0 +1 @@
|
||||
pefile>=2024.8.26
|
||||
@@ -0,0 +1 @@
|
||||
focas_mock
|
||||
@@ -0,0 +1,5 @@
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user