1111from awesomeversion import AwesomeVersion
1212import voluptuous as vol
1313
14- from homeassistant .const import __short_version__ as current_version
14+ from homeassistant .const import (
15+ EVENT_CORE_CONFIG_UPDATE ,
16+ __short_version__ as current_version ,
17+ )
1518from homeassistant .core import (
19+ Event ,
1620 HomeAssistant ,
1721 Service ,
1822 ServiceCall ,
2933 async_register_admin_service ,
3034 async_set_service_schema ,
3135)
36+ from homeassistant .helpers .translation import (
37+ _async_get_translations_cache ,
38+ async_get_cached_translations ,
39+ async_get_translations ,
40+ )
3241from homeassistant .loader import async_get_integration
3342
3443from .const import DOMAIN , LOGGER
3544
3645if TYPE_CHECKING :
46+ from collections .abc import Callable
3747 from types import ModuleType
3848
3949
4050_EntityT = TypeVar ("_EntityT" , bound = Entity , default = Entity )
51+ GHOST = "👻"
52+ SERVICE_TRANSLATION_CATEGORY = "services"
4153
4254
4355class AbstractSpookServiceBase (ABC ):
@@ -252,6 +264,10 @@ class SpookServiceManager:
252264
253265 _services : set [AbstractSpookService ] = field (default_factory = set )
254266 _service_schemas : dict [str , Any ] = field (default_factory = dict )
267+ _service_translation_overrides : dict [tuple [str , str , str ], str | None ] = field (
268+ default_factory = dict
269+ )
270+ _translation_listener : Callable [[], None ] | None = None
255271
256272 def __post_init__ (self ) -> None :
257273 """Post initialization."""
@@ -318,6 +334,12 @@ def _load_all_service_modules() -> None:
318334
319335 self .async_register_service (service )
320336
337+ await self .async_inject_service_translations ()
338+ self ._translation_listener = self .hass .bus .async_listen (
339+ EVENT_CORE_CONFIG_UPDATE ,
340+ self ._async_core_config_updated ,
341+ )
342+
321343 @callback
322344 def async_register_service (self , service : AbstractSpookService ) -> None :
323345 """Register a Spook service."""
@@ -343,10 +365,168 @@ def async_register_service(self, service: AbstractSpookService) -> None:
343365 schema = service_schema ,
344366 )
345367
368+ @callback
369+ def _service_schema_key (self , service : AbstractSpookService ) -> str :
370+ """Return the services.yaml key for a Spook service."""
371+ if service .domain == DOMAIN :
372+ return service .service
373+ return f"{ service .domain } _{ service .service } "
374+
375+ @callback
376+ def _service_translation_strings (
377+ self ,
378+ service : AbstractSpookService ,
379+ cached_spook_translations : dict [str , str ],
380+ ) -> dict [str , str ]:
381+ """Return service translation strings mapped to the target domain."""
382+ schema_key = self ._service_schema_key (service )
383+ spook_prefix = f"component.{ DOMAIN } .services.{ schema_key } ."
384+ target_prefix = f"component.{ service .domain } .services.{ service .service } ."
385+
386+ return {
387+ f"{ target_prefix } { key .removeprefix (spook_prefix )} " : (
388+ f"{ value } { GHOST } "
389+ if key == f"{ spook_prefix } name" and GHOST not in value
390+ else value
391+ )
392+ for key , value in cached_spook_translations .items ()
393+ if key .startswith (spook_prefix )
394+ }
395+
396+ @callback
397+ def _translation_component_cache (
398+ self ,
399+ language : str ,
400+ domain : str ,
401+ * ,
402+ create : bool = False ,
403+ ) -> dict [str , str ] | None :
404+ """Return the Home Assistant translation cache for a component."""
405+ translations_cache = _async_get_translations_cache (self .hass )
406+
407+ try :
408+ cache = translations_cache .cache_data .cache
409+ except AttributeError :
410+ LOGGER .warning (
411+ "Unable to access Home Assistant's translation cache, "
412+ "skipping Spook service translation update"
413+ )
414+ return None
415+
416+ if not isinstance (cache , dict ):
417+ LOGGER .warning (
418+ "Home Assistant's translation cache has an unexpected structure, "
419+ "skipping Spook service translation update"
420+ )
421+ return None
422+
423+ if create :
424+ return (
425+ cache .setdefault (language , {})
426+ .setdefault (
427+ SERVICE_TRANSLATION_CATEGORY ,
428+ {},
429+ )
430+ .setdefault (domain , {})
431+ )
432+
433+ return cache .get (language , {}).get (SERVICE_TRANSLATION_CATEGORY , {}).get (domain )
434+
435+ @callback
436+ def _inject_service_translation_strings (
437+ self ,
438+ service : AbstractSpookService ,
439+ cached_spook_translations : dict [str , str ],
440+ ) -> None :
441+ """Inject service translation strings into Home Assistant's cache."""
442+ language = self .hass .config .language
443+ component_cache = self ._translation_component_cache (
444+ language ,
445+ service .domain ,
446+ create = True ,
447+ )
448+ if component_cache is None :
449+ return
450+
451+ cached_translations = async_get_cached_translations (
452+ self .hass ,
453+ language ,
454+ SERVICE_TRANSLATION_CATEGORY ,
455+ service .domain ,
456+ )
457+
458+ for key , value in self ._service_translation_strings (
459+ service ,
460+ cached_spook_translations ,
461+ ).items ():
462+ self ._service_translation_overrides .setdefault (
463+ (language , service .domain , key ), cached_translations .get (key )
464+ )
465+ component_cache [key ] = value
466+
467+ async def async_inject_service_translations (self ) -> None :
468+ """Inject Spook service strings into Home Assistant translations."""
469+ services = [
470+ service
471+ for service in self ._services
472+ if self ._service_schema_key (service ) in self ._service_schemas
473+ ]
474+
475+ if not services :
476+ return
477+
478+ await async_get_translations (
479+ self .hass ,
480+ self .hass .config .language ,
481+ SERVICE_TRANSLATION_CATEGORY ,
482+ {DOMAIN , * (service .domain for service in services )},
483+ )
484+ cached_spook_translations = async_get_cached_translations (
485+ self .hass ,
486+ self .hass .config .language ,
487+ SERVICE_TRANSLATION_CATEGORY ,
488+ DOMAIN ,
489+ )
490+
491+ for service in services :
492+ self ._inject_service_translation_strings (
493+ service ,
494+ cached_spook_translations ,
495+ )
496+
497+ async def _async_core_config_updated (self , event : Event ) -> None :
498+ """Re-inject service translations when the language changes."""
499+ if "language" not in event .data :
500+ return
501+ await self .async_inject_service_translations ()
502+
503+ @callback
504+ def async_clear_service_translation_overrides (self ) -> None :
505+ """Restore translation strings that were overridden by Spook."""
506+ for (
507+ language ,
508+ domain ,
509+ key ,
510+ ), original_value in self ._service_translation_overrides .items ():
511+ component_cache = self ._translation_component_cache (language , domain )
512+ if component_cache is None :
513+ continue
514+
515+ if original_value is None :
516+ component_cache .pop (key , None )
517+ else :
518+ component_cache [key ] = original_value
519+
520+ self ._service_translation_overrides .clear ()
521+
346522 @callback
347523 def async_on_unload (self ) -> None :
348524 """Tear down the Spook services."""
349525 LOGGER .debug ("Tearing down Spook services" )
526+ if self ._translation_listener :
527+ self ._translation_listener ()
528+ self ._translation_listener = None
529+
350530 for service in self ._services :
351531 LOGGER .debug (
352532 "Unregistering service: %s.%s" ,
@@ -373,3 +553,5 @@ def async_on_unload(self) -> None:
373553
374554 # Flush service description schema cache
375555 self .hass .data .pop (SERVICE_DESCRIPTION_CACHE , None )
556+
557+ self .async_clear_service_translation_overrides ()
0 commit comments