Skip to content

Commit 8967a23

Browse files
committed
Initial site: landing page, blog posts, CI
1 parent f73ee01 commit 8967a23

6 files changed

Lines changed: 273 additions & 0 deletions

File tree

.github/workflows/docs.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Docs
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: false
16+
17+
jobs:
18+
build:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v6
23+
24+
- name: Install uv
25+
uses: astral-sh/setup-uv@v7
26+
with:
27+
version: "0.9.25"
28+
enable-cache: true
29+
python-version: "3.12"
30+
31+
- name: Set up Python
32+
run: uv python install
33+
34+
- name: Install docs dependencies
35+
run: uv sync --group docs
36+
37+
- name: Build docs
38+
run: uv run mkdocs build --strict
39+
40+
- name: Upload Pages artifact
41+
uses: actions/upload-pages-artifact@v4
42+
with:
43+
path: site
44+
45+
deploy:
46+
needs: build
47+
runs-on: ubuntu-latest
48+
environment:
49+
name: github-pages
50+
url: ${{ steps.deployment.outputs.page_url }}
51+
steps:
52+
- name: Deploy to GitHub Pages
53+
id: deployment
54+
uses: actions/deploy-pages@v4

docs/blog/these-arent-the-rungs.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# "These Aren't the Rungs You're Looking For"
2+
3+
### How I reverse-engineered a PLC editor's clipboard format so my bytes could paste without any problems
4+
5+
Ladder logic is the visual programming language for industrial controllers, rungs on a rail, each one a circuit that evaluates left to right: `|—[ Contact ]—( Output )|`. For years I haven't been able to test my CLICK PLC programs. I've got dozens of machines whose logic is stuck in an editor with no simulator, no scripting API, and no documented file format. So I built [pyrung](https://ssweber.github.io/pyrung/), a Python DSL where `with Rung(condition): instruction` maps directly to a ladder rung, meaning you can write logic in Python and test it with pytest.
6+
7+
But I don't want to transpose after testing. The missing piece was getting rungs from Python into the CLICK editor without retyping them. There's no documented import format, but I found it does put ctrl-c rungs onto the clipboard in some binary format. Maybe I could figure it out. With an AI that could hold context across hex dumps and structural hypotheses, I decided to give it a go.
8+
9+
Things started quickly and I thought it'd be a matter of days to figure out. Place bytes on the private clipboard, spoof the window handle so CLICK thought the paste came from itself, and write addresses to the project database so contacts wouldn't draw as blank placeholders. Done.
10+
11+
The workflow that emerged was simple: a CSV file describing each rung's layout on CLICK's grid, a CLI tool that loaded them to the clipboard one by one and asked me whether it worked or crashed or came out wrong. We stored `.bin` files for each shape that captured the known-good bytes so we could diff against them later. Each new rung shape started as a native copy from CLICK and graduated when our synthetic bytes could paste without noticeable problems.
12+
13+
## The Problem
14+
15+
The first few shapes worked. CLICK accepted simple contacts & basic wires.
16+
17+
Then we added instructions and that broke the rung. What should paste as one rung came back as multiple rungs with phantom `NOP` instructions jammed between them.
18+
19+
The approach that had gotten us this far stopped working. Early on, byte diffing was all we could do. We didn't know the format so we captured native bytes, diffed against synthetic, and patched the differences one by one. Instructions introduced variable-length data and suddenly every new shape produced new differences. The AI built elaborate theories about each one, generated patch variants, ranked candidate bytes, and wrote handoff notes that read like desert island scribbles. The explanations were internally consistent and often wrong. I was learning hex as I went and the answer had to be simpler.
20+
21+
I almost gave up, not because the problem was hard but because the approach didn't generalize. You can't diff your way to understanding a format.
22+
23+
We needed a coherent model, so we went back to the basics. Stripped out contacts and instructions entirely and just got empty rungs working, then rungs with wires, then multi-row rungs. Byte 'close-enough' matches against native captures.
24+
25+
## Stop Hex Diffing
26+
27+
LLMs are *magnetically attracted* to hex diffs. Give an AI two binary files and it will compare them byte by byte, build elaborate theories about every difference, and confidently explain which offset controls what. The explanations sound great and they're often wrong.
28+
29+
The cell grid has rigid structure: 0x40 bytes per cell when empty and 32 cells per row. But once we added content, the AI got confused. "The comment data is spilling over into column A." No it wasn't.
30+
31+
It had built a complex model entirely from hex diffs: a 32-entry seed table, a continuation stream for overflow, shape-dependent seeding rules. We had a huge if/else chain encoder for some cases; elaborate, internally consistent, and mostly hallucination. The wrong model generated correct bytes for simple cases, so there was no reason to question it until extending it to multi-rung comments became impossible. I was about to remove comment support entirely.
32+
33+
## Push, Push, Push
34+
35+
The question that cracked it was simple: "Are we sure the comments aren't just an overlay that inserts and pushes things at known safe spots?" The AI initially argued no because the wire flag offsets were different between the two regions. I pushed back. The AI ran the math and every wire flag, every NOP byte, every linkage byte across all 63 flag positions mapped perfectly once you accounted for the displacement. The "mysterious offset shift" was a fixed constant. We didn't need the if/else chain. The cell grid had just been pushed forward by the comment payload.
36+
37+
All that complexity simplified away. Each insight cascaded into the next because the AI could run the address math fast enough to keep up. The 32-entry header table turned out to be one entry plus a payload region. The "separator" between rungs turned out to be the next rung's preamble. The instruction "seed bytes" were just payload content at those addresses. The whole format collapsed to: ramp, dword, payload, grid. Repeat per rung.
38+
39+
We'd been documenting what we saw in the hex rather than what produced it. Once we asked "what if there's only one structure that got displaced," everything fell into place.
40+
41+
In code, it's two lines:
42+
43+
```python
44+
struct.pack_into('<I', out, 0x0294, len(payload))
45+
out[0x0298:0x0298] = payload
46+
```
47+
48+
Write the payload length as a dword, then slice-insert the payload at the boundary between the rung preamble and the cell grid:
49+
50+
- Everything from 0x0298 onward shifts right by `len(payload)`
51+
- The cell grid, normally starting at 0x0A60, lands at 0x0A60 + payload length instead
52+
- No pointers to update, no offsets to rewrite, just insert and push
53+
54+
## The Mind Trick
55+
56+
We place our bytes on the clipboard under the private format. CLICK reads them back, checks whatever it checks, and renders a rung. A rung we wrote in Python, from a CSV, that never touched the editor until this moment.
57+
58+
*These are perfectly normal rungs I copied myself.*
59+
60+
---
61+
62+
*The work described here lives in two repos: [clicknick](https://github.com/ssweber/clicknick) (clipboard glue, live verification) and [laddercodec](https://github.com/ssweber/laddercodec) (the binary codec). The reverse engineering ran from March 2–24, 2026, across roughly 200 commits, with a human doing the pasting and an AI doing the byte analysis. The format remains undocumented by its creator. Our encoder doesn't mind.*

docs/blog/why-pyrung.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Why pyrung? A look at what else is out there
2+
3+
## The big vendors have simulators. Click doesn't.
4+
5+
Siemens has S7-PLCSIM. Allen-Bradley has the RSLogix Emulator. Beckhoff has TwinCAT's simulation manager. CODESYS has a built-in simulation mode and a paid Test Manager add-on. Even within AutomationDirect's own lineup, the Do-more and Productivity PLCs have software simulators built into their free programming tools.
6+
7+
The Click PLC has none. AutomationDirect sells physical input simulator modules, toggle switches and potentiometer boards, but no software simulation. You write ladder in Click Programming Software, download to hardware, and test it there. If you don't have the hardware, you wait.
8+
9+
## An emerging trend: people want more than GUI-only PLC programming
10+
11+
For decades, PLC programming has meant vendor-specific graphical IDEs. You draw ladder in the vendor's editor, download to hardware, and hope. That's starting to change.
12+
13+
For Structured Text, the vendor story is already meaningful. CODESYS has simulation, a Test Manager, Python scripting, and a unit testing framework (CoUnit). Beckhoff TwinCAT gives ST developers full Visual Studio integration with unit testing via TcUnit. Running a CODESYS soft PLC and driving tests from pytest over OPC-UA is an emerging pattern, though it amounts to integration testing against a running process rather than deterministic simulation of the logic itself. If your world is ST on those platforms, you're reasonably well served.
14+
15+
Beyond the vendor ecosystems, independent projects are pushing further. [rungs.dev](https://rungs.dev/) is a browser-based ladder logic and ST editor with real-time simulation and an integrated test runner with deterministic, isolated scan cycles. It targets Allen-Bradley style (XIC/XIO/OTE, Add-On Instructions) and is the closest thing in spirit to pyrung's test-first philosophy, arriving from a completely different direction.
16+
17+
Open-source PLC platforms like **OpenPLC** and **Beremiz** are full IEC 61131-3 environments with graphical editors, simulation modes, and multiple target hardware platforms. They're serious engineering, but they're PLC *replacements*, not test-first development tools for an existing vendor's hardware.
18+
19+
## The ladder-as-text problem
20+
21+
The pattern across all of this is clear: **people who want text go to Structured Text, and people who want ladder stay graphical.** The IEC standard positions ST as the text-based option. The CODESYS/Beckhoff ecosystem is investing heavily in that direction.
22+
23+
But ladder remains the dominant language in North American discrete manufacturing, especially on platforms like Click, Do-more, and Allen-Bradley. Those users are stuck in proprietary graphical editors with no version control, no automated testing, and often no simulation. The ST crowd has options. The ladder crowd doesn't.
24+
25+
The few Python projects that have tried to represent ladder, [pyladdersim](https://github.com/akshatnerella/pyladdersim) being the most visible, model rungs as flat lists of component objects:
26+
27+
```python
28+
rung = Rung([Contact("Start"), InvertedContact("Stop"), Output("Lamp")])
29+
```
30+
31+
The structure that makes ladder readable is lost. The idea of a proper text-based ladder with testability has been [floating around since at least 2000](https://mail.python.org/pipermail/python-list/2000-March/049350.html), but nobody shipped it. A CODESYS Forge user [asked for ladder scripting in Python in 2017](https://forge.codesys.com/forge/talk/Engineering/thread/fdc3d03c95/); the answer was "You can't do Ladder with Scripting."
32+
33+
## What pyrung does differently
34+
35+
```python
36+
with Program() as logic:
37+
with Rung(Start, ~Stop):
38+
out(Motor)
39+
with Rung(Fault):
40+
reset(Motor)
41+
```
42+
43+
Condition on the rail, instruction in the body. The `with` block naturally separates "when this is true" from "do this," which is exactly what a ladder rung does. It reads like the diagram, runs as a deterministic scan cycle, and targets real Click PLC behavior faithfully.
44+
45+
**The code looks like ladder.** Not Structured Text. Not flat lists. Not ASCII art. The DSL preserves the condition/instruction structure that makes ladder readable, in code a controls engineer can map to the diagram they already know.
46+
47+
**You can watch it evaluate.** A full DAP-protocol VS Code debugger steps through scans rung by rung. Breakpoints on rungs, inline tag updates, force values, diff between scans, time-travel through history.
48+
49+
**It targets real hardware faithfully.** Nearly the complete Click instruction set, memory banks, numeric quirks, scan-cycle semantics. If your program behaves differently in pyrung than on a Click PLC, that's a bug.
50+
51+
**It deploys without transposing.** pyrung compiles to two backends from the same tested source. For Click, [laddercodec](https://github.com/ssweber/laddercodec) encodes your rungs into the bytes the CLICK editor expects on paste. For the ProductivityOpen P1AM-200, pyrung generates a self-contained CircuitPython scan loop that runs directly on the hardware with the same Modbus TCP interface as a Click. Write once, test once, deploy to either.
52+
53+
## Where pyrung fits
54+
55+
The emerging tools above are vendor-agnostic by design. rungs.dev runs in a browser. OpenPLC replaces the vendor stack entirely.
56+
57+
pyrung chose fidelity over generality, because the whole point is to test before you deploy to a specific PLC. But fidelity to Click behavior doesn't mean fidelity to Click hardware alone. The same logic that simulates faithfully against Click's instruction set can also compile to CircuitPython and run on open hardware with no proprietary toolchain in the path. That combination, faithful simulation of a real vendor's behavior plus deployment to hardware the vendor doesn't control, is something none of the tools above attempt.
58+
59+
The ladder-as-text problem has been open for 25 years. pyrung is a serious attempt at closing it.

docs/index.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Ladder as Text
2+
3+
```python
4+
from pyrung import Bool, Program, Rung, latch, reset
5+
6+
Start = Bool("Start")
7+
Stop = Bool("Stop")
8+
Motor = Bool("Motor")
9+
10+
with Program() as logic:
11+
with Rung(Start):
12+
latch(Motor)
13+
with Rung(Stop):
14+
reset(Motor)
15+
```
16+
17+
That's a ladder rung. Condition on the `Rung`, instruction in the body. It reads like the diagram, runs as a deterministic scan cycle, tests with pytest, and compiles to real hardware.
18+
19+
Ladder logic dominates North American discrete manufacturing, but the tooling hasn't kept up. No version control, no automated testing, no way to simulate without hardware. The Structured Text crowd has options. The ladder crowd doesn't.
20+
21+
## pyrung
22+
23+
[pyrung](https://ssweber.github.io/pyrung/) is a Python DSL for writing, simulating, and testing ladder logic. The `with` block naturally separates the condition from the instruction, which is exactly what a ladder rung does. A controls engineer can map it to the diagram they already know.
24+
25+
Every scan produces an immutable state snapshot. Time is a variable you control. A DAP debugger lets you step through scans rung by rung in VS Code. Currently targets AutomationDirect Click PLC behavior faithfully: nearly the complete instruction set, memory banks, numeric quirks, scan-cycle semantics.
26+
27+
### Two deployment targets
28+
29+
pyrung compiles to two backends from the same source:
30+
31+
**Click PLC** via [laddercodec](https://github.com/ssweber/laddercodec). Your tested logic encodes to the bytes the CLICK editor expects on paste. No transposing by hand.
32+
33+
**ProductivityOpen P1AM-200** via CircuitPython code generation. Your tested logic becomes a self-contained scan loop that runs directly on the hardware, with the same Modbus TCP interface as a Click. No proprietary toolchain in the path.
34+
35+
Write it once, test it once, pick your target.
36+
37+
```mermaid
38+
graph LR
39+
D[Click Project] -->|codegen| A[pyrung]
40+
A -->|encode| B[laddercodec]
41+
B -->|paste| C[ClickNick]
42+
C --> D
43+
D -->|download| E[Click PLC]
44+
A -->|generate| G[CircuitPython]
45+
G -->|deploy| H[P1AM-200]
46+
E <-->|Modbus TCP| F[pyclickplc]
47+
H <-->|Modbus TCP| F
48+
```
49+
50+
### Existing projects welcome
51+
52+
Generate pyrung code from an existing `.ckp` project. You don't have to start from scratch to get simulation and testing on programs you've already built.
53+
54+
## The supporting projects
55+
56+
Each of these works on its own, but they were designed to work with pyrung.
57+
58+
**[ClickNick](https://github.com/ssweber/clicknick)** is the Windows-side glue. Autocomplete over the CLICK editor's instruction dialogs, a modern address editor with bulk editing and search/replace, a tag browser with hierarchy and array grouping, a DataView editor with drag-and-drop, and clipboard integration for pasting encoded rungs. Works alongside your existing `.ckp` projects.
59+
60+
**[pyclickplc](https://ssweber.github.io/pyclickplc/)** is the Modbus TCP layer. Read and write registers on real Click hardware, or run pyrung as an emulated Click that any Modbus client can talk to. Also manages nickname and DataView files. Both the Click PLC and the P1AM-200 speak the same Modbus interface, so pyclickplc doesn't need to know which one it's talking to.
61+
62+
**[laddercodec](https://github.com/ssweber/laddercodec)** is the binary codec for Click's undocumented clipboard format. Encodes rungs described in CSV into the bytes the CLICK editor expects on paste. Reverse-engineered from scratch; the format remains undocumented by its creator.
63+
64+
## Limitations
65+
66+
pyrung simulates Click PLC behavior as faithfully as possible, but it is not a certified simulator. If your program behaves differently in pyrung than on a Click PLC, that's a bug we want to know about, but you should always validate on real hardware before deploying to production. The CircuitPython target runs on a garbage-collected runtime, so sub-millisecond scan timing is not realistic. Modbus TCP has no built-in authentication; keep it on isolated networks.
67+
68+
## Blog
69+
70+
- [These Aren't the Rungs You're Looking For](blog/these-arent-the-rungs.md) - How I reverse-engineered Click's clipboard format so my bytes could paste without any problems.
71+
- [Why pyrung?](blog/why-pyrung.md) - A look at the PLC tooling landscape and where pyrung fits.

mkdocs.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
site_name: ssweber
2+
site_url: https://ssweber.github.io/
3+
4+
theme:
5+
name: material
6+
7+
markdown_extensions:
8+
- pymdownx.superfences:
9+
custom_fences:
10+
- name: mermaid
11+
class: mermaid
12+
format: !!python/name:pymdownx.superfences.fence_code_format
13+
14+
nav:
15+
- Home: index.md
16+
- Blog:
17+
- "These Aren't the Rungs You're Looking For": blog/these-arent-the-rungs.md
18+
- "Why pyrung?": blog/why-pyrung.md

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "ssweber-site"
3+
version = "0.0.1"
4+
requires-python = ">=3.12"
5+
6+
[dependency-groups]
7+
docs = [
8+
"mkdocs-material",
9+
]

0 commit comments

Comments
 (0)