Skip to content

Commit a441904

Browse files
committed
chore: release 0.6.4 - Home Assistant addon in-app updates
1 parent 6ba6b09 commit a441904

14 files changed

Lines changed: 795 additions & 159 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
## [0.6.4] - 2026-04-17
6+
7+
- Added in-app update support for the Home Assistant addon — the Settings update card now checks the addon repo for new versions and triggers Supervisor to install them, replacing the "go to the Add-on Store" instruction
8+
- Replaced the brittle "interpret dispatch errors" approach to detecting HA update completion with a poll of `/system/version` every 10 seconds; the page auto-reloads once the new addon container reports its version, with a 10-minute fallback message. Real backend errors (slug lookup, entity not ready) are surfaced immediately instead of waiting out the timeout
9+
- Cleaned up Settings in HA mode: dropped the redundant "HA mode" badge and addon-repo link, kept the source-repo and addon-repo links side-by-side
10+
511
## [0.6.3] - 2026-04-13
612

713
- Fixed silent regression where the default bird placeholder image was broken on individual bird detail pages (`/bird/:name`) in standard (non-HA) deployments — path resolution dropped the route segment, producing 404s that nginx's `error_page` masked by returning `index.html` as image bytes

backend/core/api.py

Lines changed: 185 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,57 @@ def decorated_function(*args, **kwargs):
200200
UPDATE_CHECK_CACHE_TTL = 3600 # 1 hour in seconds
201201

202202

203+
_HA_CORE_API_BASE = "http://supervisor/core/api"
204+
205+
206+
def _call_supervisor(method, path, timeout=10):
207+
"""Call Home Assistant Supervisor API. Returns (data, error_message)."""
208+
token = os.environ.get('SUPERVISOR_TOKEN', '')
209+
try:
210+
resp = requests.request(
211+
method,
212+
f"http://supervisor{path}",
213+
headers={"Authorization": f"Bearer {token}"},
214+
timeout=timeout,
215+
)
216+
resp.raise_for_status()
217+
body = resp.json() if resp.content else {}
218+
return body.get('data', {}), None
219+
except requests.RequestException as e:
220+
return None, str(e)
221+
222+
223+
def _find_addon_update_entity(addon_slug, token):
224+
"""Find this addon's update.* entity_id via HA Core states.
225+
226+
HA Core registers an UpdateEntity per addon with entity_picture set to
227+
/api/hassio/addons/<full_slug>/icon — a unique key per addon. Returns
228+
(entity_id, error_message).
229+
"""
230+
try:
231+
resp = requests.get(
232+
f"{_HA_CORE_API_BASE}/states",
233+
headers={"Authorization": f"Bearer {token}"},
234+
timeout=10,
235+
)
236+
resp.raise_for_status()
237+
states = resp.json()
238+
except requests.RequestException as e:
239+
return None, f'Failed to fetch HA Core states: {e}'
240+
except ValueError as e:
241+
return None, f'Invalid response from HA Core states: {e}'
242+
243+
icon_url = f"/api/hassio/addons/{addon_slug}/icon"
244+
for state in states:
245+
entity_id = state.get('entity_id', '')
246+
if not entity_id.startswith('update.'):
247+
continue
248+
if state.get('attributes', {}).get('entity_picture') == icon_url:
249+
return entity_id, None
250+
251+
return None, f'Could not find update entity for addon {addon_slug}'
252+
253+
203254
def _build_update_check_result(update_available, current_commit, remote_commit,
204255
commits_behind, current_branch, target_branch,
205256
channel, preview_commits, fresh_sync, update_note):
@@ -1232,6 +1283,11 @@ def write_flag(flag_name, content=None):
12321283
HA_SOURCE_COMMIT_ENV = "BIRDNET_PIPY_SOURCE_COMMIT"
12331284
HA_SOURCE_COMMIT_FILE = os.path.join(BASE_DIR, "birdnet_pipy_source_commit.txt")
12341285

1286+
# How long to wait for HA Core's update entity to refresh after triggering
1287+
# homeassistant.update_entity (fire-and-forget) before giving up.
1288+
_HA_ENTITY_POLL_TIMEOUT_SECONDS = 15
1289+
_HA_ENTITY_POLL_INTERVAL_SECONDS = 0.5
1290+
12351291
def load_version_info():
12361292
"""Load version information from version.json"""
12371293
version_file = os.path.join(BASE_DIR, 'data', 'version.json')
@@ -1925,9 +1981,20 @@ def check_for_updates():
19251981
"""
19261982
try:
19271983
if is_home_assistant_mode():
1984+
force = request.args.get('force', 'false').lower() == 'true'
1985+
if force:
1986+
_call_supervisor('POST', '/store/reload', timeout=30)
1987+
1988+
info, error = _call_supervisor('GET', '/addons/self/info')
1989+
if error:
1990+
return jsonify({'error': f'Failed to check for updates: {error}'}), 502
1991+
19281992
return jsonify({
1929-
'update_available': False,
1930-
'message': 'Updates are managed by Home Assistant'
1993+
'update_available': bool(info.get('update_available')),
1994+
'runtime_mode': 'ha',
1995+
'current_version': info.get('version', 'unknown'),
1996+
'latest_version': info.get('version_latest'),
1997+
'update_note': None,
19311998
}), 200
19321999

19332000
force = request.args.get('force', 'false').lower() == 'true'
@@ -2084,6 +2151,119 @@ def trigger_system_update():
20842151
before this endpoint is called. No need to re-check here.
20852152
"""
20862153
try:
2154+
if is_home_assistant_mode():
2155+
# Supervisor forbids an addon from updating itself directly
2156+
# (supervisor/api/store.py: "App {slug} can't update itself!").
2157+
# Workaround: call HA Core's update.install service, which routes
2158+
# the request through Core so Supervisor sees Core as the caller.
2159+
addon_info, info_error = _call_supervisor('GET', '/addons/self/info')
2160+
if info_error:
2161+
return jsonify({
2162+
'error': f'Failed to determine Home Assistant addon slug: {info_error}'
2163+
}), 502
2164+
2165+
addon_slug = addon_info.get('slug')
2166+
if not addon_slug:
2167+
return jsonify({'error': 'Failed to determine Home Assistant addon slug'}), 502
2168+
2169+
token = os.environ.get('SUPERVISOR_TOKEN', '')
2170+
entity_id, lookup_error = _find_addon_update_entity(addon_slug, token)
2171+
if lookup_error:
2172+
return jsonify({'error': lookup_error}), 502
2173+
2174+
# Refresh HA Core's update entity so its latest_version is current.
2175+
# Without this, update.install fails with "No update available" when
2176+
# installed_version == latest_version (cached state right after a
2177+
# version bump). The update_entity service is fire-and-forget — it
2178+
# triggers a debounced coordinator refresh and returns immediately,
2179+
# so we then poll the entity state until the new latest_version
2180+
# propagates. (We avoid /store/reload because it kicks off
2181+
# addon-group jobs that race with update.install.)
2182+
try:
2183+
requests.post(
2184+
f"{_HA_CORE_API_BASE}/services/homeassistant/update_entity",
2185+
headers={"Authorization": f"Bearer {token}"},
2186+
json={"entity_id": entity_id},
2187+
timeout=15,
2188+
)
2189+
except requests.RequestException as e:
2190+
logger.warning("Failed to refresh HA update entity (continuing)", extra={
2191+
'entity_id': entity_id,
2192+
'error': str(e),
2193+
})
2194+
2195+
deadline = time.monotonic() + _HA_ENTITY_POLL_TIMEOUT_SECONDS
2196+
entity_ready = False
2197+
while True:
2198+
try:
2199+
state_resp = requests.get(
2200+
f"{_HA_CORE_API_BASE}/states/{entity_id}",
2201+
headers={"Authorization": f"Bearer {token}"},
2202+
timeout=5,
2203+
)
2204+
if state_resp.ok:
2205+
attrs = state_resp.json().get('attributes', {})
2206+
installed = attrs.get('installed_version')
2207+
latest = attrs.get('latest_version')
2208+
if installed and latest and installed != latest:
2209+
entity_ready = True
2210+
break
2211+
except requests.RequestException:
2212+
pass
2213+
if time.monotonic() >= deadline:
2214+
break
2215+
time.sleep(_HA_ENTITY_POLL_INTERVAL_SECONDS)
2216+
2217+
if not entity_ready:
2218+
return jsonify({
2219+
'error': 'Home Assistant has not yet refreshed the addon update state. Try again in a moment.'
2220+
}), 502
2221+
2222+
# HA Core's REST service call blocks until update.install
2223+
# finishes, but installing OUR addon kills this process mid-flight
2224+
# — so a ReadTimeout or ConnectionError after dispatch is the
2225+
# expected outcome, not a failure. Only treat HTTP error responses
2226+
# (which arrive before Supervisor swaps us out) as real failures.
2227+
try:
2228+
resp = requests.post(
2229+
f"{_HA_CORE_API_BASE}/services/update/install",
2230+
headers={"Authorization": f"Bearer {token}"},
2231+
json={"entity_id": entity_id},
2232+
timeout=10,
2233+
)
2234+
resp.raise_for_status()
2235+
except (requests.ConnectionError, requests.Timeout) as e:
2236+
logger.info(
2237+
"HA addon update dispatched; connection closed as expected during self-update",
2238+
extra={'entity_id': entity_id, 'error': str(e)},
2239+
)
2240+
except requests.RequestException as e:
2241+
response = getattr(e, 'response', None)
2242+
core_message = None
2243+
if response is not None:
2244+
try:
2245+
core_message = response.json().get('message')
2246+
except (ValueError, AttributeError):
2247+
core_message = None
2248+
logger.error("Failed to trigger HA addon update", extra={
2249+
'error': str(e),
2250+
'status_code': getattr(response, 'status_code', None),
2251+
'response_text': (getattr(response, 'text', None) or '')[:500],
2252+
})
2253+
error_message = 'Failed to trigger Home Assistant addon update'
2254+
if core_message:
2255+
error_message = f'{error_message}: {core_message}'
2256+
return jsonify({'error': error_message}), 502
2257+
2258+
logger.info("HA addon update triggered via Core service", extra={
2259+
'entity_id': entity_id,
2260+
})
2261+
return jsonify({
2262+
'status': 'update_triggered',
2263+
'message': 'Home Assistant addon update initiated.',
2264+
'estimated_downtime': '2-5 minutes',
2265+
}), 200
2266+
20872267
# Load current version info for logging
20882268
version_info = load_version_info()
20892269

@@ -2123,16 +2303,9 @@ def trigger_system_update():
21232303
def trigger_service_restart():
21242304
"""Trigger service restart for native mode or HA add-on mode."""
21252305
if is_home_assistant_mode():
2126-
token = os.environ.get('SUPERVISOR_TOKEN', '')
2127-
try:
2128-
resp = requests.post(
2129-
"http://supervisor/addons/self/restart",
2130-
headers={"Authorization": f"Bearer {token}"},
2131-
timeout=10,
2132-
)
2133-
resp.raise_for_status()
2134-
except requests.RequestException as e:
2135-
logger.error("Failed to restart HA add-on", extra={'error': str(e)})
2306+
_data, error = _call_supervisor('POST', '/addons/self/restart')
2307+
if error:
2308+
logger.error("Failed to restart HA add-on", extra={'error': error})
21362309
return jsonify({'error': 'Failed to restart Home Assistant add-on'}), 502
21372310
logger.info("Home Assistant add-on restart triggered via API")
21382311
return jsonify({

0 commit comments

Comments
 (0)