@@ -200,6 +200,57 @@ def decorated_function(*args, **kwargs):
200200UPDATE_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+
203254def _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):
12321283HA_SOURCE_COMMIT_ENV = "BIRDNET_PIPY_SOURCE_COMMIT"
12331284HA_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+
12351291def 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():
21232303def 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