Enhance AC widget controls
This commit is contained in:
parent
9368287c12
commit
18602ed083
3 changed files with 298 additions and 40 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue