feat: Add auto-listen feature with tooltip in SmartIR capture tool
This commit is contained in:
@@ -119,16 +119,24 @@ def _options_schema(platform: str, defaults: dict | None = None) -> vol.Schema:
|
|||||||
return vol.Optional(key, description={"suggested_value": val})
|
return vol.Optional(key, description={"suggested_value": val})
|
||||||
return vol.Optional(key)
|
return vol.Optional(key)
|
||||||
|
|
||||||
|
# Power sensors are only ever checked for on/off state (see
|
||||||
|
# SmartIRClimate._async_power_sensor_changed and the equivalent methods in
|
||||||
|
# fan.py, light.py, media_player.py, which compare .state to STATE_ON/STATE_OFF)
|
||||||
|
# so restrict the picker to domains that expose a binary on/off state.
|
||||||
|
power_sensor_selector = EntitySelector(
|
||||||
|
EntitySelectorConfig(domain=["binary_sensor", "switch", "input_boolean"])
|
||||||
|
)
|
||||||
|
|
||||||
if platform == "climate":
|
if platform == "climate":
|
||||||
return vol.Schema(
|
return vol.Schema(
|
||||||
{
|
{
|
||||||
_opt(CONF_TEMPERATURE_SENSOR): EntitySelector(
|
_opt(CONF_TEMPERATURE_SENSOR): EntitySelector(
|
||||||
EntitySelectorConfig(domain="sensor")
|
EntitySelectorConfig(domain="sensor", device_class="temperature")
|
||||||
),
|
),
|
||||||
_opt(CONF_HUMIDITY_SENSOR): EntitySelector(
|
_opt(CONF_HUMIDITY_SENSOR): EntitySelector(
|
||||||
EntitySelectorConfig(domain="sensor")
|
EntitySelectorConfig(domain="sensor", device_class="humidity")
|
||||||
),
|
),
|
||||||
_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig()),
|
_opt(CONF_POWER_SENSOR): power_sensor_selector,
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_POWER_SENSOR_RESTORE_STATE,
|
CONF_POWER_SENSOR_RESTORE_STATE,
|
||||||
default=bool(d.get(CONF_POWER_SENSOR_RESTORE_STATE, False)),
|
default=bool(d.get(CONF_POWER_SENSOR_RESTORE_STATE, False)),
|
||||||
@@ -144,7 +152,7 @@ def _options_schema(platform: str, defaults: dict | None = None) -> vol.Schema:
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
fields: dict = {
|
fields: dict = {
|
||||||
_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig()),
|
_opt(CONF_POWER_SENSOR): power_sensor_selector,
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_DEVICE_CLASS,
|
CONF_DEVICE_CLASS,
|
||||||
description={"suggested_value": d.get(CONF_DEVICE_CLASS, "tv")},
|
description={"suggested_value": d.get(CONF_DEVICE_CLASS, "tv")},
|
||||||
@@ -164,9 +172,7 @@ def _options_schema(platform: str, defaults: dict | None = None) -> vol.Schema:
|
|||||||
return vol.Schema(fields)
|
return vol.Schema(fields)
|
||||||
|
|
||||||
# fan and light
|
# fan and light
|
||||||
return vol.Schema(
|
return vol.Schema({_opt(CONF_POWER_SENSOR): power_sensor_selector})
|
||||||
{_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig())}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_options(platform: str, user_input: dict) -> dict:
|
def _parse_options(platform: str, user_input: dict) -> dict:
|
||||||
|
|||||||
@@ -97,6 +97,37 @@ def combo_label(combo: dict) -> str:
|
|||||||
return " / ".join(parts)
|
return " / ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class _Tooltip:
|
||||||
|
"""Simple hover tooltip for tkinter/ttk widgets."""
|
||||||
|
|
||||||
|
def __init__(self, widget, text: str, wraplength: int = 320):
|
||||||
|
self.widget = widget
|
||||||
|
self.text = text
|
||||||
|
self.wraplength = wraplength
|
||||||
|
self._tip: tk.Toplevel | None = None
|
||||||
|
widget.bind("<Enter>", self._show)
|
||||||
|
widget.bind("<Leave>", self._hide)
|
||||||
|
|
||||||
|
def _show(self, _event=None):
|
||||||
|
if self._tip is not None:
|
||||||
|
return
|
||||||
|
x = self.widget.winfo_rootx()
|
||||||
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
|
self._tip = tw = tk.Toplevel(self.widget)
|
||||||
|
tw.wm_overrideredirect(True)
|
||||||
|
tw.wm_geometry(f"+{x}+{y}")
|
||||||
|
tk.Label(
|
||||||
|
tw, text=self.text, justify="left", background="#ffffe0",
|
||||||
|
relief="solid", borderwidth=1, wraplength=self.wraplength,
|
||||||
|
font=("TkDefaultFont", 9), padx=6, pady=4,
|
||||||
|
).pack()
|
||||||
|
|
||||||
|
def _hide(self, _event=None):
|
||||||
|
if self._tip is not None:
|
||||||
|
self._tip.destroy()
|
||||||
|
self._tip = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main App
|
# Main App
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -118,6 +149,7 @@ class SmartIRCapture(tk.Tk):
|
|||||||
self._listen_timer = None
|
self._listen_timer = None
|
||||||
self.mqtt_client = None
|
self.mqtt_client = None
|
||||||
self._friendly_name: str = ""
|
self._friendly_name: str = ""
|
||||||
|
self._auto_listen_var = tk.BooleanVar(value=False)
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
@@ -407,6 +439,19 @@ class SmartIRCapture(tk.Tk):
|
|||||||
command=self._do_go_back, width=14, state="disabled")
|
command=self._do_go_back, width=14, state="disabled")
|
||||||
self._btn_back.pack(side=tk.RIGHT)
|
self._btn_back.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
self._auto_listen_cb = ttk.Checkbutton(
|
||||||
|
btn_frame, text="🔁 Auto-listen for next code",
|
||||||
|
variable=self._auto_listen_var, command=self._on_auto_listen_toggled)
|
||||||
|
self._auto_listen_cb.pack(side=tk.RIGHT, padx=(0, 12))
|
||||||
|
_Tooltip(
|
||||||
|
self._auto_listen_cb,
|
||||||
|
"When enabled, listening for the next IR code starts automatically "
|
||||||
|
"right after you Confirm, Skip, or Copy-to-remaining — so you don't "
|
||||||
|
"need to click Capture again between codes.\n\n"
|
||||||
|
"Retry always re-arms listening on its own regardless of this setting. "
|
||||||
|
"Previous and re-capturing from Overview stay manual either way."
|
||||||
|
)
|
||||||
|
|
||||||
# MQTT log
|
# MQTT log
|
||||||
log_frame = ttk.LabelFrame(tab, text="MQTT Log", padding=4)
|
log_frame = ttk.LabelFrame(tab, text="MQTT Log", padding=4)
|
||||||
log_frame.grid(row=4, column=0, sticky="ew")
|
log_frame.grid(row=4, column=0, sticky="ew")
|
||||||
@@ -541,6 +586,7 @@ class SmartIRCapture(tk.Tk):
|
|||||||
self._sv["swing_modes"].set(", ".join(cfg.get("swingModes", [])))
|
self._sv["swing_modes"].set(", ".join(cfg.get("swingModes", [])))
|
||||||
self._sv["single_code_modes"].set(", ".join(cfg.get("singleCodeModes", [])))
|
self._sv["single_code_modes"].set(", ".join(cfg.get("singleCodeModes", [])))
|
||||||
self._sv["excluded_modes"].set(", ".join(cfg.get("excludedModes", [])))
|
self._sv["excluded_modes"].set(", ".join(cfg.get("excludedModes", [])))
|
||||||
|
self._auto_listen_var.set(bool(data.get("auto_listen", False)))
|
||||||
self.session = data
|
self.session = data
|
||||||
n = len(data.get("codes", {}))
|
n = len(data.get("codes", {}))
|
||||||
self._setup_status_var.set(f"✓ Session loaded — {n} code(s) captured so far.")
|
self._setup_status_var.set(f"✓ Session loaded — {n} code(s) captured so far.")
|
||||||
@@ -548,6 +594,7 @@ class SmartIRCapture(tk.Tk):
|
|||||||
messagebox.showerror("Load error", str(exc))
|
messagebox.showerror("Load error", str(exc))
|
||||||
|
|
||||||
def _save_session(self):
|
def _save_session(self):
|
||||||
|
self.session["auto_listen"] = self._auto_listen_var.get()
|
||||||
tmp = SESSION_FILE.with_suffix(".tmp")
|
tmp = SESSION_FILE.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
@@ -556,6 +603,10 @@ class SmartIRCapture(tk.Tk):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log(f"[WARN] Session save failed: {exc}")
|
self._log(f"[WARN] Session save failed: {exc}")
|
||||||
|
|
||||||
|
def _on_auto_listen_toggled(self):
|
||||||
|
"""Persist the auto-listen preference as soon as it's toggled."""
|
||||||
|
self._save_session()
|
||||||
|
|
||||||
def _parse_config(self) -> dict | None:
|
def _parse_config(self) -> dict | None:
|
||||||
try:
|
try:
|
||||||
host = self._sv["mqtt_host"].get().strip()
|
host = self._sv["mqtt_host"].get().strip()
|
||||||
@@ -611,7 +662,11 @@ class SmartIRCapture(tk.Tk):
|
|||||||
return
|
return
|
||||||
|
|
||||||
existing_codes = self.session.get("codes", {})
|
existing_codes = self.session.get("codes", {})
|
||||||
self.session = {"config": config, "codes": existing_codes}
|
self.session = {
|
||||||
|
"config": config,
|
||||||
|
"codes": existing_codes,
|
||||||
|
"auto_listen": self._auto_listen_var.get(),
|
||||||
|
}
|
||||||
self._friendly_name = config["device_name"]
|
self._friendly_name = config["device_name"]
|
||||||
self.combos = build_combos(config)
|
self.combos = build_combos(config)
|
||||||
self.current_idx = 0
|
self.current_idx = 0
|
||||||
@@ -705,6 +760,8 @@ class SmartIRCapture(tk.Tk):
|
|||||||
self.current_idx += 1
|
self.current_idx += 1
|
||||||
self._update_capture_ui()
|
self._update_capture_ui()
|
||||||
self._update_progress()
|
self._update_progress()
|
||||||
|
if self._auto_listen_var.get() and self._current_combo() is not None:
|
||||||
|
self._do_capture()
|
||||||
|
|
||||||
# Descriptions shown under each field value in the target breakdown
|
# Descriptions shown under each field value in the target breakdown
|
||||||
_OP_MODE_DESC = {
|
_OP_MODE_DESC = {
|
||||||
|
|||||||
Reference in New Issue
Block a user