Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: area service #46

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft

Conversation

kylegl
Copy link

@kylegl kylegl commented Dec 18, 2024

📬 Changes

Manages the state of an area, determining whether it is "occupied" or "vacant" based on presence triggers or a custom callback. Optionally integrates with scene controllers to adjust scenes based on area state.

  • ...

🗒️ Checklist

  • Read the contribution guide and accept the
    code of conduct
  • Readme and docs (updated or not needed)
  • Tests (added, updated or not needed)

@kylegl
Copy link
Author

kylegl commented Dec 18, 2024

Example usage:

import type { TServiceParams } from '@digital-alchemy/core'
import { defineAreaConfig } from '../areaController'

export function OfficeController(context: TServiceParams) {
  const { hass } = context

  const officeMotion = hass.refBy.id('binary_sensor.office_motion')
  const officeLight = hass.refBy.id('light.office')
  const outsideLuxSensor = hass.refBy.id('sensor.outside_lux_level')
  const homeModeSensor = hass.refBy.id('sensor.home_mode')

  const activateSceneCondtions = [
    () => outsideLuxSensor.state !== 'bright',
    () => homeModeSensor.state !== 'away',
  ]

  const officeLights = [officeLight]

  defineAreaConfig(context, {
    name: 'Office',
    triggers: [officeMotion],
    scenes: {
      triggers: [outsideLuxSensor],
      conditions: activateSceneCondtions,
      definitions: [
        {
          name: 'default',
          on: [() => hass.refBy.id('scene.office_default').turn_on({ transition: 7 })],
          timeout: () =>
            ['home', '1_home', '1_sleep'].includes(homeModeSensor.state)
              ? { minutes: 30 }
              : { seconds: 10 },
          isDefault: true,
        },
        {
          name: 'sleep',
          on: [() => hass.refBy.id('scene.office_sleep').turn_on({ transition: 1 })],
          timeout: { minutes: 1 },
        },
        {
          name: 'chill',
          on: [() => hass.refBy.id('scene.office_chill').turn_on({ transition: 3 })],
          timeout: { minutes: 30 },
        },
      ],
      off: () => officeLights.forEach(light => light.turn_off({ transition: 3 })),
    },
  }, { triggerType: 'motion' })
}

@kylegl kylegl marked this pull request as draft December 18, 2024 02:50
@zoe-codez
Copy link
Member

zoe-codez commented Dec 18, 2024

General

Interesting approach, so this seems like a functional approach to defining scenes & their various interactions, where the other one is more declarative. The data structure doesn't work well for type validation, so I am mostly looking at it from that (using some techniques from hass / synapse) and how this might be most useful for both simple and complex use cases

Current API

Definitions

You have it defined as an array, with a name property. It allows for name collisions, which can be less than ideal for devs who may accidentally set it up in an invalid way
If you use an object, the key can deduplicate the names and provide a little more structure to the definition

{
  definitions: {
    [name]: {
      on, timeout, friendly_name
    }
  }
}

The difference being that the name / key could be used to generate relevant entity_ids (via suggested_object_id), where friendly_name could be provided as the human readable version to to hass

Timeout

You have hour / minute as an object

timeout: () =>['home', '1_home', '1_sleep'].includes(homeModeSensor.state)
  ? { minutes: 30 } : { seconds: 10 },

You can find the ISO_8601_PARTIAL or OffsetTypes as an existing standard for time based offset in this repo. Gets used with solar offset math, but it'd be convenient to reuse here

Operations being performed

Breaking down high level concerns I'm seeing here

  1. define scenes
    • static definitions
    • dynamic definition
  2. rules engine & state resolution
  3. hooks
  4. metadata & hass related bindings

@zoe-codez
Copy link
Member

zoe-codez commented Dec 18, 2024

Proposed structure

This as a structure would allow for a more flexible approach to types, and consolidate those individual operations slightly differently. Bit of chicken scratch / part way done for vibes

ManageArea({
  // simple metadata
  area_id: TAareaId,

  // definitions
  definitions: {
    [scene_id]: {
      friendly_name: string,
      // replace = this scene becomes primary scene when registered
      // merge = this definition modifies 
      type: "replace" | "merge"
      hooks: {
        // registered hooks from lib 
        pre: { type: "delay", duration: "5s" },
        // pass as async function
        post: () => sleep(5000),
        // issue service calls / etc
      },

      // can be empty if you only want access to hooks
      definition: { ... current room definition / static },
    },
  },

  // related states
  flags: {
    [flag_name]: {
      onUpdate: entity_ref[],
      value: () => boolean
    },
    occupied: {
      onUpdate: [motionSensor, houseMode],
      value: () => motionSensor.state === "" && .....
    }
  },

  // 
  rules: {
    default: "scene_id",
    rules: [

    ]
  }
})

You can use generic types to extract keys out of objects pretty easy, and use them as string unions elsewhere (like with the default scene near the bottom.


This covers functionality from both the current rooms, as well as your dynamic triggers. The base scenes can type into rooms, and provide the same "eventually correct" logic. Where it will detect where things are different from their intended / declared state, and correct those after the fact

Also gives access to hooks, which can refine the way a transition is implemented. Call a service, add a delay, log some stuff 🤷🏻‍♀️

You could even have one that does a delay on the pre, calls a service on the post, and has an empty definition. I believe that'd be close to some of the use cases you have going on here?


Additional metadata ideas:

ManageArea({
// feature flags for how to generate helper entities
  generate: {
  		sceneSelect: true,
       flagSwitches: true,
       sceneEntities: true,
       currentSceneSensor: true,
       circadian: true,
  } 
}) 

@kylegl
Copy link
Author

kylegl commented Dec 19, 2024

Object over array for scene definitions makes sense. I didn't consider scene id conflicts. ✅

What would be the possible flags in the proposed structure?

In the additional metadata ideas. For ui controls like (switch or select) I think instead of boolean flags it could be beneficial to have one option like

options:   {
	controls: 'switch' | 'select' | ({ setScene, activeScene }) => your own custom ui control adapter
}

other notes:

ManageArea({
// feature flags for how to generate helper entities
  generate: {
  		sceneSelect: true, 
       flagSwitches: true,
       sceneEntities: true, // could this be inferred based on whether they include the scene config params?
       currentSceneSensor: true, 
       circadian: true, // could this be a per scene option? Like some scenes use it some don't?
  } 
}) 

thoughts?

@zoe-codez
Copy link
Member

zoe-codez commented Dec 19, 2024

It'd be great if the scene definitions can be calculated via function also

definition: {
  high: {
   definition: () => {
	return evening? FULL_BRIGHTNESS : 80_PCT
   }
  }
}

A big headache in my own flows is needing a high & evening_high, and needing to pick which button to push. This would give only 1 button, and shift the control over to the implementation.

As a neat side effect, the definition of "high" can be made to transition gradually (across a period of minutes) by doing fancy math in the definition. Would make some slower / more gradual effects much easier to pull off without having another random turn_on command interrupt it


What would be the possible flags in the proposed structure?

Can be used to create calculated states, like the local storage that can be done with synapse.locals. Like occupied, which can have a value calculated based on multiple sensors

  • at home +
  • mode === sleep
  • or scene != "off" && recent motion sensor

These could optionally generate sensor entities on behalf of the dev, and automatically associate them with the room's device.


Rooms & synapse entities provide a return value. The problem is that return value cannot be used within a callback, a variable would indirectly be used in it's own definition. Typescript would respond by silently turning values to any, even though the code works.

For example: this looks to be valid code at first glance, and may or may not produce build errors (seen it both ways depending on details), but entity will turn into any either way.

const entity = synapse.binary_sensor({
  is_on() {
	return !entity.is_on;
  }
})

In order to work around, the callbacks need to provide data as params into the callback. Here is an extended example to show a more complex interaction

CreateRoom({
  flags: {
    sleeping: {
      type: "boolean",
      default: false,
    },
    occupied: {
      type: "string",
      onUpdate: [isHomeSensor, motionSensor],
      value: ({ scene, flags }) =>
        isHomeSensor.is_on === false ||
        flags.sleeping ||
        (scene !== "off" && motionSensor.state === "on"),
    },
  },
  definition: {
    high: {
      onUpdate: [isEveningSensor],
      definition() {
        return isEveningSensor.is_on ? FULL_BRIGHTNESS : PARTIAL_BRIGHTNESS;
      },
    },
    auto: {
      definition({ flags }) {
        return flags.occupied ? PARTIAL_BRIGHTNESS : OFF;
      },
    },
  },
});

Functions typically run on a 30s interval, onUpdate for provided entities, and when something about itself changes.

In the case of sleeping -

const room = CreateRoom(...);

// available to assign this way
room.flags.sleeping = true;

@zoe-codez
Copy link
Member

zoe-codez commented Dec 19, 2024

On the other stuff, def! Like options that allow rolling your own / calculated implementation and putting control where it's most relevant.

There's a few spots in the project where you can accomplish the same thing 3 ways, but the implications are different depending on where you put the flag. No issues with adding to that list, haha

@kylegl
Copy link
Author

kylegl commented Dec 19, 2024

Thats a good call there should definitely be a way to auto switch scenes and/or transition between them. Still a little confused on what flags are. Are those like conditions or option toggles?

@kylegl
Copy link
Author

kylegl commented Dec 19, 2024

Did a little rework of the input types. still room for improvement.

  • changed scene definitions input to object
  • deleted triggerType option and replaced with
  presence?: {
    sensors: PresenceControllerInput['sensors']
  }

that way if its defined we know to use the presenceController

  • added friendlyName
  • attempted to type action, condtion, trigger (having trouble with this one).
  • fixed the area state sensor typed as any
  • added option to provide your own ui controls adapter

should circadian option be in the scene definition?

@zoe-codez
Copy link
Member

Still a little confused on what flags are. Are those like conditions or option toggles?

Variables attached to the room basically. They can be reflected as entities in home assistant as the most appropriate entity type

Type Entity
boolean switch, binary_sensor
string text, sensor
enum select, sensor
number number, sensor

Broken up as a:

  • sensor only
  • controllable
  • nothing

In the case of nothing, the data can be persisted via the synapse sqlite db


They wouldn't do anything you technically couldn't do with said entities, but this could generate all the entities & handle the coordination for you. The module config system has similar type interactions to this, where it converts objects with type properties to a different data structure

Since synapse allows the creation of sub-devices underneath the main one created by the app - all generated entities can be automatically associated with the room device


Maybe flags is the wrong word, helpers?

@zoe-codez
Copy link
Member

should circadian option be in the scene definition?

So far I've been doing a "circadian unless color is specified" approach. I haven't convinced myself that I'd ever want to just turn on to an unknown state with a scene definition.

Is there a use case for you?

@kylegl
Copy link
Author

kylegl commented Dec 20, 2024

No I don't actually know what it does haha. Just trying to sort out if it affects an area, all scenes, or a specific scene

@zoe-codez
Copy link
Member

Gotcha! In that case it's a system that's intended to actively manage the color / temp (if supported) of a light. Brightness only lights are unsupported.

It works by setting up a sensor that tracks the current light temp (related to sun position), and sending periodic color update requests to those devices so they are approx the sensor value. There's some internal shenanigans to not just flood everything with update requests

As long as the scene doesn't say "turn on to this color", just brightness, the light might as well track solar temperature. Low hanging fruit for making the rooms feel nice

@kylegl
Copy link
Author

kylegl commented Dec 20, 2024

Oh thats cool! Ok so I personally could see myself using that on a 'default' scene for an area. default would be like like normal lighting with circadian so the color temp adjusts throughout the day. If i have other scenes like 'chill', for example, with purple mood lighting, I wouldn't want circadian running. Does that seem like a correct mental model?

@zoe-codez
Copy link
Member

Exactly!

@kylegl
Copy link
Author

kylegl commented Dec 20, 2024

Ok got it. Sounds like it should be an option or flag for a particular scene.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants