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