Initial scaffold: CLAUDE.md modules, directory structure, .gitignore

This commit is contained in:
2026-03-17 08:29:14 -05:00
commit c1603c24b0
7 changed files with 1136 additions and 0 deletions

40
.gitignore vendored Normal file
View File

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

127
CLAUDE.md Normal file
View File

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

38
README.md Normal file
View File

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

149
docker/CLAUDE.md Normal file
View File

@@ -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 <service>` 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/<device-group>.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:<port>` 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)
```

188
ignition/CLAUDE.md Normal file
View File

@@ -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-name>/
│ │ ├── view.json ← view definition
│ │ └── resource.json ← resource metadata
│ └── page-config/
├── com.inductiveautomation.webdev/
│ └── <endpoint-name>/
│ ├── code.py ← Jython 2.7 handler
│ └── resource.json
└── ignition/
├── named-query/
│ └── <query-name>/
│ ├── query.sql
│ └── resource.json
├── script-python/
│ └── <package>/
│ ├── 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": "<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.

272
testing/CLAUDE.md Normal file
View File

@@ -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=<path>"""
def tag_browse(self, root: str = "") -> dict:
"""GET /tagBrowse?root=<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)
```

322
webdev/CLAUDE.md Normal file
View File

@@ -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://<gateway>:8088/system/webdev/<project-name>
```
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
```