IRI Filler Pro — HAL Interface Specification v0.6
Confidential — under NDA · YAVU · rev. 0.6, 2026-08-18 Audience: manufacturer electronics/firmware engineers Scope: everything the manufacturer implements in firmware. Nothing more.
1. The two-layer model (contract)
| Layer | Who | Contents | Change process |
|---|---|---|---|
/core |
YAVU only | licence client, state machine, dose limit (60 µl), shot counting/billing, offline unlock codes, OTA logic, display screen content | closed — manufacturer never edits |
/hal |
Manufacturer | pin mapping, motor/servo/actuator drivers, sensors, power/charging, display panel driver | pull request against the YAVU repository; main is protected, YAVU reviews & merges |
The manufacturer implements the C++ interfaces in src/hal/hal.h (v0.6) for the chosen controller. The reference target is ESP32-S3 (hal_esp32.cpp stub provided). The core never touches registers, pins or buses — if a hardware detail changes, only /hal changes.
Deliverable for the quotation: implementation of the six interfaces below + board bring-up. No server, no platform, no application logic, no screen design — these exist already (see repository: API contract, acceptance test plan, UI screens, boot assets).
Changes in v0.6 (2026-08-18): -
IFirmwareUpdateis new (§2.11) — the field-update sink. Core downloads, verifies (SHA-256) and only then flashes; only in the dock. Returnnullptrif the board cannot update in the field. -IDisplayUi::showCodeEntry()added with a no-op default (§2.10): the offline unlock code is now typed on the four panel keys; nothing else to implement. - Watchdog / brownout policy (§2.12) documented;main.cpparms the task watchdog. - Wi-Fi onboarding requirement stated in §6 — your choice, must be repeatable by the user.Changes in v0.5 (2026-08-10, later the same day): -
IUserInputis new (§2.10) — the four front-panel keys. Core now drives dose, end-treatment and fault acknowledgement from them, so the device is operable without a display module. Returnnullptrif your device has no local panel. -IVoiceCoilDriver::busy()is new and mandatory (§2.9). Core no longer bills a voice-coil shot until the driver reports completion andplungerMm()confirms the plunger actually moved. "No error" is not proof of delivery. - The licence transport now requires a pinned root CA (§6). It fails closed.Changes in v0.4 (2026-08-10): added
IPersist(§2.7) andhalBegin()(§2.8);BiphasicProfiletiming changed from milliseconds to microseconds; the referencehal_esp32.cppnow provides working stubs for every factory (includinghalVoiceCoil(), which returnsnullptron spring-drive builds).
2. Interfaces to implement (from hal.h v0.6)
2.1 ICockingMotor — gearmotor + pinion on ring gear (middle sleeve)
startCock(fastMode)— performs the half-turn (180°) cocking rotation. Fast profile ≤ 1.5 s, quiet profile ~2.5 s (soft-start/soft-stop ramps for acoustics).startDecock()— controlled spring release without firing: reverses the half turn back to home. Used every time the practitioner ends a treatment; the device is stored decocked.busy(),lastStatus()— non-blocking; core polls. Status:OK / TIMEOUT / STALL / SENSOR_FAULT.isCocked(),isHome()— two limit/position switches.- Timeout budget: fast ≤ 2.5 s, quiet ≤ 4 s → then
TIMEOUT, core shows F-code.
2.2 IDoseServo — rotates the existing front dose sleeve
moveToUnits(1..10)with position feedback (currentUnits(),atTarget()); index sensor on the existing detent positions is sufficient.- The mechanical hard stop (60 µl) stays physical — the servo must never be able to push past it (slip clutch or torque limit in the drive).
2.3 IReleaseActuator — fires nothing by itself
- One
pulse(ms)unlatches the existing ball detent (< 5 N). Armed/disarmed exclusively by core logic.
2.4 ITrigger — TOP button on the grip
pressed()returns the LEVEL: true for as long as the button is held. Core samples it and applies its own 30 ms debounce and edge detection. Do not implement an auto-clearing "edge-latched" flag — a one-sample pulse is swallowed by core's debounce and the device would never fire. Light hardware/RC debouncing is welcome; latching is not.- Thumb/index operation; exact position per ergonomic mock-up.
2.5 IPowerSystem — battery + rear dock charging
- Certified 18650 brand cell (IEC 62133 / UN 38.3) + standard charger/protection IC; rear pogo pins or inductive dock contact.
batteryPercent(),inDock(),charging().- Hard safety rule: while
inDock()is true the release actuator supply is physically cut (load switch) — a shot in the dock must be impossible even on firmware fault.
2.6 IDisplayUi — panel driver only
- Screen layouts, wording, animations and videos come from YAVU (
docs/ui_screens.htmlv1.7,assets/boot/). The HAL renders the provided assets/framebuffers. - Controller note: ESP32-S3 plays MJPEG at moderate resolution; full 720p H.264 needs ESP32-P4-class. Both asset formats are already provided. → to be decided with YAVU.
2.7 IPersist — non-volatile key/value store (NEW in v0.4)
getU32 / putU32 / getBlob / putBlob, ESP32 reference:Preferenceson NVS, namespaceiri.- Why it is not optional: core stores the shot sequence counter, the unsent-shot queue,
the last-online timestamp and the unlock-window start here. If these do not survive a
power cycle, billing records are lost and the 72 h offline grace can be reset by simply
restarting the device. Blob capacity needed: ~3.9 kB in one entry, and it is
rewritten on every shot (~1.9 kB average per shot). Size the NVS partition
accordingly — the default 20 kB
nvspartition is not enough. TODO(manufacturer): propose a partition layout and a flash-endurance figure for 300 shots/day over the intended service life. If that is not comfortable, tell us and we will move core to an append-only ring of small entries. putBlobmust return false when the write fails. Core treats a failed write as a lost shot and refuses to fire rather than silently dropping a billing record.
2.8 halBegin() — one-time platform init (NEW in v0.4)
- Pin modes, pull-ups, NVS open. Called once from
setup()before the controller starts.
2.9 IVoiceCoilDriver — only on voice-coil variants
capReady / charge / fire(BiphasicProfile) / abort / plungerMm / lastStatus.- The profile requests a plunger FORCE in newtons, not a coil current. Core knows the force the hydraulics need; only you know the coil's Kf [N/A] and can convert to amps (and clamp to its safe pulse current). This also removes the old 65.5 A ceiling.
- The discharge stage must be unpowered while
inDock(), and the bank must bleed down within a defined time after power-off and on docking (IEC 60601-1, skin-contact device). - ~~Open item: the interface has no completion signal.~~ Closed in v0.5.
busy()is now mandatory, and core bills a voice-coil shot only after the driver reports completion andplungerMm()confirms the plunger moved far enough.plungerMm()must therefore be trustworthy — the same reading also drives the chamber cross-check (§4b.3). - Return
nullptrfromhalVoiceCoil()on spring-drive builds — core then selects the spring path automatically. Timing in the profile is in microseconds (a 1-unit shot lasts ~2 ms, so millisecond resolution would quantise the smallest dose by up to 25 %). - Target specification and the required bench test:
docs/VOICE_COIL_DRIVE.mdrev. D — this is YAVU's preferred drive, not a side option.
2.10 IUserInput — front-panel keys (NEW in v0.5)
- Four plain level signals:
dosePlus,doseMinus,endTreatment,confirm. True while held. Core debounces and does edge detection, exactly as forITrigger— do not latch them. - Behaviour core attaches to them: dose ±1 unit within 1–10; end treatment de-cocks and stands the device down (it does not re-cock afterwards — a device put down between clients must never sit with a loaded spring); confirm acknowledges a fault, and resumes a stood-down treatment.
halUserInput()may returnnullptr— then there are simply no local controls.- v0.6: the same four keys also carry the offline unlock-code dialog while the
device is LOCKED (confirm opens, dose ± sets the digit, confirm advances / submits,
end-treatment cancels). Nothing to implement on your side; if you have a display,
implement
IDisplayUi::showCodeEntry()to render the eight digits and the cursor — it has a no-op default, so an existing driver still compiles.
2.11 IFirmwareUpdate — field update sink (NEW in v0.6)
begin(size) / write(data,len) / end() / abort() / reboot() / runningVersion().- Core does everything above this: it fetches the manifest over the pinned licence
channel, streams the image, recomputes the SHA-256 on the fly and compares it to the
manifest before calling
end(). Yourend()must only mark the image bootable if it is complete;abort()must leave the running image untouched. - Core calls this only while docked, idle, above 50 % battery and with the spring
released — never in the customer's hand.
reboot()is likewise only called from there. - ESP32 reference:
Update.hwith a two-slot OTA partition table (default_ota.csv). ReturnnullptrfromhalFirmwareUpdate()if the board cannot update in the field; core then never attempts it. runningVersion()is what the heartbeat reports asfw_version. Set it at build time (-DIRI_FW_VERSION=\"x.y.z\").
2.12 Watchdog and brownout (policy, no interface — NEW in v0.6)
main.cpparms the ESP32 task watchdog (20 s) after the network dance and feeds it once perloop(). Core is non-blocking; the longest single call is a bounded HTTPS request. If your board uses a different MCU, provide the equivalent.- The hardware brownout detector stays enabled (default). After a brownout, watchdog or panic reset the controller boots stood down: it does not cock on its own until confirm is pressed once. All billing state is in NVS and survives; nothing to do for you.
6. The licence transport is pinned and fails closed (NEW in v0.5)
Every shot is billed over this channel. Without certificate validation a studio's own access point could impersonate the register, answer "all fine, grace 8760 h" to every heartbeat and swallow the shot uploads.
Core therefore requires a pinned root CA, provisioned into NVS as ca_pem, and refuses
to connect at all without it — it never downgrades to an unverified link. A device without
a provisioned CA simply behaves as if it were offline: the 72 h grace carries the treatment
and then it locks.
Provisioning — two ways, your choice. (a) Cable-free pairing (default): you write
NOTHING per device. api_base and the root-CA bundle are compiled into the image
(-DIRI_API_BASE, -DIRI_ROOT_CA_PEM — the bundle may hold two roots, current and
successor). A fresh device joins Wi-Fi, calls POST /enroll, shows a 6-digit code on the
display (IDisplayUi::showPairing), and receives its token and unlock secret once the
operator pairs it in the register. Firmware stores both in NVS. (b) Factory token:
POST /admin/devices/provision in the register returns device_token + unlock_secret_hex
once; write dev_token (string) and unlock_key (32 raw bytes) into NVS namespace iri
on the line. Either way, NVS values override the compiled defaults.
Wi-Fi onboarding (your choice, please state it in the quotation): the device joins the studio's own Wi-Fi, so the studio has to hand over its credentials once — and again whenever it changes network (a training device moves from our premises to the studio). Two usual options: BLE onboarding from a phone (the ESP32-S3 has BLE on the same chip, no extra part), or a one-time captive portal (the device opens its own access point, the user types the password in a browser). Core reads stored credentials; the onboarding flow itself is yours. It must be repeatable by the end user, not a factory-only step.
3. Sequencing (owned by core — for reference)
Menu → start treatment → startCock() → [cocked] → treatment cockpit
→ trigger pressed → core checks (licence · cocked · dose · not inDock)
→ releaseActuator.pulse() → shot → auto startCock() → …
End treatment → startDecock() → [home] → trigger disarmed → menu
Radio: pre-certified Wi-Fi module (CE-RED modular approval) — see Drive Concept v1.3 §8. Wi-Fi/TLS stack and licence traffic are core; HAL only guarantees the module is wired per its reference design.
3b. Verifying your HAL against core, before any hardware exists
The repository ships a simulated HAL and a host test suite. They run on a plain PC in about a second, with no ESP32 and no toolchain:
./tools/host_test.sh # runs the full device logic against a simulated HAL (204 checks)
./tools/syntax_check.sh # compiles the ESP32-specific sources with lightweight shims
test/host/hal_sim.cpp is a complete worked example of every interface in this document —
use it as the reference for what core expects from each call.
4. Acceptance (payment-relevant)
Each HAL function maps to measurable tests in docs/ACCEPTANCE_TEST_PLAN.md (sections A–E): re-cock times fast/quiet, decock-on-end, dose positioning accuracy, trigger debounce, dock-shot-inhibit, noise ≤ 45 dB(A) @ 30 cm (quiet mode), battery/charging telemetry. Milestone payments are gated on these tests.
4b. Safety features that must be quoted AND tested (introduced in v0.4, item 3 revised 10.08.2026)
These three are requirements, not options. Each needs a line in the acceptance test plan before the corresponding milestone can be signed off:
- Non-backdrivable holding element on the cocking drive (worm stage or ratchet pawl).
The spring must stay safely cocked with power removed, and be releasable under motor
control on the next power-up.
ICockingMotorsays nothing about holding today — quote it explicitly. Test: cock, cut power, verify no backdrive; power up, verify the controlled de-cock runs before the device becomes ready. - Mechanical dry-fire block — the device must not be able to fire with no chamber fitted, or with an empty one. Firmware has no way to know the fill level. Test: attempt to fire with no chamber, and after the chamber's fifth 60 µl shot.
- Chamber presence detection at the chamber seat — a plain switch or contact telling the device that a chamber is fitted at all. It feeds the dry-fire block in item 2. Test: attempt to fire with no chamber fitted; fit each chamber and verify normal operation.
Changed 10.08.2026 — please read, this REDUCES your scope. Earlier revisions asked
for electrical chamber keying (coding resistor or Hall key) so the device could tell
the two chambers apart by itself. That requirement is withdrawn. Both chambers hold
0.3 ml and use the same screw thread, and the chamber is a proven, tooled part that we
are not changing to carry a coding element. Do not design a coding element into the
chamber. Instead the operator states which chamber is fitted (core::Chamber, already
implemented — the device asks once after every power-up and will not fire until it is
answered), and the firmware cross-checks that answer against the measured plunger travel
of every shot. No new HAL interface is needed; the HAL contract stays at v0.5.
Two things we do ask of you here:
- Leave room at the chamber seat for two spring contacts or a small magnet pocket,
without fitting them. It costs nothing in the design and keeps the option open if
practice shows the manual selection is not reliable enough.
- Measure the nozzle bore of the 5 ml refill chamber when you receive the reference
unit. The 0.3 ml chamber is measured at 0.18 mm (see measured data); the
refill chamber is believed to be narrower but has never been measured, and firmware
currently carries a placeholder for it. Background: docs/CHAMBER_SELECTION.md.
5. Open technical points
- Motor final sizing after torque measurement on the manual device (≥ 2× margin, ring-gear ratio 3:1–5:1 adjustable).
- Release actuator choice: mini solenoid vs. micro servo — evaluate on the rig.
- Display controller: ESP32-S3 (MJPEG) vs. ESP32-P4/SoC (720p H.264).
- Dock contact system: pogo pins vs. inductive.
- Trigger position on the ergonomic mock-up (top of grip, thumb/index).
Source: firmware repo docs/HAL_SPEC.md · confidential · NDA