Enhance AC widget controls

This commit is contained in:
ltadeu6 2026-04-01 16:31:13 -03:00
parent 9368287c12
commit 18602ed083
3 changed files with 298 additions and 40 deletions

View file

@ -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`

View file

@ -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"<span size='60%' rise='-1500'>{fan_value}</span>"
print(
json.dumps(
{
"text": f"<span font_family='FiraCode Nerd Font Mono' rise='-1500' size='140%'>{icon}</span> {target_text} - ",
"text": f"<span font_family='FiraCode Nerd Font Mono' rise='-1500' size='140%'>{icon}</span>{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__":

View file

@ -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"
}