The Inno Setup script hardcoded AppVersion '0.1.0' - a stale second source of truth that would ship the wrong version in Add/Remove Programs. It now takes the version at compile time via /DAppVersion (0.0.0-dev sentinel if omitted), and the README/CLAUDE.md build commands derive it from cim_suite.__version__. Also documents the full release sequence and notes-before-bump ordering in the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A modern Python 3 + PySide6 rebuild of a legacy VB6 instrument tool, the DA-12
Monitoring Station Service Tool — a serial/RS-232 utility that configures and
monitors CIMTechniques DA-12 sensor stations during production and in the field.
The DA-12 VB6 baseline (DA12-Service.exe, Main.frm, Calib.frm, Support.bas,
DA-Service.vbp, etc.) now lives at cim_suite/modules/da12/legacy/ — read it to
confirm protocol behavior, never modify it.
Bigger direction: DA-12 is now module #1 of the unified CIMTechniques Service
Suite — a monorepo under import root cim_suite/ with a shared core package and
a shell launcher, so customers install/sign/update one app. See
docs/SUITE-ARCHITECTURE.md.
Module #2 — DA-07 ("eLink"): rebuilt 2026-06-02 at cim_suite/modules/da07/
following the same five-layer pattern (--module da07 --simulate). Its protocol is a
different dialect of the same CIMTechniques family, so it has its own
da07/protocol/ rather than reusing DA-12's: frames are ~<payload><checksum>\r
(checksum = sum of ASCII incl. ~, mod 256), multi-byte numbers are little-endian,
and channel values are IEEE-754 floats (vs DA-12's big-endian fixed-point). It also
has an extra hierarchy level — Station → Devices (pods) → Channels. Its I/O model
is a polled handshake, not a stream (DA-12 streams {...} frames unsolicited): the
station sends a refresh reply one frame per ACK and interleaves Z2 idle polls
into it. The controller must both ACK every inbound data frame with Z1
(da07/domain/controller.py::_process) and answer the station's Z2 idles with
Z2 (_handle_poll; Z0=NAK→resend) — miss either and the load stalls partway. This
was hardware-verified 2026-06-03; the simulator models the stream via handshake=True.
(A real station also sends an unmodelled M frame the controller ACKs past.) The DA-07 VB6
baseline is at cim_suite/modules/da07/legacy/ (read-only). Spec:
docs/superpowers/specs/2026-06-02-da07-service-tool-rebuild-design.md. Deferred
production features and hardware-verification items are tracked in docs/BACKLOG.md
(BL-E1..E3) and docs/HARDWARE-VERIFICATION.md (DA-07 section).
Module #3 — IOModbus: rebuilt 2026-06-03 at cim_suite/modules/iomodbus/
following the same five-layer pattern (--module iomodbus --simulate). It is the
odd one out: a standard, config-driven Modbus RTU master, not a CIMTechniques
proprietary stream. It has no fixed protocol of its own — a bundled text catalog
(resources/IOModbus.txt, parsed by protocol/regdef.py) describes every supported
device as Modbus register maps, and everything shown/written is derived from it.
Its own iomodbus/protocol/: Modbus CRC-16 (crc.py), the 11 register
data-type codecs (codecs.py — big-endian regs, signed-16 bipolar, the
device-specific -19999 offset, normal/reversed floats), an 8-byte request builder
and a transaction-scoped ResponseAssembler (pdu.py). The key structural
difference from DA-12/DA-07: the link is half-duplex request/response, so the
controller (domain/controller.py) drives a timer-stepped scan + poll state
machine with an _awaiting interlock (send one request → wait for its response or a
timeout → advance; the response handler never re-sends, so the synchronous simulator
can't recurse). _tick is a plain method so tests drive it deterministically. The
simulator (transport/simulator.py) is an in-memory Modbus slave bank seeded from
the catalog (pull-based: write(request)→reply; tick() only mutates state). The
IOModbus VB6 baseline is at cim_suite/modules/iomodbus/legacy/ (read-only; the
IOBuilder/ companion edits the catalog). Spec:
docs/superpowers/specs/2026-06-03-iomodbus-service-tool-rebuild-design.md. Deferred
features (LAN/Modbus-TCP, Debug/Test/RegInfo windows, IOBuilder) and HW-verification
items are tracked in docs/BACKLOG.md (BL-I1..I3) and docs/HARDWARE-VERIFICATION.md
(IOModbus section).
Cross-module — Device settings repository: a suite-wide SQLite store at
%LOCALAPPDATA%\CIMTechniques\Service Suite\devices.db (separate devices.sim.db
under --simulate) that change-logs DA-12/DA-07 configuration settings keyed by
station MAC (first sighting + on change; live values excluded). Pure store at
cim_suite/core/repository/store.py; a Qt RepositoryRecorder debounces controller
signals and writes only changed config fields; per-module domain/repo_snapshot.py
adapters flatten models into namespaced (key, label, value, display) tuples; a
reusable SettingHistoryDialog shows per-row + whole-device history. IOModbus is out
of scope. Revert-to-previous is BL-R1. Spec:
docs/superpowers/specs/2026-06-06-device-settings-repository-design.md.
All three rebuilds are functionally complete against their simulators. The outstanding milestone is verification against real DA-12, DA-07, and Modbus hardware (see Hardware verification below).
Commands
# Setup (Windows, Python 3.11+)
py -m venv .venv
.venv\Scripts\python -m pip install -e ".[dev]"
# Run against the in-memory simulator (no hardware) — the normal dev path
.venv\Scripts\python -m cim_suite.shell.app --module da12 --simulate
# Against real hardware / a saved port
.venv\Scripts\python -m cim_suite.shell.app --module da12 --port COM3
# Open the suite launcher landing page (module card grid)
.venv\Scripts\python -m cim_suite.shell.app
# Tests & lint
.venv\Scripts\python -m pytest -q
.venv\Scripts\python -m pytest tests/core/test_codecs.py -q # one file
.venv\Scripts\python -m pytest tests/core/test_codecs.py::test_name # one test
.venv\Scripts\python -m ruff check cim_suite tests
# Build standalone exe (one-folder) + verify it actually booted
.venv\Scripts\pyinstaller --noconfirm --distpath packaging\dist --workpath packaging\build packaging\suite.spec
$env:SUITE_SELFTEST="1"; .\packaging\dist\CIM-Service-Suite\CIM-Service-Suite.exe --module da12 --simulate; echo "exit=$LASTEXITCODE"; Remove-Item Env:\SUITE_SELFTEST
# Build installer (needs Inno Setup 6). Version is single-sourced from pyproject.toml,
# so pass it in (else the installer falls back to the 0.0.0-dev sentinel):
$v = .venv\Scripts\python -c "import cim_suite; print(cim_suite.__version__)"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$v packaging\installer.iss
The whole test suite runs headless with no hardware (SimulatedStation speaks the
real wire protocol; UI tests use pytest-qt offscreen). Keep pytest green and
ruff clean — both are part of "done" here.
Architecture
Five layers, strictly ordered. The protocol core is pure (no I/O, no Qt) and the device is swappable — this is the central discipline, not an accident.
| Layer | Package | Role |
|---|---|---|
| Shared core | cim_suite/core |
protocol/codecs.py (hex/scale primitives + FieldReader); transport/base.py + serial_transport.py (BaseTransport interface + pyserial reader thread); ui/theme/ (design system tokens → fonts → stylesheet → apply_theme); ui/table_tab.py; config.py (generic, app-name-keyed); module.py (the Module Protocol + ComingSoonModule). Extracted conservatively — only obviously-shared pieces (rule of three). |
| DA-12 Protocol (pure) | cim_suite/modules/da12/protocol |
framing (extract {...} bodies), decoder (body → messages dataclass), encoder (build outbound command strings). No I/O. Fully unit-tested. |
| DA-12 Transport | cim_suite/modules/da12/transport |
SimulatedStation (in-memory fake DA-12, same BaseTransport interface). SerialTransport now lives in cim_suite/core/transport. |
| DA-12 Domain | cim_suite/modules/da12/domain |
models (in-memory SensorTable/LimitsTable/StatsTable/StationSettings, plain Python); calibration, logger; StationController (the DA-12 hub — see below). |
| DA-12 UI | cim_suite/modules/da12/ui |
MainWindow + 5 tabs (Station, Sensors, Alarm Limits, Statistics, Calibration), dialogs. Thin views: render models, call controller methods on edit. |
| Shell | cim_suite/shell |
app.py (entry point); registry.py (static module list); launcher.py (card landing page — grid of module cards); window.py (SuiteWindow hosting the launcher or the active module, with a "☰ Suite" back action). |
StationController is the hub (domain/controller.py): it owns the models and
the transport, decodes inbound frames into model updates, emits high-level Qt signals
(sensorsChanged, limitsChanged, etc.) the UI subscribes to, and exposes one method
per outbound command. Data flow:
transport (thread) → controller._rawReceived signal → _process → framer → decode → models → *Changed signals → UI
UI edit → controller.set_* → encoder → transport.write
Qt threading — the key trick: the reader thread feeds the controller via the
internal _rawReceived signal connected with AutoConnection (default). It
delivers synchronously when emitted from the GUI thread (simulator/tests need no
event loop) and queues onto the GUI thread when emitted from the serial reader
thread (thread-safe). Preserve this — don't add explicit Qt.QueuedConnection or
locks around it.
Entry point quirk: cim_suite/shell/app.py uses relative imports within the
package, so it must run as a module (python -m cim_suite.shell.app). The
PyInstaller bundle entry is packaging/suite_launcher.py, which imports the package
absolutely — running app.py directly as the frozen entry crashes with "attempted
relative import." Don't "fix" this by flattening imports.
VB6-fidelity rules (where silent wrong-data bugs hide)
This is a reverse-engineered instrument protocol. The codecs intentionally mimic VB6
semantics — when touching protocol/codecs.py, decoder.py, or encoder.py, keep
these exact:
- Signed hex. Station values are scaled integers in hex, parsed as signed
16-bit (
CInt("&H"...)):&H8000= −32768,&HFFFF= −1. Use the signed helpers; never plainint(s, 16). Int()floors toward −∞, not toward zero.encode_scaledusesmath.floorfor this reason (matters for negative calibration offsets). Python'sint()would be wrong.- Sentinels are per-field, not global.
8000/FFFF8000means "no value" in value/limit fields but is a legitimate number in the scale/offset (AddFloat) fields — hence the separatedecode_floatvsdecode_scaled. Don't unify them. - Each field can have its own scale (the format-code argument, 0–6 = decimal
places). Map them individually from the parse routine, mirroring
UpdateServiceinMain.frm.
The general version of these traps is documented in docs/VB6-MIGRATION-PLAYBOOK.md
§5 — read it before reverse-engineering any new protocol field or the next app.
Wire protocol shape
Inbound: the station streams {<TypeLetter><field0>\t<field1>...} frames. Decoder
type letters: A full sensor record, B average update, C/I current value,
D/E station setting lines, F status frame, G alarm limits, H statistics.
Outbound commands are built in encoder.py (e.g. {Y} refresh-all, {X} refresh
stats, {V} reset, {T...} set clock, and {<letter><fmt><id><payload>} settings
writes). The controller's set_* methods map UI grid columns to these letters.
Hardware verification (still pending)
Five protocol details can only be confirmed against a real DA-12 and are each
isolated to a small, clearly-commented spot: station clock epoch/format, sensor
type/Calc enum codes, the 16-channel message/letter mapping, and especially the F
status-frame layout (decoded but unverified — decoder._decode_status is flagged,
and the type-F counters are deliberately not surfaced in the UI until a real capture
exists). Work through docs/HARDWARE-VERIFICATION.md with hardware connected. The
repo has no ground-truth frame captures yet (Exceptions.txt/VB187.tmp are a
crash log and a form backup) — the first real frames you capture should be saved as
test fixtures.
Conventions & docs
- Branches:
mainis now the Python rebuild mainline (the rebuild was promoted tomainon 2026-06-02).rebuild/python-pyside6still exists, pointing at the same commit — either branch is fine to keep working on; they diverge from wherever you commit next. The original VB6 baseline is preserved per-module undercim_suite/modules/<id>/legacy/(read-only), not as a branch. Commit per logical step; keep tests green. - Dropped legacy features (wireless sensors, OP-05 annunciator, Debug tab,
factory serial-number write) are documented with exact restore locations in
docs/superpowers/specs/2026-06-01-da12-service-tool-rebuild-design.md. Don't silently re-add or assume they're gone for good — they're restorable on request. - Data locations (never next to the exe): config JSON, calibration history
(
DACal.csv), and per-sensor logs (DA-Logs\<serial>.txt) all live under the user app-data folder viacim_suite/modules/da12/config.py. - The doc pair:
docs/BACKLOG.mdis the live to-do list;docs/REBUILD-STATUS.mdis point-in-time status. They cross-link — keep them in sync when finishing an item. - UI edit loop: when repopulating a table from the model, block its
itemChangedsignal so programmatic fills don't fire phantom "user edited" events. - Design system: all UI follows
docs/DESIGN-SYSTEM.md— the suite-wide visual standard, implemented as a self-contained theme layer atcim_suite/core/ui/theme/(tokens → fonts → stylesheet →apply_theme). Never hard-code colors/fonts/px in a widget or callsetStyleSheetin module code; pull fromthemetokens and extend the system there if it's missing something. The doc has a per-module adoption checklist. - Spreadsheet export: every data screen can be exported to
.xlsx; the engine iscim_suite/core/export/and the per-module adoption checklist is indocs/EXPORT.md. - Versioning & changelog: SemVer, single-sourced from
[project] versioninpyproject.toml(cim_suite.__version__reads it; the frozen build bundles the file; the Inno Setup installer takes it viaISCC /DAppVersion=...— see Commands). Commits are Conventional Commits (<type>: <desc>) — validated by.githooks/commit-msg(install once withpython scripts/setup_hooks.py). The deterministicpython scripts/bump.pycomputes the next version from commit prefixes (fix=patch,feat=minor,BREAKING CHANGE/!=major) — never let an LLM pick the bump. The pure logic lives inscripts/release/(tested intests/release/); git I/O is isolated inscripts/release/gitio.py. Two records, two audiences: git history is the technical record;CHANGELOG.mdis plain-language release notes for non-technical users (also shown via the launcher's "What's new" dialog). The changelog prose is the only LLM-assisted step — draft it with the/release-notescommand, and never commit it unreviewed; accuracy over flattery (don't invent user benefits a commit doesn't support).