appdaemon icon indicating copy to clipboard operation
appdaemon copied to clipboard

Global Names for Entity Identification

Open boriken72 opened this issue 7 months ago • 12 comments

Is there an existing feature request for this?

  • [x] I have searched the existing issues

Your feature request

I would like to request, if possible, the ability to define in app daemon a global name for a home assistant entity. THe user would then be able to use this global name as the reference to this entity in all apps. If the entity name changes in home assistant, be it because the sensor was replaced or failed, then the user would simply update the global name in 1 location and which would then be reflected correctly across all apps. This feature would remove the need to manually update all apps referencing that changed entity, it would also greatly reduce user error by removing the possibility that they forget to update it in any of the apps referencing the entity.

boriken72 avatar May 26 '25 16:05 boriken72

Not sure if I understand correctly, but can't you define constants for your entities in a global app and reference it from there? In 4.5.0 you don't even need a global app for that anymore, you can simply import a global "entities.py" file in your apps and reference the constant(s).

Thats exactly what I did as well when I started out. I have since then migrated to create individual apps for each entity category/type (switch, light, climate etc.) and I am now referencing these apps instead of the "raw" constants. This lets me access common functionality of these apps as well, like "setting a climate temperature", or "closing the blinds" without the need to remember the exact service call needed.

markusressel avatar May 26 '25 19:05 markusressel

I have a door sensor, it's used in my front light, my hallway light, my alarm and a couple of other apps, so there are around 6 total. So rather than having to supply my "entity id" in appdaemon in six different places I would simply create a global alias, as you suggested, with the exception I would not have to import it anywhere it would just be "available" as other global entries are. So the idea is that when I have to replace my sensor instead of having to update my sensor entity in 6 places I would do it in the global reference.

This would simplify maintaining the apps and prevent any user error in the future when names change.

--Juan

On Mon, May 26, 2025 at 3:11 PM Markus Ressel @.***> wrote:

markusressel left a comment (AppDaemon/appdaemon#2276) https://github.com/AppDaemon/appdaemon/issues/2276#issuecomment-2910488330

Not sure if I understand correctly, but can't you define constants for your entities in a global app and reference it from there? In 4.5.0 you don't even need a global app for that anymore, you can simply import a global "entities.py" file in your apps and reference the constant.

Thats exactly what I did as well when I started out. I have since then migrated to create individual apps for each entity category/type (switch, light, climate etc.) and I am now referencing these apps instead of the "raw" constants. This lets me access common functionality of these apps as well, like "setting a climate temperature", or "closing the blinds" without the need to remember the exact service call needed.

— Reply to this email directly, view it on GitHub https://github.com/AppDaemon/appdaemon/issues/2276#issuecomment-2910488330, or unsubscribe https://github.com/notifications/unsubscribe-auth/AF5RMOQ4MR4M2RANE43YIT33ANRN7AVCNFSM6AAAAAB56BGUPWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSMJQGQ4DQMZTGA . You are receiving this because you authored the thread.Message ID: @.***>

-- Juan Concepcion @.***

boriken72 avatar May 27 '25 01:05 boriken72

I think this is similar to what @acockburn does, with a HAL (hardware abstraction layer):

# hal.py
HAL_LIVING_ROOM_MOTION = 'binary_sensor.living_room_motion_occupancy5'

Then in your apps you would import it like this, which would presumably happen in many places:

# app1.py
from appdaeon.adapi import ADAPI

import hal


class App1(ADAPI):
    def initialize(self):
        self.log(f'{self.__class__.__name__} Initialized')
        self.motion_sensor = self.get_entity(hal.GLOBAL_LIVING_ROOM_MOTION)
        self.log(f'Motion sensor state: {self.motion_sensor.get_state()}')

Then if you ever need to change the entity, you change it in that single place (hal.py)

jsl12 avatar May 27 '25 02:05 jsl12

Conceptually that's similar to what I do but not exactly. I have a lookup table in ,my HAL module for all of the entities, and additionally I have methods that hide the use of the variables like:

self.hal = HAL(self)
...
self.hal.listen_state(self.hvac_action_cb, "some_entity_reference_in_my_lookup_table", attribute="hvac_action")

Then I change the values in my table if actual entity names change.

Either way @boriken72 you will have a ton of work initially building your registry and converting all your apps to use it. I see value in the concept which is why I built it, but the conversion process is not for the faint-hearted!

acockburn avatar May 27 '25 12:05 acockburn

Possible to provide an example??  I don’t mind work - keeps my mind busy 😬Sent from my iPhoneOn May 27, 2025, at 8:47 AM, Andrew Cockburn @.***> wrote:acockburn left a comment (AppDaemon/appdaemon#2276) Conceptually that's exactly what I do, except additionally I have methods that hide the use of the variables like: self.hal = HAL(self) ... self.hal.listen_state(self.hvac_action_cb, self.device, attribute="hvac_action") Either way @boriken72 you will have a ton of work initially building your registry and converting all your apps to use it. I see value in the concept which is why I built it, but the conversion process is not for the faint-hearted!

—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you were mentioned.Message ID: @.***>

boriken72 avatar May 27 '25 13:05 boriken72

What do you want to see? I gave you a basic example in the last post, it doesn't get more complex than that :)

If you want the actual HAL code, well that's several hundred lines.

acockburn avatar May 27 '25 19:05 acockburn

If you want the actual HAL code, well that's several hundred lines.

So it is more complex than that 😄

I would simply create a global alias, as you suggested, with the exception I would not have to import it anywhere it would just be "available" as other global entries are.

Whats wrong with importing something? We are using python here, after all 😄 Writing a line like this in each file that needs to reference entities (which is probably pretty much any file), is not a "good enough" solution?

from entities import *

Lookup

I don't use this kind of lookup myself, and I wouldn't really recommend it either (I would recommend constants or the app approach I mentioned earlier), but I guess it would look something like this (WARNING untested code!)

class HAL:

    entity_alias_mapping = {
        "living_room_ceiling_light": "light.living_room_ceiling",
    }

    def __init__(self, app):
        self.app = app

    def listen_state(self, cb: callable, alias: str, **kwargs):
        self.app.listen_state_and_call_immediately(
            callback=cb,
            entity_id=self.entity_alias_mapping[alias],
            **kwargs
        )

class YourApp(hass.Hass):

    def initialize(self):
        self.hal = HAL(self)
        self.hal.listen_state(self.on_state_change, "living_room_ceiling_light")

    def on_state_change(self, entity, attribute, old, new, kwargs):
        self.log(f"State changed for {entity}: {old} -> {new}")
        # Add your logic here

Pros:

  • no need to remember the entity id
  • updating the entity id updates all references of the alias

Cons:

  • need to remember the alias, instead of the entity id
  • you still need to correctly use the alias, unless using a constant, at which point you can also use a constant for the entity id itself
  • no compile time safety, specifying an invalid alias will throw an error at runtime
  • need to instantiate and maintain a separate class where the alias mapping is defined, and also wrapper functions to lookup the alias
  • appdaemon functions cannot be used directly anymore, the official documentation of appdaemon cannot be used as reference anymore

Constants

This is the same thing with constants:

consts.py

LIVING_ROOM_CEILING_LIGHT_ENTITY = "light.living_room_ceiling"

yourapp.py

from consts import *

class YourApp(hass.Hass):

    def initialize(self):
        self.listen_state(self.on_state_change, LIVING_ROOM_CEILING_LIGHT_ENTITY)

    def on_state_change(self, entity, attribute, old, new, kwargs):
        self.log(f"State changed for {entity}: {old} -> {new}")
        # Add your logic here

Pros:

  • no need to remember the entity id
  • importing all available constants is easy in a single line
  • updating the id updates all usages
  • no need to create and maintain a separate alias mapping class (the name of the constant already is the alias) and no need to write "extensions"/wrappers on existing appdaemon functions
  • constant can be refactored and automatically update in all places
  • compiler can suggest constants and complain if it doesn't exist

Cons:

  • not the most structured way to remember entity ids
  • no information about what kind of states and service calls the entity supports

App for each Device Type

NOTE: Since I am using the async variants myself, the method signatures use the async and await keywords. This should not be a requirement though, since AppDaemon also supports using sync methods. Make adjustments as needed.

apps.yaml

living_room_ceiling_light:
  module: light
  class: Light
  args:
    entity: light.living_room_ceiling

living_room:
  module: room
  class: LivingRoom
  dependencies:
    - living_room_ceiling_light

consts.py

KEY_ARGS = "args"
KEY_ENTITY = "entity"

light.py

from consts import *

class Light(hass.Hass):

    @property
    def entity(self) -> str:
        return self.args[KEY_ARGS][KEY_ENTITY]

    async def initialize(self):
        pass

    async def turn_on(self):
        await self.call_service("light/turn_on", entity_id=self.entity)

    async def turn_off(self):
        await self.call_service("light/turn_off", entity_id=self.entity)

room.py

from light import Light

class LivingRoom(hass.Hass):
    
    async def initialize(self):
        self._ceiling_light: Light = await self.get_app("living_room_ceiling_light")
        await self.listen_state(self._on_ceiling_light_state_changed, entity_id=self._ceiling_light.entity)

        await self._ceiling_light.turn_on()

    async def _start_reset_user_overrides_timer(self, entity, attribute, old, new, kwargs):
        pass

Pros:

  • no need to remember the entity id, its specified once in the app definition
  • updating the id updates all usages
  • sub-entities of a device can be automatically determined based on device logic (f.ex. sensor.x and sensor.x_battery_level)
  • changes to the logic of a device affects all instances of this type
  • has all the information about what kind of states and service calls the entity supports, without the need to actually know how to call the service (well only once when implementing the device type)
  • compiler can suggest available functions and properties for a device
  • no need to wrap existing AppDaemon functions (if you don't count tun_on/turn_off in the example above as "wrapper")
  • separation of concerns, makes it much easier to reason about the code

Cons:

  • needs a lot more app definitions, but they can usually be copied easily
  • app name is not a constant, so typos are possible
  • compiler cannot complain if an app doesn't exist, however "fetching" them on initialize will reveal errors quickly
  • at least in my setup the current dependency logic of appdaemon (as of 4.5.0) is not smart enough to figure out all dependencies correctly by itself, so a lot of dependencies between apps have to be specified
  • typing is available, but has to be provided by the user, since get_app cannot provide the type automatically

markusressel avatar May 28 '25 01:05 markusressel

That's the idea! I do appreciate the response, but I want to make a few points in case this gets some visibility:

  • Using import * works, but is generally frowned upon in Python. Instead, it's better to use either of these forms

    from consts import LIVING_ROOM_CEILING_LIGHT_ENTITY
    ...
    LIVING_ROOM_CEILING_LIGHT_ENTITY # use name as-is
    
    import consts as c
    ...
    c.LIVING_ROOM_CEILING_LIGHT_ENTITY  # use with a preceding name
    
  • We generally discourage people from using async methods, unless absolutely necessary. AppDaemon already uses some sleight of hand to hide what are actually async calls behind the scenes. Using async methods puts the execution in the main thread instead of in an app thread, which can be dangerous, but it also forces you to await lots of things in the methods themselves. Many of those things are currently async and don't need to be. There's some technical debt there, and we plan on combing a lot of that out in the near future.

  • I'd encourage using the @property decorator, but a little differently. Maybe something like this:

    from appdaemon.adbase import ADBase
    from appdaemon.entity import Entity
    
    from consts import KEY_ARGS, KEY_ENTITY
    
    
    class LivingRoom(ADBase):
        def initialize(self):
            self.adapi = self.get_ad_api()
            self.entity.listen_state(self.state_callback, new='on')
    
        @property
        def entity(self) -> Entity:
            entity_id = self.args[KEY_ARGS][KEY_ENTITY]
            return self.adapi.get_entity(entity_id)
    
        @property
        def light_on(self) -> bool:
            return self.entity.get_state() == 'on'
    
        def state_callback(self, *args: str, **kwargs) -> None:
            ... # do some stuff
    

jsl12 avatar May 28 '25 03:05 jsl12

I really do appreciate all the feedback and the @property setup is a bit more advanced python than I currently understand.

I supposed I could just go the constants route and that would satisfy my ask. Intent was for something a little simple, like using the globals.py, to setup my alias and not really have to do anything at the app layer, not out of laziness but to minimize the number of file touches that would be required if I had an entity change. Setting up a .py file is also possible.

Any good reading reference points so I can understand the @property method and maybe then I could incorporate something like this into my code??

With that said I guess I will weight my options on how to address my need and this can be closed.

On Tue, May 27, 2025 at 11:25 PM John Lancaster @.***> wrote:

jsl12 left a comment (AppDaemon/appdaemon#2276) https://github.com/AppDaemon/appdaemon/issues/2276#issuecomment-2914776220

That's the idea! I do appreciate the response, but I want to make a few points in case this gets some visibility:

Using import * works, but is generally frowned upon https://stackoverflow.com/a/2360808 in Python. Instead, it's better to use either of these forms

from consts import LIVING_ROOM_CEILING_LIGHT_ENTITY ...LIVING_ROOM_CEILING_LIGHT_ENTITY # use name as-is

import consts as c ...c.LIVING_ROOM_CEILING_LIGHT_ENTITY # use with a preceding name

We generally discourage https://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#async-apps people from using async methods, unless absolutely necessary. AppDaemon already uses some sleight of hand to hide what are actually async calls behind the scenes. Using async methods puts the execution in the main thread instead of in an app thread, which can be dangerous, but it also forces you to await lots of things in the methods themselves. Many of those things are currently async and don't need to be. There's some technical debt there, and we plan on combing a lot of that out in the near future.

I'd encourage using the @property decorator, but a little differently. Maybe something like this:

from appdaemon.adbase import ADBasefrom appdaemon.entity import Entity from consts import KEY_ARGS, KEY_ENTITY

class LivingRoom(ADBase): def initialize(self): self.adapi = self.get_ad_api() self.entity.listen_state(self.state_callback, new='on')

   @property
   def entity(self) -> Entity:
       entity_id = self.args[KEY_ARGS][KEY_ENTITY]
       return self.adapi.get_entity(entity_id)

   @property
   def light_on(self) -> bool:
       return self.entity.get_state() == 'on'

   def state_callback(self, *args: str, **kwargs) -> None:
       ... # do some stuff

— Reply to this email directly, view it on GitHub https://github.com/AppDaemon/appdaemon/issues/2276#issuecomment-2914776220, or unsubscribe https://github.com/notifications/unsubscribe-auth/AF5RMOQ7D5GCPSACJKD3J6D3AUUBDAVCNFSM6AAAAAB56BGUPWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSMJUG43TMMRSGA . You are receiving this because you were mentioned.Message ID: @.***>

-- Juan Concepcion @.***

boriken72 avatar May 28 '25 04:05 boriken72

I don't mean this as a RTFM 😆, but the python docs themselves are generally pretty good. Otherwise, I really like RealPython

Essentially it lets you put a function behind what would otherwise be a static attribute, which can enable some slick things.

jsl12 avatar May 28 '25 04:05 jsl12

Using import * works, but is generally frowned upon in Python.

That is correct. However, there is an exception to every rule, and as long as you stick to "your own" convention of only defining constants in a file thats called consts.py, none of the reasons mentioned in the Stackoverflow post as to why importing using the star notation is problematic applies. I have been using this ever since I started to work with AppDaemon without ever having issues of any sort, and I'd argue the simplicity and practicality when working within the code of apps is a worthy trade-off in this case, but this is subjective, of course.

We generally discourage people from using async methods, unless absolutely necessary.

I agree, just to clarify, I need it because of third party libs, which is one of the reasons to use it also mentioned in the AppDaemon docs (see async-advantages), but I would not recommend it for beginners, as the pitfalls mentioned on the same page have bitten my a** multiple times 😄 but I am fine with that.

markusressel avatar May 28 '25 12:05 markusressel

Agreed, on all counts! The thing with the imports is indeed subjective, and isn't problematic in this case, as you've laid out. Using async stuff is totally valid if you've got a reason, but I've also fallen into pits myself using them 😆

jsl12 avatar May 28 '25 13:05 jsl12