Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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/
25 changes: 12 additions & 13 deletions custom_components/google_assistant_entity_console/__init__.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"iot_class": "local_polling",
"config_flow": true,
"requirements": [],
"version": "1.7.0"
"version": "1.8.0"
}
119 changes: 92 additions & 27 deletions custom_components/google_assistant_entity_console/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', () => {
Expand All @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1073,7 +1138,7 @@ function showToast(message, type = 'success') {
async function fetchBlocklist() {
blocklistContainer.innerHTML = '<span style="color: var(--on-surface-variant); font-size: 0.9rem; font-style: italic;">Loading blocklist...</span>';
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');
Expand Down Expand Up @@ -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',
Expand All @@ -1143,15 +1208,15 @@ 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');
const getData = await getRes.json();

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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1487,7 +1552,7 @@ function setupAiEventListeners() {
aiGenNicknamesBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="margin-right: 0.5rem;"></i> 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',
Expand Down Expand Up @@ -1555,7 +1620,7 @@ function setupAiEventListeners() {
aiSuggestExposureBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="margin-right: 0.5rem;"></i> 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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading