diff --git a/assets/js/app.js b/assets/js/app.js index 7f7ba19..5334071 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -648,15 +648,38 @@ async function loadSettingsUI() { `${t.icon} ${t.label}` ).join(''); } - // TTS settings + // TTS settings — init defaults on first load + if (!s._tts_initialized) { + s.tts_url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak'; + s.tts_token = s.tts_token || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2OGQ3Njk1N2E0MjY0ZTBjOWQ4YjczZDY4ZDVmMWJlZCIsImlhdCI6MTc2MDI1NjIxNSwiZXhwIjoyMDc1NjE2MjE1fQ.X5UyMMPd7wTA6Gh11Nzg7Ox-enlDDom_lJIAJruUtcE'; + s.tts_payload_key = s.tts_payload_key || 'message'; + s.tts_method = s.tts_method || 'POST'; + s.tts_auth_type = s.tts_auth_type || 'bearer'; + s.tts_content_type = s.tts_content_type || 'application/json'; + s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : true; + s._tts_initialized = true; + saveSettingsToStorage(s); + } const ttsEnabledEl = document.getElementById('setting-tts-enabled'); if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true; const ttsUrlEl = document.getElementById('setting-tts-url'); - if (ttsUrlEl) ttsUrlEl.value = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak'; + if (ttsUrlEl) ttsUrlEl.value = s.tts_url || ''; + const ttsMethEl = document.getElementById('setting-tts-method'); + if (ttsMethEl) ttsMethEl.value = s.tts_method || 'POST'; + const ttsAuthTypeEl = document.getElementById('setting-tts-auth-type'); + if (ttsAuthTypeEl) { ttsAuthTypeEl.value = s.tts_auth_type || 'bearer'; onTtsAuthTypeChange(ttsAuthTypeEl.value); } const ttsTokenEl = document.getElementById('setting-tts-token'); if (ttsTokenEl) ttsTokenEl.value = s.tts_token || ''; + const ttsAuthHdrNameEl = document.getElementById('setting-tts-auth-header-name'); + if (ttsAuthHdrNameEl) ttsAuthHdrNameEl.value = s.tts_auth_header_name || ''; + const ttsAuthHdrValEl = document.getElementById('setting-tts-auth-header-value'); + if (ttsAuthHdrValEl) ttsAuthHdrValEl.value = s.tts_auth_header_value || ''; + const ttsCtEl = document.getElementById('setting-tts-content-type'); + if (ttsCtEl) ttsCtEl.value = s.tts_content_type || 'application/json'; const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key'); if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message'; + const ttsExtraEl = document.getElementById('setting-tts-extra-fields'); + if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || ''; // Load server-side settings if not already set locally try { @@ -760,12 +783,24 @@ async function saveSettings() { // TTS settings const ttsEnabledEl = document.getElementById('setting-tts-enabled'); if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked; - const ttsUrlEl = document.getElementById('setting-tts-url'); - if (ttsUrlEl) s.tts_url = ttsUrlEl.value.trim(); - const ttsTokenEl = document.getElementById('setting-tts-token'); - if (ttsTokenEl) s.tts_token = ttsTokenEl.value.trim(); - const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key'); - if (ttsPayloadKeyEl) s.tts_payload_key = ttsPayloadKeyEl.value.trim() || 'message'; + const ttsUrlEl2 = document.getElementById('setting-tts-url'); + if (ttsUrlEl2) s.tts_url = ttsUrlEl2.value.trim(); + const ttsMethEl2 = document.getElementById('setting-tts-method'); + if (ttsMethEl2) s.tts_method = ttsMethEl2.value; + const ttsAuthTypeEl2 = document.getElementById('setting-tts-auth-type'); + if (ttsAuthTypeEl2) s.tts_auth_type = ttsAuthTypeEl2.value; + const ttsTokenEl2 = document.getElementById('setting-tts-token'); + if (ttsTokenEl2) s.tts_token = ttsTokenEl2.value.trim(); + const ttsAuthHdrNameEl2 = document.getElementById('setting-tts-auth-header-name'); + if (ttsAuthHdrNameEl2) s.tts_auth_header_name = ttsAuthHdrNameEl2.value.trim(); + const ttsAuthHdrValEl2 = document.getElementById('setting-tts-auth-header-value'); + if (ttsAuthHdrValEl2) s.tts_auth_header_value = ttsAuthHdrValEl2.value.trim(); + const ttsCtEl2 = document.getElementById('setting-tts-content-type'); + if (ttsCtEl2) s.tts_content_type = ttsCtEl2.value; + const ttsPayloadKeyEl2 = document.getElementById('setting-tts-payload-key'); + if (ttsPayloadKeyEl2) s.tts_payload_key = ttsPayloadKeyEl2.value.trim() || 'message'; + const ttsExtraEl2 = document.getElementById('setting-tts-extra-fields'); + if (ttsExtraEl2) s.tts_extra_fields = ttsExtraEl2.value.trim(); // Save spesa AI prompt if the field exists const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt'); if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim(); @@ -6593,22 +6628,40 @@ function renderCookingStep() { if (_cookingTTS) speakCookingStep(cleanStep); } +function _buildTtsRequest(text, s) { + const url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak'; + const method = s.tts_method || 'POST'; + const authType = s.tts_auth_type || 'bearer'; + const token = s.tts_token || ''; + const payloadKey = s.tts_payload_key || 'message'; + const contentType = s.tts_content_type || 'application/json'; + let extraFields = {}; + try { extraFields = JSON.parse(s.tts_extra_fields || '{}'); } catch(e) { /* invalid JSON, ignore */ } + const headers = { 'Content-Type': contentType }; + if (authType === 'bearer' && token) { + headers['Authorization'] = `Bearer ${token}`; + } else if (authType === 'header' && s.tts_auth_header_name) { + headers[s.tts_auth_header_name] = s.tts_auth_header_value || ''; + } + const payload = { [payloadKey]: text, ...extraFields }; + let body; + if (contentType === 'application/json') { + body = JSON.stringify(payload); + } else if (contentType === 'application/x-www-form-urlencoded') { + body = new URLSearchParams(Object.entries(payload).map(([k, v]) => [k, String(v)])).toString(); + } else { + body = text; + } + return { url, method, headers, body }; +} + async function speakCookingStep(text) { if (!text) return; const s = getSettings(); if (!s.tts_enabled) return; - const url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak'; - const token = s.tts_token || ''; - const payloadKey = s.tts_payload_key || 'message'; try { - await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ [payloadKey]: text }) - }); + const req = _buildTtsRequest(text, s); + await fetch(req.url, { method: req.method, headers: req.headers, body: req.body }); } catch(e) { /* silent — TTS is non-critical */ } } @@ -6619,33 +6672,46 @@ function replayCookingTTS() { if (text) speakCookingStep(text); } +function onTtsAuthTypeChange(type) { + const tokenGroup = document.getElementById('tts-token-group'); + const headerGroup = document.getElementById('tts-custom-header-group'); + if (tokenGroup) tokenGroup.style.display = type === 'bearer' ? '' : 'none'; + if (headerGroup) headerGroup.style.display = type === 'header' ? '' : 'none'; +} + async function testTTS() { const statusEl = document.getElementById('tts-test-status'); - // Read values directly from the form (not yet saved settings) + // Build settings from current form values (before saving) + let extraFields = {}; + try { extraFields = JSON.parse((document.getElementById('setting-tts-extra-fields')?.value || '{}').trim() || '{}'); } catch(e) { /* ignore */ } + const formSettings = { + tts_url: (document.getElementById('setting-tts-url')?.value || '').trim(), + tts_method: document.getElementById('setting-tts-method')?.value || 'POST', + tts_auth_type: document.getElementById('setting-tts-auth-type')?.value || 'bearer', + tts_token: (document.getElementById('setting-tts-token')?.value || '').trim(), + tts_auth_header_name: (document.getElementById('setting-tts-auth-header-name')?.value || '').trim(), + tts_auth_header_value: (document.getElementById('setting-tts-auth-header-value')?.value || '').trim(), + tts_content_type: document.getElementById('setting-tts-content-type')?.value || 'application/json', + tts_payload_key: (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message', + tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || '' + }; const enabled = document.getElementById('setting-tts-enabled')?.checked; - const url = (document.getElementById('setting-tts-url')?.value || '').trim() || 'http://192.168.1.133:8123/api/events/noemi_speak'; - const token = (document.getElementById('setting-tts-token')?.value || '').trim(); - const payloadKey = (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message'; - if (!enabled) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ TTS non attivo — attiva il toggle prima di testare.'; } return; } - if (!token) { - if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ Bearer Token mancante.'; } + if (!formSettings.tts_url) { + if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; } return; } if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio in corso…'; } try { - const res = await fetch(url, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ [payloadKey]: 'Test vocale Dispensa Manager' }) - }); + const req = _buildTtsRequest('Test vocale Dispensa Manager', formSettings); + const res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body }); if (res.ok || res.status === 200) { - if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Richiesta inviata! Controlla che il tuo altoparlante abbia parlato.'; } + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ Risposta ${res.status} — controlla che l'altoparlante abbia parlato.`; } } else { - if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `⚠️ Risposta HTTP ${res.status}: ${res.statusText}`; } + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `⚠️ HTTP ${res.status}: ${res.statusText}`; } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ Errore di rete: ${e.message}`; } diff --git a/data/cron.log b/data/cron.log index 1cfdd52..9198324 100644 --- a/data/cron.log +++ b/data/cron.log @@ -967,3 +967,4 @@ [2026-04-04 14:25:01] OK — 11 items cached [2026-04-04 14:30:02] OK — 11 items cached [2026-04-04 14:35:02] OK — 11 items cached +[2026-04-04 14:40:01] OK — 11 items cached diff --git a/data/smart_shopping_cache.json b/data/smart_shopping_cache.json index ab4b4e3..b243a82 100644 --- a/data/smart_shopping_cache.json +++ b/data/smart_shopping_cache.json @@ -1 +1 @@ -{"success":true,"items":[{"product_id":151,"name":"Arance Tarocco","brand":"","category":"frutta","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":9,"buy_count":2,"daily_rate":0.55,"uses_per_month":13.6,"days_since_last_use":2,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":70,"name":"Latte Parzialmente Scremato Uht","brand":"Latteria","category":"bevande","unit":"conf","current_qty":0,"default_qty":500,"package_unit":"ml","pct_left":0,"use_count":13,"buy_count":3,"daily_rate":6.74,"uses_per_month":15.6,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (13x)"],"score":135,"on_bring":true,"locations":""},{"product_id":129,"name":"Latte di Montagna","brand":"Mila","category":"en:dairies","unit":"conf","current_qty":0,"default_qty":1000,"package_unit":"ml","pct_left":0,"use_count":9,"buy_count":3,"daily_rate":0.2,"uses_per_month":13.6,"days_since_last_use":1,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":47,"name":"Lenticchie","brand":"Primia","category":"en:plant-based-foods-and-beverages","unit":"g","current_qty":0,"default_qty":400,"package_unit":"","pct_left":0,"use_count":5,"buy_count":3,"daily_rate":35.88,"uses_per_month":6,"days_since_last_use":0,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (5x)"],"score":130,"on_bring":true,"locations":""},{"product_id":115,"name":"Aglio rosso","brand":"Duoccio","category":"condimenti","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":2,"buy_count":2,"daily_rate":0.14,"uses_per_month":2.7,"days_since_last_use":15,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":true,"locations":""},{"product_id":152,"name":"Muesli Frutta Secca","brand":"Crownfield","category":"altro","unit":"g","current_qty":0,"default_qty":750,"package_unit":"","pct_left":0,"use_count":4,"buy_count":2,"daily_rate":34.01,"uses_per_month":6.9,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":false,"locations":""},{"product_id":3,"name":"Cracker integrali","brand":"Barilla,Mulino Bianco","category":"en:snacks","unit":"conf","current_qty":2,"default_qty":25,"package_unit":"g","pct_left":12,"use_count":6,"buy_count":1,"daily_rate":0.56,"uses_per_month":7.2,"days_since_last_use":6,"days_left":4,"expiry_date":"2026-04-28","days_to_expiry":23,"is_opened":true,"urgency":"high","reasons":["Quasi finito (12%)"],"score":90,"on_bring":true,"locations":"dispensa"},{"product_id":132,"name":"Noci sgusciate","brand":"Fruttbella","category":"conserve","unit":"g","current_qty":60,"default_qty":200,"package_unit":"","pct_left":30,"use_count":4,"buy_count":1,"daily_rate":7.04,"uses_per_month":6,"days_since_last_use":0,"days_left":9,"expiry_date":"2026-04-29","days_to_expiry":24,"is_opened":true,"urgency":"medium","reasons":["Finisce tra ~9gg"],"score":50,"on_bring":true,"locations":"dispensa"},{"product_id":69,"name":"Cipolla Dorata","brand":"","category":"verdura","unit":"pz","current_qty":4,"default_qty":0,"package_unit":"","pct_left":44,"use_count":12,"buy_count":1,"daily_rate":0.32,"uses_per_month":14.4,"days_since_last_use":0,"days_left":12,"expiry_date":"2026-04-13","days_to_expiry":8,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~12gg"],"score":40,"on_bring":true,"locations":"frigo"},{"product_id":154,"name":"Mela Rossa","brand":"","category":"frutta","unit":"pz","current_qty":7,"default_qty":1,"package_unit":"","pct_left":32,"use_count":5,"buy_count":1,"daily_rate":0.87,"uses_per_month":8.7,"days_since_last_use":0,"days_left":8,"expiry_date":"2026-04-15","days_to_expiry":10,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~8gg"],"score":35,"on_bring":false,"locations":"frigo"},{"product_id":136,"name":"Biscotti Pastefrolle","brand":"Balocco","category":"snack","unit":"g","current_qty":350,"default_qty":700,"package_unit":"","pct_left":67,"use_count":4,"buy_count":2,"daily_rate":35.22,"uses_per_month":6,"days_since_last_use":0,"days_left":10,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~10gg"],"score":25,"on_bring":false,"locations":"dispensa"}],"cached_at":"2026-04-04T14:35:02+00:00","cached_ts":1775313302} \ No newline at end of file +{"success":true,"items":[{"product_id":151,"name":"Arance Tarocco","brand":"","category":"frutta","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":9,"buy_count":2,"daily_rate":0.55,"uses_per_month":13.6,"days_since_last_use":2,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":70,"name":"Latte Parzialmente Scremato Uht","brand":"Latteria","category":"bevande","unit":"conf","current_qty":0,"default_qty":500,"package_unit":"ml","pct_left":0,"use_count":13,"buy_count":3,"daily_rate":6.74,"uses_per_month":15.6,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (13x)"],"score":135,"on_bring":true,"locations":""},{"product_id":129,"name":"Latte di Montagna","brand":"Mila","category":"en:dairies","unit":"conf","current_qty":0,"default_qty":1000,"package_unit":"ml","pct_left":0,"use_count":9,"buy_count":3,"daily_rate":0.2,"uses_per_month":13.6,"days_since_last_use":1,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":47,"name":"Lenticchie","brand":"Primia","category":"en:plant-based-foods-and-beverages","unit":"g","current_qty":0,"default_qty":400,"package_unit":"","pct_left":0,"use_count":5,"buy_count":3,"daily_rate":35.87,"uses_per_month":6,"days_since_last_use":0,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (5x)"],"score":130,"on_bring":true,"locations":""},{"product_id":115,"name":"Aglio rosso","brand":"Duoccio","category":"condimenti","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":2,"buy_count":2,"daily_rate":0.14,"uses_per_month":2.7,"days_since_last_use":15,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":true,"locations":""},{"product_id":152,"name":"Muesli Frutta Secca","brand":"Crownfield","category":"altro","unit":"g","current_qty":0,"default_qty":750,"package_unit":"","pct_left":0,"use_count":4,"buy_count":2,"daily_rate":34,"uses_per_month":6.9,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":false,"locations":""},{"product_id":3,"name":"Cracker integrali","brand":"Barilla,Mulino Bianco","category":"en:snacks","unit":"conf","current_qty":2,"default_qty":25,"package_unit":"g","pct_left":12,"use_count":6,"buy_count":1,"daily_rate":0.56,"uses_per_month":7.2,"days_since_last_use":6,"days_left":4,"expiry_date":"2026-04-28","days_to_expiry":23,"is_opened":true,"urgency":"high","reasons":["Quasi finito (12%)"],"score":90,"on_bring":true,"locations":"dispensa"},{"product_id":132,"name":"Noci sgusciate","brand":"Fruttbella","category":"conserve","unit":"g","current_qty":60,"default_qty":200,"package_unit":"","pct_left":30,"use_count":4,"buy_count":1,"daily_rate":7.04,"uses_per_month":6,"days_since_last_use":0,"days_left":9,"expiry_date":"2026-04-29","days_to_expiry":24,"is_opened":true,"urgency":"medium","reasons":["Finisce tra ~9gg"],"score":50,"on_bring":true,"locations":"dispensa"},{"product_id":69,"name":"Cipolla Dorata","brand":"","category":"verdura","unit":"pz","current_qty":4,"default_qty":0,"package_unit":"","pct_left":44,"use_count":12,"buy_count":1,"daily_rate":0.32,"uses_per_month":14.4,"days_since_last_use":0,"days_left":12,"expiry_date":"2026-04-13","days_to_expiry":8,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~12gg"],"score":40,"on_bring":true,"locations":"frigo"},{"product_id":154,"name":"Mela Rossa","brand":"","category":"frutta","unit":"pz","current_qty":7,"default_qty":1,"package_unit":"","pct_left":32,"use_count":5,"buy_count":1,"daily_rate":0.87,"uses_per_month":8.7,"days_since_last_use":0,"days_left":8,"expiry_date":"2026-04-15","days_to_expiry":10,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~8gg"],"score":35,"on_bring":false,"locations":"frigo"},{"product_id":136,"name":"Biscotti Pastefrolle","brand":"Balocco","category":"snack","unit":"g","current_qty":350,"default_qty":700,"package_unit":"","pct_left":67,"use_count":4,"buy_count":2,"daily_rate":35.22,"uses_per_month":6,"days_since_last_use":0,"days_left":10,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~10gg"],"score":25,"on_bring":false,"locations":"dispensa"}],"cached_at":"2026-04-04T14:40:01+00:00","cached_ts":1775313601} \ No newline at end of file diff --git a/index.html b/index.html index 15a1676..7b90594 100644 --- a/index.html +++ b/index.html @@ -850,10 +850,10 @@
Configura la sintesi vocale tramite Home Assistant. I passi della ricetta e i timer verranno letti ad alta voce attraverso l'altoparlante configurato.
+Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.
Nome del campo JSON inviato all'evento (default: message).
Nome del campo JSON che conterrà il testo da leggere (es: message, text).
Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.