diff --git a/assets/js/app.js b/assets/js/app.js index bea12a5..7f7ba19 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -648,6 +648,15 @@ async function loadSettingsUI() { `${t.icon} ${t.label}` ).join(''); } + // TTS settings + 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'; + const ttsTokenEl = document.getElementById('setting-tts-token'); + if (ttsTokenEl) ttsTokenEl.value = s.tts_token || ''; + const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key'); + if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message'; // Load server-side settings if not already set locally try { @@ -748,6 +757,15 @@ async function saveSettings() { // Meal plan enabled toggle const mpEnabledEl = document.getElementById('setting-meal-plan-enabled'); if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked; + // 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'; // 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(); @@ -6495,34 +6513,11 @@ function startCookingMode() { document.getElementById('cooking-overlay').style.display = 'flex'; document.body.classList.add('cooking-mode-active'); try { screen.orientation?.lock('portrait'); } catch (_) { /* ignore */ } - - // Ensure voices are loaded, then render (and speak) the first step - if ('speechSynthesis' in window) { - const voices = window.speechSynthesis.getVoices(); - if (voices.length > 0) { - renderCookingStep(); - } else { - // Voices not yet loaded β render immediately (muted), then re-speak after voices arrive - renderCookingStep(); - window.speechSynthesis.addEventListener('voiceschanged', function _onVoices() { - window.speechSynthesis.removeEventListener('voiceschanged', _onVoices); - if (_cookingTTS && _cookingStep === 0) { - const text = (_cookingRecipe.steps[0] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, ''); - if (text) speakCookingStep(text); - } - }); - } - } else { - _cookingTTS = false; - document.getElementById('cooking-tts-btn').textContent = 'π'; - renderCookingStep(); - } + renderCookingStep(); } - function closeCookingMode() { document.getElementById('cooking-overlay').style.display = 'none'; document.body.classList.remove('cooking-mode-active'); - if ('speechSynthesis' in window) window.speechSynthesis.cancel(); // NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited // so the user can resume from the same step when they reopen try { screen.orientation?.unlock(); } catch (_) { /* ignore */ } @@ -6598,36 +6593,23 @@ function renderCookingStep() { if (_cookingTTS) speakCookingStep(cleanStep); } -function _bestItalianVoice() { - const voices = window.speechSynthesis.getVoices(); - const it = voices.filter(v => v.lang.startsWith('it')); - if (it.length === 0) return null; - // Prefer high-quality online voices (Google / Microsoft) over local robotic ones - const priority = [ - v => /google/i.test(v.name), - v => /microsoft/i.test(v.name) && !v.localService, - v => !v.localService, - v => /alice|federica|luca|paola/i.test(v.name), - () => true, // any italian - ]; - for (const pred of priority) { - const match = it.find(pred); - if (match) return match; - } - return it[0]; -} - -function speakCookingStep(text) { - if (!('speechSynthesis' in window)) return; - window.speechSynthesis.cancel(); - const utt = new SpeechSynthesisUtterance(text); - utt.lang = 'it-IT'; - utt.rate = 0.88; - utt.pitch = 1.0; - utt.volume = 1.0; - const voice = _bestItalianVoice(); - if (voice) utt.voice = voice; - window.speechSynthesis.speak(utt); +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 }) + }); + } catch(e) { /* silent β TTS is non-critical */ } } function replayCookingTTS() { @@ -6637,6 +6619,39 @@ function replayCookingTTS() { if (text) speakCookingStep(text); } +async function testTTS() { + const statusEl = document.getElementById('tts-test-status'); + // Read values directly from the form (not yet saved settings) + 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.'; } + 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' }) + }); + if (res.ok || res.status === 200) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = 'β Richiesta inviata! Controlla che il tuo altoparlante abbia parlato.'; } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `β οΈ Risposta HTTP ${res.status}: ${res.statusText}`; } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `β Errore di rete: ${e.message}`; } + } +} + // ===== COOKING TIMER SYSTEM ===== let _cookingTimers = []; // { id, label, total, seconds, running, interval } let _cookingTimerIdCounter = 0; @@ -6846,8 +6861,6 @@ function toggleCookingTTS() { const steps = _cookingRecipe?.steps || []; const text = (steps[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, ''); speakCookingStep(text); - } else { - window.speechSynthesis?.cancel(); } } diff --git a/data/cron.log b/data/cron.log index eaed3cc..1cfdd52 100644 --- a/data/cron.log +++ b/data/cron.log @@ -966,3 +966,4 @@ [2026-04-04 14:20:02] OK β 11 items cached [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 diff --git a/data/smart_shopping_cache.json b/data/smart_shopping_cache.json new file mode 100644 index 0000000..ab4b4e3 --- /dev/null +++ b/data/smart_shopping_cache.json @@ -0,0 +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 diff --git a/index.html b/index.html index 4660b01..15a1676 100644 --- a/index.html +++ b/index.html @@ -648,6 +648,7 @@ +
Configura la sintesi vocale tramite Home Assistant. I passi della ricetta e i timer verranno letti ad alta voce attraverso l'altoparlante configurato.
+Nome del campo JSON inviato all'evento (default: message).