Ho spostato l'analisi in locale sul vecchio macbook pro m1 (16/512) con LM Studio (Qwen3.5-4B-mlx 4bit) che espone un endpoint compatibile OpenAI. Invece di fare tutto il giro da LLM Vision passo quasi tutto da uno script in python. Nonostante l'aumento della latenza, dovuta a un più lento preprocessing, passare in locale mi costa solo 3 secondi in più per un doppio check con due temperature diverse. E Qwen3.5-4B, anche a 4 bit di quantizzazione, è decisamente più preciso di Seed-1.6 flash.
Script:
#!/usr/bin/env python3
"""lm_studio_analyze.py v2 — Doppio check sicurezza via LM Studio localeARCHITETTURA: Tutte le mappe (nomi camera, contesto geometrico) sonoINTERNE allo script. Riceve solo parametri semplici senza spazi.Uso:python3 lm_studio_analyze.py <snapshot_path> <device_id> <alarm_state> <periodo> <output_path>Esempio:python3 lm_studio_analyze.py /config/www/tmp/sec_cam5_20260308.jpg \ds_7616ni_m21620230321ccrrl43839803wcvu_5 disarmed GIORNO \/config/www/tmp/llm_security_result.json"""import sysimport jsonimport base64import loggingimport timeimport refrom pathlib import Pathtry:import requestsHAS_REQUESTS = Trueexcept ImportError:import urllib.requestimport urllib.errorHAS_REQUESTS = False# ══════════════════════════════════════════════════════════════════# CONFIGURAZIONE — modifica questi valori secondo il tuo setup# ══════════════════════════════════════════════════════════════════# IP LAN del MacBook (Tailscale non raggiungibile dal Core HAOS)LM_STUDIO_URL = "http://...:1234"# Identificativo del modello come appare in LM Studio# (verifica con: curl http://...:1234/v1/models)MODEL = "qwen3.5-4b"TIMEOUT_SECONDS = 45TEMPERATURES = [0.1, 0.3]MAX_TOKENS = 200# ══════════════════════════════════════════════════════════════════# MAPPE TELECAMERE (spostate qui dallo YAML dell'automazione)# ══════════════════════════════════════════════════════════════════FRIENDLY_NAMES = {"ds_7616ni_m21620230321ccrrl43839803wcvu_1": "Cortile Nord","ds_7616ni_m21620230321ccrrl43839803wcvu_2": "Giardino Ovest","ds_7616ni_m21620230321ccrrl43839803wcvu_3": "Giardino Sud","ds_7616ni_m21620230321ccrrl43839803wcvu_4": "Cortile Ovest","ds_7616ni_m21620230321ccrrl43839803wcvu_5": "Cancelletto","ds_7616ni_m21620230321ccrrl43839803wcvu_6": "Cortile Sud","ds_7616ni_m21620230321ccrrl43839803wcvu_7": "Giardino Est","ds_7616ni_m21620230321ccrrl43839803wcvu_8": "Giardino Nord","ds_7616ni_m21620230321ccrrl43839803wcvu_9": "Cortile Est",}CAMERA_CONTEXT_MAP = {"ds_7616ni_m21620230321ccrrl43839803wcvu_1": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_2": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_3": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_4": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_5": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_6": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_7": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_8": ("...""..."),"ds_7616ni_m21620230321ccrrl43839803wcvu_9": ("...""..."),}# ══════════════════════════════════════════════════════════════════# JSON SCHEMA per structured output# ══════════════════════════════════════════════════════════════════DETECTION_SCHEMA = {"type": "object","properties": {"presenza_umana": {"type": "string","enum": ["SI", "NO"]},"tipo_soggetto": {"type": "string","enum": ["sconosciuto", "postino", "familiare", "animale", "veicolo"]},"posizione": {"type": "string","enum": ["strada", "cortile", "vicino_casa"]},"distanza": {"type": "string","enum": ["vicino", "medio", "lontano"]},"livello_rischio": {"type": "string","enum": ["VERDE", "GIALLO", "ROSSO"]},"azione_raccomandata": {"type": "string","enum": ["ignora", "controlla", "intervieni"]},"descrizione_breve": {"type": "string","maxLength": 65,"minLength": 1}},"required": ["presenza_umana", "tipo_soggetto", "posizione","distanza", "livello_rischio", "azione_raccomandata","descrizione_breve"],"additionalProperties": False}DEFAULT_RESULT = {"presenza_umana": "NO","livello_rischio": "GIALLO","descrizione_breve": "Errore analisi","tipo_soggetto": "sconosciuto","posizione": "sconosciuta","distanza": "medio","azione_raccomandata": "controlla"}logging.basicConfig(level=logging.INFO,format="[SEC-AI] %(asctime)s %(levelname)s %(message)s",datefmt="%H:%M:%S",handlers=[logging.StreamHandler(), # stderr (catturato da HA)logging.FileHandler("/config/www/tmp/lm_analyze.log", mode="a"), # file persistente])log = logging.getLogger(__name__)def encode_image_b64(image_path: str) -> str:with open(image_path, "rb") as f:return base64.b64encode(f.read()).decode("utf-8")def build_prompt(camera_friendly: str, alarm_state: str,periodo: str, camera_context: str) -> str:return (f"Analisi sicurezza telecamera: {camera_friendly}\n"f"Stato allarme: {alarm_state} | Periodo: {periodo}\n"f"Geometria camera: {camera_context}\n\n""ISTRUZIONI PASSO PER PASSO:\n""1. Guarda l'immagine con attenzione.\n""2. C'è una PERSONA visibile? (non ombre, non cespugli, non oggetti)\n"" - Se NO → presenza_umana=NO, tipo_soggetto=animale, livello_rischio=VERDE\n"" - Se SI → vai al passo 3\n""3. C'è un VEICOLO visibile?\n"" - Tesla bianca o Polo grigia → tipo_soggetto=veicolo, livello_rischio=VERDE\n"" - Altro veicolo in cortile → tipo_soggetto=veicolo, livello_rischio=GIALLO\n""4. Se c'è una PERSONA:\n"f" - Allarme armed_away + persona in cortile → ROSSO\n"f" - {periodo}=NOTTE + persona vicino casa → ROSSO\n"f" - {periodo}=GIORNO + persona in zona privata → GIALLO\n"" - Persona su strada pubblica → VERDE\n"" - Postino/corriere → tipo_soggetto=postino, VERDE\n\n""REGOLA DI COERENZA OBBLIGATORIA:\n""- Se presenza_umana=NO e nessun veicolo sospetto → livello_rischio DEVE essere VERDE\n""- Se presenza_umana=NO → tipo_soggetto NON può essere sconosciuto\n""- descrizione_breve deve descrivere SOLO ciò che si VEDE realmente\n\n""Rispondi SOLO con un JSON valido.\n""ESEMPIO scena vuota di notte:\n"'{"presenza_umana":"NO","tipo_soggetto":"animale",''"posizione":"cortile","distanza":"lontano",''"livello_rischio":"VERDE","azione_raccomandata":"ignora",''"descrizione_breve":"Nessuna presenza, scena notturna vuota"}\n\n'"ESEMPIO persona sospetta:\n"'{"presenza_umana":"SI","tipo_soggetto":"sconosciuto",''"posizione":"vicino_casa","distanza":"vicino",''"livello_rischio":"ROSSO","azione_raccomandata":"intervieni",''"descrizione_breve":"Persona sconosciuta vicino alla porta"}\n\n'"Analizza l'immagine. JSON:")def call_lm_studio(image_b64: str, prompt: str, temperature: float) -> dict | None:"""Chiama LM Studio con visione. NO response_format (causa <|im_end|> leak)."""url = f"{LM_STUDIO_URL}/v1/chat/completions"payload = {"model": MODEL,"messages": [{"role": "system","content": ("Sei un analizzatore di sicurezza. ""Rispondi ESCLUSIVAMENTE con un singolo oggetto JSON valido. ""Nessun testo prima o dopo il JSON. Nessun commento. ""Nessun markdown. Solo JSON puro.")},{"role": "user","content": [{"type": "image_url","image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},{"type": "text","text": prompt}]}],"temperature": temperature,"max_tokens": MAX_TOKENS,"stream": False# NO response_format — causa leak di <|im_end|> con VLM in LM Studio}headers = {"Content-Type": "application/json"}try:log.info(f" POST {url} (T={temperature}, model={MODEL})")if HAS_REQUESTS:resp = requests.post(url, json=payload, headers=headers,timeout=TIMEOUT_SECONDS)log.info(f" HTTP {resp.status_code}")resp.raise_for_status()data = resp.json()else:body = json.dumps(payload).encode("utf-8")req = urllib.request.Request(url, data=body, headers=headers,method="POST")with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as response:log.info(f" HTTP {response.status}")data = json.loads(response.read().decode("utf-8"))content = data["choices"][0]["message"]["content"]log.info(f" Raw (first 200): {repr(content[:200])}")# ── Pulizia aggressiva dei token speciali Qwen ──# Rimuovi tutti i token di controllo del chat templatecontent = re.sub(r'<\|im_start\|>[^\n]*\n?', '', content)content = re.sub(r'<\|im_end\|>', '', content)content = re.sub(r'<\|endoftext\|>', '', content)content = re.sub(r'<\|end\|>', '', content)# Rimuovi tag <think>...</think>content = re.sub(r'(?s)<think>.*?</think>\s*', '', content)# Rimuovi backtick markdowncontent = re.sub(r'^\s*```[a-zA-Z]*\s*', '', content)content = re.sub(r'\s*```\s*$', '', content)content = content.strip()log.info(f" Cleaned (first 200): {repr(content[:200])}")# ── Estrai JSON con regex (robusto) ──# Cerca il primo oggetto JSON {...} nella rispostajson_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', content)if json_match:json_str = json_match.group(0)else:log.warning(f" Nessun JSON trovato nella risposta")return Noneresult = json.loads(json_str)if "livello_rischio" not in result:log.warning(" Risposta manca livello_rischio")return None# ── Validazione coerenza ──# Un modello 4B spesso si contraddice. Correggiamo programmaticamente.presenza = result.get("presenza_umana", "NO").upper()tipo = result.get("tipo_soggetto", "")rischio = result.get("livello_rischio", "VERDE").upper()if presenza == "NO" and tipo not in ("veicolo",):# Nessuna persona e nessun veicolo → non può essere ROSSO o GIALLOif rischio in ("ROSSO", "GIALLO"):log.warning(f" COERENZA: presenza_umana=NO ma rischio={rischio} → forzato VERDE")result["livello_rischio"] = "VERDE"result["azione_raccomandata"] = "ignora"if presenza == "SI" and tipo in ("animale",):# Se dice persona presente ma tipo=animale, c'è confusionelog.warning(f" COERENZA: presenza_umana=SI ma tipo=animale → forzato sconosciuto")result["tipo_soggetto"] = "sconosciuto"log.info(f" Parsed OK: rischio={result.get('livello_rischio')} "f"(presenza={presenza}, tipo={result.get('tipo_soggetto')})")return resultexcept json.JSONDecodeError as e:log.error(f" JSON DECODE ERROR: {e}")log.error(f" Content was: {repr(content[:300])}")return Noneexcept Exception as e:log.error(f" ERRORE: {type(e).__name__}: {e}")return Nonedef compare_risks(result_1: dict | None, result_2: dict | None) -> dict:"""Confronta due analisi, il rischio più alto vince."""def get_risk®:if r and isinstance(r, dict):return r.get("livello_rischio", "INVALID").upper()return "INVALID"risk_1 = get_risk(result_1)risk_2 = get_risk(result_2)valid_risks = [r for r in [risk_1, risk_2]if r in ("ROSSO", "GIALLO", "VERDE")]if "ROSSO" in valid_risks:final_risk = "ROSSO"elif "GIALLO" in valid_risks:final_risk = "GIALLO"elif "VERDE" in valid_risks:final_risk = "VERDE"else:final_risk = "GIALLO"if final_risk == risk_1 and result_1:analysis = result_1elif final_risk == risk_2 and result_2:analysis = result_2elif result_1:analysis = result_1elif result_2:analysis = result_2else:analysis = DEFAULT_RESULT.copy()risk_emoji = {"ROSSO": "", "GIALLO": "", "VERDE": ""}return {"presenza_umana": analysis.get("presenza_umana", "NO"),"tipo_soggetto": analysis.get("tipo_soggetto", "sconosciuto"),"posizione": analysis.get("posizione", "sconosciuta"),"distanza": analysis.get("distanza", "medio"),"livello_rischio": final_risk,"azione_raccomandata": analysis.get("azione_raccomandata", "controlla"),"descrizione_breve": analysis.get("descrizione_breve", "Rilevamento"),"risk_1": risk_1,"risk_2": risk_2,"final_risk": final_risk,"risk_color": risk_emoji.get(final_risk, ""),"debug_info": (f"T1:{risk_1}|T2:{risk_2}→{final_risk}|"f"{analysis.get('posizione', 'N/A')}"f"[{analysis.get('distanza', '?')}]"),"success": True}def write_error(output_path: str, msg: str):"""Scrive un risultato di errore e esce."""result = {**DEFAULT_RESULT,"success": False,"debug_info": msg,"risk_1": "INVALID", "risk_2": "INVALID","final_risk": "GIALLO", "risk_color": ""}Path(output_path).parent.mkdir(parents=True, exist_ok=True)Path(output_path).write_text(json.dumps(result, ensure_ascii=False))log.error(msg)def main():# ── Log TUTTO per debug ──log.info(f"{'='*60}")log.info(f"Script avviato")log.info(f"sys.argv ({len(sys.argv)} elementi):")for i, arg in enumerate(sys.argv):log.info(f" [{i}] = '{arg}'" if len(arg) < 100 else f" [{i}] = '{arg[:80]}...' ({len(arg)} chars)")log.info(f"CWD: {Path.cwd()}")# ── Parsing argomenti ──# Uso: script.py <snapshot_path> <device_id> <alarm_state> <periodo> <output_path># NESSUN argomento contiene spazi → nessun problema di quoting shellif len(sys.argv) < 6:msg = f"Args insufficienti: {len(sys.argv)-1}/5 — argv={sys.argv}"log.error(msg)# Scrivi errore anche nel file di output se possibileout = sys.argv[-1] if len(sys.argv) > 1 else "/config/www/tmp/llm_security_result.json"write_error(out, msg)sys.exit(1)snapshot_path = sys.argv[1]device_id = sys.argv[2]alarm_state = sys.argv[3]periodo = sys.argv[4]output_path = sys.argv[5]log.info(f"Parsed args:")log.info(f" snapshot_path = '{snapshot_path}'")log.info(f" device_id = '{device_id}'")log.info(f" alarm_state = '{alarm_state}'")log.info(f" periodo = '{periodo}'")log.info(f" output_path = '{output_path}'")# ── Risolvi nomi e contesto dalla mappa interna ──camera_friendly = FRIENDLY_NAMES.get(device_id, device_id)camera_context = CAMERA_CONTEXT_MAP.get(device_id, "Standard")log.info(f"═══ INIZIO ANALISI ═══")log.info(f"Camera: {camera_friendly} ({device_id})")log.info(f"Periodo: {periodo} | Allarme: {alarm_state}")log.info(f"Snapshot: {snapshot_path}")log.info(f"LM Studio: {LM_STUDIO_URL} | Model: {MODEL}")# ── Verifica immagine ──if not Path(snapshot_path).exists():write_error(output_path, f"Immagine non trovata: {snapshot_path}")sys.exit(1)# ── Codifica immagine ──log.info("Codifica base64...")try:image_b64 = encode_image_b64(snapshot_path)except Exception as e:write_error(output_path, f"Errore codifica: {e}")sys.exit(1)log.info(f"Immagine: {len(image_b64)} chars base64 "f"({Path(snapshot_path).stat().st_size} bytes)")# ── Test connettività rapido ──log.info(f"Test connettività {LM_STUDIO_URL}...")try:test_url = f"{LM_STUDIO_URL}/v1/models"if HAS_REQUESTS:test_resp = requests.get(test_url, timeout=5)log.info(f"Connettività OK: HTTP {test_resp.status_code}")else:with urllib.request.urlopen(test_url, timeout=5) as resp:log.info(f"Connettività OK: HTTP {resp.status}")except Exception as e:write_error(output_path, f"LM Studio non raggiungibile: {type(e).__name__}: {e}")sys.exit(1)# ── Costruisci prompt ──prompt = build_prompt(camera_friendly, alarm_state, periodo, camera_context)# ── Prima analisi ──log.info(f"── Chiamata 1/2 (T={TEMPERATURES[0]}) ──")t0 = time.time()result_1 = call_lm_studio(image_b64, prompt, TEMPERATURES[0])dt1 = time.time() - t0log.info(f"Chiamata 1: {dt1:.1f}s → "f"{result_1.get('livello_rischio', 'FAIL') if result_1 else 'FAIL'}")# ── Seconda analisi ──log.info(f"── Chiamata 2/2 (T={TEMPERATURES[1]}) ──")t0 = time.time()result_2 = call_lm_studio(image_b64, prompt, TEMPERATURES[1])dt2 = time.time() - t0log.info(f"Chiamata 2: {dt2:.1f}s → "f"{result_2.get('livello_rischio', 'FAIL') if result_2 else 'FAIL'}")# ── Confronto ──consolidated = compare_risks(result_1, result_2)log.info(f"═══ RISULTATO: {consolidated['debug_info']} "f"(totale: {dt1+dt2:.1f}s) ═══")# ── Scrivi output ──Path(output_path).parent.mkdir(parents=True, exist_ok=True)Path(output_path).write_text(json.dumps(consolidated, ensure_ascii=False))if __name__ == "__main__":main()
Inutile spostare in variabili esterne il context delle telecamere, l'ip di lm studio e così via. E' roba che deve girare solo per me.
Questo è quanto aggiunto in configuration.yaml:
input_text:
security_device_id:name: "Security Device ID"max: 255initial: ""security_snapshot_path:name: "Security Snapshot Path"max: 255initial: ""shell_command:hikvision_isapi_snap: >curl --digest -u "admin:VVVCIC1a!" -X GET "http://10.0.40.16/IS...ing/channels/{{channel }}01/picture" -o "{{ filename }}" --connect-timeout 5clean_old_snapshots: >find /config/www/tmp/ -name "security_*.jpg" -mtime +7 -deletelm_analyze_security: >-python3 /config/scripts/lm_studio_analyze.py"{{ states('input_text.security_snapshot_path') }}""{{ states('input_text.security_device_id') }}""{{ states('alarm_control_panel.risco_bosco_paolo_partition_0') }}""{{ 'NOTTE' if is_state('sun.sun', 'below_horizon') else 'GIORNO' }}""/config/www/tmp/llm_security_result.json"command_line:- sensor:name: "LM Security Result"unique_id: lm_security_resultcommand: >-cat /config/www/tmp/llm_security_result.json 2>/dev/null ||echo '{"livello_rischio":"GIALLO","debug_info":"Nessun dato","success":false}'value_template: "{{ value_json.livello_rischio | default('GIALLO') }}"json_attributes:- presenza_umana- tipo_soggetto- posizione- distanza- livello_rischio- azione_raccomandata- descrizione_breve- risk_1- risk_2- final_risk- risk_color- debug_info- successscan_interval: 31536000
Questa l'automazione:
alias: Sicurezza Perimetrale - Doppio Check AI Locale (v2)
description: >-Analisi doppio check Qwen3.5-4b-mlx via LM Studio locale. Usa input_text comeponte per passare device_id e snapshot_path dall'automazione alloshell_command.triggers:- entity_id:- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_5_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_1_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_6_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_9_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_4_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_7_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_2_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_8_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_3_fielddetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_5_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_9_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_6_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_4_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_7_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_2_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_8_linedetection- binary_sensor.ds_7616ni_m21620230321ccrrl43839803wcvu_3_linedetectionto: "on"id: security_eventtrigger: stateconditions:- condition: templatevalue_template: "{{ true }}"- condition: orconditions:- condition: notconditions:- condition: stateentity_id: device_tracker.papoystate: home- condition: stateentity_id: device_tracker.pixel_10_prostate: home- condition: stateentity_id: sun.sunstate: below_horizonactions:- data:channel: "{{ nvr_channel }}"filename: "{{ snapshot_path }}"action: shell_command.hikvision_isapi_snap- action: input_text.set_valuetarget:entity_id: input_text.security_device_iddata:value: "{{ device_id }}"- action: input_text.set_valuetarget:entity_id: input_text.security_snapshot_pathdata:value: "{{ snapshot_path }}"- delay:milliseconds: 500- action: shell_command.lm_analyze_security- action: homeassistant.update_entitydata:entity_id: sensor.lm_security_result- delay:milliseconds: 300- variables:final_risk: >-{{ state_attr('sensor.lm_security_result', 'final_risk') |default('GIALLO') }}analysis_tipo: >-{{ state_attr('sensor.lm_security_result', 'tipo_soggetto') |default('sconosciuto') }}analysis_distanza: >-{{ state_attr('sensor.lm_security_result', 'distanza') | default('?') }}analysis_descr: >-{{ state_attr('sensor.lm_security_result', 'descrizione_breve') |default('Rilevamento') }}analysis_posizione: >-{{ state_attr('sensor.lm_security_result', 'posizione') | default('N/A')}}analysis_azione: >-{{ state_attr('sensor.lm_security_result', 'azione_raccomandata') |default('controlla') }}risk_color: >-{{ state_attr('sensor.lm_security_result', 'risk_color') | default('')}}debug_info: >-{{ state_attr('sensor.lm_security_result', 'debug_info') |default('N/A') }}- target:entity_id: input_datetime.last_security_{{ device_id }}data:datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"action: input_datetime.set_datetime- action: notify.mobile_app_pixel_10_prodata:title: "{{ risk_color }}{{ final_risk }}|{{ camera_friendly }}"message: "{{ analysis_tipo }} ({{ analysis_distanza }}): {{ analysis_descr }}"data:priority: highimportance: highchannel: Sicurezza_AIvisibility: publicnotification_icon: mdi:shield-alertcolor: >-{{ 'red' if final_risk == 'ROSSO' else ('yellow' if final_risk =='GIALLO' else 'green') }}image: /local/tmp/{{ snapshot_path.split('/') | last }}clickAction: /local/tmp/{{ snapshot_path.split('/') | last }}sticky: trueongoing: "{{ true if final_risk == 'ROSSO' else false }}"tag: sec_img_{{ device_id }}_{{ now().strftime('%H%M%S') }}- action: notify.mobile_app_pixel_10_prodata:title: Debug | {{ camera_friendly }}message: "{{ debug_info }} | Azione: {{ analysis_azione }}"data:priority: highimportance: highchannel: Sicurezza_AI_Debugvisibility: publicnotification_icon: mdi:information-outlinecolor: graytag: sec_dbg_{{ device_id }}_{{ now().strftime('%H%M%S') }}- data:level: infomessage: "[SEC-AI-LOCAL] {{ camera_friendly }} | {{ debug_info }}"action: system_log.writevariables:device_id: |-{{ trigger.entity_id.split('.')[1]| regex_replace('_fielddetection$', '')| regex_replace('_linedetection$', '') }}friendly_names:ds_7616ni_m21620230321ccrrl43839803wcvu_1: Cortile Nordds_7616ni_m21620230321ccrrl43839803wcvu_2: Giardino Ovestds_7616ni_m21620230321ccrrl43839803wcvu_3: Giardino Sudds_7616ni_m21620230321ccrrl43839803wcvu_4: Cortile Ovestds_7616ni_m21620230321ccrrl43839803wcvu_5: Cancellettods_7616ni_m21620230321ccrrl43839803wcvu_6: Cortile Sudds_7616ni_m21620230321ccrrl43839803wcvu_7: Giardino Estds_7616ni_m21620230321ccrrl43839803wcvu_8: Giardino Nordds_7616ni_m21620230321ccrrl43839803wcvu_9: Cortile Estchannel_mapping:ds_7616ni_m21620230321ccrrl43839803wcvu_1: "1"ds_7616ni_m21620230321ccrrl43839803wcvu_2: "2"ds_7616ni_m21620230321ccrrl43839803wcvu_3: "3"ds_7616ni_m21620230321ccrrl43839803wcvu_4: "4"ds_7616ni_m21620230321ccrrl43839803wcvu_5: "5"ds_7616ni_m21620230321ccrrl43839803wcvu_6: "6"ds_7616ni_m21620230321ccrrl43839803wcvu_7: "7"ds_7616ni_m21620230321ccrrl43839803wcvu_8: "8"ds_7616ni_m21620230321ccrrl43839803wcvu_9: "9"nvr_channel: "{{ channel_mapping.get(device_id, '1') }}"camera_friendly: "{{ friendly_names.get(device_id, device_id) }}"snapshot_path: >-/config/www/tmp/security_{{ device_id }}_{{ now().strftime('%Y%m%d_%H%M%S')}}.jpgmode: queuedmax: 5
Per chi proprio ne sentisse il bisogno: 1,5 ore di lavoro tra le 4 e le 5.30 di mattina di domenica con Opus.










