meter_pin_unlock.tc¶
Smart-Meter Optical PIN Unlocker (eHZ / IEC 62056-21 InF)
// ============================================================
// Smart-Meter Optical PIN Unlocker (eHZ / IEC 62056-21 InF)
//
// Many German smart meters (EMH eHZ, Iskra MT175, EasyMeter
// Q3M, Holley DTZ541, Logarex LK13BE, ...) ship locked: only
// total energy is shown. Extended values (1.8.0/1.8.1,
// momentary power, per-phase) and the SML data telegram are
// only emitted *after* a 4-digit PIN has been entered
// optically on the front photodiode.
//
// The provider mails the PIN; the manual normally says "use
// a flashlight". But if you already have an ESP with an IR
// LED + photodiode head clipped on the meter (the standard
// Hichi / IR-WLAN-Lesekopf setup that reads SML), the same
// IR LED can send the unlock pulses — no flashlight needed,
// no re-clipping.
//
// USAGE
// 1. Upload this .tcb to the device, run it.
// 2. Open http://<device>/tc_ui in a browser.
// 3. Enter the PIN, pick the GPIO of your IR LED, leave
// polarity + timings at defaults.
// 4. Hit "Send PIN" — the LED flashes for ~10..20 s, then
// the meter LCD should expose extended values.
// 5. If nothing happens: try toggling polarity, or tune
// pulse-on / pulse-off / digit-gap (see TIMINGS below).
//
// All form values are persisted across reboots, so once
// it works for your meter the settings stick.
//
// PULSE ENCODING (vendor convention, EMH/Iskra-style)
// - Each digit N → N short light pulses
// - Digit 0 → 10 pulses (NOT zero pulses)
// - Digits sent most-significant first
// - Pulses inside one digit: pulse_on_ms on, pulse_off_ms off
// - Long dark gap between digits: digit_gap_ms
//
// Example PIN 4271:
// ···· … ·· … ······· … · (4, 2, 7, 1 pulses)
//
// TIMINGS — IMPORTANT
// The defaults below (200/200/1500 ms) are a best-guess
// from public eHZ documentation. Different meter vendors
// (and even firmware revisions of the same meter) accept
// different ranges. If the default doesn't unlock yours:
// - Try slowing pulses down: 300/300/2000
// - Or speeding them up: 150/150/1000
// - Try toggling polarity (active HIGH ↔ active LOW)
// The vendor manual usually documents the "InF" /
// "Information" interface timing.
//
// HARDWARE
// - The IR LED GPIO must be FREE in Tasmota's GPIO config
// (shown as "None" — not assigned to I2C, SPI, IRsend,
// Relay, etc.). Picking a reserved GPIO will halt the
// VM with "forbidden pin" — just pick a different one
// and reload.
// - Do NOT set the GPIO to "IRsend" — that mode would 38 kHz
// modulate the pulses, which is wrong for InF; the meter
// wants raw on/off at the millisecond scale, just leave
// it as plain "None" (or "Output Hi") in the GPIO config.
// - Active-LOW polarity is correct if your IR head drives
// the LED through an inverting transistor.
// - The IR receiver pin used for SML reading is untouched.
//
// CONSOLE (alternative to web form)
// METRUN send PIN sequence using current settings
// METSTOP abort a running sequence
// METSTAT dump current config + last status
// ============================================================
// ── form-bound persisted settings ──────────────────────────
persist int pin = 0; // 4-digit PIN, 0..9999
persist watch int ir_pin = -1; // GPIO of the IR LED, -1 = not selected
// filled by webPulldown "@getfreepins"
// so only currently-free GPIOs appear
persist int active_low = 0; // 0 = active HIGH, 1 = active LOW
persist int pulse_on_ms = 200; // duration LED is lit per pulse
persist int pulse_off_ms = 200; // dark gap between pulses (same digit)
persist int digit_gap_ms = 1500; // dark gap between two digits
// ── form-bound transient buttons ───────────────────────────
int send_btn = 0; // toggles 0→1 to trigger send
int abort_btn = 0; // toggles 0→1 to abort
// ── runtime state ──────────────────────────────────────────
int prev_send = 0;
int prev_abort = 0;
int last_status = 0; // 0=idle 1=running 2=done -1=aborted
int abort_flag = 0;
// ── progress (worker writes, WebUI polls every ~2 s) ───────
int progress_total = 0; // total pulses across all 4 digits
int progress_done = 0; // pulses transmitted so far
int progress_digit = 0; // 1..4 = currently sending, 0 = idle
// ------------------------------------------------------------
// IR LED helpers (polarity-aware). Guarded by ir_pin >= 0 so
// they're harmless when no GPIO has been selected yet.
// ------------------------------------------------------------
void ir_on() {
if (ir_pin >= 0) digitalWrite(ir_pin, 1 - active_low);
}
void ir_off() {
if (ir_pin >= 0) digitalWrite(ir_pin, active_low);
}
// ------------------------------------------------------------
// Claim the IR pin. Called once on the first valid selection
// and whenever the user changes the dropdown. On unset (-1)
// the helpers above no-op, so we just don't pinMode anything.
// ------------------------------------------------------------
void apply_pin() {
if (ir_pin < 0) {
addLog("meter_pin_unlock: pick a free GPIO in /tc_ui to enable sending");
return;
}
pinMode(ir_pin, 1); // 1 = OUTPUT
ir_off(); // park idle
addLog("meter_pin_unlock: IR pin → GPIO %d", ir_pin);
}
// ------------------------------------------------------------
// Send one digit as N pulses (digit 0 → 10 pulses).
// ------------------------------------------------------------
void send_digit(int d) {
int n = d;
if (n == 0) n = 10;
int i = 0;
while (i < n) {
if (abort_flag) return;
ir_on();
delay(pulse_on_ms);
ir_off();
delay(pulse_off_ms);
progress_done = progress_done + 1; // for the WebUI bar
i = i + 1;
}
}
// ------------------------------------------------------------
// Worker task — runs in its own FreeRTOS task so the ~15 s
// blocking pulse train doesn't stall the Tasmota main loop.
// ------------------------------------------------------------
void Unlocker() {
last_status = 1;
abort_flag = 0;
int d1 = (pin / 1000) % 10; // MSB-first; integer math
int d2 = (pin / 100) % 10; // handles leading zeros
int d3 = (pin / 10) % 10; // (PIN 0042 → 0,0,4,2 → 10,10,4,2 pulses)
int d4 = pin % 10;
// Pre-compute total pulse count so the progress bar has a denominator.
// Digit 0 means 10 pulses (vendor convention).
int n1 = d1; if (n1 == 0) n1 = 10;
int n2 = d2; if (n2 == 0) n2 = 10;
int n3 = d3; if (n3 == 0) n3 = 10;
int n4 = d4; if (n4 == 0) n4 = 10;
progress_total = n1 + n2 + n3 + n4;
progress_done = 0;
progress_digit = 0;
char log[96];
sprintf(log, "meter_pin_unlock: sending %d%d%d%d on GPIO %d (on/off/gap = %d/%d/%d ms, %d pulses)",
d1, d2, d3, d4, ir_pin, pulse_on_ms, pulse_off_ms, digit_gap_ms, progress_total);
addLog(log);
ir_off();
delay(500);
progress_digit = 1;
send_digit(d1); if (abort_flag) { last_status = -1; ir_off(); addLog("aborted"); return; }
delay(digit_gap_ms);
progress_digit = 2;
send_digit(d2); if (abort_flag) { last_status = -1; ir_off(); addLog("aborted"); return; }
delay(digit_gap_ms);
progress_digit = 3;
send_digit(d3); if (abort_flag) { last_status = -1; ir_off(); addLog("aborted"); return; }
delay(digit_gap_ms);
progress_digit = 4;
send_digit(d4); ir_off();
if (abort_flag) {
last_status = -1;
addLog("meter_pin_unlock: aborted");
} else {
last_status = 2;
addLog("meter_pin_unlock: sequence sent — check meter LCD for extended view");
}
}
// ------------------------------------------------------------
// Web form. Open http://<device>/tc_ui to see it.
// "@getfreepins" auto-populates the dropdown with GPIOs
// currently free in Tasmota's runtime config — no hand-rolled
// table, no risk of picking a reserved pin.
//
// Styled with a self-contained .mpu-panel CSS block (mirrors
// the marstek_emu.tc layout): scoped class names so this won't
// collide with other tabs that ship their own panel CSS.
// Single-line CSS string because TinyC has no adjacent-literal
// concatenation.
// ------------------------------------------------------------
void WebUI() {
webSend("<style>.mpu-panel{max-width:340px;margin:12px auto;background:#f0f0f0;color:#000;padding:18px;border:2px solid #ccc;border-radius:6px;text-align:left}.mpu-panel h3{margin:0 0 8px;font-size:15px}.mpu-panel hr{border:0;border-top:1px solid #bbb;margin:14px 0}.mpu-panel b{display:inline-block;margin-bottom:4px;font-size:13px}.mpu-panel div{margin:6px 0}.mpu-panel small{display:block;color:#555;font-size:11px;margin-top:2px}.mpu-panel button{padding:6px 10px;border-radius:4px}.mpu-panel progress{width:100%;height:14px;display:block;margin-top:6px}.mpu-stat{padding:8px;border-radius:4px;margin-bottom:10px}.mpu-run{background:#dde6f5;color:#103060}.mpu-ok{background:#dff0d8;color:#205020}.mpu-err{background:#f5d6d6;color:#702020}</style>");
webSend("<div class='mpu-panel'>");
webSend("<h3>💡 Smart-Meter PIN Unlocker</h3>");
// ─ Status / progress block ────────────────────────────
// Refreshes automatically because /tc_ui re-polls every ~2 s
// (la() in the parent page). The IR LED is invisible to the
// human eye, so this is the only feedback the user gets.
if (last_status == 1) {
char prog[256];
int pct = 0;
if (progress_total > 0) pct = (progress_done * 100) / progress_total;
sprintf(prog, "<div class='mpu-stat mpu-run'><b>📡 Sending PIN... digit %d/4 pulse %d/%d %d%%</b><progress value='%d' max='100'></progress></div>",
progress_digit, progress_done, progress_total, pct, pct);
webSend(prog);
// Force the parent page's la() to keep polling even if the user
// last interacted with a number input (which calls pr(0) and
// suspends auto-refresh). Without this the bar appears frozen.
webSend("<script>rfsh=1;clearTimeout(lt);lt=setTimeout(la,800);</script>");
} else if (last_status == 2) {
webSend("<div class='mpu-stat mpu-ok'><b>✅ PIN sequence sent — check meter LCD for extended view</b></div>");
} else if (last_status < 0) {
webSend("<div class='mpu-stat mpu-err'><b>❌ Aborted</b></div>");
}
// ─ PIN ─
webSend("<b>🔑 PIN</b>");
webNumber(pin, 0, 9999, "4-digit PIN (leading zeros OK)");
webSend("<hr>");
// ─ Hardware ─
webSend("<b>📡 Hardware</b>");
webPulldown(ir_pin, "IR LED GPIO", "@getfreepins");
webPulldown(active_low, "LED polarity",
"Active HIGH (default)|Active LOW (inverting driver)");
webSend("<small>If your IR head drives the LED through an inverting transistor, pick Active LOW.</small>");
webSend("<hr>");
// ─ Timing ─
webSend("<b>⏱️ Timing</b>");
webNumber(pulse_on_ms, 50, 500, "Pulse ON (ms)");
webNumber(pulse_off_ms, 50, 500, "Pulse OFF (ms)");
webNumber(digit_gap_ms, 200, 5000, "Digit gap (ms)");
webSend("<small>Defaults are EMH-style 200/200/1500 ms. If unlock fails, try 300/300/2000 or 150/150/1000.</small>");
webSend("<hr>");
// ─ Action ─
webSend("<b>🚀 Aktion</b>");
webButton(send_btn, "Send PIN");
webButton(abort_btn, "Abort");
webSend("</div>");
}
// ------------------------------------------------------------
// Edge-detect web buttons + GPIO-change once per second.
// Buttons auto-reset to 0 after handling so the next click
// is a fresh rising edge. The watch on ir_pin avoids re-running
// pinMode every tick — only when the dropdown actually changes.
// ------------------------------------------------------------
void EverySecond() {
// Apply GPIO selector change.
if (changed(ir_pin)) {
apply_pin();
snapshot(ir_pin);
saveVars();
}
// Send-button rising edge.
if (send_btn == 1 && prev_send == 0) {
if (ir_pin < 0) {
addLog("meter_pin_unlock: pick an IR GPIO in /tc_ui first");
} else if (pin < 0 || pin > 9999) {
addLog("meter_pin_unlock: PIN out of range");
} else if (taskRunning("Unlocker")) {
addLog("meter_pin_unlock: already running");
} else {
saveVars(); // persist any field edits before sending
spawnTask("Unlocker");
}
send_btn = 0;
}
prev_send = send_btn;
// Abort-button rising edge.
if (abort_btn == 1 && prev_abort == 0) {
abort_flag = 1;
killTask("Unlocker");
ir_off();
last_status = -1;
abort_btn = 0;
}
prev_abort = abort_btn;
}
// ------------------------------------------------------------
// Console fallback — same actions without the web form.
// ------------------------------------------------------------
void Command(char cmd[]) {
char resp[96];
int n = strlen(cmd);
// NOTE: we use `strFind(cmd, "X") == 0 && strlen == N` instead of
// strcmp(cmd, "X") because the IDE compiler currently has a
// truncation bug in the strcmp(arr, literal) fast-path: syscall id
// 275 (STRCMP_CONST) is emitted as 0x80 + (275&0xFF) = 19 instead of
// 0x81 + u16(275). Result: strcmp dispatches to the wrong syscall
// and never returns 0. strFind goes through STR_FIND_CONST=47 which
// fits in u8 cleanly.
if (strFind(cmd, "RUN") == 0 && n == 3) {
if (ir_pin < 0) {
responseCmnd("IR GPIO not set — open /tc_ui first");
return;
}
if (pin < 0 || pin > 9999) {
responseCmnd("PIN not set — open /tc_ui or send no METRUN until set");
return;
}
if (taskRunning("Unlocker")) {
responseCmnd("already running");
return;
}
saveVars();
spawnTask("Unlocker");
responseCmnd("unlock sequence started");
} else if (strFind(cmd, "STOP") == 0 && n == 4) {
abort_flag = 1;
killTask("Unlocker");
ir_off();
last_status = -1;
responseCmnd("aborted");
} else if (strFind(cmd, "STAT") == 0 && n == 4) {
char st[12];
if (last_status == 0) strcpy(st, "idle");
else if (last_status == 1) strcpy(st, "running");
else if (last_status == 2) strcpy(st, "done");
else strcpy(st, "aborted");
char pol[8];
if (active_low == 0) strcpy(pol, "high");
else strcpy(pol, "low");
sprintf(resp, "PIN=%04d GPIO=%d pol=%s on=%dms off=%dms gap=%dms %s",
pin, ir_pin, pol,
pulse_on_ms, pulse_off_ms, digit_gap_ms, st);
responseCmnd(resp);
} else {
responseCmnd("MET: RUN | STOP | STAT (or open /tc_ui)");
}
}
// ------------------------------------------------------------
// Promote any zero/unset timing slot to its sensible default.
// `persist int x = N` only seeds N when the .pvs file is fresh
// — but if a layout-hash reset (or an early-version .pvs that
// happened to land on 0) leaves the slot at 0, the form would
// otherwise come up blank. Guarding here means a clean install
// shows 200 / 200 / 1500 in the number boxes immediately.
// Values below the form's `min` (50 / 50 / 200) are also
// snapped up so the input never shows an out-of-range value.
// ------------------------------------------------------------
int main() {
if (pulse_on_ms < 50) pulse_on_ms = 200;
if (pulse_off_ms < 50) pulse_off_ms = 200;
if (digit_gap_ms < 200) digit_gap_ms = 1500;
saveVars();
addCommand("MET");
webPageLabel(0, "Smart-Meter PIN Unlocker");
addLog("meter_pin_unlock ready — open /tc_ui to configure & send");
return 0;
}