From 18602ed0835a3e7281920eae2e2c3f74eeddd28a Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Wed, 1 Apr 2026 16:31:13 -0300 Subject: [PATCH] Enhance AC widget controls --- AGENTS.md | 7 + waybar/air_control.py | 321 +++++++++++++++++++++++++++++++++++++----- waybar/config | 10 +- 3 files changed, 298 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e03cfdf..6cd239a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,13 @@ Instrucoes para agentes trabalhando neste repositorio. - Prefira blocos Nix claros e agrupados por topico. - Ao adicionar servicos, habilite apenas o necessario. +## Waybar (AC) +- `waybar/air_control.py` usa apenas stdlib (`urllib`) para falar com o Home Assistant. +- Alteracoes de setpoint/modo/fan sao imediatas na UI, com envio real atrasado (debounce) via arquivos em `~/.cache/`. +- `fan_only` e `dry` mostram sempre a temperatura atual (room) no display. +- Indicador pequeno de fan speed aparece como subscrito ao lado do icone. +- `waybar/config` (modulo `custom/ac`): clique alterna on/off, clique direito cicla modos (sem `off`), clique do meio cicla fan; `interval` esta em 3s. + ## Aplicar mudancas - Rebuild: `sudo nixos-rebuild switch` - Validar sintaxe basica: `nix-instantiate --parse nixos/configuration.nix` diff --git a/waybar/air_control.py b/waybar/air_control.py index e20a103..bf65e91 100755 --- a/waybar/air_control.py +++ b/waybar/air_control.py @@ -15,7 +15,11 @@ TOKEN_FILE = os.path.expanduser("~/.config/secrets/ha_token") STEP = 1 MIN_TEMP = 18 MAX_TEMP = 30 -PENDING_FILE = os.path.expanduser("~/.cache/waybar_air_pending.json") +PENDING_TEMP_FILE = os.path.expanduser("~/.cache/waybar_air_pending.json") +PENDING_MODE_FILE = os.path.expanduser("~/.cache/waybar_air_pending_mode.json") +PENDING_FAN_FILE = os.path.expanduser("~/.cache/waybar_air_pending_fan.json") +LAST_MODE_FILE = os.path.expanduser("~/.cache/waybar_air_last_mode.json") +LAST_TARGET_FILE = os.path.expanduser("~/.cache/waybar_air_last_target.json") SEND_DELAY = 3 PENDING_TTL = 15 CONFIRM_TTL = 20 @@ -49,6 +53,14 @@ def set_mode(mode): ) +def set_fan_mode(mode): + request_json( + "POST", + "/api/services/climate/set_fan_mode", + {"entity_id": ENTITY, "fan_mode": mode}, + ) + + def request_json(method, path, payload=None): url = f"{HA_URL}{path}" data = None @@ -70,20 +82,20 @@ def request_json(method, path, payload=None): return None -def read_pending(): +def read_pending(path): try: - with open(PENDING_FILE) as f: + with open(path) as f: data = json.load(f) - temp = data.get("temperature") + value = data.get("value") ts = data.get("ts") sent = data.get("sent", False) sent_ts = data.get("sent_ts") - if isinstance(temp, (int, float)) and isinstance(ts, (int, float)): + if isinstance(value, (int, float, str)) and isinstance(ts, (int, float)): sent_ts_val = None if isinstance(sent_ts, (int, float)): sent_ts_val = float(sent_ts) return { - "temperature": float(temp), + "value": value, "ts": float(ts), "sent": bool(sent), "sent_ts": sent_ts_val, @@ -95,26 +107,74 @@ def read_pending(): return None -def write_pending(temp, sent=False, sent_ts=None, ts=None): - os.makedirs(os.path.dirname(PENDING_FILE), exist_ok=True) +def write_pending(path, value, sent=False, sent_ts=None, ts=None): + os.makedirs(os.path.dirname(path), exist_ok=True) if ts is None: ts = time.time() - payload = {"temperature": float(temp), "ts": float(ts), "sent": bool(sent)} + payload = {"value": value, "ts": float(ts), "sent": bool(sent)} if sent_ts is not None: payload["sent_ts"] = float(sent_ts) - with open(PENDING_FILE, "w") as f: + with open(path, "w") as f: json.dump(payload, f) -def clear_pending(): +def clear_pending(path): try: - os.remove(PENDING_FILE) + os.remove(path) except FileNotFoundError: pass except OSError: pass +def read_last_mode(): + try: + with open(LAST_MODE_FILE) as f: + data = json.load(f) + mode = data.get("mode") + if isinstance(mode, str): + return mode + except FileNotFoundError: + return None + except (OSError, ValueError, json.JSONDecodeError): + return None + return None + + +def write_last_mode(mode): + os.makedirs(os.path.dirname(LAST_MODE_FILE), exist_ok=True) + with open(LAST_MODE_FILE, "w") as f: + json.dump({"mode": mode}, f) + + +def read_last_target(): + try: + with open(LAST_TARGET_FILE) as f: + data = json.load(f) + value = data.get("value") + if isinstance(value, (int, float)): + return float(value) + except FileNotFoundError: + return None + except (OSError, ValueError, json.JSONDecodeError): + return None + return None + + +def write_last_target(value): + os.makedirs(os.path.dirname(LAST_TARGET_FILE), exist_ok=True) + with open(LAST_TARGET_FILE, "w") as f: + json.dump({"value": float(value)}, f) + + +def pending_active(pending): + now = time.time() + show = now - pending["ts"] <= PENDING_TTL + if pending["sent"] and pending.get("sent_ts") is not None: + show = show or (now - pending["sent_ts"] <= CONFIRM_TTL) + return show + + def display(): state = get_state() if not state: @@ -125,20 +185,35 @@ def display(): current = attrs.get("current_temperature") target = attrs.get("temperature") - pending = read_pending() - if pending is not None: - pending_temp = pending["temperature"] - pending_ts = pending["ts"] - now = time.time() - show_pending = now - pending_ts <= PENDING_TTL - if pending["sent"] and pending.get("sent_ts") is not None: - show_pending = show_pending or (now - pending["sent_ts"] <= CONFIRM_TTL) - if target == pending_temp: - clear_pending() - elif show_pending: - target = pending_temp + pending_temp = read_pending(PENDING_TEMP_FILE) + if pending_temp is not None: + pending_temp_value = float(pending_temp["value"]) + if target == pending_temp_value: + clear_pending(PENDING_TEMP_FILE) + elif pending_active(pending_temp): + target = pending_temp_value else: - clear_pending() + clear_pending(PENDING_TEMP_FILE) + if target is not None: + write_last_target(target) + + pending_mode = read_pending(PENDING_MODE_FILE) + if pending_mode is not None: + pending_mode_value = str(pending_mode["value"]) + if mode == pending_mode_value: + clear_pending(PENDING_MODE_FILE) + elif pending_active(pending_mode): + mode = pending_mode_value + else: + clear_pending(PENDING_MODE_FILE) + + pending_fan = read_pending(PENDING_FAN_FILE) + if pending_fan is not None: + current_fan = attrs.get("fan_mode") + if current_fan == pending_fan["value"]: + clear_pending(PENDING_FAN_FILE) + elif not pending_active(pending_fan): + clear_pending(PENDING_FAN_FILE) if current is None: current = 0 @@ -153,6 +228,7 @@ def display(): ) ) return + write_last_mode(mode) mode_map = { "cool": ("", "cool"), @@ -162,14 +238,22 @@ def display(): } icon, css_class = mode_map.get(mode, ("", "other")) - if target is None: + if mode in ("fan_only", "dry"): + target_text = f"{current:.0f}" + elif target is None: target_text = f"{current:.0f}" else: target_text = f"{target:.0f}" + fan_value = attrs.get("fan_mode") + if pending_fan is not None and pending_active(pending_fan): + fan_value = pending_fan["value"] + fan_text = "" + if fan_value is not None: + fan_text = f"{fan_value}" print( json.dumps( { - "text": f"{icon} {target_text} - ", + "text": f"{icon}{fan_text} {target_text} - ", "class": css_class, } ) @@ -178,6 +262,9 @@ def display(): def change(delta): state = get_state() + if state and state.get("state") == "fan_only": + change_fan(delta) + return if state: attrs = state["attributes"] current_target = attrs.get("temperature") @@ -185,14 +272,20 @@ def change(delta): current_target = attrs.get("current_temperature") else: current_target = None - pending = read_pending() - if pending is not None: - current_target = pending["temperature"] + pending = read_pending(PENDING_TEMP_FILE) + if pending is not None and pending_active(pending): + current_target = float(pending["value"]) + elif pending is not None: + clear_pending(PENDING_TEMP_FILE) + if current_target is None: + current_target = read_last_target() + if current_target is None and state: + current_target = state["attributes"].get("current_temperature") if current_target is None: return new_temp = max(MIN_TEMP, min(MAX_TEMP, current_target + delta)) - write_pending(new_temp, sent=False) + write_pending(PENDING_TEMP_FILE, float(new_temp), sent=False) subprocess.Popen( [sys.executable, os.path.abspath(__file__), "flush"], stdout=subprocess.DEVNULL, @@ -200,23 +293,167 @@ def change(delta): ) +def change_fan(delta): + state = get_state() + if not state: + return + attrs = state["attributes"] + modes = attrs.get("fan_modes") + current = attrs.get("fan_mode") + if not modes or current is None: + return + pending = read_pending(PENDING_FAN_FILE) + if pending is not None and pending_active(pending): + current = pending["value"] + elif pending is not None: + clear_pending(PENDING_FAN_FILE) + try: + idx = modes.index(current) + except ValueError: + idx = 0 + new_idx = (idx + delta) % len(modes) + if new_idx != idx: + write_pending(PENDING_FAN_FILE, modes[new_idx], sent=False) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush-fan"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def cycle_mode(): + state = get_state() + if not state: + return + attrs = state["attributes"] + modes = attrs.get("hvac_modes") or [] + modes = [m for m in modes if m != "off"] + if not modes: + return + current = state.get("state") + pending = read_pending(PENDING_MODE_FILE) + if pending is not None and pending_active(pending): + current = pending["value"] + elif pending is not None: + clear_pending(PENDING_MODE_FILE) + try: + idx = modes.index(current) + except ValueError: + idx = -1 + new_idx = (idx + 1) % len(modes) + new_mode = modes[new_idx] + write_pending(PENDING_MODE_FILE, new_mode, sent=False) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush-mode"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def toggle_mode(): + state = get_state() + if not state: + return + attrs = state["attributes"] + modes = attrs.get("hvac_modes") or [] + current = state.get("state") + pending = read_pending(PENDING_MODE_FILE) + if pending is not None and pending_active(pending): + current = pending["value"] + elif pending is not None: + clear_pending(PENDING_MODE_FILE) + if current == "off": + last = read_last_mode() + if last in modes and last != "off": + write_pending(PENDING_MODE_FILE, last, sent=False) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush-mode"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + for m in modes: + if m != "off": + write_pending(PENDING_MODE_FILE, m, sent=False) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush-mode"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + else: + write_pending(PENDING_MODE_FILE, "off", sent=False) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush-mode"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def cycle_fan(): + change_fan(1) + + def flush_pending(): - pending = read_pending() + pending = read_pending(PENDING_TEMP_FILE) if pending is None: return ts = pending["ts"] remaining = SEND_DELAY - (time.time() - ts) if remaining > 0: time.sleep(remaining) - pending = read_pending() + pending = read_pending(PENDING_TEMP_FILE) if pending is None: return - temp2 = pending["temperature"] + temp2 = float(pending["value"]) ts2 = pending["ts"] if ts2 != ts: return set_temperature(temp2) - write_pending(temp2, sent=True, sent_ts=time.time(), ts=ts2) + write_pending(PENDING_TEMP_FILE, float(temp2), sent=True, sent_ts=time.time(), ts=ts2) + + +def flush_mode(): + pending = read_pending(PENDING_MODE_FILE) + if pending is None: + return + ts = pending["ts"] + remaining = SEND_DELAY - (time.time() - ts) + if remaining > 0: + time.sleep(remaining) + pending = read_pending(PENDING_MODE_FILE) + if pending is None: + return + mode2 = str(pending["value"]) + ts2 = pending["ts"] + if ts2 != ts: + return + set_mode(mode2) + write_pending(PENDING_MODE_FILE, mode2, sent=True, sent_ts=time.time(), ts=ts2) + subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "flush"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def flush_fan(): + pending = read_pending(PENDING_FAN_FILE) + if pending is None: + return + ts = pending["ts"] + remaining = SEND_DELAY - (time.time() - ts) + if remaining > 0: + time.sleep(remaining) + pending = read_pending(PENDING_FAN_FILE) + if pending is None: + return + fan2 = str(pending["value"]) + ts2 = pending["ts"] + if ts2 != ts: + return + set_fan_mode(fan2) + write_pending(PENDING_FAN_FILE, fan2, sent=True, sent_ts=time.time(), ts=ts2) def main(): @@ -236,8 +473,22 @@ def main(): change(STEP) elif cmd == "down": change(-STEP) + elif cmd == "fan-up": + change_fan(STEP) + elif cmd == "fan-down": + change_fan(-STEP) + elif cmd == "cycle-mode": + cycle_mode() + elif cmd == "toggle": + toggle_mode() + elif cmd == "cycle-fan": + cycle_fan() elif cmd == "flush": flush_pending() + elif cmd == "flush-mode": + flush_mode() + elif cmd == "flush-fan": + flush_fan() if __name__ == "__main__": diff --git a/waybar/config b/waybar/config index 4ca1927..c878d99 100644 --- a/waybar/config +++ b/waybar/config @@ -180,7 +180,7 @@ }, "network": { "format": "{icon}", - "format-alt": "󰲝 ︁ {ipaddr}", + "format-alt": "󰲝 {ipaddr}", "format-alt-click": "click-left", "format-wifi": " ", "format-ethernet": "󰲝", @@ -189,12 +189,12 @@ }, "custom/ac": { "exec": "$HOME/nixos-config/waybar/air_control.py", - "interval": 1, + "interval": 3, "return-type": "json", "markup": "true", - "on-click-right": "$HOME/nixos-config/waybar/air_control.py heat", - "on-click": "$HOME/nixos-config/waybar/air_control.py cool", - "on-click-middle": "$HOME/nixos-config/waybar/air_control.py off", + "on-click": "$HOME/nixos-config/waybar/air_control.py toggle", + "on-click-right": "$HOME/nixos-config/waybar/air_control.py cycle-mode", + "on-click-middle": "$HOME/nixos-config/waybar/air_control.py cycle-fan", "on-scroll-up": "$HOME/nixos-config/waybar/air_control.py up", "on-scroll-down": "$HOME/nixos-config/waybar/air_control.py down" }