"""
PresN2X Inflator HMI - MicroPython (Wokwi ESP32-S3 simulation)
--------------------------------------------------------------
v2: restructured around a 4-wheel-position state machine (FL/FR/RR/RL)
with independent FRONT and REAR target PSI, per Section 1 spec.
Maps to blueprint components:
- N2 output valve = 2/2-Way NC Output Solenoid Valve (220V AC)
"Opens to deliver N2 when tool handle is pulled"
- Vent valve = 3/2-Way Quick Exhaust Vent Solenoid Valve
"Vents hose line instantly between tire cycles"
NOTE: Real valves are 220V AC - GPIO must drive a relay/SSR, never
the coil directly. LEDs here simulate "valve energized" state.
--------------------------------------------------------------------
PIN MAP (matches diagram.json unless noted "NOT YET IN diagram.json")
--------------------------------------------------------------------
Buttons (active LOW, internal pull-up, debounced):
BTN_N2 - GPIO4 (btn1) - select N2, open N2 valve, fill current wheel
BTN_AIR - GPIO5 (btn2) - select regular air, open air valve, fill
BTN_UP - GPIO6 (btn3) - target PSI + 1 (adjusts FRONT or REAR
depending on current wheel position)
BTN_DOWN - GPIO7 (btn4) - target PSI - 1 (same rule as above)
BTN_PURGE - GPIO15 (btn5) - nitrogen purge sequence (vent/fill x2), fill
BTN_FLAT - GPIO16 (btn6) - flat tyre: direct N2 fill, no purge
BTN_COUNT - GPIO17 (btn7) - show today's + all-time inflation counts
Outputs:
N2_VALVE - GPIO40 (led1) - N2 output solenoid energized
(FIXED: diagram.json wires led1:A -> esp:42, NOT esp:40.
Either rewire led1 to GPIO40 in diagram.json, or treat
"led1" as a purely cosmetic label - the code below is
the source of truth: GPIO40 = N2, GPIO41 = VENT,
GPIO42 = AIR, regardless of which LED part lights up.)
VENT_VALVE - GPIO41 (led2) - quick-exhaust vent solenoid energized
AIR_VALVE - GPIO42 (led3) - regular-air valve energized
BUZZER - GPIO9 (buzzer1) - two patterns:
* COMPLETE: 3 short beeps when target PSI reached
* ERROR: fast siren-style beeping (sensor fault,
wrench-interlock violation, etc.)
WRENCH_SENSE - GPIO18 - NOT YET IN diagram.json.
Digital input from a pressure/flow switch on the
pneumatic wrench line (Line 1). HIGH = wrench in
use -> inflation must not run, per spec:
"if wrench is on inflation process will stop
completely and vice versa."
FAN_COMPRESSOR - GPIO12 - NOT YET IN diagram.json.
Relay/SSR driving the compressor-area fan.
Slaved to compressor-run status unless overridden.
FAN_UNIT - GPIO13 - NOT YET IN diagram.json.
Relay/SSR driving the whole-unit fan. Same logic.
FAN_COMP_MANUAL - GPIO14 - NOT YET IN diagram.json.
Momentary/toggle input: manual override for the
compressor fan (spec requires manual mode to ride
over automatic mode for both fans).
FAN_UNIT_MANUAL - GPIO21 - NOT YET IN diagram.json.
Manual override for the whole-unit fan.
COMPRESSOR_RUN - GPIO47 - NOT YET IN diagram.json.
Digital input reflecting compressor on/off state
(e.g. dry contact from the Condor MDR2 module, or
a current-sense relay). Drives both fans when not
in manual override.
These five pins are declared below but harmless if left unwired in
the simulator - they simply read/drive nothing visible until you add
the matching parts to diagram.json. Comment them out if MicroPython
complains about a specific pin being reserved on your board revision.
Sensor sim:
POT_TIRE - GPIO8 (ADC, pot1) - stands in for the tyre-side pressure
sensor. pot1:SIG must be wired to GPIO8 in diagram.json,
NOT GPIO38 - GPIO38 has no ADC capability on the ESP32-S3
(only GPIO1-10 and GPIO11-20 support ADC on this chip).
--------------------------------------------------------------------
WHEEL POSITION STATE MACHINE
--------------------------------------------------------------------
POSITIONS = ["FL", "FR", "RR", "RL"] (clockwise, per rider SOP)
Each job fill uses:
- FRONT_PSI target for FL, FR
- REAR_PSI target for RR, RL
current_position starts at FL each time a NEW car / NEW job set
begins (see start_new_job()), and auto-advances to the next position
every time a fill completes successfully. After RL completes, the
job set is considered finished (all 4 done) and position wraps back
to FL for the next car, but filling will not restart until the rider
presses N2/AIR/PURGE/FLAT again for the next car.
UP/DOWN adjust FRONT_PSI if current_position is FL or FR, and
REAR_PSI if current_position is RR or RL - this matches "front
standard pressure, rear standard pressure" from the app/Excel data
source described in the spec. Manual override of either value is
always available regardless of Case 1 / Case 2 auto-set.
--------------------------------------------------------------------
COUNTERS
--------------------------------------------------------------------
today_count : inflations done since the last midnight rollover
total_count : all-time inflations, persisted to flash (count_data.json)
NOTE: Wokwi's simulated filesystem resets each Play press - that's
a simulator limitation only, not a real-hardware issue.
- A job counts once per WHEEL, at the moment that wheel's fill
successfully reaches target and the valve closes.
- Real midnight reset needs a real wall clock (NTP over WiFi on real
hardware). SECONDS_PER_DAY is small here so a rollover is visible
while testing. For deployment: set SECONDS_PER_DAY = 86400 and
sync time once via ntptime.settime() after connecting to WiFi.
"""
from machine import Pin, ADC
import utime
# ---------------- Pins: buttons ----------------
btn_n2 = Pin(4, Pin.IN, Pin.PULL_UP)
btn_air = Pin(5, Pin.IN, Pin.PULL_UP)
btn_up = Pin(6, Pin.IN, Pin.PULL_UP)
btn_down = Pin(7, Pin.IN, Pin.PULL_UP)
btn_purge = Pin(15, Pin.IN, Pin.PULL_UP)
btn_flat = Pin(16, Pin.IN, Pin.PULL_UP)
btn_count = Pin(17, Pin.IN, Pin.PULL_UP)
# ---------------- Pins: valve/buzzer outputs ----------------
n2_valve = Pin(40, Pin.OUT)
vent_valve = Pin(41, Pin.OUT)
air_valve = Pin(42, Pin.OUT)
buzzer = Pin(9, Pin.OUT)
# ---------------- Pins: NOT YET IN diagram.json (see header) ----------------
try:
wrench_sense = Pin(18, Pin.IN, Pin.PULL_UP) # HIGH(idle)/LOW(in use) - adjust polarity to real switch
fan_compressor = Pin(12, Pin.OUT)
fan_unit = Pin(13, Pin.OUT)
fan_comp_manual = Pin(14, Pin.IN, Pin.PULL_UP)
fan_unit_manual = Pin(21, Pin.IN, Pin.PULL_UP)
compressor_run = Pin(47, Pin.IN, Pin.PULL_UP)
EXTRA_IO_OK = True
except Exception as e:
print("[IO] optional wrench/fan pins not available on this board/sim:", e)
EXTRA_IO_OK = False
# ---------------- Sensor ----------------
pot_tire = ADC(Pin(8)) # pot1:SIG must be wired to GPIO8, not GPIO38
pot_tire.atten(ADC.ATTN_11DB) # full 0-3.3V range
# NOTE: .width(ADC.WIDTH_12BIT) intentionally omitted - deprecated/
# unsupported on newer MicroPython ESP32 builds. 12-bit is default.
# ---------------- Wheel position state machine ----------------
POSITIONS = ["FL", "FR", "RR", "RL"] # clockwise fill order per SOP
pos_index = 0 # index into POSITIONS
FRONT_PSI = 32
REAR_PSI = 32
MIN_PSI = 15
MAX_PSI = 80
job_active = False # True once rider has started a job set (pressed N2/AIR/PURGE/FLAT for car #1 of the round)
def current_position():
return POSITIONS[pos_index]
def current_target():
pos = current_position()
return FRONT_PSI if pos in ("FL", "FR") else REAR_PSI
def set_current_target(psi):
global FRONT_PSI, REAR_PSI
pos = current_position()
if pos in ("FL", "FR"):
FRONT_PSI = psi
else:
REAR_PSI = psi
def advance_position():
"""Move to next wheel clockwise. Wrap to FL and end the job set
when RL is finished."""
global pos_index, job_active
if pos_index >= len(POSITIONS) - 1:
pos_index = 0
job_active = False
print("[JOB] all 4 wheels complete - job set finished, ready for next car")
else:
pos_index += 1
print("[JOB] advancing to next wheel -> {}".format(current_position()))
def start_new_job():
"""Call when rider begins a fresh car / job set (e.g. after
selecting a car reg in the app, or manually restarting)."""
global pos_index, job_active
pos_index = 0
job_active = True
print("[JOB] new job set started at {}".format(current_position()))
def apply_case1_defaults():
"""Case 1: brand-new car, no history. Placeholder for rider-entered
or default PSI until the app pushes real values over BLE."""
global FRONT_PSI, REAR_PSI
FRONT_PSI = 32
REAR_PSI = 32
print("[CASE1] new car - defaulted FRONT={} REAR={}, awaiting app data".format(FRONT_PSI, REAR_PSI))
def apply_case2_from_app(front_psi, rear_psi):
"""Case 2: known car. Call this once the BLE/app link delivers the
stored FRONT/REAR target PSI for this reg number."""
global FRONT_PSI, REAR_PSI
FRONT_PSI = front_psi
REAR_PSI = rear_psi
print("[CASE2] known car - app set FRONT={} REAR={}".format(FRONT_PSI, REAR_PSI))
# ---------------- Fill state ----------------
air_type = None # "N2" or "AIR"
filling = False
flat_mode = False
purging = False
purge_cycles_remaining = 0
purge_step = 0
purge_step_timer = 0
PURGE_STEP_MS = 1200
last_print = 0
PRINT_MS = 500
# ---------------- Beep patterns (non-blocking) ----------------
BEEP_STATE_IDLE = 0
BEEP_STATE_COMPLETE = 1
BEEP_STATE_ERROR = 2
beep_state = BEEP_STATE_IDLE
beep_step_timer = 0
beep_step_index = 0
# complete = 3 short beeps; error = continuous fast siren for 5s
COMPLETE_PATTERN_MS = [150, 150, 150, 150, 150] # on,off,on,off,on
ERROR_TOTAL_MS = 5000
ERROR_TOGGLE_MS = 120
def start_complete_beep():
global beep_state, beep_step_timer, beep_step_index
beep_state = BEEP_STATE_COMPLETE
beep_step_timer = utime.ticks_ms()
beep_step_index = 0
buzzer.value(1)
def start_error_beep():
global beep_state, beep_step_timer, beep_step_index
beep_state = BEEP_STATE_ERROR
beep_step_timer = utime.ticks_ms()
beep_step_index = 0
buzzer.value(1)
print("[ERROR] fault condition - siren active")
def update_beep():
global beep_state, beep_step_timer, beep_step_index
if beep_state == BEEP_STATE_COMPLETE:
elapsed = utime.ticks_diff(utime.ticks_ms(), beep_step_timer)
if elapsed >= COMPLETE_PATTERN_MS[beep_step_index]:
beep_step_index += 1
beep_step_timer = utime.ticks_ms()
if beep_step_index >= len(COMPLETE_PATTERN_MS):
buzzer.value(0)
beep_state = BEEP_STATE_IDLE
else:
buzzer.value(0 if buzzer.value() else 1)
elif beep_state == BEEP_STATE_ERROR:
total_elapsed = utime.ticks_diff(utime.ticks_ms(), beep_step_timer)
# crude toggle using a second timestamp stored in beep_step_index*ERROR_TOGGLE_MS
if total_elapsed >= ERROR_TOTAL_MS:
buzzer.value(0)
beep_state = BEEP_STATE_IDLE
else:
phase = (total_elapsed // ERROR_TOGGLE_MS) % 2
buzzer.value(1 if phase == 0 else 0)
# ---------------- Valve state tracking (for OPEN/CLOSE logging) ----------------
prev_valve_state = {"n2": False, "vent": False, "air": False}
def set_valves(n2_on, vent_on, air_on):
global prev_valve_state
n2_valve.value(1 if n2_on else 0)
vent_valve.value(1 if vent_on else 0)
air_valve.value(1 if air_on else 0)
new_state = {"n2": n2_on, "vent": vent_on, "air": air_on}
names = {"n2": "N2 valve", "vent": "Vent valve", "air": "Air valve"}
for key in ("n2", "vent", "air"):
if new_state[key] != prev_valve_state[key]:
action = "OPENED" if new_state[key] else "CLOSED"
print("[VALVE] {} {}".format(names[key], action))
prev_valve_state = new_state
# ---------------- Counters ----------------
COUNT_FILE = "count_data.json"
SECONDS_PER_DAY = 60 # simulated day length; set to 86400 for real deployment
today_count = 0
total_count = 0
last_day_index = 0
def load_counts():
global today_count, total_count, last_day_index
try:
with open(COUNT_FILE, "r") as f:
data = f.read()
parts = dict(
item.split(":") for item in data.strip("{}").replace('"', "").split(",")
)
today_count = int(parts.get("today", 0))
total_count = int(parts.get("total", 0))
last_day_index = int(parts.get("day", 0))
print("[COUNT] loaded saved counters: today={} total={}".format(today_count, total_count))
except Exception:
print("[COUNT] no saved counter file yet, starting at 0")
def save_counts():
try:
with open(COUNT_FILE, "w") as f:
f.write('{{"today":{},"total":{},"day":{}}}'.format(
today_count, total_count, last_day_index))
except Exception as e:
print("[COUNT] warning: could not save counters:", e)
def current_day_index():
return utime.ticks_ms() // 1000 // SECONDS_PER_DAY
def check_day_rollover():
global today_count, last_day_index
idx = current_day_index()
if idx != last_day_index:
print("[COUNT] new day rolled over - today's count reset to 0")
today_count = 0
last_day_index = idx
save_counts()
def record_inflation_done():
global today_count, total_count
today_count += 1
total_count += 1
save_counts()
print("[LOG] wheel={} target={} PSI job_today#{} all_time#{}".format(
current_position(), current_target(), today_count, total_count))
# ---------------- Debounced button edge detection ----------------
DEBOUNCE_MS = 50
btn_state = {
"n2": {"pin": btn_n2, "stable": 1, "last_raw": 1, "last_change": 0},
"air": {"pin": btn_air, "stable": 1, "last_raw": 1, "last_change": 0},
"up": {"pin": btn_up, "stable": 1, "last_raw": 1, "last_change": 0},
"down": {"pin": btn_down, "stable": 1, "last_raw": 1, "last_change": 0},
"purge": {"pin": btn_purge, "stable": 1, "last_raw": 1, "last_change": 0},
"flat": {"pin": btn_flat, "stable": 1, "last_raw": 1, "last_change": 0},
"count": {"pin": btn_count, "stable": 1, "last_raw": 1, "last_change": 0},
}
def pressed(key):
"""Debounced press detection. Returns True exactly once per press."""
s = btn_state[key]
raw = s["pin"].value()
now = utime.ticks_ms()
if raw != s["last_raw"]:
s["last_raw"] = raw
s["last_change"] = now
return False
if utime.ticks_diff(now, s["last_change"]) >= DEBOUNCE_MS:
if s["stable"] == 1 and raw == 0:
s["stable"] = 0
return True
s["stable"] = raw
return False
# ---------------- Wrench interlock ----------------
def wrench_in_use():
"""LOW = wrench line pressurized/active, per Pin.PULL_UP wiring
convention used elsewhere in this file. Adjust polarity to match
the real pressure/flow switch once wired."""
if not EXTRA_IO_OK:
return False
return wrench_sense.value() == 0
# ---------------- Fan control ----------------
def update_fans():
if not EXTRA_IO_OK:
return
comp_manual_on = (fan_comp_manual.value() == 0) # active LOW override
unit_manual_on = (fan_unit_manual.value() == 0)
if comp_manual_on:
fan_compressor.value(1)
else:
fan_compressor.value(1 if compressor_run.value() == 0 else 0)
if unit_manual_on:
fan_unit.value(1)
else:
fan_unit.value(1 if compressor_run.value() == 0 else 0)
# ---------------- Fill/purge actions ----------------
def read_tire_psi():
raw = pot_tire.read() # 0-4095
return int(raw * 100 / 4095) # simulate 0-100 PSI
def start_purge():
global purging, purge_cycles_remaining, purge_step, purge_step_timer, air_type, filling
if wrench_in_use():
print("[INTERLOCK] wrench active - purge blocked")
start_error_beep()
return
if not job_active:
start_new_job()
air_type = "N2"
filling = False
purging = True
purge_cycles_remaining = 2
purge_step = 0
purge_step_timer = utime.ticks_ms()
print("[PURGE] {} - nitrogen purge started - {} cycles".format(current_position(), purge_cycles_remaining))
def start_flat_fill():
global flat_mode, air_type, filling, purging
if wrench_in_use():
print("[INTERLOCK] wrench active - flat-tyre fill blocked")
start_error_beep()
return
if not job_active:
start_new_job()
flat_mode = True
air_type = "N2"
purging = False
filling = True
print("[FLAT TYRE] {} - direct N2 fill (no purge) -> target {} PSI".format(
current_position(), current_target()))
def start_n2_fill():
global air_type, filling, purging, flat_mode
if wrench_in_use():
print("[INTERLOCK] wrench active - N2 fill blocked")
start_error_beep()
return
if not job_active:
start_new_job()
air_type = "N2"
purging = False
flat_mode = False
filling = True
print("[N2] {} - valve OPEN -> filling to {} PSI".format(current_position(), current_target()))
def start_air_fill():
global air_type, filling, purging, flat_mode
if wrench_in_use():
print("[INTERLOCK] wrench active - air fill blocked")
start_error_beep()
return
if not job_active:
start_new_job()
air_type = "AIR"
purging = False
flat_mode = False
filling = True
print("[AIR] {} - valve OPEN -> filling to {} PSI".format(current_position(), current_target()))
def run_purge_step(current_psi):
global purge_step, purge_step_timer, purge_cycles_remaining, purging, filling
if wrench_in_use():
set_valves(False, False, False)
purging = False
print("[INTERLOCK] wrench engaged mid-purge - aborting purge")
start_error_beep()
return
elapsed = utime.ticks_diff(utime.ticks_ms(), purge_step_timer)
venting = (purge_step % 2 == 0)
set_valves(n2_on=(not venting), vent_on=venting, air_on=False)
if elapsed > PURGE_STEP_MS:
purge_step += 1
purge_step_timer = utime.ticks_ms()
if purge_step % 2 == 0:
purge_cycles_remaining -= 1
print("[PURGE] {} - cycle complete, {} remaining".format(current_position(), purge_cycles_remaining))
if purge_cycles_remaining <= 0:
purging = False
filling = True
print("[PURGE] {} - done -> proceeding to final N2 fill to {} PSI".format(
current_position(), current_target()))
def run_fill_step(current_psi):
global filling, flat_mode
if wrench_in_use():
set_valves(False, False, False)
filling = False
print("[INTERLOCK] wrench engaged mid-fill - aborting fill")
start_error_beep()
return
target = current_target()
if current_psi < target:
set_valves(n2_on=(air_type == "N2"), vent_on=False, air_on=(air_type == "AIR"))
else:
set_valves(n2_on=False, vent_on=False, air_on=False)
filling = False
flat_mode = False
record_inflation_done()
print("[SENSOR] {} target reached: {} PSI (actual {} PSI)".format(
current_position(), target, current_psi))
start_complete_beep()
advance_position()
print("PresN2X HMI sim booted (v2 - 4-position state machine).")
print("Buttons: N2 / AIR / UP / DOWN / PURGE / FLAT TYRE / COUNT")
print("Adjust pot_tire to simulate live tyre pressure during fill.")
load_counts()
while True:
now = utime.ticks_ms()
current_psi = read_tire_psi()
check_day_rollover()
update_fans()
# --- button edges (debounced) ---
if pressed("n2"):
start_n2_fill()
if pressed("air"):
start_air_fill()
if pressed("up"):
set_current_target(min(MAX_PSI, current_target() + 1))
print("[PSI] {} target now {} (FRONT={} REAR={})".format(
current_position(), current_target(), FRONT_PSI, REAR_PSI))
if pressed("down"):
set_current_target(max(MIN_PSI, current_target() - 1))
print("[PSI] {} target now {} (FRONT={} REAR={})".format(
current_position(), current_target(), FRONT_PSI, REAR_PSI))
if pressed("purge"):
start_purge()
if pressed("flat"):
start_flat_fill()
if pressed("count"):
print("[COUNT] Today: {} All-time: {}".format(today_count, total_count))
# --- run active process ---
if purging:
run_purge_step(current_psi)
elif filling:
run_fill_step(current_psi)
update_beep()
# --- periodic status line ---
if utime.ticks_diff(now, last_print) > PRINT_MS:
last_print = now
print("wheel={} tyre={:3d}psi target={:3d}psi air_type={} filling={} purging={} job_active={}".format(
current_position(), current_psi, current_target(), air_type, filling, purging, job_active))
utime.sleep_ms(20)Loading
esp32-s3-devkitc-1
esp32-s3-devkitc-1