diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e639f01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main, feature/**] + pull_request: + branches: [main] + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff pytest pytest-asyncio pytest-mock anyio pyyaml aiohttp + + - name: Run Ruff linter + run: ruff check . + + - name: Run Pytest test suite + run: pytest tests/ diff --git a/custom_components/google_assistant_entity_console/__init__.py b/custom_components/google_assistant_entity_console/__init__.py index e0b738f..4bf5f9d 100644 --- a/custom_components/google_assistant_entity_console/__init__.py +++ b/custom_components/google_assistant_entity_console/__init__.py @@ -1,28 +1,27 @@ import logging -import json -import os -from homeassistant.core import HomeAssistant -from homeassistant.config_entries import ConfigEntry + from homeassistant.components.frontend import ( async_register_built_in_panel, async_remove_panel, ) from homeassistant.components.http import StaticPathConfig +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .const import DOMAIN from .views import ( + AIGenerateNicknamesView, + AIGenerateSingleEntityNicknameView, + AIHaAgentsView, + AIModelsView, + AISettingsView, + AISuggestExposureView, + BlocklistAddView, + BlocklistView, EntitiesView, - UpdateEntityView, RebuildView, RestartView, - BlocklistView, - BlocklistAddView, - AISettingsView, - AIModelsView, - AIHaAgentsView, - AIGenerateNicknamesView, - AISuggestExposureView, - AIGenerateSingleEntityNicknameView, + UpdateEntityView, ) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/google_assistant_entity_console/config_flow.py b/custom_components/google_assistant_entity_console/config_flow.py index a03fb98..3ec2343 100644 --- a/custom_components/google_assistant_entity_console/config_flow.py +++ b/custom_components/google_assistant_entity_console/config_flow.py @@ -1,11 +1,13 @@ from homeassistant import config_entries + from .const import DOMAIN + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Assistant Entity Console.""" - + VERSION = 1 - + async def async_step_user(self, user_input=None): """Handle the initial step.""" if self._async_current_entries(): diff --git a/custom_components/google_assistant_entity_console/manifest.json b/custom_components/google_assistant_entity_console/manifest.json index e3db374..964b0ed 100644 --- a/custom_components/google_assistant_entity_console/manifest.json +++ b/custom_components/google_assistant_entity_console/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "config_flow": true, "requirements": [], - "version": "1.7.0" + "version": "1.8.0" } diff --git a/custom_components/google_assistant_entity_console/static/app.js b/custom_components/google_assistant_entity_console/static/app.js index bd9fa8d..778390e 100644 --- a/custom_components/google_assistant_entity_console/static/app.js +++ b/custom_components/google_assistant_entity_console/static/app.js @@ -39,6 +39,23 @@ const blocklistContainer = document.getElementById('blocklistContainer'); const closeBlocklistModal = document.getElementById('closeBlocklistModal'); const closeBlocklistModalBtn = document.getElementById('closeBlocklistModalBtn'); +// Listen for token updates from parent panel +window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'HA_TOKEN_UPDATE' && event.data.token) { + sessionStorage.setItem('ha_token', event.data.token); + } +}); + +function requestParentToken() { + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'REQUEST_HA_TOKEN' }, '*'); + } + } catch (e) { + console.debug('Failed to request token from parent window:', e); + } +} + // Get auth headers from URL token parameter or session storage function getAuthHeaders() { const params = new URLSearchParams(window.location.search); @@ -57,6 +74,26 @@ function getAuthHeaders() { return {}; } +// Authenticated fetch wrapper that handles token injection and 401 refresh retry +async function authFetch(url, options = {}) { + const headers = { + ...getAuthHeaders(), + ...(options.headers || {}) + }; + let response = await fetch(url, { ...options, headers }); + if (response.status === 401) { + requestParentToken(); + // Wait briefly for token update from parent + await new Promise(resolve => setTimeout(resolve, 300)); + const retryHeaders = { + ...getAuthHeaders(), + ...(options.headers || {}) + }; + response = await fetch(url, { ...options, headers: retryHeaders }); + } + return response; +} + // Init if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { @@ -72,7 +109,7 @@ if (document.readyState === 'loading') { async function fetchEntities() { showTableLoading(); try { - const response = await fetch('/api/google_assistant_entity_console/entities', { + const response = await authFetch('/api/google_assistant_entity_console/entities', { headers: getAuthHeaders() }); if (!response.ok) throw new Error('Failed to fetch entities'); @@ -137,6 +174,23 @@ function setupEventListeners() { // Rebuild Button if (rebuildBtn) rebuildBtn.addEventListener('click', handleRebuild); + + // Copy Include Snippet Button + const copyIncludeBtn = document.getElementById('copyIncludeBtn'); + if (copyIncludeBtn) { + copyIncludeBtn.addEventListener('click', () => { + const input = document.getElementById('missingIncludeInput'); + if (input && input.value) { + navigator.clipboard.writeText(input.value).then(() => { + showToast('Include snippet copied to clipboard!', 'success'); + }).catch(() => { + input.select(); + document.execCommand('copy'); + showToast('Include snippet copied to clipboard!', 'success'); + }); + } + }); + } // Restart Modal controls if (cancelRestartBtn && restartModal) { @@ -776,7 +830,7 @@ window.openQuickAliasModal = function(entityId, event) { const updatedAliases = [...entity.aliases, val]; try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -830,22 +884,33 @@ async function handleRebuild() { icon.className = 'fa-solid fa-spinner fa-spin'; try { - const response = await fetch('/api/google_assistant_entity_console/rebuild', { + const result = await authFetch('/api/google_assistant_entity_console/rebuild', { method: 'POST', headers: { - 'Content-Type': 'application/json', - ...getAuthHeaders() + 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - if (!response.ok) { - const err = await response.json(); + if (!result.ok) { + const err = await result.json(); throw new Error(err.error || 'Rebuild failed'); } - const result = await response.json(); - showToast(`Configuration generated successfully. (${result.exposed_count} entities exposed)`, 'success'); + const data = await result.json(); + showToast(`Configuration generated successfully. (${data.exposed_count} entities exposed)`, 'success'); + + const missingIncludeContainer = document.getElementById('missingIncludeContainer'); + const missingIncludeInput = document.getElementById('missingIncludeInput'); + if (missingIncludeContainer && missingIncludeInput) { + if (data.has_include_directive === false && data.required_include) { + missingIncludeInput.value = data.required_include; + missingIncludeContainer.style.display = 'block'; + } else { + missingIncludeContainer.style.display = 'none'; + } + } + // Open restart confirmation modal restartModal.style.display = 'block'; } catch (error) { @@ -863,7 +928,7 @@ async function handleRestart() { confirmRestartBtn.textContent = 'Restarting...'; try { - const response = await fetch('/api/google_assistant_entity_console/restart', { + const response = await authFetch('/api/google_assistant_entity_console/restart', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -901,7 +966,7 @@ window.toggleExposeDirectly = async function(entityId, event) { const newExposeStatus = !entity.should_expose; try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -936,7 +1001,7 @@ window.removeAliasDirectly = async function(entityId, alias, event) { const updatedAliases = (entity.aliases || []).filter(a => a !== alias); try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1007,7 +1072,7 @@ window.openEditNameInline = function(entityId, event) { } try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1073,7 +1138,7 @@ function showToast(message, type = 'success') { async function fetchBlocklist() { blocklistContainer.innerHTML = 'Loading blocklist...'; try { - const response = await fetch('/api/google_assistant_entity_console/blocklist', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist', { headers: getAuthHeaders() }); if (!response.ok) throw new Error('Failed to fetch blocklist'); @@ -1116,7 +1181,7 @@ function renderBlocklist(patterns) { // Add a regex pattern to blocklist async function addBlocklistPattern(pattern) { try { - const response = await fetch('/api/google_assistant_entity_console/blocklist/add', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist/add', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1143,7 +1208,7 @@ async function addBlocklistPattern(pattern) { async function removeBlocklistPattern(pattern) { try { // Fetch current list first - const getRes = await fetch('/api/google_assistant_entity_console/blocklist', { + const getRes = await authFetch('/api/google_assistant_entity_console/blocklist', { headers: getAuthHeaders() }); if (!getRes.ok) throw new Error('Failed to load current list'); @@ -1151,7 +1216,7 @@ async function removeBlocklistPattern(pattern) { const updatedList = (getData.blocklist || []).filter(p => p !== pattern); - const response = await fetch('/api/google_assistant_entity_console/blocklist', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1185,7 +1250,7 @@ window.addToBlocklistDirectly = async function(entityId, event) { const pattern = `^${entityId.replace(/\./g, '\\.')}$`; try { - const response = await fetch('/api/google_assistant_entity_console/blocklist/add', { + const response = await authFetch('/api/google_assistant_entity_console/blocklist/add', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1220,7 +1285,7 @@ let aiSettings = {}; async function loadAiSettings() { try { - const response = await fetch('/api/google_assistant_entity_console/ai/settings', { + const response = await authFetch('/api/google_assistant_entity_console/ai/settings', { headers: getAuthHeaders() }); if (response.ok) { @@ -1309,7 +1374,7 @@ function setupAiEventListeners() { queryModelsBtn.disabled = true; queryModelsBtn.textContent = 'Fetching...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/models', { + const response = await authFetch('/api/google_assistant_entity_console/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1368,7 +1433,7 @@ function setupAiEventListeners() { saveAiSettingsBtn.disabled = true; saveAiSettingsBtn.textContent = 'Saving...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/settings', { + const response = await authFetch('/api/google_assistant_entity_console/ai/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1427,7 +1492,7 @@ function setupAiEventListeners() { queryHaAgentsBtn.disabled = true; queryHaAgentsBtn.textContent = 'Fetching...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/ha_agents', { + const response = await authFetch('/api/google_assistant_entity_console/ai/ha_agents', { headers: getAuthHeaders() }); if (!response.ok) { @@ -1487,7 +1552,7 @@ function setupAiEventListeners() { aiGenNicknamesBtn.innerHTML = ' Generating...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/generate_nicknames', { + const response = await authFetch('/api/google_assistant_entity_console/ai/generate_nicknames', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1555,7 +1620,7 @@ function setupAiEventListeners() { aiSuggestExposureBtn.innerHTML = ' Analyzing...'; try { - const response = await fetch('/api/google_assistant_entity_console/ai/suggest_exposure', { + const response = await authFetch('/api/google_assistant_entity_console/ai/suggest_exposure', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1704,7 +1769,7 @@ function getEntitiesInGroup(targetType, targetId) { // Helpers for Direct updates async function setExposureDirectly(entityId, newExposeStatus) { try { - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1730,7 +1795,7 @@ async function setAliasesDirectly(entityId, aliases) { try { const entity = entities.find(e => e.entity_id === entityId); if (!entity) return false; - const response = await fetch('/api/google_assistant_entity_console/entities/update', { + const response = await authFetch('/api/google_assistant_entity_console/entities/update', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1765,7 +1830,7 @@ window.generateSingleEntityNickname = async function(entityId, event) { showToast(`AI suggesting nicknames for ${entity.display_name}...`, 'info'); try { - const response = await fetch('/api/google_assistant_entity_console/ai/generate_single_nickname', { + const response = await authFetch('/api/google_assistant_entity_console/ai/generate_single_nickname', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/custom_components/google_assistant_entity_console/static/index.html b/custom_components/google_assistant_entity_console/static/index.html index 3f524dd..a13b141 100644 --- a/custom_components/google_assistant_entity_console/static/index.html +++ b/custom_components/google_assistant_entity_console/static/index.html @@ -180,10 +180,24 @@

Manage Regex Blocklist