Push inventory over server-sent events and log why the HA websocket closes (0.2.21)
/api/events streams the light inventory whenever the sync loop or a save changes it (3 s check during firmware updates). The page stops polling while the stream is open and falls back to the 10 s poll otherwise. The websocket close is logged with duration, close code and exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,9 @@
|
|||||||
|
## 0.2.21 (firmware 0.2.18 blijft actueel)
|
||||||
|
|
||||||
|
- **Live updates zonder pollen**: de pagina luistert op `/api/events` (server-sent events). De add-on stuurt schermstatus en updatestatus zodra de synchronisatielus of een opslag iets verandert, en controleert elke 3 s op wijzigingen tijdens een firmware-update. Zolang de stream open is pollt de pagina niet meer; valt de stream weg, dan neemt de poll van 10 s het over. De volledige lijst met entiteiten, achtergronden en iconen wordt nog elke 5 minuten ververst.
|
||||||
|
- Het log meldt bij het wegvallen van de Home Assistant-verbinding hoe lang die stond, de websocket close-code en de fout, ook wanneer de verbinding zonder foutmelding sluit. Het add-on-log op de Yellow toonde herhaalde "Home Assistant verbonden"-regels zonder waarschuwing ervoor; met deze regels is de oorzaak bij de volgende keer uit het log te halen.
|
||||||
|
- Alleen de app verandert; firmware 0.2.18 blijft actueel.
|
||||||
|
|
||||||
## 0.2.20 (firmware 0.2.18 blijft actueel)
|
## 0.2.20 (firmware 0.2.18 blijft actueel)
|
||||||
|
|
||||||
- **Minder verbruik in rust**: de synchronisatielus wordt alleen nog wakker voor entiteiten die op een indeling staan of bij een scherm horen (Tegelinstellingen, Schermfirmware, Apparaatnaam, IP-adres, Guition schermtype), niet meer bij elke `state_changed` in Home Assistant.
|
- **Minder verbruik in rust**: de synchronisatielus wordt alleen nog wakker voor entiteiten die op een indeling staan of bij een scherm horen (Tegelinstellingen, Schermfirmware, Apparaatnaam, IP-adres, Guition schermtype), niet meer bij elke `state_changed` in Home Assistant.
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ class HomeAssistant:
|
|||||||
self.registry_changed.set()
|
self.registry_changed.set()
|
||||||
raise ConnectionError('Home Assistant-verbinding verbroken.')
|
raise ConnectionError('Home Assistant-verbinding verbroken.')
|
||||||
|
|
||||||
|
def describe_close(self, connected):
|
||||||
|
"""Why and after how long the websocket ended; helps explain unexpected reconnects in the log."""
|
||||||
|
if connected is None:
|
||||||
|
return ''
|
||||||
|
ws = self.ws
|
||||||
|
reason = ws.exception() if ws is not None else None
|
||||||
|
return ' na %d s, close-code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
|
||||||
|
f', {type(reason).__name__}' if reason else '')
|
||||||
|
|
||||||
async def registries(self):
|
async def registries(self):
|
||||||
self.registry, self.devices, self.areas = await asyncio.gather(
|
self.registry, self.devices, self.areas = await asyncio.gather(
|
||||||
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
|
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
|
||||||
@@ -92,7 +101,7 @@ class HomeAssistant:
|
|||||||
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
|
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
|
||||||
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
|
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
|
||||||
while True:
|
while True:
|
||||||
reader = None
|
reader, connected = None, None
|
||||||
try:
|
try:
|
||||||
async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws:
|
async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
@@ -113,6 +122,7 @@ class HomeAssistant:
|
|||||||
self.time_zone = timezone.utc
|
self.time_zone = timezone.utc
|
||||||
self.online = True
|
self.online = True
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
|
connected = time.monotonic()
|
||||||
LOG.info('Home Assistant verbonden')
|
LOG.info('Home Assistant verbonden')
|
||||||
# Refresh the registry when HA reports a change (debounced), with a slow
|
# Refresh the registry when HA reports a change (debounced), with a slow
|
||||||
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
|
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
|
||||||
@@ -134,10 +144,12 @@ class HomeAssistant:
|
|||||||
fetched = time.monotonic()
|
fetched = time.monotonic()
|
||||||
self.changed.set()
|
self.changed.set()
|
||||||
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
|
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
|
||||||
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)', type(error).__name__)
|
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)%s', type(error).__name__, self.describe_close(connected))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOG.warning('Verbinding opnieuw starten (%s)', type(error).__name__)
|
LOG.warning('Verbinding opnieuw starten (%s)%s', type(error).__name__, self.describe_close(connected))
|
||||||
finally:
|
finally:
|
||||||
|
if self.online:
|
||||||
|
LOG.info('Home Assistant-verbinding gesloten%s', self.describe_close(connected))
|
||||||
self.online = False
|
self.online = False
|
||||||
self.ws = None
|
self.ws = None
|
||||||
if reader:
|
if reader:
|
||||||
@@ -196,6 +208,7 @@ class Manager:
|
|||||||
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
|
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
|
||||||
self.histories = {}
|
self.histories = {}
|
||||||
self.forecasts = {}
|
self.forecasts = {}
|
||||||
|
self.listeners = set() # asyncio.Event per open /api/events stream
|
||||||
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
|
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
|
||||||
self.updates = Updater(self, self.path.parent / 'updates.json')
|
self.updates = Updater(self, self.path.parent / 'updates.json')
|
||||||
if self.path.exists():
|
if self.path.exists():
|
||||||
@@ -270,6 +283,11 @@ class Manager:
|
|||||||
self.sent.pop(inbox, None)
|
self.sent.pop(inbox, None)
|
||||||
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
||||||
self.ha.changed.set()
|
self.ha.changed.set()
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
for listener in self.listeners:
|
||||||
|
listener.set()
|
||||||
|
|
||||||
def watched_entities(self):
|
def watched_entities(self):
|
||||||
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics."""
|
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics."""
|
||||||
@@ -333,6 +351,7 @@ class Manager:
|
|||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
if not self.ha.online:
|
if not self.ha.online:
|
||||||
self.sent.clear()
|
self.sent.clear()
|
||||||
|
self.notify()
|
||||||
continue
|
continue
|
||||||
for event in getattr(self.ha,'setting_events',[])[:]:
|
for event in getattr(self.ha,'setting_events',[])[:]:
|
||||||
try:
|
try:
|
||||||
@@ -369,6 +388,7 @@ class Manager:
|
|||||||
self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen'
|
self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen'
|
||||||
LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__)
|
LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
self.notify()
|
||||||
|
|
||||||
def create_app(manager, development=False):
|
def create_app(manager, development=False):
|
||||||
csrf = secrets.token_urlsafe(32)
|
csrf = secrets.token_urlsafe(32)
|
||||||
@@ -385,6 +405,8 @@ def create_app(manager, development=False):
|
|||||||
return web.json_response({'error': str(error)}, status=400)
|
return web.json_response({'error': str(error)}, status=400)
|
||||||
except (TypeError, KeyError):
|
except (TypeError, KeyError):
|
||||||
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400)
|
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400)
|
||||||
|
if response.prepared:
|
||||||
|
return response # streamed (SSE) responses set their headers before prepare()
|
||||||
response.headers['Cache-Control'] = 'no-store'
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
|
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
|
||||||
@@ -395,14 +417,18 @@ def create_app(manager, development=False):
|
|||||||
|
|
||||||
async def index(request):
|
async def index(request):
|
||||||
return web.FileResponse(static / 'index.html')
|
return web.FileResponse(static / 'index.html')
|
||||||
async def inventory(request):
|
def light_payload():
|
||||||
|
"""Screens and update status: everything that changes while the page is open."""
|
||||||
screens, entities = manager.inventory()
|
screens, entities = manager.inventory()
|
||||||
profiles = manager.firmware.profile_names()
|
profiles = manager.firmware.profile_names()
|
||||||
for screen in screens:
|
for screen in screens:
|
||||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []})
|
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []})
|
||||||
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
|
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
|
||||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||||
payload = {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'updates': manager.updates.summary(screens, profiles)}
|
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
|
||||||
|
'updates': manager.updates.summary(screens, profiles)}, entities
|
||||||
|
async def inventory(request):
|
||||||
|
payload, entities = light_payload()
|
||||||
if request.query.get('light') != '1':
|
if request.query.get('light') != '1':
|
||||||
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
|
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
|
||||||
payload['entities'] = entities
|
payload['entities'] = entities
|
||||||
@@ -410,6 +436,30 @@ def create_app(manager, development=False):
|
|||||||
payload['icons'] = tile_icons.editor()
|
payload['icons'] = tile_icons.editor()
|
||||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||||
return web.json_response(payload)
|
return web.json_response(payload)
|
||||||
|
async def events(request):
|
||||||
|
"""Server-sent events: pushes the light inventory whenever it changes, so the page need not poll."""
|
||||||
|
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store',
|
||||||
|
'X-Accel-Buffering': 'no', 'X-Content-Type-Options': 'nosniff'})
|
||||||
|
await response.prepare(request)
|
||||||
|
wake, sent = asyncio.Event(), None
|
||||||
|
manager.listeners.add(wake)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Sync results are pushed at once; updater phases are picked up by the 3 s check.
|
||||||
|
with contextlib.suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(wake.wait(), 3)
|
||||||
|
wake.clear()
|
||||||
|
body = json.dumps(light_payload()[0], ensure_ascii=False)
|
||||||
|
if body != sent:
|
||||||
|
await response.write(f'data: {body}\n\n'.encode())
|
||||||
|
sent = body
|
||||||
|
else:
|
||||||
|
await response.write(b': keepalive\n\n')
|
||||||
|
except (ConnectionResetError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
manager.listeners.discard(wake)
|
||||||
|
return response
|
||||||
async def save(request):
|
async def save(request):
|
||||||
manager.save(request.match_info['inbox'], await request.json())
|
manager.save(request.match_info['inbox'], await request.json())
|
||||||
return web.json_response({'saved': True})
|
return web.json_response({'saved': True})
|
||||||
@@ -452,6 +502,7 @@ def create_app(manager, development=False):
|
|||||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||||
app.router.add_get('/', index)
|
app.router.add_get('/', index)
|
||||||
app.router.add_get('/api/inventory', inventory)
|
app.router.add_get('/api/inventory', inventory)
|
||||||
|
app.router.add_get('/api/events', events)
|
||||||
app.router.add_put('/api/screens/{inbox}', save)
|
app.router.add_put('/api/screens/{inbox}', save)
|
||||||
app.router.add_post('/api/install', download)
|
app.router.add_post('/api/install', download)
|
||||||
app.router.add_static('/static/', static)
|
app.router.add_static('/static/', static)
|
||||||
|
|||||||
@@ -1017,18 +1017,37 @@ window.addEventListener("beforeunload", (e) => {
|
|||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
|
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
|
||||||
let pollTimer, lastFull = Date.now();
|
// Live updates arrive over server-sent events; polling is the fallback while the stream is down,
|
||||||
|
// plus a full catalogue refresh every 5 minutes.
|
||||||
|
let pollTimer, lastFull = Date.now(), live = false, stream;
|
||||||
|
function applyLive(data) {
|
||||||
|
inventory = { ...inventory, ...data };
|
||||||
|
$("#connection").textContent = inventory.connected ? "● Home Assistant verbonden" : "Verbinding met Home Assistant herstellen…";
|
||||||
|
$("#connection").classList.toggle("online", inventory.connected);
|
||||||
|
renderScreens();
|
||||||
|
if (selected) renderSettingsSupport();
|
||||||
|
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
|
||||||
|
}
|
||||||
|
function listen() {
|
||||||
|
if (stream || typeof EventSource === "undefined") return;
|
||||||
|
stream = new EventSource("api/events");
|
||||||
|
stream.onopen = () => { live = true; poll(); };
|
||||||
|
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
|
||||||
|
stream.onerror = () => { live = false; poll(); };
|
||||||
|
}
|
||||||
function poll() {
|
function poll() {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
|
const wait = live ? 60000 : inventory.updates?.busy ? 3000 : 10000;
|
||||||
pollTimer = setTimeout(async () => {
|
pollTimer = setTimeout(async () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
const full = Date.now() - lastFull >= 300000;
|
const full = Date.now() - lastFull >= 300000;
|
||||||
if (full) lastFull = Date.now();
|
if (full) lastFull = Date.now();
|
||||||
await refresh(full);
|
if (full || !live) await refresh(full);
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
}, inventory.updates?.busy ? 3000 : 10000);
|
}, wait);
|
||||||
}
|
}
|
||||||
|
listen();
|
||||||
poll();
|
poll();
|
||||||
document.addEventListener("visibilitychange", async () => {
|
document.addEventListener("visibilitychange", async () => {
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: ESP Screen Manager
|
name: ESP Screen Manager
|
||||||
version: "0.2.20"
|
version: "0.2.21"
|
||||||
slug: esp_screen_manager
|
slug: esp_screen_manager
|
||||||
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
|
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
|
||||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||||
|
|||||||
@@ -231,6 +231,16 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(inventory['screens'][0]['update']['available'])
|
self.assertTrue(inventory['screens'][0]['update']['available'])
|
||||||
self.assertIn('entities', inventory)
|
self.assertIn('entities', inventory)
|
||||||
light = await (await client.get('/api/inventory?light=1')).json()
|
light = await (await client.get('/api/inventory?light=1')).json()
|
||||||
|
async with client.get('/api/events') as stream:
|
||||||
|
self.assertEqual(stream.headers['Content-Type'], 'text/event-stream')
|
||||||
|
line = await asyncio.wait_for(stream.content.readline(), 5)
|
||||||
|
pushed = json.loads(line.decode().removeprefix('data: '))
|
||||||
|
self.assertEqual([x['id'] for x in pushed['screens']], [x['id'] for x in light['screens']])
|
||||||
|
self.assertEqual(len(m.listeners), 1)
|
||||||
|
for _ in range(50): # the stream notices the closed client at its next write
|
||||||
|
if not m.listeners: break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
self.assertEqual(len(m.listeners), 0)
|
||||||
self.assertNotIn('entities', light)
|
self.assertNotIn('entities', light)
|
||||||
self.assertNotIn('icons', light)
|
self.assertNotIn('icons', light)
|
||||||
self.assertEqual([x['id'] for x in light['screens']], [x['id'] for x in inventory['screens']])
|
self.assertEqual([x['id'] for x in light['screens']], [x['id'] for x in inventory['screens']])
|
||||||
|
|||||||
Reference in New Issue
Block a user