From c1603c24b06e15472656a1e16d6dd91980cad158 Mon Sep 17 00:00:00 2001 From: "b.peck" Date: Tue, 17 Mar 2026 08:29:14 -0500 Subject: [PATCH] Initial scaffold: CLAUDE.md modules, directory structure, .gitignore --- .gitignore | 40 ++++++ CLAUDE.md | 127 ++++++++++++++++++ README.md | 38 ++++++ docker/CLAUDE.md | 149 +++++++++++++++++++++ ignition/CLAUDE.md | 188 ++++++++++++++++++++++++++ testing/CLAUDE.md | 272 ++++++++++++++++++++++++++++++++++++++ webdev/CLAUDE.md | 322 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1136 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docker/CLAUDE.md create mode 100644 ignition/CLAUDE.md create mode 100644 testing/CLAUDE.md create mode 100644 webdev/CLAUDE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa696ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Secrets +.env +.env.* + +# Backups (created by safety rules before overwrites) +*.bak + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.venv/ +venv/ + +# Docker volumes (data managed by Docker, not Git) +docker/volumes/ + +# Ignition runtime data (not project resources) +ignition/data/ +*.gwbk + +# Test results (generated output, not source) +testing/results/ + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Node (if any tooling uses it) +node_modules/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9826c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# Ignition + Docker Development Framework + +## What This Is + +Reusable scaffold for Ignition + Docker projects with Claude Code. +The PLC I/O Testing Platform is the first project built on this framework. + +## Stack + +- Ignition 8.3 (Git-native project storage, Perspective, WebDev, OPC-UA) +- Rockwell Allen-Bradley ControlLogix/CompactLogix, Studio 5000, PlantPAx +- Docker Compose on Ubuntu 24 (Ignition, PostgreSQL, Traefik, Modbus sims) +- Gitea at 192.168.3.59:3000 for source control +- Jython 2.7 inside Ignition; Python 3.10+ for external tooling +- Claude Code on the Linux host (VS Code Remote to Docker host) + +## Language Rules + +### Jython 2.7 (Inside Ignition) + +All scripts in `ignition/project/` and gateway scripting run Jython 2.7. +Hard constraints — violating these causes runtime errors: + +- NO f-strings → use `"text {}".format(val)` or `"text %s" % val` +- NO walrus operator `:=` +- NO `pathlib` → use `os.path` +- NO type hints → no `def foo(x: int) -> str:` +- NO `dataclasses`, `enum.auto()`, `asyncio` +- NO dictionary unpacking `{**d1, **d2}` → use `d1.update(d2)` +- NO `yield from` → use explicit loops +- Imports: `system.*` for Ignition API, standard Java/Jython libs only + +### Python 3.10+ (External Tooling) + +All scripts outside Ignition (test runners, utilities, CI/CD) use Python 3.10+. +Comment the target environment at the top of every file: + +```python +# env: python3.10+ (external tooling — NOT Ignition) +``` + +## Safety Rules (Non-Negotiable) + +1. **Validate JSON** before writing to any Ignition resource directory. +2. **Back up files** before overwriting: `cp file file.bak` +3. **Sorted-key JSON** for all JSON files (Git-friendly diffs). +4. **Never restart Ignition gateway** without explicit user confirmation. +5. **Never `docker compose down`** without confirmation (destroys unnamed volumes). +6. **Every change needs a verification step** — scenario run, curl, tag read. + +## Canonical Patterns (Do Not Change Without Discussion) + +### UDT / Linking Pattern + +Linking/Link sub-UDT with `LinkPath` + `LinkData` for PLC signal binding. +Decouples simulation from PLC I/O structure. All device test tags follow this. + +### JSON Test Scenarios + +Five required sections: +- `identity` — scenario ID, version, description +- `device` — device type, UDT path, tag references +- `initial_state` — tag values to set before test begins +- `sequence` — ordered steps with `delay_ms` between actions +- `expected_outcomes` — pass/fail criteria with `tolerance` + +See `testing/CLAUDE.md` for full schema. + +### WebDev API + +All external I/O goes through WebDev endpoints — never direct tag file edits at runtime: +- `tagWrite` — write values to tags +- `tagRead` — read tag values +- `tagBrowse` — browse tag tree +- `health` — gateway and connection status + +See `webdev/CLAUDE.md` for endpoint contracts. + +### Ignition as I/O Orchestrator + +Ignition writes to Modbus containers and PLC I/O tags. +PLC runs real logic. Test runner talks to Ignition only. +Never bypass Ignition to write directly to Modbus or PLC. + +## Directory Layout + +``` +project-root/ +├── CLAUDE.md ← you are here +├── docker/ +│ ├── CLAUDE.md ← Docker Compose patterns +│ ├── docker-compose.yml +│ ├── docker-compose.override.yml +│ └── config/ +│ ├── ignition/ +│ ├── postgres/ +│ └── traefik/ +├── ignition/ +│ ├── CLAUDE.md ← Ignition project structure +│ └── project/ +│ ├── com.inductiveautomation.perspective/ +│ ├── com.inductiveautomation.webdev/ +│ └── ignition/ +│ ├── named-query/ +│ ├── script-python/ +│ └── global-props/ +├── testing/ +│ ├── CLAUDE.md ← test scenario schema & runner +│ ├── scenarios/ +│ ├── runner/ +│ └── results/ +├── webdev/ +│ ├── CLAUDE.md ← WebDev API contract +│ └── endpoints/ +├── tools/ +│ └── (Python 3.10+ external utilities) +└── docs/ + └── (project documentation) +``` + +## Module CLAUDE.md Files + +Each subdirectory has its own CLAUDE.md with domain-specific rules: +- `docker/CLAUDE.md` — container orchestration, volumes, networking +- `ignition/CLAUDE.md` — project structure, UDTs, scripting, resources +- `testing/CLAUDE.md` — scenario schema, test runner, results format +- `webdev/CLAUDE.md` — API endpoint contracts, request/response formats \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a5b86e --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Ignition + Docker Development Framework + +Reusable scaffold for Ignition SCADA + Docker projects, designed to work with Claude Code as the AI development assistant. + +## First Consumer + +**PLC I/O Testing Platform** — automated PLC I/O validation using JSON-defined test scenarios. + +## Stack + +- Ignition 8.3 (Perspective, WebDev, OPC-UA, Git-native project storage) +- Docker Compose on Ubuntu 24 +- Rockwell Allen-Bradley ControlLogix/CompactLogix +- PostgreSQL 16 +- Traefik v3 (optional reverse proxy) +- Gitea for source control + +## Getting Started + +See [CLAUDE.md](CLAUDE.md) for project directives, canonical patterns, and directory layout. + +## Directory Structure + +``` +├── CLAUDE.md ← root directives (Claude Code reads this automatically) +├── docker/ ← Docker Compose stack and configs +├── ignition/ ← Ignition project (mounted into container) +├── testing/ ← test scenarios, runner, results +├── webdev/ ← WebDev API endpoint contracts +├── tools/ ← Python 3.10+ external utilities +└── docs/ ← project documentation +``` + +Each subdirectory contains its own `CLAUDE.md` with domain-specific rules. + +## License + +Internal use only. \ No newline at end of file diff --git a/docker/CLAUDE.md b/docker/CLAUDE.md new file mode 100644 index 0000000..418aa46 --- /dev/null +++ b/docker/CLAUDE.md @@ -0,0 +1,149 @@ +# Docker Compose Patterns — CLAUDE.md + +Parent: [../CLAUDE.md](../CLAUDE.md) + +## Overview + +All services run via Docker Compose on Ubuntu 24. +The base `docker-compose.yml` defines the core stack. +Project-specific overrides go in `docker-compose.override.yml`. + +## Core Services + +### Ignition Gateway + +```yaml +ignition: + image: inductiveautomation/ignition:8.3.X + ports: + - "8088:8088" # HTTP gateway + - "8043:8043" # HTTPS gateway + - "62541:62541" # OPC-UA + volumes: + - ignition-data:/usr/local/bin/ignition/data + - ./config/ignition/gateway.xml:/usr/local/bin/ignition/data/gateway.xml + - ../ignition/project:/usr/local/bin/ignition/data/projects/framework + environment: + ACCEPT_IGNITION_EULA: "Y" + GATEWAY_ADMIN_PASSWORD: "${IGNITION_ADMIN_PASSWORD}" + IGNITION_EDITION: standard + restart: unless-stopped +``` + +### PostgreSQL + +```yaml +postgres: + image: postgres:16-alpine + volumes: + - postgres-data:/var/lib/postgresql/data + - ./config/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + environment: + POSTGRES_DB: ignition + POSTGRES_USER: ignition + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + restart: unless-stopped +``` + +### Traefik (Reverse Proxy) + +```yaml +traefik: + image: traefik:v3 + ports: + - "80:80" + - "443:443" + - "8080:8080" # Traefik dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config/traefik:/etc/traefik + restart: unless-stopped +``` + +### Modbus Simulator (Template) + +One container per simulated device or device group. +Use `docker-compose.override.yml` to add project-specific simulators. + +```yaml +modbus-sim-pumps: + image: oitc/modbus-server:latest + ports: + - "5020:5020" + volumes: + - ./config/modbus/pumps.json:/app/config.json + restart: unless-stopped +``` + +## Rules + +### Volume Management + +- **Named volumes** (`ignition-data`, `postgres-data`) persist across restarts. +- **Never run `docker compose down -v`** — this destroys named volumes. +- **Never run `docker compose down`** without explicit user confirmation. +- Use `docker compose stop` to halt services without removing containers. +- Use `docker compose restart ` for individual service restarts. + +### Networking + +- All services share the default compose network. +- Ignition connects to Modbus sims via container name (e.g., `modbus-sim-pumps:5020`). +- Ignition connects to PostgreSQL via `postgres:5432`. +- External access to WebDev API goes through Traefik or direct port mapping. + +### Configuration Files + +- Gateway backup/restore: mount `gateway.xml` for initial config only. +- Modbus configs: JSON files in `config/modbus/`, one per simulator. +- PostgreSQL init: `config/postgres/init.sql` runs on first start only. + +### Adding a New Modbus Simulator + +1. Create config file: `config/modbus/.json` +2. Add service to `docker-compose.override.yml` +3. Assign unique port (start at 5020, increment by 1) +4. Add OPC-UA connection in Ignition config +5. Verify: `curl -s localhost:` or test from Ignition + +### Environment Variables + +Store secrets in `.env` (gitignored): + +``` +IGNITION_ADMIN_PASSWORD=changeme +POSTGRES_PASSWORD=changeme +``` + +Never hardcode credentials in compose files or configs. + +### Health Checks + +```yaml +ignition: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/StatusPing"] + interval: 30s + timeout: 10s + retries: 5 +``` + +## File Structure + +``` +docker/ +├── CLAUDE.md ← you are here +├── docker-compose.yml ← base stack (do not modify per-project) +├── docker-compose.override.yml ← project-specific additions +├── .env ← secrets (gitignored) +└── config/ + ├── ignition/ + │ └── gateway.xml + ├── postgres/ + │ └── init.sql + ├── traefik/ + │ ├── traefik.yml + │ └── dynamic/ + └── modbus/ + └── (device-group configs) +``` \ No newline at end of file diff --git a/ignition/CLAUDE.md b/ignition/CLAUDE.md new file mode 100644 index 0000000..b05b86e --- /dev/null +++ b/ignition/CLAUDE.md @@ -0,0 +1,188 @@ +# Ignition Project Structure — CLAUDE.md + +Parent: [../CLAUDE.md](../CLAUDE.md) + +## Overview + +Ignition 8.3 with Git-native project storage. The project directory is +mounted into the Ignition Docker container and is the single source of truth. +All resources are JSON files on disk. + +## Git-Native Project Storage + +Ignition 8.3 stores projects as files and directories: + +``` +ignition/project/ +├── project.json ← project metadata +├── com.inductiveautomation.perspective/ +│ ├── views/ +│ │ └── / +│ │ ├── view.json ← view definition +│ │ └── resource.json ← resource metadata +│ └── page-config/ +├── com.inductiveautomation.webdev/ +│ └── / +│ ├── code.py ← Jython 2.7 handler +│ └── resource.json +└── ignition/ + ├── named-query/ + │ └── / + │ ├── query.sql + │ └── resource.json + ├── script-python/ + │ └── / + │ ├── code.py ← project library scripts + │ └── resource.json + └── global-props/ + └── props.json +``` + +## JSON File Rules + +All JSON files in the project directory: + +1. **Must be valid JSON** — validate before writing (use `json.loads()` then write). +2. **Must use sorted keys** — `json.dumps(data, sort_keys=True, indent=2)` +3. **Must be backed up** before overwriting — `cp file.json file.json.bak` +4. **Must use 2-space indentation** — matches Ignition's native format. + +### resource.json Template + +Every resource directory contains a `resource.json`: + +```json +{ + "props": { + "resource.name": "" + }, + "scope": "G", + "version": 1 +} +``` + +- `scope`: `"G"` = gateway, `"C"` = client, `"A"` = all +- `version`: increment on changes for cache-busting + +## UDT / Linking Pattern + +### Structure + +``` +[Device Type UDT] +├── Config/ +│ ├── DeviceType (String) +│ ├── DeviceId (String) +│ └── Description (String) +├── Status/ +│ ├── Running (Boolean) +│ ├── Faulted (Boolean) +│ └── ... +├── Command/ +│ ├── Start (Boolean) +│ ├── Stop (Boolean) +│ └── ... +└── Linking/ + └── Link/ + ├── LinkPath (String) ← path to PLC tag + └── LinkData (Dataset/Document) ← signal mapping +``` + +### How LinkPath Works + +`LinkPath` contains the OPC tag path prefix for the PLC signals. +Example: `[ControlLogix]Program:MainProgram.P_101` + +`LinkData` maps UDT members to PLC tag suffixes: +- `Status/Running` → `LinkPath + ".Sts_Running"` +- `Command/Start` → `LinkPath + ".Cmd_Start"` + +This decouples the test/simulation layer from specific PLC I/O structures. +Changing the PLC program only requires updating `LinkPath` and `LinkData`. + +### Rules + +- Never modify `LinkPath` or `LinkData` at runtime from external tools. +- Set linking configuration through Ignition Designer or startup scripts. +- Test scenarios reference device UDT paths, not raw PLC tag paths. + +## Scripting + +### Project Library Scripts (`script-python/`) + +Jython 2.7 — see root CLAUDE.md for language constraints. + +Naming convention: +``` +script-python/ +├── testing/ +│ ├── code.py ← testing.runScenario(), testing.validateResults() +│ └── resource.json +├── devices/ +│ ├── code.py ← devices.getStatus(), devices.sendCommand() +│ └── resource.json +└── util/ + ├── code.py ← util.formatTag(), util.validateJson() + └── resource.json +``` + +Access in Ignition: `project.testing.runScenario()` + +### Gateway Event Scripts + +Located in project config, not in `script-python/`. +Used for startup initialization, tag change events, scheduled tasks. + +### Common Ignition API Calls + +```python +# Tag operations +system.tag.readBlocking(["path/to/tag"]) +system.tag.writeBlocking(["path/to/tag"], [value]) +system.tag.browseConfiguration("path", {}) + +# Named queries +system.db.runNamedQuery("queryName", {"param": value}) + +# WebDev (inside endpoint handlers) +# request object has: data, params, headers, remoteAddr +# return dict or string — Ignition serializes to JSON + +# Logging +logger = system.util.getLogger("testing") +logger.info("message") +``` + +## Tag Provider Structure + +``` +[default] +├── _types_/ ← UDT definitions +│ ├── Devices/ +│ │ ├── Motor/ +│ │ ├── Valve/ +│ │ └── Pump/ +│ └── Linking/ +│ └── Link/ +├── Devices/ ← UDT instances +│ ├── Pumps/ +│ │ ├── P-101/ +│ │ └── P-102/ +│ ├── Valves/ +│ │ └── XV-1001/ +│ └── Motors/ +│ └── M-201/ +└── System/ ← system/status tags + ├── Health/ + └── Config/ +``` + +## Perspective Views + +Views are JSON definitions in `com.inductiveautomation.perspective/views/`. +Each view folder contains `view.json` (component tree) and `resource.json`. + +Rules: +- Do not hand-edit `view.json` unless you understand the component schema. +- Prefer creating views through Ignition Designer when possible. +- If editing programmatically, validate the full JSON structure before writing. \ No newline at end of file diff --git a/testing/CLAUDE.md b/testing/CLAUDE.md new file mode 100644 index 0000000..ea7af46 --- /dev/null +++ b/testing/CLAUDE.md @@ -0,0 +1,272 @@ +# Test Scenario Schema & Runner — CLAUDE.md + +Parent: [../CLAUDE.md](../CLAUDE.md) + +## Overview + +Automated PLC I/O validation through JSON-defined test scenarios. +The test runner is Python 3.10+ and communicates with Ignition exclusively +through the WebDev API. It never touches tags, files, or Modbus directly. + +## Scenario Schema + +Every scenario JSON file has exactly five top-level sections. + +```json +{ + "identity": { + "id": "PMP-101-START-001", + "version": "1.0.0", + "description": "Verify P-101 starts when start command is issued and permissives are met", + "author": "engineer@company.com", + "created": "2026-03-14", + "tags": ["pump", "start", "P-101", "plantpax"] + }, + "device": { + "type": "Pump", + "udt_path": "[default]Devices/Pumps/P-101", + "plc_connection": "ControlLogix", + "related_devices": [ + { + "type": "Valve", + "udt_path": "[default]Devices/Valves/XV-1001", + "role": "discharge_valve" + } + ] + }, + "initial_state": { + "tags": [ + { + "path": "[default]Devices/Pumps/P-101/Status/Running", + "value": false + }, + { + "path": "[default]Devices/Pumps/P-101/Status/Faulted", + "value": false + }, + { + "path": "[default]Devices/Pumps/P-101/Status/Ready", + "value": true + }, + { + "path": "[default]Devices/Valves/XV-1001/Status/FullOpen", + "value": true + } + ] + }, + "sequence": [ + { + "step": 1, + "action": "write", + "description": "Issue start command", + "path": "[default]Devices/Pumps/P-101/Command/Start", + "value": true, + "delay_ms": 0 + }, + { + "step": 2, + "action": "wait", + "description": "Allow PLC scan time for logic execution", + "delay_ms": 2000 + }, + { + "step": 3, + "action": "read", + "description": "Check running status", + "path": "[default]Devices/Pumps/P-101/Status/Running", + "delay_ms": 0 + } + ], + "expected_outcomes": [ + { + "path": "[default]Devices/Pumps/P-101/Status/Running", + "expected": true, + "tolerance": null, + "comparison": "equals", + "description": "Pump should be running after start command" + }, + { + "path": "[default]Devices/Pumps/P-101/Status/Faulted", + "expected": false, + "tolerance": null, + "comparison": "equals", + "description": "Pump should not be faulted" + }, + { + "path": "[default]Devices/Pumps/P-101/Status/Speed", + "expected": 60.0, + "tolerance": 2.0, + "comparison": "within_tolerance", + "description": "Pump speed should be near setpoint" + } + ] +} +``` + +### Section Details + +#### identity + +Required fields: `id`, `version`, `description` +Optional fields: `author`, `created`, `tags` + +ID convention: `{DEVICE_PREFIX}-{NUMBER}-{ACTION}-{SEQUENCE}` +Example: `PMP-101-START-001`, `VLV-1001-OPEN-002` + +Version follows semver. Bump on any change to sequence or expected outcomes. + +#### device + +Required fields: `type`, `udt_path` +Optional fields: `plc_connection`, `related_devices` + +`udt_path` is the full Ignition tag path to the device UDT instance. +`related_devices` lists other devices involved in the test with their role. + +#### initial_state + +Array of `{path, value}` objects. These tags are written (via WebDev API) +before the test sequence begins. The runner writes all initial_state tags, +waits 500ms for propagation, then verifies they were set correctly. + +#### sequence + +Ordered array of steps. Each step has: +- `step` (integer) — execution order +- `action` — one of: `write`, `read`, `wait`, `assert` +- `description` — human-readable step description +- `path` — tag path (required for `write`, `read`, `assert`) +- `value` — value to write (required for `write`) +- `delay_ms` — milliseconds to wait before executing this step + +Actions: +- `write` — write `value` to `path` via WebDev tagWrite +- `read` — read `path` via WebDev tagRead, store result for later assertion +- `wait` — pause execution for `delay_ms` (no tag interaction) +- `assert` — immediately check `path` against `value` (mid-sequence validation) + +#### expected_outcomes + +Array of final assertions evaluated after the sequence completes. +Each outcome has: +- `path` — tag to check +- `expected` — expected value +- `tolerance` — numeric tolerance (null for exact match) +- `comparison` — one of: `equals`, `within_tolerance`, `greater_than`, `less_than`, `not_equals` +- `description` — what this assertion validates + +## Scenario File Rules + +1. **One scenario per file.** Named: `{identity.id}.json` +2. **Validate JSON before writing.** Use sorted keys, 2-space indent. +3. **Store in `testing/scenarios/` directory.** +4. **Back up before overwriting:** `cp file.json file.json.bak` + +## Test Runner + +### Architecture + +``` +runner/ +├── main.py ← CLI entry point (Python 3.10+) +├── executor.py ← scenario execution engine +├── api_client.py ← WebDev API client wrapper +├── validator.py ← scenario JSON schema validation +├── reporter.py ← results formatting and output +└── config.py ← runner configuration (gateway URL, timeouts) +``` + +### Execution Flow + +1. Load and validate scenario JSON against schema. +2. Connect to Ignition WebDev API (health check first). +3. Write `initial_state` tags → wait 500ms → verify writes. +4. Execute `sequence` steps in order, respecting `delay_ms`. +5. After sequence completes, evaluate `expected_outcomes`. +6. Generate results report (JSON + human-readable summary). + +### API Client + +The runner talks to Ignition through WebDev endpoints only: + +```python +# env: python3.10+ (external tooling — NOT Ignition) + +class IgnitionAPIClient: + def __init__(self, base_url: str = "http://localhost:8088/system/webdev"): + self.base_url = base_url + + def tag_write(self, path: str, value) -> dict: + """POST /tagWrite {"path": path, "value": value}""" + + def tag_read(self, path: str) -> dict: + """GET /tagRead?path=""" + + def tag_browse(self, root: str = "") -> dict: + """GET /tagBrowse?root=""" + + def health(self) -> dict: + """GET /health""" +``` + +## Results Format + +```json +{ + "scenario_id": "PMP-101-START-001", + "scenario_version": "1.0.0", + "timestamp": "2026-03-14T10:30:00Z", + "duration_ms": 4523, + "result": "PASS", + "initial_state_verification": { + "status": "OK", + "details": [] + }, + "sequence_log": [ + { + "step": 1, + "action": "write", + "path": "[default]Devices/Pumps/P-101/Command/Start", + "value": true, + "response": {"status": "ok"}, + "elapsed_ms": 45 + } + ], + "outcomes": [ + { + "path": "[default]Devices/Pumps/P-101/Status/Running", + "expected": true, + "actual": true, + "result": "PASS", + "description": "Pump should be running after start command" + } + ], + "summary": { + "total_outcomes": 3, + "passed": 3, + "failed": 0 + } +} +``` + +Results are stored in `testing/results/` as `{scenario_id}_{timestamp}.json`. + +## File Structure + +``` +testing/ +├── CLAUDE.md ← you are here +├── scenarios/ +│ ├── PMP-101-START-001.json +│ ├── PMP-101-STOP-001.json +│ └── VLV-1001-OPEN-001.json +├── runner/ +│ ├── main.py +│ ├── executor.py +│ ├── api_client.py +│ ├── validator.py +│ ├── reporter.py +│ └── config.py +└── results/ + └── (timestamped result files) +``` \ No newline at end of file diff --git a/webdev/CLAUDE.md b/webdev/CLAUDE.md new file mode 100644 index 0000000..ee458d4 --- /dev/null +++ b/webdev/CLAUDE.md @@ -0,0 +1,322 @@ +# WebDev API Contract — CLAUDE.md + +Parent: [../CLAUDE.md](../CLAUDE.md) + +## Overview + +All external interaction with Ignition tags goes through WebDev endpoints. +No external tool should read or write tag files directly at runtime. +The test runner, CLI tools, and any future integrations use these endpoints. + +## Base URL + +``` +http://:8088/system/webdev/ +``` + +Default: `http://localhost:8088/system/webdev/framework` + +## Endpoints + +### POST /tagWrite + +Write one or more tag values. + +**Request:** +```json +{ + "writes": [ + { + "path": "[default]Devices/Pumps/P-101/Command/Start", + "value": true + }, + { + "path": "[default]Devices/Pumps/P-101/Command/SpeedSetpoint", + "value": 60.0 + } + ] +} +``` + +**Response (200):** +```json +{ + "status": "ok", + "results": [ + { + "path": "[default]Devices/Pumps/P-101/Command/Start", + "success": true + }, + { + "path": "[default]Devices/Pumps/P-101/Command/SpeedSetpoint", + "success": true + } + ] +} +``` + +**Response (400 — bad request):** +```json +{ + "status": "error", + "message": "Invalid tag path: [default]Devices/Pumps/P-999/Command/Start" +} +``` + +**Handler (Jython 2.7):** +```python +# WebDev endpoint: tagWrite +# Method: POST +# Jython 2.7 — no f-strings, no type hints + +import json + +def doPost(request, session): + logger = system.util.getLogger("webdev.tagWrite") + try: + payload = json.loads(request["data"]) + writes = payload.get("writes", []) + + if not writes: + return {"status": "error", "message": "No writes provided"} + + paths = [w["path"] for w in writes] + values = [w["value"] for w in writes] + + results = system.tag.writeBlocking(paths, values) + + response_results = [] + for i, qv in enumerate(results): + response_results.append({ + "path": paths[i], + "success": qv.isGood() + }) + + return {"status": "ok", "results": response_results} + + except Exception as e: + logger.error("tagWrite error: %s" % str(e)) + return {"status": "error", "message": str(e)} +``` + +### GET /tagRead + +Read one or more tag values. + +**Request:** +``` +GET /tagRead?paths=[default]Devices/Pumps/P-101/Status/Running,[default]Devices/Pumps/P-101/Status/Speed +``` + +Multiple paths are comma-separated in the query string. + +**Response (200):** +```json +{ + "status": "ok", + "results": [ + { + "path": "[default]Devices/Pumps/P-101/Status/Running", + "value": true, + "quality": "Good", + "timestamp": "2026-03-14T10:30:00Z" + }, + { + "path": "[default]Devices/Pumps/P-101/Status/Speed", + "value": 59.8, + "quality": "Good", + "timestamp": "2026-03-14T10:30:00Z" + } + ] +} +``` + +**Handler (Jython 2.7):** +```python +# WebDev endpoint: tagRead +# Method: GET +# Jython 2.7 — no f-strings, no type hints + +def doGet(request, session): + logger = system.util.getLogger("webdev.tagRead") + try: + paths_param = request["params"].get("paths", "") + if not paths_param: + return {"status": "error", "message": "No paths provided"} + + paths = [p.strip() for p in paths_param.split(",")] + qvs = system.tag.readBlocking(paths) + + results = [] + for i, qv in enumerate(qvs): + results.append({ + "path": paths[i], + "value": qv.value, + "quality": str(qv.quality), + "timestamp": str(qv.timestamp) + }) + + return {"status": "ok", "results": results} + + except Exception as e: + logger.error("tagRead error: %s" % str(e)) + return {"status": "error", "message": str(e)} +``` + +### GET /tagBrowse + +Browse the tag tree from a root path. + +**Request:** +``` +GET /tagBrowse?root=[default]Devices/Pumps +``` + +**Response (200):** +```json +{ + "status": "ok", + "root": "[default]Devices/Pumps", + "children": [ + { + "name": "P-101", + "path": "[default]Devices/Pumps/P-101", + "type": "UdtInstance", + "has_children": true + }, + { + "name": "P-102", + "path": "[default]Devices/Pumps/P-102", + "type": "UdtInstance", + "has_children": true + } + ] +} +``` + +**Handler (Jython 2.7):** +```python +# WebDev endpoint: tagBrowse +# Method: GET +# Jython 2.7 — no f-strings, no type hints + +def doGet(request, session): + logger = system.util.getLogger("webdev.tagBrowse") + try: + root = request["params"].get("root", "") + browse_results = system.tag.browse(root) + + children = [] + for result in browse_results.getResults(): + children.append({ + "name": result["name"], + "path": str(result["fullPath"]), + "type": str(result["tagType"]), + "has_children": result["hasChildren"] + }) + + return {"status": "ok", "root": root, "children": children} + + except Exception as e: + logger.error("tagBrowse error: %s" % str(e)) + return {"status": "error", "message": str(e)} +``` + +### GET /health + +Check gateway and connection status. + +**Request:** +``` +GET /health +``` + +**Response (200):** +```json +{ + "status": "ok", + "gateway": { + "state": "RUNNING", + "uptime_ms": 3600000 + }, + "connections": { + "plc": { + "ControlLogix": { + "connected": true, + "status": "Connected" + } + }, + "database": { + "postgres": { + "connected": true, + "status": "Valid" + } + } + } +} +``` + +## Rules + +1. **All external I/O goes through these endpoints.** No exceptions at runtime. +2. **Never bypass WebDev** to edit tag JSON files while the gateway is running. +3. **Validate inputs** in every handler — check for missing fields, bad paths. +4. **Log errors** with `system.util.getLogger()` in every handler. +5. **Return consistent JSON** — always include `status` field ("ok" or "error"). +6. **Jython 2.7 only** in handler code — no f-strings, no type hints. + +## Error Handling Convention + +All endpoints return this structure on error: + +```json +{ + "status": "error", + "message": "Human-readable error description" +} +``` + +HTTP status codes: +- `200` — success (status: "ok") +- `400` — bad request (missing params, invalid paths) +- `500` — server error (unhandled exceptions) + +## Verification Commands + +Quick checks from the host or test runner: + +```bash +# Health check +curl -s http://localhost:8088/system/webdev/framework/health | python3 -m json.tool + +# Read a tag +curl -s "http://localhost:8088/system/webdev/framework/tagRead?paths=[default]Devices/Pumps/P-101/Status/Running" | python3 -m json.tool + +# Write a tag +curl -s -X POST http://localhost:8088/system/webdev/framework/tagWrite \ + -H "Content-Type: application/json" \ + -d '{"writes":[{"path":"[default]Devices/Pumps/P-101/Command/Start","value":true}]}' | python3 -m json.tool + +# Browse tags +curl -s "http://localhost:8088/system/webdev/framework/tagBrowse?root=[default]Devices" | python3 -m json.tool +``` + +## File Structure + +``` +webdev/ +├── CLAUDE.md ← you are here +└── endpoints/ + ├── tagWrite/ + │ ├── code.py ← handler source (Jython 2.7) + │ └── resource.json + ├── tagRead/ + │ ├── code.py + │ └── resource.json + ├── tagBrowse/ + │ ├── code.py + │ └── resource.json + └── health/ + ├── code.py + └── resource.json +``` \ No newline at end of file