From 1f4932fa9d8bbe18f564fdbd29eb9236fe5b46de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Tue, 28 Apr 2026 02:42:48 +0200 Subject: [PATCH 1/7] Fix EL15 set writes and Set button sync race --- el15/app.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/el15/app.py b/el15/app.py index b7de26b..117cf40 100644 --- a/el15/app.py +++ b/el15/app.py @@ -63,7 +63,7 @@ def create_worker(self, device, **callbacks): poll_cmd=POLL_PKT, notify_hook=_el15_notify_filter, write_buf_size=10, - cmd_requires_notify=False, + cmd_requires_notify=True, **callbacks, ) @@ -153,13 +153,21 @@ def build_control_bar(self, bar: tk.Frame) -> None: tk.Label(bar, textvariable=self._setpoint_unit_var, width=2).pack( side=tk.LEFT, padx=(0, 6) ) - ttk.Button(bar, text="Set", style="MenuBar.TButton", - command=self._on_set_setpoint, padding=6, width=0).pack(side=tk.LEFT) + self._setpoint_btn = ttk.Button( + bar, + text="Set", + style="MenuBar.TButton", + command=self._on_set_setpoint, + padding=6, + width=0, + ) + self._setpoint_btn.pack(side=tk.LEFT) self._all_controls = [ *self._mode_buttons, self._load_btn, self._setpoint_entry, + self._setpoint_btn, ] self.set_control_state(False) @@ -286,12 +294,15 @@ def _apply_status_buttons(self, s: EL15Status) -> None: self._setpoint_entry.state(["disabled" if unreachable else "!disabled"]) if unreachable: self._setpoint_var.set("") + return # Keep the setpoint entry synced to the device unless the user is editing # it. In CAP mode the packet doesn't carry a setpoint, so preserve the # last value the user typed. + focus = self._setpoint_entry.focus_get() if ( s.ready and s.setpoint_in_packet - and self._setpoint_entry.focus_get() is not self._setpoint_entry + and focus is not self._setpoint_entry + and focus is not self._setpoint_btn ): self._setpoint_var.set(f"{s.setpoint:.{s.setpoint_decimals}f}") From 2164bd5e38785981ba25d52b0c259e4380d67ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Tue, 28 Apr 2026 21:36:13 +0200 Subject: [PATCH 2/7] Add EL15 CAP setpoint readback support --- el15/app.py | 48 ++++++++++++++++++++++++++++++++++---- el15/protocol_constants.py | 18 +++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/el15/app.py b/el15/app.py index 117cf40..83d607d 100644 --- a/el15/app.py +++ b/el15/app.py @@ -8,14 +8,17 @@ from .protocol_constants import ( EL15Status, HEADER, + CAP_SETPOINT_HEADER, POLL_PKT, CMD_LOAD_OFF, CMD_LOAD_ON, CMD_MODE_PREFIX, + CMD_GET_CAP_SETPOINT, MODE_NAMES, MODE_CC, MODE_CV, MODE_CR, MODE_CP, MODE_CAP, MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT, build_set_setpoint_cmd, + parse_cap_setpoint_response, parse_status_packet, ) @@ -33,7 +36,7 @@ def _el15_notify_filter(data: bytes) -> bool: - return data[:4] == HEADER + return data[:4] in (HEADER, CAP_SETPOINT_HEADER) _FMT6 = ("%.5f", "%.4f", "%.3f", "%.2f", "%.1f") @@ -53,6 +56,8 @@ def __init__(self, app) -> None: self.app = app self._last_status: EL15Status | None = None self._last_valid_mode: int = MODE_CC + self._cap_setpoint: float | None = None + self._cap_setpoint_query_pending = False self._mode_var = tk.IntVar(value=MODE_CC) self._load_var = tk.BooleanVar(value=False) self._all_controls: list = [] @@ -176,7 +181,11 @@ def set_control_state(self, enabled: bool) -> None: for widget in self._all_controls: widget.state([state]) - def pre_connect_reset(self) -> None: pass + def pre_connect_reset(self) -> None: + self._last_status = None + self._cap_setpoint = None + self._cap_setpoint_query_pending = False + def clear_capture(self) -> None: pass def on_connected(self) -> None: pass def teardown(self) -> None: pass @@ -187,12 +196,30 @@ def on_packet(self, data: bytes) -> None: s = parse_status_packet(data) app._append_raw_text(f"RX {s.raw} CRC:{s.crc_str}\n") + cap_setpoint = parse_cap_setpoint_response(data) + if cap_setpoint is not None: + self._cap_setpoint = cap_setpoint + last = self._last_status + if last is not None and last.mode == MODE_CAP: + self._apply_status_buttons(last) + return + if not s.valid: return + prev_mode = self._last_status.mode if self._last_status else None self._last_status = s self._apply_status_buttons(s) + if s.mode == MODE_CAP: + if prev_mode != MODE_CAP: + self._cap_setpoint_query_pending = True + if self._cap_setpoint_query_pending: + self._cap_setpoint_query_pending = False + self.app.send_command(CMD_GET_CAP_SETPOINT) + else: + self._cap_setpoint_query_pending = False + if s.ready: if s.mode == MODE_DCR: self._amp_label.configure(text=f"{_fmt6(s.dcr_i1)} A") @@ -296,9 +323,16 @@ def _apply_status_buttons(self, s: EL15Status) -> None: self._setpoint_var.set("") return # Keep the setpoint entry synced to the device unless the user is editing - # it. In CAP mode the packet doesn't carry a setpoint, so preserve the - # last value the user typed. + # it. CAP reports its setpoint through a separate 0x0A readback packet. focus = self._setpoint_entry.focus_get() + if ( + s.mode == MODE_CAP + and self._cap_setpoint is not None + and focus is not self._setpoint_entry + and focus is not self._setpoint_btn + ): + self._setpoint_var.set(f"{self._cap_setpoint:.{s.setpoint_decimals}f}") + return if ( s.ready and s.setpoint_in_packet and focus is not self._setpoint_entry @@ -325,4 +359,8 @@ def _on_set_setpoint(self, _event=None) -> None: show_error(self.app, "Setpoint", "Enter a valid numeric value.", theme=(self.app.ui.theme.bg, self.app.ui.theme.outline)) return - self.app.send_command(build_set_setpoint_cmd(value)) + last = self._last_status + mode = last.mode if last is not None else self._last_valid_mode + if mode == MODE_CAP: + self._cap_setpoint_query_pending = True + self.app.send_command(build_set_setpoint_cmd(value, mode)) diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py index b085c0b..942c0a5 100644 --- a/el15/protocol_constants.py +++ b/el15/protocol_constants.py @@ -1,10 +1,13 @@ """EL15 protocol constants and packet parsing.""" HEADER = b"\xdf\x07\x03\x08" +CAP_SETPOINT_HEADER = b"\xdf\x07\x03\x0a" # Pre-computed poll packet (CMD_QUERY prefix + CRC byte 0x3F) POLL_PKT = b"\xaf\x07\x03\x08\x00\x3f" _SETPOINT_PREFIX = b"\xaf\x07\x03\x04\x04" +_CAP_SETPOINT_PREFIX = b"\xaf\x07\x03\x05\x04" +CMD_GET_CAP_SETPOINT = b"\xaf\x07\x03\x0a\x00" _setpoint_fbuf = bytearray(4) _setpoint_fview = memoryview(_setpoint_fbuf).cast('f') @@ -42,7 +45,7 @@ # (unit_str, decimal_places, label) MODE_SETPOINT_INFO = { MODE_CC: ("A", 3, "Current"), - MODE_CAP: ("A", 3, "Current"), + MODE_CAP: ("mA", 3, "Current"), MODE_CV: ("V", 3, "Voltage"), MODE_DCR: ("A", 3, "Current"), MODE_CR: ("Ω", 1, "Resistance"), @@ -55,9 +58,18 @@ } -def build_set_setpoint_cmd(value: float) -> bytes: +def build_set_setpoint_cmd(value: float, mode: int | None = None) -> bytes: _setpoint_fview[0] = value - return _SETPOINT_PREFIX + bytes(_setpoint_fbuf) + prefix = _CAP_SETPOINT_PREFIX if mode == MODE_CAP else _SETPOINT_PREFIX + return prefix + bytes(_setpoint_fbuf) + + +def parse_cap_setpoint_response(data: bytes) -> float | None: + if len(data) < 10 or data[:4] != CAP_SETPOINT_HEADER or data[4] != 0x04: + return None + if (sum(data) & 0xFF) != 0: + return None + return memoryview(data)[5:9].cast('f')[0] # Status byte 6 bit layout: bit1=load, bit2=lock; upper nibble=protection code From f8013fedfb1fb777bf1a36f042bc9ba4c4c37779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Tue, 28 Apr 2026 21:37:55 +0200 Subject: [PATCH 3/7] Add EL15 fault and cycle handling --- GUI/themed_messagebox.py | 16 ++++++++++ el15/app.py | 65 +++++++++++++++++++++++++++++++------- el15/protocol_constants.py | 52 ++++++++++++++++++++++-------- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/GUI/themed_messagebox.py b/GUI/themed_messagebox.py index 47e2e68..425857e 100644 --- a/GUI/themed_messagebox.py +++ b/GUI/themed_messagebox.py @@ -93,6 +93,8 @@ def _build_ui(self, message, icon, detail, buttons, default, cancel_value): def _apply_minsize(self): container = self._layout_root + if container is None: + return try: container.update_idletasks() required_w = container.winfo_reqwidth() @@ -168,4 +170,18 @@ def show_error(parent, title, message, *, theme: tuple, detail=None): ) +def show_clear(parent, title, message, *, theme: tuple, detail=None): + return _show_dialog( + parent, + title, + message, + theme=theme, + icon=_ERROR_ICON, + buttons=[("Clear", True)], + default=True, + cancel_value=False, + detail=detail, + ) + + diff --git a/el15/app.py b/el15/app.py index 83d607d..8ade702 100644 --- a/el15/app.py +++ b/el15/app.py @@ -3,20 +3,19 @@ from tkinter import ttk from shared.ble_worker import BleWorker -from GUI.themed_messagebox import show_error +from GUI.themed_messagebox import show_clear, show_error from .protocol_constants import ( EL15Status, HEADER, CAP_SETPOINT_HEADER, POLL_PKT, - CMD_LOAD_OFF, - CMD_LOAD_ON, CMD_MODE_PREFIX, CMD_GET_CAP_SETPOINT, MODE_NAMES, MODE_CC, MODE_CV, MODE_CR, MODE_CP, MODE_CAP, MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT, + build_control_cmd, build_set_setpoint_cmd, parse_cap_setpoint_response, parse_status_packet, @@ -56,10 +55,12 @@ def __init__(self, app) -> None: self.app = app self._last_status: EL15Status | None = None self._last_valid_mode: int = MODE_CC + self._last_alarm_ui = 0 self._cap_setpoint: float | None = None self._cap_setpoint_query_pending = False self._mode_var = tk.IntVar(value=MODE_CC) self._load_var = tk.BooleanVar(value=False) + self._lock_var = tk.BooleanVar(value=False) self._all_controls: list = [] def create_worker(self, device, **callbacks): @@ -147,7 +148,13 @@ def build_control_bar(self, bar: tk.Frame) -> None: bar, text="Load", style="MenuBar.TCheckbutton", variable=self._load_var, command=self._on_load_clicked, ) - self._load_btn.pack(side=tk.LEFT, padx=(0, 18)) + self._load_btn.pack(side=tk.LEFT, padx=(0, 6)) + + self._lock_btn = ttk.Checkbutton( + bar, text="Lock", style="MenuBar.TCheckbutton", + variable=self._lock_var, command=self._on_lock_clicked, + ) + self._lock_btn.pack(side=tk.LEFT, padx=(0, 18)) tk.Label(bar, text="Setpoint:").pack(side=tk.LEFT, padx=(0, 4)) self._setpoint_var = tk.StringVar(value="") @@ -171,6 +178,7 @@ def build_control_bar(self, bar: tk.Frame) -> None: self._all_controls = [ *self._mode_buttons, self._load_btn, + self._lock_btn, self._setpoint_entry, self._setpoint_btn, ] @@ -183,6 +191,7 @@ def set_control_state(self, enabled: bool) -> None: def pre_connect_reset(self) -> None: self._last_status = None + self._last_alarm_ui = 0 self._cap_setpoint = None self._cap_setpoint_query_pending = False @@ -210,6 +219,7 @@ def on_packet(self, data: bytes) -> None: prev_mode = self._last_status.mode if self._last_status else None self._last_status = s self._apply_status_buttons(s) + self._handle_alarm(s) if s.mode == MODE_CAP: if prev_mode != MODE_CAP: @@ -253,18 +263,18 @@ def on_packet(self, data: bytes) -> None: self._info_load_var.set("Load: --- Lock: ---") self._info_setp_var.set(f"{s.setpoint_label}: ---") self._info_runtime_var.set("Runtime: --:--:--") - if s.warning: - self._mode_label_var.set(f"EL15 [PROT: {s.warning}]") + if s.warning_code: + self._mode_label_var.set(f"EL15 [PROT: {s.warning_code}]") else: self._mode_label_var.set("EL15 [MENU]") self._volt_label.configure(text=f"{_fmt6(s.voltage)} V") self._info_mode_var.set(f"Mode: {s.mode_name}") - if not s.warning: + if not s.warning_code: self._info_temp_var.set(f"Temp: {s.temperature:.3f}\u00b0C") self._info_fan_var.set(f"Fan: {s.fan_speed}/5") - if s.warning: - self._info_warn_var.set(f"\u26a0 {s.warning}") + if s.warning_code: + self._info_warn_var.set(f"\u26a0 {s.warning_code}") else: self._info_warn_var.set("") @@ -275,7 +285,7 @@ def on_packet(self, data: bytes) -> None: hide_setp = mode in _UNREACHABLE for key, hide in ( ("temp", hide_temp), ("runtime", hide_runtime), ("setp", hide_setp), - ("warn", not s.warning), + ("warn", not s.warning_code), ): lbl = self._info_labels[key] if hide: @@ -317,6 +327,7 @@ def _apply_status_buttons(self, s: EL15Status) -> None: self._unreach_btn.configure(text="---") self._mode_var.set(display_mode) self._load_var.set(s.load_on) + self._lock_var.set(s.lock_on) self._setpoint_unit_var.set(s.setpoint_unit) self._setpoint_entry.state(["disabled" if unreachable else "!disabled"]) if unreachable: @@ -340,6 +351,29 @@ def _apply_status_buttons(self, s: EL15Status) -> None: ): self._setpoint_var.set(f"{s.setpoint:.{s.setpoint_decimals}f}") + def _handle_alarm(self, s: EL15Status) -> None: + alarm_ui = s.alarm_ui + if alarm_ui == 0: + self._last_alarm_ui = 0 + return + if alarm_ui == self._last_alarm_ui: + return + self._last_alarm_ui = alarm_ui + if show_clear( + self.app, + "EL15 Alarm", + s.warning, + theme=(self.app.ui.theme.bg, self.app.ui.theme.outline), + detail=None, + ): + self.app.send_command( + build_control_cmd( + output_on=s.load_on, + lock_on=s.lock_on, + clear_alarm=True, + ) + ) + def _on_mode_clicked(self, mode_val: int) -> None: # Revert the radio until the device confirms via the next status packet. self._mode_var.set(self._last_valid_mode) @@ -348,9 +382,16 @@ def _on_mode_clicked(self, mode_val: int) -> None: def _on_load_clicked(self) -> None: last = self._last_status desired_on = self._load_var.get() - # Revert visible toggle; status packet will update it once the device responds. + lock_on = bool(last and last.lock_on) self._load_var.set(bool(last and last.load_on)) - self.app.send_command(CMD_LOAD_ON if desired_on else CMD_LOAD_OFF) + self.app.send_command(build_control_cmd(output_on=desired_on, lock_on=lock_on)) + + def _on_lock_clicked(self) -> None: + last = self._last_status + desired_on = self._lock_var.get() + load_on = bool(last and last.load_on) + self._lock_var.set(bool(last and last.lock_on)) + self.app.send_command(build_control_cmd(output_on=load_on, lock_on=desired_on)) def _on_set_setpoint(self, _event=None) -> None: try: diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py index 942c0a5..52313ab 100644 --- a/el15/protocol_constants.py +++ b/el15/protocol_constants.py @@ -11,10 +11,8 @@ _setpoint_fbuf = bytearray(4) _setpoint_fview = memoryview(_setpoint_fbuf).cast('f') -CMD_LOAD_ON = b"\xaf\x07\x03\x09\x01\x04" -CMD_LOAD_OFF = b"\xaf\x07\x03\x09\x01\x00" -CMD_LOCK = b"\xaf\x07\x03\x09\x01\x01" CMD_MODE_PREFIX = b"\xaf\x07\x03\x03\x01" +_CONTROL_PREFIX = b"\xaf\x07\x03\x09\x01" MODE_CC = 0x01 MODE_CAP = 0x02 @@ -72,6 +70,11 @@ def parse_cap_setpoint_response(data: bytes) -> float | None: return memoryview(data)[5:9].cast('f')[0] +def build_control_cmd(*, output_on: bool = False, lock_on: bool = False, clear_alarm: bool = False) -> bytes: + payload = (0x04 if output_on else 0x00) | (0x01 if lock_on else 0x00) | (0x02 if clear_alarm else 0x00) + return _CONTROL_PREFIX + bytes((payload,)) + + # Status byte 6 bit layout: bit1=load, bit2=lock; upper nibble=protection code _STATUS_LOAD_BIT = 0x02 _STATUS_LOCK_BIT = 0x04 @@ -80,8 +83,19 @@ def parse_cap_setpoint_response(data: bytes) -> float | None: _B5_WARN_FLAG = 0x06 # bits 1+2 are both set when protection has tripped FAN_SPEED_MAX = 5 -# Upper nibble of byte6 when _B5_WARN_FLAG is set -_WARN_NAMES = {0x6: "REV", 0x9: "UVP"} +_ALARMS = ( + ("", ""), + ("OVP", "Overvoltage Protection (OVP)"), + ("OCP", "Overcurrent Protection (OCP)"), + ("OPP", "Overpower Protection (OPP)"), + ("OTP", "Over-temperature Protection (OTP)"), + ("LEAK", "Leakage detected"), + ("RPP", "Reverse Polarity Protection (RPP)"), + ("TIMER END", "Timer end detected"), + ("End of cycle!", "End of cycle!"), + ("UVP", "Undervoltage Protection (UVP)"), + ("ALARM", "Custom alarm"), +) class EL15Status: @@ -91,8 +105,9 @@ class EL15Status: "energy_wh", "capacity_ah", "dcr_mohm", "dcr_i1", "dcr_i2", "mode", "mode_name", "fan_speed", "load_on", "lock_on", "ready", + "alarm_ui", "timer_switch", "work_mode", "measurement_mode", "setpoint_unit", "setpoint_decimals", "setpoint_label", - "setpoint_in_packet", "warning", + "setpoint_in_packet", "warning_code", "warning", ) def __init__(self): @@ -110,10 +125,15 @@ def __init__(self): self.load_on = False self.lock_on = False self.ready = False + self.alarm_ui = 0 + self.timer_switch = False + self.work_mode = 0 + self.measurement_mode = 0 self.setpoint_unit = "A" self.setpoint_decimals = 3 self.setpoint_label = "Current" self.setpoint_in_packet = True + self.warning_code = "" self.warning = "" @@ -131,17 +151,21 @@ def parse_status_packet(data: bytes) -> EL15Status: s.power = s.voltage * s.current b5 = data[5] b6 = data[6] - # Bits 1+2 of byte5 are BOTH set when protection has tripped. Each bit - # individually is part of normal mode encoding (CAP=0x02, DCR=0x0A etc.), - # so the fault test must require both bits set simultaneously. - # Bit 0 is the "ready/measuring" flag for CC/CV/CR/CP. - warn_flag = (b5 & _B5_WARN_FLAG) == _B5_WARN_FLAG + status_word = b5 | (b6 << 8) + s.alarm_ui = (status_word >> 12) & 0x0F + s.timer_switch = ((status_word >> 11) & 0x01) != 0 + s.work_mode = (status_word >> 3) & 0x07 + s.measurement_mode = status_word & 0x07 + warn_flag = s.alarm_ui != 0 raw_mode = (b5 & (_MODE_MASK & ~_B5_WARN_FLAG)) if warn_flag else (b5 & _MODE_MASK) mode = raw_mode if raw_mode in MODE_NAMES else (raw_mode | 0x01) s.mode = mode if warn_flag: - warn_code = b6 >> 4 - s.warning = _WARN_NAMES.get(warn_code, "PROT %X" % warn_code) + if 0 <= s.alarm_ui < len(_ALARMS): + s.warning_code, s.warning = _ALARMS[s.alarm_ui] + else: + s.warning_code = "ALARM %X" % s.alarm_ui + s.warning = s.warning_code s.ready = False else: s.ready = (raw_mode & 0x01) != 0 or mode in (MODE_CAP, MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT) @@ -170,7 +194,7 @@ def parse_status_packet(data: bytes) -> EL15Status: s.setpoint = mv[23:27].cast('f')[0] # Fan speed (0=off..5=max) is split across two bytes: # byte5 bits 6-7 -> low 2 bits, byte6 bit 0 -> MSB. Byte5 bit 5 is unused. - s.fan_speed = (b5 >> 6) | ((b6 & 0x01) << 2) + s.fan_speed = (status_word >> 6) & 0x07 s.load_on = (b6 & _STATUS_LOAD_BIT) != 0 s.lock_on = (b6 & _STATUS_LOCK_BIT) != 0 s.mode_name = MODE_NAMES.get(mode, "?%02X" % mode) From c76d800d9766b907b3952f33cafe96d12669bad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Tue, 28 Apr 2026 23:33:51 +0200 Subject: [PATCH 4/7] Handle EL15 CAP setpoint units separately --- el15/protocol_constants.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py index 52313ab..ee9c124 100644 --- a/el15/protocol_constants.py +++ b/el15/protocol_constants.py @@ -57,8 +57,12 @@ def build_set_setpoint_cmd(value: float, mode: int | None = None) -> bytes: - _setpoint_fview[0] = value - prefix = _CAP_SETPOINT_PREFIX if mode == MODE_CAP else _SETPOINT_PREFIX + if mode == MODE_CAP: + _setpoint_fview[0] = value * 0.001 + prefix = _CAP_SETPOINT_PREFIX + else: + _setpoint_fview[0] = value + prefix = _SETPOINT_PREFIX return prefix + bytes(_setpoint_fbuf) @@ -67,7 +71,7 @@ def parse_cap_setpoint_response(data: bytes) -> float | None: return None if (sum(data) & 0xFF) != 0: return None - return memoryview(data)[5:9].cast('f')[0] + return memoryview(data)[5:9].cast('f')[0] * 1000.0 def build_control_cmd(*, output_on: bool = False, lock_on: bool = False, clear_alarm: bool = False) -> bytes: From 20bf91919c34ecfe28786d48af4a5e8ad13b1444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Wed, 29 Apr 2026 00:06:19 +0200 Subject: [PATCH 5/7] Label EL15 timer status during load --- el15/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/el15/app.py b/el15/app.py index 8ade702..ac56f71 100644 --- a/el15/app.py +++ b/el15/app.py @@ -253,8 +253,9 @@ def on_packet(self, data: bytes) -> None: f"{s.setpoint_label}: {s.setpoint:.{s.setpoint_decimals}f} {s.setpoint_unit}" ) rs = s.runtime + runtime_label = "Timer" if s.timer_switch and s.load_on else "Runtime" self._info_runtime_var.set( - "Runtime: %02d:%02d:%02d" % (rs // 3600, (rs % 3600) // 60, rs % 60) + "%s: %02d:%02d:%02d" % (runtime_label, rs // 3600, (rs % 3600) // 60, rs % 60) ) self._mode_label_var.set(f"EL15 [LOAD {'ON' if s.load_on else 'OFF'}]") else: From 5761f46894e066d4ccee6c6d733cf5559fb33241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Wed, 29 Apr 2026 00:13:49 +0200 Subject: [PATCH 6/7] Quantize EL15 CAP setpoints to integer mA --- el15/protocol_constants.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py index ee9c124..45b8221 100644 --- a/el15/protocol_constants.py +++ b/el15/protocol_constants.py @@ -43,7 +43,7 @@ # (unit_str, decimal_places, label) MODE_SETPOINT_INFO = { MODE_CC: ("A", 3, "Current"), - MODE_CAP: ("mA", 3, "Current"), + MODE_CAP: ("mA", 0, "Current"), MODE_CV: ("V", 3, "Voltage"), MODE_DCR: ("A", 3, "Current"), MODE_CR: ("Ω", 1, "Resistance"), @@ -58,7 +58,8 @@ def build_set_setpoint_cmd(value: float, mode: int | None = None) -> bytes: if mode == MODE_CAP: - _setpoint_fview[0] = value * 0.001 + cap_ma = max(0, min(12000, round(value))) + _setpoint_fview[0] = cap_ma * 0.001 prefix = _CAP_SETPOINT_PREFIX else: _setpoint_fview[0] = value @@ -71,7 +72,7 @@ def parse_cap_setpoint_response(data: bytes) -> float | None: return None if (sum(data) & 0xFF) != 0: return None - return memoryview(data)[5:9].cast('f')[0] * 1000.0 + return round(memoryview(data)[5:9].cast('f')[0] * 1000.0) def build_control_cmd(*, output_on: bool = False, lock_on: bool = False, clear_alarm: bool = False) -> bytes: From 998543178b6b7b9e1b356030fc9e49a73dc8cdd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Wed, 29 Apr 2026 00:19:22 +0200 Subject: [PATCH 7/7] Show actual current and power in EL15 DCR mode --- el15/app.py | 8 ++------ el15/protocol_constants.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/el15/app.py b/el15/app.py index ac56f71..2c310c1 100644 --- a/el15/app.py +++ b/el15/app.py @@ -231,12 +231,8 @@ def on_packet(self, data: bytes) -> None: self._cap_setpoint_query_pending = False if s.ready: - if s.mode == MODE_DCR: - self._amp_label.configure(text=f"{_fmt6(s.dcr_i1)} A") - self._watt_label.configure(text=f"{s.dcr_mohm:.1f} m\u03a9") - else: - self._amp_label.configure(text=f"{_fmt6(s.current)} A") - self._watt_label.configure(text=f"{_fmt6(s.power)} W") + self._amp_label.configure(text=f"{_fmt6(s.current)} A") + self._watt_label.configure(text=f"{_fmt6(s.power)} W") self._info_load_var.set( f"Load: {'ON' if s.load_on else 'OFF'} Lock: {'ON' if s.lock_on else 'OFF'}" ) diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py index 45b8221..955cf11 100644 --- a/el15/protocol_constants.py +++ b/el15/protocol_constants.py @@ -177,7 +177,7 @@ def parse_status_packet(data: bytes) -> EL15Status: # Bytes [15:19], [19:23] and [23:27] carry mode-specific measurements. # CC/CV/CR/CP: runtime(i), temperature(f), setpoint(f) # CAP: runtime(i), energy(f, mWh), capacity(f, mAh) - # DCR: I1(f, A), I2(f, A), resistance(f, m\u03a9); [11:15] unused + # DCR: current(f), I1(f, A), I2(f, A), resistance(f, m\u03a9) # ADV/POWER: unused (V/I only; power computed) if mode == MODE_CAP: s.energy_wh = mv[19:23].cast('f')[0] * 0.001 @@ -188,8 +188,6 @@ def parse_status_packet(data: bytes) -> EL15Status: s.dcr_i2 = mv[19:23].cast('f')[0] s.dcr_mohm = mv[23:27].cast('f')[0] s.runtime = 0 - s.current = 0.0 - s.power = 0.0 s.setpoint_in_packet = False elif mode in (MODE_ADV, MODE_POWER, MODE_DT, MODE_POWER_RPT): s.runtime = 0