Building automations in Typescript with @digital-alchemy

:crystal_ball: Welcome!

@digital-alchemy is an ergonomic Typescript framework with the goal of providing the easiest text-based automating experience. The tools are straightforward and friendly to use, allowing you to have a working first automation in a few minutes.

Previous experience writing code not required! (it does help)

All of the tools are customized to your specific instance. Know exactly how to call that service without looking at the documentation. Never call fan.turn_on with a light again!

Links

discord stars

:spiral_notepad: Changelog is maintained in a discord channel (build #, link to pr with details on what was changed)

Repo Description
version Docs Badge Wiring, configuration, boilerplate utils
version Docs Badge Friendly API bindings for home assistant
version Docs Badge Entity creation tools (typescript side)
synapse-extension Docs Badge Entity creation tools (custom component)
version Docs Badge Support tool for Home Assistant to Typescript type conversions
version Docs Badge Advanced tools for building more powerful automations with less code

:rocket: Getting started

:warning: Home Assistant 2024.4 or higher required

The project has two main starting points depending on your current setup:

  • HAOS Based: For those who want to use the Studio Code Server add-on to get the project started, run the dev server, and maintain the code. Also has access to a Code Runner to run a production copy of your code in the background.
  • Generic: This details the setup without all the Home Assistant-specific tooling and focuses more on cross-environment support and docker / pm2 based production environments.

These pre-built projects are intended as starting points. There isn’t any complex requirements under the hood though, so you’re able to easily customize to your needs.

:technologist: Writing logic

All code using @digital-alchemy follows the same basic format. You gain access to the various library tools by importing TServiceParams, then write your logic inside a service function.

Your services get wired together at a central point (example, docs), allowing you to declare everything that goes into your project and the required libraries. Adding new libraries adds new tools for your service to utilize, and your own services can be wired together to efficiently lay out logic.

import { TServiceParams } from "@digital-alchemy/core";

export function ExampleService({ hass, logger, ...etc }: TServiceParams) {
  // logic goes here
}

The hass property is a general purpose bag of tools for interacting with your setup. It forms the backbone of any automation setup with:

:parasol_on_ground: Do things the easiest way

A big focus of the framework is providing you the tools to express yourself in the way that is easiest in the moment. For an example call to light.turn_on

Via service call:

// a quick service call
hass.call.light.turn_on({ entity_id: "light.example", brightness: 255 });

// this time with some logic
hass.call.light.turn_on({ entity_id: "light.example", brightness: isDaytime? 255 : 128 });

Via entity reference:

// create reference
const mainKitchenLight = hass.refBy.id("light.kitchen_light_1") 

// issue call
mainKitchenLight.turn_on({ brightness: isDaytime? 255 : 125 });

:thinking: How custom is this?

All of the tools are powered by the same APIs that run the :framed_picture: Developer Tools screen of your setup.
The type-writer script will gather all the useful details from your setup, allowing the details to be updated at any time.

  • :white_check_mark: entity attributes are preserved
  • :white_check_mark: all integration services available
  • :white_check_mark: helpful text provided by integration devs preserved as tsdoc
  • :soon: suggestions are supported_features aware

Want to spend an emergency notification to a specific device? :framed_picture: Easy!

hass.call.notify.mobile_app_air_plant({
  data: {
    color: "#ff0000",
    group: "High Priority",
    importance: "max",
  },
  message: "Leak detected under kitchen sink",
  title: "🚰🌊 Leak detected",
});

The notification: :framed_picture: Imgur: The magic of the Internet

:supervillain: Entity references

For building logic, entity references really are the star of the show. They expose a variety of useful features for expressing your logic:

  • call related services
  • access current & previous state
  • receive update events
  • and more! (no really)

In a simple event → response example:

// create references
const isHome = hass.refBy.id("binary_sensor.is_home");
const entryLight = hass.refBy.id("light.living_room_light_6");

// watch for updates
isHome.onUpdate((new_state, old_state) => {
  logger.debug(`changed state from %s to %s`, new_state.state, old_state.state);

  // gate logic to only return home updates
  if (new_state.state !== "on" || old_state.state !== "off") {
    return;
  }

  // put together some logic
  const hour = new Date().getHours(); // 0-23
  const isDaytime = hour > 8 && hour < 19;

  // call services
  hass.call.notify.send({ message: "welcome home!" });
  entryLight.turn_on({ brightness: isDaytime ? 255 : 128 });
});

:building_construction: Getting more practical

Using just the tools provided by hass, and some standard javascript code, you can build very complex systems. That’s only the start of the tools provided by the project though. As part of the the quickstart project, there is an extended example.

It demonstrates a workflow where some helper entities are created via the synapse library. These put together to coordinate the scene of a room based on the time of day and the presence of guests. It also includes example of the scheduler in use, as well as tests against time and solar position being made.

:spiral_notepad: Conclusions

@digital-alchemy is a powerful modern Typescript framework capable of creating production applications. It has a fully featured set of plug in modules for a variety of uses, with the ability to easily export your own for others.

If you’re looking for a practical tool that is friendly to whatever crazy ideas you want to throw at it, and more than capable of running for long periods without being touched, look no further.

Digital Alchemy is a passion project that is is entirely free, open-source, and actively maintained by yours truly. For a perspective from one of the early testers:

:link: Migrating my HomeAssistant automations from NodeRED to Digital-Alchemy

Question for those who make it this far:

What is a workflow you would like to see a demo of?

I am setting up an example project to showcase various ways to use the library and provide some inspiration for building automations.

3 Likes

This looks very interesting. Are there any examples around how to write test driven automations? Like mocking/stubbing the home assistant api?

Thx

Updated: 2024-09-29

You can see the full docs at the link below

@digital-alchemy/core has the ability to help you translate an application or library module into a test runner, then use a tool like Jest to make assertions.

Below is the list of libraries that currently have test coverage - They’ve all been validated to operate as expected with test runners including:

  • :broom: proper resource cleanup so no open resources/timers are present
  • :mailbox: internal feature flags & dedicated testing helper libraries
  • :timer_clock: no unexpected interactions with mock timers
  • :anger: running tests doesn’t impact a running system if done from the same folder

@digital-alchemy/core

:link: Library Tests

The core provides the base TestRunner module as well as other tooling for building a test runner based off an existing service or module.

Includes tools for:

  • testing the scheduler
  • interacting with the logger
  • altering configurations

@digital-alchemy/hass

:link: Library Tests

The hass library has a companion library mock_assistant. When used with the test runner, it has the ability to:

  • utilize a fixtures file to mock hass apis
  • set up specific entity states
  • emit mock events and entity updates

@digital-alchemy/synapse

:link: Library Tests

This library is still under development in general, but tests are being worked in now. It exports a mock_synapse helper library that helps set up the library to be test friendly. Has some quality of life features like:

  • preventing conflicts with local entity databases
  • setting test flags & disabling test unfriendly logic
2 Likes

New feature as part of April updates

The hass library has gained the ability to directly work with:

  • zones
  • floors
  • areas
  • labels

You can create, update, apply, remove, etc these across your system. There are also new quick querying APIs for quickly calling services using these groups. Optionally filtering by domain with.

export function Example({ hass }: TServiceParams) {
  const exampleSensor = hass.entity.byId("binary_sensor.example");
  exampleSensor.onUpdate(async () => {
    if (exampleSensor.state === "off") {
      await hass.call.switch.turn_off({
        entity_id: hass.entity.byArea("living_room", "switch"),
      });
      await hass.call.homeassistant.turn_off({
        entity_id: hass.entity.byFloor("downstairs")
      });
    } else {
      await hass.call.light.turn_on({
        entity_id: hass.entity.byLabel("my_special_label", "light"),
      });
    }
  });
}

Just like with entities, all your labels / areas / etc will have typescript suggestions & validations right in the editor.

1 Like

:chart_with_upwards_trend: May Updates

Integration / Platform Support

Building on the updates from April, the hass library now has support for entity platforms. If a service says that it can only accept entities from a given integration, other entities will now cause type errors in your code even if they are the required domain. A new .byPlatform query is available to retrieve these values at runtime.

Unit Testing

Unit testing flows are officially supported with the project now. The core & hass libraries have much better internal test coverage now, provide additional hooks for injecting testing logic, and have had some internal workflows upgraded so they respond more predictably during tests.

The repo can be configured to test as part of github actions, but doing that that has the big asterisk of needing significant detailed information about your setup committed to the repo. I’ve included a docker based home assistant install, as well as it’s configuration, and the node app I used to generate the entities in the repo.

The docs will receive some readability enhancements soon, but it has all the basics there now

Setting up tests for logic is a straightforward process with the new test tooling. Quick example test the repo -

it("turns off porch light at 5AM", async () => {
  expect.assertions(1);
  await runner(
    () => {
      // 4:59:59 AM
      jest.setSystemTime(dayjs("2024-01-01 04:59:59").toDate());
      jest.runOnlyPendingTimersAsync();
    },
    async ({ hass }) => {
      const turnOn = jest.spyOn(hass.call.switch, "turn_off");
      // move clock forward by 2 seconds
      jest.advanceTimersByTime(2000);
      expect(turnOn).toHaveBeenCalledWith({ entity_id: "switch.porch_light" });
    },
  );
});

:computer: Upcoming dev items

Synapse Upgrades

Synapse is the current major target for development this month. This library contains tools for generating artificial entities with Home Assistant, which can be controlled / hooked into by your application. It comes paired with a custom component which provides the Home Assistant side of the logic in the Python code.

The current implementation is extremely rough, has limited support for different domains, and can only be configured via yaml.

The integration is going to be overhauled with a UI based configuration flow, improved support for multiple node applications, and support for generating entities across more domains. Getting the next version of the integration into Home Assistant core is on the stretch goals list right now

Automations Playground

As part of rebuilding the integration, I am going to be setting up an official playground project leveraging the same docker based approach implemented in the unit testing sample above. It will allow you to set up an instance full of fake entities, and develop your logic/tests against that.

1 Like

Here with some mid month project updates for the automation library.

:sun_with_face: Solar math with offsets

The big upgrade this time is to the automation.solar, which provides reference points in time for solar events (dawn, dusk, etc). The library has provided the ability to attach events to those, but previously you had to implement custom logic if you wanted anything wasn’t exactly when the event was.

You are now able to provide time duration offsets to affect when your logic is run relative to a solar event. These can be positive offsets (running later), or negative (running sooner). These are the most common, but the internals are able to accept a variety of other data structures

  • number - ms offset
  • string - partial ISO 8601 string: (##H)(##M)(##S). ex: 1h30s | 30m | -2h15m
automation.solar.onEvent({
  eventName: "dusk",
  offset: "-1h",
  exec() {
    logger.info("running an hour before dusk");
  }
})

You are also able to provide the offset as a function, in order to calculate a different offset every day

automation.solar.onEvent({
  eventName: "dusk",
  offset: () => Math.random() * 1000 * 60 * 60,
  exec() {
    logger.info("running within an hour after dusk");
  }
})

:alarm_clock: Easier time based math

As a minor breaking change, automation.utils has been renamed to automation.time. This set of functionality allows for quickly creating reference points in time in order to perform math

It implements a method of building dayjs objects from short strings, as a way for quickly gathering reference points for today

hass.entity.byId("sensor.example").onUpdate(() => {
  const [AM8, PM5] = automation.time.shortTime(["AM8", "PM5"])
  if (dayjs().isBetween(AM8, PM5)) {
    logger.info("doing thing")
    return
  }
  logger.info("doing something else");
})

The methods that return dayjs objects still exist, but new methods have been added that allow the same logic to be expressed more concisely

hass.entity.byId("sensor.example").onUpdate(() => {
  if (automation.time.isBetween("AM8", "PM5")) {
    logger.info("doing thing")
    return
  }
  logger.info("doing something else");
})

Also availablee in the more concise format:

  • isBefore
  • isAfter