Plugin Home Assistant (scheda Casa) e motore interno: ragionamento a scelta, formato Spark, cache del prompt esatta
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
209228aea0
commit
59027d1d5b
6 files changed
+444
-29
No files matched your search
+2
-2
@@ -181,7 +181,7 @@ class Assistant:
|
||||
s = self.settings
|
||||
provider = s.get("provider")
|
||||
self.name, self.user_name = _identity()
|
||||
key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("mlx_model"), s.get("search_api_key"),
|
||||
key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("mlx_model"), s.get("mlx_thinking"), s.get("search_api_key"),
|
||||
s.get("claudecode_model"), s.get("claudecode_access"), s.get("claudecode_config_dir"),
|
||||
s.get("claudecode_path"), self.name, self.user_name, s.get_secret("anthropic_api_key")[-6:],
|
||||
s.get_secret("local_api_key")[-4:])
|
||||
@@ -191,7 +191,7 @@ class Assistant:
|
||||
if old is not None and hasattr(old, "close"):
|
||||
old.close() # libera il modello interno dalla memoria
|
||||
if provider == "mlx":
|
||||
self._engine = MLXEngine(s.get("mlx_model") or "", s.get("search_api_key"), self.name, self.user_name)
|
||||
self._engine = MLXEngine(s.get("mlx_model") or "", s.get("search_api_key"), self.name, self.user_name, s.get("mlx_thinking") or "auto")
|
||||
elif provider == "local":
|
||||
if s.get("local_base_url") and s.get("local_model"):
|
||||
self._engine = OpenAICompatEngine(s.get("local_base_url"), s.get("local_model"), s.get_secret("local_api_key"),
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -21,6 +22,8 @@ from .openai_compat import SEARCH_TOOL, Aborted
|
||||
MAX_ROUNDS = 8
|
||||
MAX_HISTORY = 30
|
||||
MAX_TOKENS = 3000
|
||||
SAMPLING = {"temp": 0.6, "top_p": 0.8, "top_k": 20} # poco sotto i valori consigliati da Qwen: più costante nell'uso degli strumenti
|
||||
DEBUG = bool(os.environ.get("MLX_DEBUG")) # stampa il testo grezzo generato (tag compresi)
|
||||
DEFAULT_MODEL = "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit"
|
||||
HUB = Path.home() / ".cache" / "huggingface" / "hub"
|
||||
_EXCLUDE = ("whisper", "tts", "embed", "rerank", "clip", "vl-", "-vl", "vision", "diffusion", "parakeet")
|
||||
@@ -47,6 +50,10 @@ def load_model(name: str):
|
||||
if _loaded["name"] == name and _loaded["model"] is not None:
|
||||
return _loaded["model"], _loaded["tokenizer"]
|
||||
unload()
|
||||
try:
|
||||
import optiq # noqa: F401 registra architetture extra (es. spark2_5) in mlx-lm, se installato
|
||||
except Exception:
|
||||
pass
|
||||
from mlx_lm import load
|
||||
model, tokenizer = load(name)
|
||||
_loaded.update(name=name, model=model, tokenizer=tokenizer, cache=None, tokens=[], snap=None)
|
||||
@@ -78,7 +85,7 @@ class TagFilter:
|
||||
|
||||
def _emit(self, piece: str) -> str:
|
||||
if self.strip_next:
|
||||
piece = piece.lstrip()
|
||||
piece = piece.lstrip().lstrip("-").lstrip()
|
||||
if piece:
|
||||
self.strip_next = False
|
||||
return piece
|
||||
@@ -128,10 +135,14 @@ class TagFilter:
|
||||
return self._emit(c)
|
||||
|
||||
|
||||
# Formati di chiamata: Hermes/Qwen (JSON in <tool_call>) e Gemma 4 (call:nome{chiave:<|"|>testo<|"|>,...}).
|
||||
# Formati di chiamata: Hermes/Qwen (JSON in <tool_call>), Gemma 4 (call:nome{chiave:<|"|>testo<|"|>,...}),
|
||||
# argkey/Spark-GLM (<tool_call>nome<arg_key>k</arg_key><arg_value>v</arg_value>...</tool_call>).
|
||||
# "thinking": alcuni modelli (Spark) usano gli strumenti in modo affidabile solo col ragionamento acceso.
|
||||
# "raw_args": il template riproduce gli argomenti come stringa grezza (prompt identico a ciò che il modello ha scritto).
|
||||
FORMATS = {
|
||||
"hermes": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>")},
|
||||
"gemma4": {"think": ("<|channel>", "<channel|>"), "call": ("<|tool_call>", "<tool_call|>")},
|
||||
"hermes": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>"), "thinking": False, "raw_args": False},
|
||||
"gemma4": {"think": ("<|channel>", "<channel|>"), "call": ("<|tool_call>", "<tool_call|>"), "thinking": False, "raw_args": True},
|
||||
"argkey": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>"), "thinking": True, "raw_args": False},
|
||||
}
|
||||
GEMMA_ESC = '<|"|>'
|
||||
|
||||
@@ -141,7 +152,11 @@ def detect_format(tokenizer) -> str:
|
||||
vocab = tokenizer.get_vocab()
|
||||
except Exception:
|
||||
vocab = {}
|
||||
return "gemma4" if "<|tool_call>" in vocab else "hermes"
|
||||
if "<|tool_call>" in vocab:
|
||||
return "gemma4"
|
||||
if "<arg_key>" in str(getattr(tokenizer, "chat_template", "") or ""):
|
||||
return "argkey"
|
||||
return "hermes"
|
||||
|
||||
|
||||
def _gemma_to_json(body: str) -> str:
|
||||
@@ -169,6 +184,18 @@ def _parse_call(raw: str, fmt: str) -> tuple[str, dict, str] | None:
|
||||
except Exception:
|
||||
return None
|
||||
return m.group(1), dict(args or {}), body[1:-1]
|
||||
if fmt == "argkey" or "<arg_key>" in raw:
|
||||
m = re.match(r"\s*([\w.-]+)\s*(.*)$", raw, re.S)
|
||||
if not m:
|
||||
return None
|
||||
args = {}
|
||||
for k, v in re.findall(r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", m.group(2), re.S):
|
||||
v = v.strip()
|
||||
try:
|
||||
args[k.strip()] = json.loads(v)
|
||||
except Exception:
|
||||
args[k.strip()] = v
|
||||
return m.group(1), args, json.dumps(args, ensure_ascii=False)
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
@@ -190,8 +217,9 @@ def _parse_call(raw: str, fmt: str) -> tuple[str, dict, str] | None:
|
||||
class MLXEngine:
|
||||
name = "mlx"
|
||||
|
||||
def __init__(self, model_name: str, search_api_key: str, assistant_name: str, user_name: str) -> None:
|
||||
def __init__(self, model_name: str, search_api_key: str, assistant_name: str, user_name: str, thinking: str = "auto") -> None:
|
||||
self.model_name = model_name or DEFAULT_MODEL
|
||||
self.thinking = thinking # auto | on | off
|
||||
self.search_api_key = search_api_key
|
||||
self.assistant_name, self.user_name = assistant_name, user_name
|
||||
self.history = History("mlx")
|
||||
@@ -232,7 +260,6 @@ class MLXEngine:
|
||||
def _prefill(self, tokens: list[int], cut: int):
|
||||
"""Cache che contiene esattamente `tokens`, riusando la fotografia precedente se ne è un'estensione.
|
||||
La nuova fotografia viene scattata a `cut` (fine dell'ultimo messaggio): il turno successivo riparte da lì."""
|
||||
from mlx_lm import stream_generate
|
||||
from mlx_lm.models.cache import make_prompt_cache, trim_prompt_cache, can_trim_prompt_cache
|
||||
snap, cache, fed = _loaded["snap"], None, 0
|
||||
if snap and len(tokens) >= len(snap["tokens"]) and tokens[: len(snap["tokens"])] == snap["tokens"]:
|
||||
@@ -249,17 +276,22 @@ class MLXEngine:
|
||||
_loaded.update(cache=None, tokens=[])
|
||||
if cache is None:
|
||||
cache = make_prompt_cache(self.model)
|
||||
# max_tokens=1: il token campionato non entra nella cache, che contiene quindi esattamente i token passati
|
||||
if cut > fed:
|
||||
for _ in stream_generate(self.model, self.tokenizer, tokens[fed:cut], max_tokens=1, prompt_cache=cache):
|
||||
pass
|
||||
self._feed(cache, tokens[fed:cut])
|
||||
_loaded["snap"] = {"tokens": list(tokens[:cut]), "cache": copy.deepcopy(cache)}
|
||||
fed = cut
|
||||
if len(tokens) > fed:
|
||||
for _ in stream_generate(self.model, self.tokenizer, tokens[fed:], max_tokens=1, prompt_cache=cache):
|
||||
pass
|
||||
self._feed(cache, tokens[fed:])
|
||||
return cache
|
||||
|
||||
def _feed(self, cache, tokens: list[int], step: int = 2048) -> None:
|
||||
"""Inserisce esattamente `tokens` nella cache, senza campionare (stream_generate aggiungerebbe un token in più)."""
|
||||
import mlx.core as mx
|
||||
for i in range(0, len(tokens), step):
|
||||
self.model(mx.array(tokens[i:i + step])[None], cache=cache)
|
||||
mx.eval([c.state for c in cache])
|
||||
mx.clear_cache()
|
||||
|
||||
def close(self) -> None:
|
||||
self.model = self.tokenizer = None
|
||||
unload()
|
||||
@@ -275,12 +307,19 @@ class MLXEngine:
|
||||
self.model, self.tokenizer = load_model(self.model_name)
|
||||
self.fmt = detect_format(self.tokenizer)
|
||||
|
||||
def _thinking(self) -> bool:
|
||||
if self.thinking == "on":
|
||||
return True
|
||||
if self.thinking == "off":
|
||||
return False
|
||||
return FORMATS[self.fmt]["thinking"]
|
||||
|
||||
def _system(self) -> str:
|
||||
note = ("\n\nRegole sugli strumenti (obbligatorie):\n"
|
||||
"- Quando l'utente ti chiede di ricordare qualcosa, o ti dice un fatto importante su di sé, DEVI chiamare salva_memoria prima di rispondere. Non dire mai di aver salvato senza averlo chiamato davvero.\n"
|
||||
"- Per cancellare una memoria chiama dimentica_memoria; per cercarne una non presente nel prompt chiama cerca_memoria.\n"
|
||||
"- Per azioni sul Mac (per esempio il calendario) usa gli strumenti dedicati. Se uno risponde con [CONFIRMATION_PENDING], chiedi all'utente di confermare sul pannello e non dire che è fatto.\n"
|
||||
"- Non inventare mai dati reali (meteo, ora, calendario, mail, messaggi, file, stato dei server): se esiste uno strumento che li fornisce, chiamalo prima di rispondere.\n"
|
||||
"- Non inventare mai dati reali: meteo, calendario, mail, messaggi, file, ora, server e memoria li ottieni SOLO chiamando lo strumento corrispondente, anche a metà conversazione. Rispondere senza averlo chiamato è un errore grave.\n"
|
||||
"- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.")
|
||||
note += ("\n- Per informazioni aggiornate chiama cerca_web e rispondi in base ai risultati." if self.search_api_key
|
||||
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
|
||||
@@ -302,21 +341,22 @@ class MLXEngine:
|
||||
"""Prompt completo; con generation=False si ferma alla fine dell'ultimo messaggio (parte stabile)."""
|
||||
messages = [{"role": "system", "content": self._system()}, *self._recent()]
|
||||
try:
|
||||
return self.tokenizer.apply_chat_template(messages, tools=self._tools(), add_generation_prompt=generation, tokenize=False, enable_thinking=False)
|
||||
return self.tokenizer.apply_chat_template(messages, tools=self._tools(), add_generation_prompt=generation, tokenize=False, enable_thinking=self._thinking())
|
||||
except TypeError:
|
||||
# Template senza supporto strumenti: li descrive nel sistema.
|
||||
messages[0]["content"] += "\n\nStrumenti disponibili (JSON):\n" + json.dumps(self._tools(), ensure_ascii=False) + \
|
||||
"\nPer usarne uno scrivi solo: <tool_call>{\"name\": \"…\", \"arguments\": {…}}</tool_call>"
|
||||
return self.tokenizer.apply_chat_template(messages, add_generation_prompt=generation, tokenize=False, enable_thinking=False)
|
||||
return self.tokenizer.apply_chat_template(messages, add_generation_prompt=generation, tokenize=False, enable_thinking=self._thinking())
|
||||
|
||||
def _prompt_tokens(self) -> tuple[list[int], int]:
|
||||
"""Token del prompt e lunghezza della parte stabile (comune al turno successivo)."""
|
||||
tokens = list(self.tokenizer.encode(self._prompt()))
|
||||
def _prompt_tokens(self) -> tuple[list[int], int, str]:
|
||||
"""Token del prompt, lunghezza della parte stabile (comune al turno successivo) e testo del prompt."""
|
||||
prompt = self._prompt()
|
||||
tokens = list(self.tokenizer.encode(prompt))
|
||||
stable = list(self.tokenizer.encode(self._prompt(generation=False)))
|
||||
cut = 0
|
||||
while cut < min(len(tokens), len(stable)) and tokens[cut] == stable[cut]:
|
||||
cut += 1
|
||||
return tokens, cut
|
||||
return tokens, cut, prompt
|
||||
|
||||
def _run_tool(self, name: str, args: dict, emit: Emit, sources: list) -> str:
|
||||
if name == "cerca_web":
|
||||
@@ -343,9 +383,10 @@ class MLXEngine:
|
||||
from mlx_lm.sample_utils import make_sampler
|
||||
f = FORMATS[self.fmt]
|
||||
think, tools, text, truncated = TagFilter(*f["think"], keep=False), TagFilter(*f["call"], keep=True), "", False
|
||||
sampler = make_sampler(temp=0.7, top_p=0.8, top_k=20)
|
||||
sampler = make_sampler(**SAMPLING)
|
||||
# Cache del prompt: la parte già vista (fotografia del turno precedente) non viene ricalcolata.
|
||||
tokens, cut = self._prompt_tokens()
|
||||
tokens, cut, prompt = self._prompt_tokens()
|
||||
think.inside = prompt.rstrip().endswith(f["think"][0]) # il template ha già aperto il blocco di pensiero
|
||||
cache = self._prefill(tokens[:-1], min(cut, len(tokens) - 1))
|
||||
generated: list[int] = []
|
||||
try:
|
||||
@@ -353,6 +394,8 @@ class MLXEngine:
|
||||
if abort.is_set():
|
||||
raise Aborted()
|
||||
generated.append(r.token)
|
||||
if DEBUG:
|
||||
print(r.text, end="", flush=True)
|
||||
visible = tools.push(think.push(r.text))
|
||||
if visible:
|
||||
if not announced:
|
||||
@@ -365,8 +408,8 @@ class MLXEngine:
|
||||
except Aborted:
|
||||
raise
|
||||
else:
|
||||
# generate_step inserisce nella cache ogni token emesso, tranne l'ultimo quando si ferma per limite.
|
||||
_loaded.update(cache=cache, tokens=tokens + (generated[:-1] if truncated else generated))
|
||||
# generate_step inserisce nella cache ogni token emesso (anche l'ultimo, anche quando si ferma per limite).
|
||||
_loaded.update(cache=cache, tokens=tokens + generated)
|
||||
tail = tools.flush()
|
||||
if tail:
|
||||
text += tail
|
||||
@@ -389,7 +432,7 @@ class MLXEngine:
|
||||
break
|
||||
self.history.messages.append({
|
||||
"role": "assistant", "content": round_text.strip(),
|
||||
"tool_calls": [{"id": f"call_{rnd}_{i}", "type": "function", "function": {"name": n, "arguments": raw}} for i, (n, a, raw) in enumerate(calls)],
|
||||
"tool_calls": [{"id": f"call_{rnd}_{i}", "type": "function", "function": {"name": n, "arguments": raw if FORMATS[self.fmt]["raw_args"] else a}} for i, (n, a, raw) in enumerate(calls)],
|
||||
})
|
||||
for i, (n, a, raw) in enumerate(calls):
|
||||
result = self._run_tool(n, a, emit, sources)
|
||||
|
||||
@@ -37,3 +37,8 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
|
||||
- Con gli strumenti "server_…" tieni d'occhio i server Linux dell'utente: lo stato della flotta, gli allarmi e i backup li leggi subito dal monitoraggio; per diagnosi e interventi ("perché nginx dà 502?", "riavvia php-fpm su web1") inoltri la richiesta all'assistente sysadmin con server_chiedi e riferisci la sua risposta in breve.
|
||||
- Quando l'assistente sysadmin chiede l'approvazione di un comando, leggi all'utente cosa vuole fare e attendi la sua decisione prima di usare server_approva. Non approvare mai di tua iniziativa.
|
||||
- Se una risposta tarda, dillo e continua con server_attendi invece di inventare l'esito.
|
||||
|
||||
## Casa
|
||||
|
||||
- Con gli strumenti "casa_…" controlli la casa tramite Home Assistant: luci, prese, clima, tapparelle, media, scene e sensori. Se l'utente nomina un dispositivo che non trovi, cerca con casa_dispositivi prima di dire che non esiste; per comandi su più stanze usa casa_chiedi.
|
||||
- Conferma a voce cosa hai fatto in poche parole ("Luce cucina accesa"). Serrature e allarme solo dopo la conferma esplicita dell'utente.
|
||||
+3
-1
@@ -18,6 +18,7 @@ DEFAULTS: dict[str, Any] = {
|
||||
"local_base_url": "http://localhost:8000/v1",
|
||||
"local_model": "",
|
||||
"mlx_model": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", # motore interno (mlx-lm), repo Hugging Face o cartella
|
||||
"mlx_thinking": "auto", # ragionamento del modello interno: auto (secondo il modello) | on | off
|
||||
"search_api_key": "",
|
||||
"claudecode_model": "sonnet",
|
||||
"claudecode_access": "chat", # chat | read | full
|
||||
@@ -50,9 +51,10 @@ DEFAULTS: dict[str, Any] = {
|
||||
"server_assist_ssh": "", # utente@host del server ponte (assistenza server), es. root@1.2.3.4
|
||||
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
|
||||
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
|
||||
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
|
||||
}
|
||||
|
||||
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key")
|
||||
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token")
|
||||
|
||||
|
||||
class Settings:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
|
||||
@@ -63,7 +65,7 @@ class SettingsDialog(QDialog):
|
||||
self.tabs.addTab(sc, title)
|
||||
lay.addStretch(1)
|
||||
return lay
|
||||
pg_motore, pg_voce, pg_msg, pg_mon = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor")
|
||||
pg_motore, pg_voce, pg_msg, pg_mon, pg_casa = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor"), page("Casa")
|
||||
def add(lay, item): # inserisce prima dello stretch finale
|
||||
if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item)
|
||||
else: lay.insertLayout(lay.count() - 1, item)
|
||||
@@ -117,6 +119,8 @@ class SettingsDialog(QDialog):
|
||||
self.mlx_model.addItems(cached_models() or [DEFAULT_MODEL])
|
||||
self.mlx_model.setEditText(s.get("mlx_model") or DEFAULT_MODEL)
|
||||
f.addRow("Modello (repo Hugging Face mlx-community)", self.mlx_model)
|
||||
self.mlx_thinking = _combo([("auto", "Automatico (acceso solo dove serve, es. Spark)"), ("off", "Spento: risposte rapide"), ("on", "Acceso: più lento, meglio su domande complesse")], s.get("mlx_thinking") or "auto")
|
||||
f.addRow("Ragionamento", self.mlx_thinking)
|
||||
hint = QLabel("Elenco: modelli già scaricati. Un nome nuovo viene scaricato al primo uso. "
|
||||
"Con 32 GB di RAM: modelli fino a ~20 GB in 4 bit (Qwen3-30B-A3B è veloce e supporta gli strumenti). "
|
||||
"Non tenere aperto anche VLLMac con lo stesso modello.")
|
||||
@@ -243,6 +247,19 @@ class SettingsDialog(QDialog):
|
||||
form5.addRow("Escludi dagli avvisi", self.mon_excl)
|
||||
add(pg_mon, form5)
|
||||
|
||||
# ── Casa (Home Assistant) ─────────────────────────────────────────
|
||||
form_ha = QFormLayout()
|
||||
form_ha.addRow(QLabel("Home Assistant: crea un token di accesso a lunga durata dal tuo profilo (Sicurezza) e incollalo qui. Viene salvato nel portachiavi."))
|
||||
self.ha_url = QLineEdit(str(s.get("homeassistant_url") or "")); self.ha_url.setPlaceholderText("http://homeassistant.local:8123")
|
||||
form_ha.addRow("Indirizzo", self.ha_url)
|
||||
row = QHBoxLayout()
|
||||
self.ha_token = QLineEdit(); self.ha_token.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.ha_token.setPlaceholderText("•••••• (salvato)" if s.get_secret("homeassistant_token") else "token di accesso a lunga durata"); row.addWidget(self.ha_token, 1)
|
||||
b = QPushButton("Verifica"); b.clicked.connect(self._ha_check); row.addWidget(b)
|
||||
form_ha.addRow("Token", row)
|
||||
self.ha_hint = QLabel(""); self.ha_hint.setWordWrap(True); form_ha.addRow("", self.ha_hint)
|
||||
add(pg_casa, form_ha)
|
||||
|
||||
btns = QHBoxLayout(); btns.addStretch()
|
||||
cancel = QPushButton("Annulla"); cancel.clicked.connect(self.reject); btns.addWidget(cancel)
|
||||
save = QPushButton("Salva"); save.setObjectName("primary"); save.clicked.connect(self._save); btns.addWidget(save)
|
||||
@@ -390,7 +407,26 @@ class SettingsDialog(QDialog):
|
||||
self._async.emit("el_error", str(err)[:120])
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _ha_check(self) -> None:
|
||||
vals = {"homeassistant_url": self.ha_url.text().strip().rstrip("/")}
|
||||
if self.ha_token.text().strip():
|
||||
vals["homeassistant_token"] = self.ha_token.text().strip()
|
||||
self.settings.update(vals)
|
||||
self.ha_hint.setText("Verifico…")
|
||||
|
||||
def go():
|
||||
try:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins"))
|
||||
import importlib
|
||||
mod = importlib.import_module("home_assistant")
|
||||
self._async.emit("ha", mod.verifica())
|
||||
except Exception as err:
|
||||
self._async.emit("ha", f"Errore: {err}")
|
||||
threading.Thread(target=go, daemon=True).start()
|
||||
|
||||
def _on_async(self, kind: str, payload) -> None:
|
||||
if kind == "ha":
|
||||
self.ha_hint.setText(str(payload)); return
|
||||
if kind == "vb":
|
||||
self.vb_profile.clear()
|
||||
for pid, label in payload:
|
||||
@@ -456,7 +492,7 @@ class SettingsDialog(QDialog):
|
||||
values = {
|
||||
"provider": self.provider.currentData(), "effort": self.effort.currentData(),
|
||||
"local_base_url": self.local_url.text().strip().rstrip("/"), "local_model": self.local_model.currentText().strip(),
|
||||
"mlx_model": self.mlx_model.currentText().strip(),
|
||||
"mlx_model": self.mlx_model.currentText().strip(), "mlx_thinking": self.mlx_thinking.currentData() or "auto",
|
||||
"search_api_key": self.search_key.text().strip(),
|
||||
"claudecode_model": self.cc_model.currentData(), "claudecode_access": self.cc_access.currentData(),
|
||||
"claudecode_config_dir": self.cc_config.currentText().strip(), "claudecode_path": self.cc_path.text().strip(),
|
||||
@@ -480,7 +516,10 @@ class SettingsDialog(QDialog):
|
||||
"server_assist_ssh": self.srv_ssh.text().strip(),
|
||||
"server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance",
|
||||
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
|
||||
"homeassistant_url": self.ha_url.text().strip().rstrip("/"),
|
||||
}
|
||||
if self.ha_token.text().strip():
|
||||
values["homeassistant_token"] = self.ha_token.text().strip()
|
||||
if self.tg_hash.text().strip():
|
||||
values["telegram_api_hash"] = self.tg_hash.text().strip()
|
||||
if self.api_key.text().strip():
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Home Assistant: dispositivi, stati, accensione/spegnimento, regolazioni, scene, servizi e Assist.
|
||||
|
||||
Usa l'API REST con un token di accesso a lunga durata (Profilo > Sicurezza in Home Assistant),
|
||||
salvato nel portachiavi. Indirizzo e token si impostano nella scheda "Casa" delle impostazioni.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from avatar.macos import confirm_or_param, CONFERMATO # noqa: E402
|
||||
from avatar.settings import Settings # noqa: E402
|
||||
|
||||
DOMINI_IT = {"light": "luce", "switch": "interruttore", "fan": "ventilatore", "climate": "clima", "cover": "tapparella",
|
||||
"media_player": "media", "lock": "serratura", "sensor": "sensore", "binary_sensor": "sensore", "scene": "scena",
|
||||
"script": "script", "automation": "automazione", "vacuum": "aspirapolvere", "camera": "telecamera",
|
||||
"alarm_control_panel": "allarme", "humidifier": "umidificatore", "water_heater": "scaldabagno", "input_boolean": "opzione"}
|
||||
AZIONABILI = ("light", "switch", "fan", "climate", "cover", "media_player", "lock", "scene", "script", "automation",
|
||||
"vacuum", "humidifier", "water_heater", "input_boolean", "siren", "remote")
|
||||
DELICATI = ("lock", "alarm_control_panel", "siren") # richiedono conferma
|
||||
_cache: dict = {"t": 0.0, "items": []}
|
||||
|
||||
|
||||
def _cfg() -> tuple[str, str]:
|
||||
s = Settings()
|
||||
url = str(s.get("homeassistant_url") or "").strip().rstrip("/")
|
||||
tok = s.get_secret("homeassistant_token")
|
||||
if not url or not tok:
|
||||
raise RuntimeError("Home Assistant non configurato: indirizzo e token di accesso nella scheda Casa delle impostazioni.")
|
||||
return url, tok
|
||||
|
||||
|
||||
def api(path: str, data: dict | None = None, timeout: int = 15):
|
||||
url, tok = _cfg()
|
||||
req = urllib.request.Request(url + path, method="POST" if data is not None else "GET",
|
||||
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"},
|
||||
data=json.dumps(data).encode() if data is not None else None)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RuntimeError("Home Assistant rifiuta il token (401): rigeneralo dal tuo profilo e incollalo nelle impostazioni.")
|
||||
raise RuntimeError(f"Home Assistant: HTTP {e.code} {e.read()[:200].decode(errors='replace')}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Home Assistant non raggiungibile ({e.reason}).")
|
||||
|
||||
|
||||
def verifica() -> str:
|
||||
c = api("/api/config")
|
||||
return f"Collegato a «{c.get('location_name')}», Home Assistant {c.get('version')}, {len(entita(force=True))} entità."
|
||||
|
||||
|
||||
def entita(force: bool = False) -> list[dict]:
|
||||
"""Elenco entità con nome, stato e area (via template; ripiego su /api/states)."""
|
||||
if not force and time.time() - _cache["t"] < 60 and _cache["items"]:
|
||||
return _cache["items"]
|
||||
items = []
|
||||
try:
|
||||
tpl = "{% for s in states %}{{ s.entity_id }}\t{{ s.name }}\t{{ s.state }}\t{{ area_name(s.entity_id) or '' }}\n{% endfor %}"
|
||||
url, tok = _cfg()
|
||||
req = urllib.request.Request(url + "/api/template", method="POST", data=json.dumps({"template": tpl}).encode(),
|
||||
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
for line in r.read().decode().splitlines():
|
||||
p = line.split("\t")
|
||||
if len(p) >= 3:
|
||||
items.append({"id": p[0], "nome": p[1], "stato": p[2], "area": p[3] if len(p) > 3 else ""})
|
||||
except Exception:
|
||||
for s in api("/api/states") or []:
|
||||
items.append({"id": s["entity_id"], "nome": s.get("attributes", {}).get("friendly_name", s["entity_id"]), "stato": s.get("state", ""), "area": ""})
|
||||
_cache.update(t=time.time(), items=items)
|
||||
return items
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
|
||||
|
||||
|
||||
def trova(nome: str, dominio: str = "") -> dict | None:
|
||||
"""Entità per id, nome esatto, nome parziale o parole in comune (nome + area)."""
|
||||
nome = (nome or "").strip()
|
||||
if not nome:
|
||||
return None
|
||||
items = entita()
|
||||
if dominio:
|
||||
items = [e for e in items if e["id"].startswith(dominio + ".")]
|
||||
for e in items:
|
||||
if e["id"] == nome:
|
||||
return e
|
||||
n = _norm(nome)
|
||||
for e in items:
|
||||
if _norm(e["nome"]) == n:
|
||||
return e
|
||||
words = set(n.split())
|
||||
best, score = None, 0
|
||||
for e in items:
|
||||
hay = set(_norm(e["nome"] + " " + e["area"] + " " + e["id"].split(".", 1)[-1].replace("_", " ")).split())
|
||||
dom_it = DOMINI_IT.get(e["id"].split(".")[0], "")
|
||||
if dom_it:
|
||||
hay.add(dom_it)
|
||||
common = len(words & hay)
|
||||
if n in _norm(e["nome"]):
|
||||
common += 2
|
||||
if common > score:
|
||||
best, score = e, common
|
||||
return best if score > 0 else None
|
||||
|
||||
|
||||
def _lista(items: list[dict], limite: int) -> str:
|
||||
out = []
|
||||
for e in items[:limite]:
|
||||
area = f" ({e['area']})" if e["area"] else ""
|
||||
out.append(f"- {e['nome']}{area}: {e['stato']} [{e['id']}]")
|
||||
if len(items) > limite:
|
||||
out.append(f"… e altre {len(items) - limite}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def dispositivi(params: dict, ctx: dict) -> str:
|
||||
filtro = _norm(str(params.get("filtro", "")))
|
||||
dominio = str(params.get("dominio", "")).strip().lower()
|
||||
items = entita(force=True)
|
||||
if dominio:
|
||||
items = [e for e in items if e["id"].split(".")[0] == dominio or DOMINI_IT.get(e["id"].split(".")[0]) == dominio]
|
||||
else:
|
||||
items = [e for e in items if e["id"].split(".")[0] in AZIONABILI or e["id"].split(".")[0] in ("sensor", "binary_sensor", "person", "weather")]
|
||||
if filtro:
|
||||
items = [e for e in items if filtro in _norm(e["nome"] + " " + e["area"] + " " + e["id"])]
|
||||
if not items:
|
||||
return "Nessun dispositivo corrisponde." + ("" if filtro else " Home Assistant non espone entità azionabili.")
|
||||
if not filtro and not dominio:
|
||||
# panoramica compatta per area e dominio
|
||||
by_area: dict[str, list] = {}
|
||||
for e in items:
|
||||
if e["id"].split(".")[0] in AZIONABILI:
|
||||
by_area.setdefault(e["area"] or "senza area", []).append(e)
|
||||
out = [f"Dispositivi azionabili: {sum(len(v) for v in by_area.values())} in {len(by_area)} aree."]
|
||||
for area, lst in sorted(by_area.items()):
|
||||
out.append(f"{area}: " + ", ".join(f"{e['nome']} ({e['stato']})" for e in lst[:12]) + (f", +{len(lst)-12}" if len(lst) > 12 else ""))
|
||||
return "\n".join(out) + "\nPer i dettagli usa casa_dispositivi con filtro o dominio."
|
||||
return _lista(items, max(5, min(int(params.get("limite") or 40), 100)))
|
||||
|
||||
|
||||
def stato(params: dict, ctx: dict) -> str:
|
||||
e = trova(str(params.get("entita", "")))
|
||||
if not e:
|
||||
return f"Non trovo '{params.get('entita')}' in Home Assistant. Prova casa_dispositivi con un filtro."
|
||||
s = api(f"/api/states/{e['id']}") or {}
|
||||
a = s.get("attributes", {})
|
||||
utili = {k: v for k, v in a.items() if k in ("brightness", "color_temp", "rgb_color", "temperature", "current_temperature", "hvac_mode",
|
||||
"humidity", "current_position", "volume_level", "media_title", "battery_level", "unit_of_measurement", "device_class", "percentage")}
|
||||
stato_txt = s.get("state", "?")
|
||||
if a.get("unit_of_measurement"):
|
||||
stato_txt += " " + str(a["unit_of_measurement"])
|
||||
if "brightness" in a and a["brightness"] is not None:
|
||||
utili["luminosita_pct"] = round(int(a["brightness"]) * 100 / 255)
|
||||
det = ", ".join(f"{k}={v}" for k, v in utili.items() if k not in ("unit_of_measurement",))
|
||||
return f"{e['nome']}{' (' + e['area'] + ')' if e['area'] else ''}: {stato_txt}" + (f" — {det}" if det else "") + f"\nAggiornato: {str(s.get('last_updated', ''))[:19].replace('T', ' ')}"
|
||||
|
||||
|
||||
def _call(dominio: str, servizio: str, dati: dict) -> str:
|
||||
api(f"/api/services/{dominio}/{servizio}", dati)
|
||||
return "ok"
|
||||
|
||||
|
||||
def _accendi_spegni(params: dict, ctx: dict, on: bool) -> str:
|
||||
nome = str(params.get("entita", ""))
|
||||
e = trova(nome)
|
||||
if not e:
|
||||
return f"Non trovo '{nome}'. Prova casa_dispositivi con un filtro."
|
||||
dom = e["id"].split(".")[0]
|
||||
if dom not in AZIONABILI:
|
||||
return f"{e['nome']} è un {DOMINI_IT.get(dom, dom)}: non si può accendere o spegnere."
|
||||
verbo = "Accendere" if on else "Spegnere"
|
||||
|
||||
def do() -> str:
|
||||
_cache["t"] = 0
|
||||
if dom == "lock":
|
||||
_call("lock", "unlock" if on else "lock", {"entity_id": e["id"]})
|
||||
return f"{e['nome']}: {'sbloccata' if on else 'bloccata'}."
|
||||
if dom == "cover":
|
||||
_call("cover", "open_cover" if on else "close_cover", {"entity_id": e["id"]})
|
||||
return f"{e['nome']}: {'apro' if on else 'chiudo'}."
|
||||
_call("homeassistant", "turn_on" if on else "turn_off", {"entity_id": e["id"]})
|
||||
return f"{e['nome']}: {'accesa' if on else 'spenta'}."
|
||||
|
||||
if dom in DELICATI:
|
||||
return confirm_or_param(ctx, params, "casa_delicato", f"{verbo} {e['nome']}?", f"{DOMINI_IT.get(dom, dom)} [{e['id']}]", do)
|
||||
return do()
|
||||
|
||||
|
||||
def accendi(params: dict, ctx: dict) -> str:
|
||||
return _accendi_spegni(params, ctx, True)
|
||||
|
||||
|
||||
def spegni(params: dict, ctx: dict) -> str:
|
||||
return _accendi_spegni(params, ctx, False)
|
||||
|
||||
|
||||
def imposta(params: dict, ctx: dict) -> str:
|
||||
e = trova(str(params.get("entita", "")))
|
||||
if not e:
|
||||
return f"Non trovo '{params.get('entita')}'."
|
||||
dom = e["id"].split(".")[0]
|
||||
fatto = []
|
||||
_cache["t"] = 0
|
||||
if dom == "light":
|
||||
dati = {"entity_id": e["id"]}
|
||||
if params.get("luminosita") is not None:
|
||||
dati["brightness_pct"] = max(0, min(100, int(params["luminosita"])))
|
||||
fatto.append(f"luminosità {dati['brightness_pct']}%")
|
||||
if params.get("colore"):
|
||||
dati["color_name"] = str(params["colore"]).strip()
|
||||
fatto.append(f"colore {dati['color_name']}")
|
||||
if params.get("temperatura") is not None:
|
||||
dati["kelvin"] = int(params["temperatura"])
|
||||
fatto.append(f"{dati['kelvin']} K")
|
||||
_call("light", "turn_on", dati)
|
||||
elif dom == "climate":
|
||||
if params.get("temperatura") is not None:
|
||||
_call("climate", "set_temperature", {"entity_id": e["id"], "temperature": float(params["temperatura"])})
|
||||
fatto.append(f"temperatura {params['temperatura']}°")
|
||||
if params.get("modo"):
|
||||
_call("climate", "set_hvac_mode", {"entity_id": e["id"], "hvac_mode": str(params["modo"])})
|
||||
fatto.append(f"modalità {params['modo']}")
|
||||
elif dom == "cover":
|
||||
if params.get("posizione") is not None:
|
||||
_call("cover", "set_cover_position", {"entity_id": e["id"], "position": max(0, min(100, int(params["posizione"])))})
|
||||
fatto.append(f"posizione {params['posizione']}%")
|
||||
elif dom == "media_player":
|
||||
if params.get("volume") is not None:
|
||||
_call("media_player", "volume_set", {"entity_id": e["id"], "volume_level": max(0, min(100, int(params["volume"]))) / 100})
|
||||
fatto.append(f"volume {params['volume']}%")
|
||||
if params.get("modo") in ("play", "pause", "stop", "next", "previous"):
|
||||
svc = {"play": "media_play", "pause": "media_pause", "stop": "media_stop", "next": "media_next_track", "previous": "media_previous_track"}[params["modo"]]
|
||||
_call("media_player", svc, {"entity_id": e["id"]})
|
||||
fatto.append(params["modo"])
|
||||
elif dom == "fan":
|
||||
if params.get("luminosita") is not None or params.get("posizione") is not None:
|
||||
pct = int(params.get("luminosita") if params.get("luminosita") is not None else params.get("posizione"))
|
||||
_call("fan", "set_percentage", {"entity_id": e["id"], "percentage": max(0, min(100, pct))})
|
||||
fatto.append(f"velocità {pct}%")
|
||||
if not fatto:
|
||||
return f"Per {e['nome']} ({DOMINI_IT.get(dom, dom)}) non ho una regolazione applicabile con questi parametri."
|
||||
return f"{e['nome']}: " + ", ".join(fatto) + "."
|
||||
|
||||
|
||||
def scena(params: dict, ctx: dict) -> str:
|
||||
nome = str(params.get("nome", ""))
|
||||
e = trova(nome, "scene") or trova(nome, "script") or trova(nome, "automation")
|
||||
if not e:
|
||||
return f"Non trovo la scena o lo script '{nome}'."
|
||||
dom = e["id"].split(".")[0]
|
||||
_call(dom, "turn_on" if dom == "scene" else ("trigger" if dom == "automation" else "turn_on"), {"entity_id": e["id"]})
|
||||
return f"{DOMINI_IT.get(dom, dom).capitalize()} «{e['nome']}» avviata."
|
||||
|
||||
|
||||
def servizio(params: dict, ctx: dict) -> str:
|
||||
dom = str(params.get("dominio", "")).strip()
|
||||
svc = str(params.get("servizio", "")).strip()
|
||||
if not dom or not svc:
|
||||
return "Errore: servono dominio e servizio (es. light / turn_on)."
|
||||
dati = params.get("dati") or {}
|
||||
if isinstance(dati, str):
|
||||
try:
|
||||
dati = json.loads(dati)
|
||||
except Exception:
|
||||
return "Errore: 'dati' deve essere un oggetto JSON."
|
||||
if params.get("entita"):
|
||||
e = trova(str(params["entita"]))
|
||||
if not e:
|
||||
return f"Non trovo '{params['entita']}'."
|
||||
dati["entity_id"] = e["id"]
|
||||
|
||||
def do() -> str:
|
||||
_cache["t"] = 0
|
||||
_call(dom, svc, dati)
|
||||
return f"Servizio {dom}.{svc} eseguito."
|
||||
|
||||
if dom in DELICATI or dom in ("homeassistant", "hassio", "shell_command", "persistent_notification") and svc not in ("turn_on", "turn_off", "toggle"):
|
||||
return confirm_or_param(ctx, params, "casa_servizio", f"Eseguire {dom}.{svc}?", json.dumps(dati, ensure_ascii=False)[:300], do)
|
||||
return do()
|
||||
|
||||
|
||||
def chiedi(params: dict, ctx: dict) -> str:
|
||||
testo = str(params.get("testo", "")).strip()
|
||||
if not testo:
|
||||
return "Errore: serve il testo del comando."
|
||||
r = api("/api/conversation/process", {"text": testo, "language": "it"}) or {}
|
||||
_cache["t"] = 0
|
||||
try:
|
||||
speech = r["response"]["speech"]["plain"]["speech"]
|
||||
except Exception:
|
||||
speech = ""
|
||||
kind = (r.get("response") or {}).get("response_type", "")
|
||||
if kind == "error":
|
||||
return f"Assist non ha capito: {speech or 'nessun dettaglio'}. Prova con casa_dispositivi e gli strumenti diretti."
|
||||
return speech or "Assist ha eseguito il comando senza commento."
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{"name": "casa_dispositivi", "description": "Elenca i dispositivi di casa (Home Assistant) con stato e stanza. Senza parametri: panoramica per stanza. Con filtro (parola nel nome/stanza) o dominio (luce, interruttore, clima, tapparella, media, sensore, scena, …) l'elenco dettagliato con gli id.",
|
||||
"parameters": {"type": "object", "properties": {"filtro": {"type": "string"}, "dominio": {"type": "string"}, "limite": {"type": "integer"}}}, "run": dispositivi},
|
||||
{"name": "casa_stato", "description": "Stato e dettagli di un dispositivo o sensore di casa, per nome (es. 'luce cucina', 'temperatura salotto') o id.",
|
||||
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}}, "required": ["entita"]}, "run": stato},
|
||||
{"name": "casa_accendi", "description": "Accende un dispositivo di casa per nome (luce, presa, ventilatore, clima, media, apre una tapparella, sblocca una serratura con conferma).",
|
||||
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "confermato": CONFERMATO}, "required": ["entita"]}, "run": accendi},
|
||||
{"name": "casa_spegni", "description": "Spegne un dispositivo di casa per nome (chiude una tapparella, blocca una serratura con conferma).",
|
||||
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "confermato": CONFERMATO}, "required": ["entita"]}, "run": spegni},
|
||||
{"name": "casa_imposta", "description": "Regola un dispositivo: luminosita (0-100) e colore (nome inglese es. red, warm white) o temperatura in kelvin per le luci; temperatura in gradi e modo (heat/cool/off/auto) per il clima; posizione (0-100) per tapparelle; volume (0-100) e modo (play/pause/stop/next/previous) per i media; luminosita come velocità per i ventilatori.",
|
||||
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "luminosita": {"type": "integer"}, "colore": {"type": "string"}, "temperatura": {"type": "number"}, "modo": {"type": "string"}, "posizione": {"type": "integer"}, "volume": {"type": "integer"}}, "required": ["entita"]}, "run": imposta},
|
||||
{"name": "casa_scena", "description": "Attiva una scena, uno script o un'automazione di Home Assistant per nome (es. 'serata film', 'buonanotte').",
|
||||
"parameters": {"type": "object", "properties": {"nome": {"type": "string"}}, "required": ["nome"]}, "run": scena},
|
||||
{"name": "casa_servizio", "description": "Chiama un servizio generico di Home Assistant (dominio + servizio, es. vacuum/start, notify/mobile_app) con dati JSON e opzionale entità per nome. Per casi non coperti dagli altri strumenti; serrature, allarmi e sirene chiedono conferma.",
|
||||
"parameters": {"type": "object", "properties": {"dominio": {"type": "string"}, "servizio": {"type": "string"}, "entita": {"type": "string"}, "dati": {"type": "object"}, "confermato": CONFERMATO}, "required": ["dominio", "servizio"]}, "run": servizio},
|
||||
{"name": "casa_chiedi", "description": "Manda una frase in italiano ad Assist di Home Assistant (es. 'spegni tutte le luci del piano di sopra') e riporta la sua risposta. Utile per comandi su più dispositivi o quando non trovi l'entità.",
|
||||
"parameters": {"type": "object", "properties": {"testo": {"type": "string"}}, "required": ["testo"]}, "run": chiedi},
|
||||
]
|
||||
Reference in new issue
Block a user