If something has an API and a meaningful desired state, it can be modeled declaratively. That idea feels obvious in the cloud. We describe virtual machines, networks, load balancers, and container apps in code, and the platform converges reality toward that definition. We call it Infrastructure as Code.
But what happens if you apply that same thinking to something in the physical world?
I decided to find out. Using Bicep’s experimental local extension capability, I built a custom resource provider that treats a Home Assistant light as a first-class Infrastructure-as-Code resource. Running az bicep local-deploy now physically changes the state of a Zigbee light in my office, declaratively and idempotently. Not through a script. Not through an automation. Through a resource definition.
Let’s start by seeing it in action in the video below!
Why This Wasn’t Just a Gimmick
Home automation APIs are almost always imperative. You tell them to “turn on,” “turn off,” or “toggle.” That works perfectly for dashboards and mobile apps, but it clashes with Infrastructure as Code principles. IaC is about convergence. You don’t toggle a VM. You declare its desired configuration. You don’t flip a load balancer. You define its final state and let the system move toward it. So my first rule of this extension was simple: no toggle support. A deployment must explicitly declare “on” or “off”. Running the same deployment twice must result in the same outcome. Anything else would undermine the model.
The Real Hardware Behind It
This isn’t simulated. It’s running on actual hardware. Home Assistant is running locally in a VM on Windows 11. For Zigbee connectivity, I’m using the Sonoff ZBDongle-E as the Zigbee 3.0 coordinator. The light itself is an Aqara T2 Light Bulb, capable of RGB and color temperature modes.
The signal path looks like this: Laptop → Home Assistant VM → Zigbee dongle → Aqara bulb
The extension never talks to Zigbee directly. It communicates with Home Assistant via its REST API. Home Assistant handles the radio communication and device state management. That separation is important. The Bicep extension doesn’t care whether the device is Zigbee, Thread, Wi-Fi, or something else. It only cares that the system exposes a stable API and that the resource has a meaningful desired state. Once you abstract at that level, the pattern becomes clean.
The Enabler: Bicep Local Extensions
The experimental local extension model in Bicep allows you to register a custom executable as a resource provider. Instead of deploying to Azure Resource Manager, az bicep local-deploy invokes your handler locally. The execution flow looks like this:
Bicep CLI parses the template. It discovers the registered extension. It invokes the resource handler. The handler performs the operation locally. Outputs are returned to Bicep.
In this case, the handler calls the Home Assistant REST API, applies the declared state, reads back the entity to confirm convergence, and returns outputs. Everything runs locally. Nothing touches Azure. That’s what makes this interesting. Bicep becomes a declarative engine, not just an Azure deployment tool.
Modeling the Light as Infrastructure
Here’s what the resource definition in main.Bicep looks like:
targetScope = 'local'
extension homeassist@secure()
@description('Home Assistant long-lived access token')
param accessToken string@description('Home Assistant instance URL')
param homeAssistantUrl string = 'http://192.168.68.70:8123'@description('The entity ID of the light to control')
param lightEntityId string = 'light.aqara_lumi_light_agl003'@description('Desired state of the light')
@allowed(['on', 'off'])
param lightState string = 'on'@description('Brightness level (0-255)')
@minValue(0)
@maxValue(255)
param brightness int = 100@description('Color temperature mireds (153-500, 0 to skip)')
@minValue(0)
@maxValue(500)
param colorTemp int = 0@description('Hue 0-360 (-1 to skip)')
@minValue(-1)
@maxValue(360)
param hue int = -1@description('Saturation 0-100 (-1 to skip)')
@minValue(-1)
@maxValue(100)
param saturation int = -1resource aqaraLight 'Light' = {
entityId: lightEntityId
homeAssistantUrl: homeAssistantUrl
accessToken: accessToken
state: lightState
brightness: brightness
colorTemp: colorTemp
hue: hue
saturation: saturation
}output currentState string = aqaraLight.currentState
output friendlyName string = aqaraLight.friendlyName
output entityId string = aqaraLight.entityId
This is the main.bicepparam file, I left out the access token for obvious reasons.
using 'main.bicep'
param accessToken = 'XXX'
param homeAssistantUrl = 'http://192.168.68.72:8123'
param lightEntityId = 'light.aqara_lumi_light_agl003'
param lightState = 'off'
param brightness = 100
param colorTemp = 10
param hue = -1
param saturation = -1And to be able to import the HomeAssist extension, the following is configured in the bicepconfig.json file. It contains the references to the bin folder where the homeassist extension is located, as well as enabling the localDeploy experimental feature.
{
"experimentalFeaturesEnabled": {
"localDeploy": true
},
"extensions": {
"homeassist": "./bin/bicep-ext-homeassist"
}
}To kick off the Bicep local deploy run:
az bicep local-deploy main.bicepparamThe Extension Itself
Under the hood, the extension is a .NET 9 resource host that registers a Light resource type and implements CreateOrUpdate semantics. Instead of imperative commands, it computes a desired end state from the template properties and applies exactly one color mode per deployment. It deliberately avoids toggle behavior and enforces idempotency at the handler level. The executable is packaged using az bicep publish-extension and invoked locally through the experimental local-deploy runtime, effectively acting as a custom resource provider, just one that happens to manage a Zigbee bulb instead of a cloud resource.
The Color Mode Lesson
One of the more subtle issues during development was color handling. Home Assistant does not allow color_temp and hs_color in the same service call. Sending both results in an HTTP 400. The extension now enforces exclusivity: if colorTemp is set, it uses color_temp. Otherwise, if hue and saturation are set, it uses hs_color. Never both. It’s a small detail, but it reinforces an important principle: declarative modeling only works when you respect the semantics of the underlying API.
Building and Publishing the Extension
Because this relies on the experimental local extension model, the build and registration steps are important. Here’s the exact build process I use on Windows:
# Restore dependencies
dotnet restore HomeAssistExtension.csproj# Build
dotnet build HomeAssistExtension.csproj --configuration Release# Publish self-contained binary
dotnet publish HomeAssistExtension.csproj --configuration Release -r win-x64# Register with Bicep
az bicep publish-extension `
--bin-win-x64 .\bin\Release\net9.0\win-x64\publish\bicep-ext-homeassist.exe `
--target .\bin\bicep-ext-homeassist `
--force
Dotnet publish produces the executable that implements the resource handlers. az bicep publish-extension packages and registers it locally so Bicep can discover and invoke it during local-deploy. After that, any Bicep file declaring the extension homeassist can use the custom Light resource. It’s Bicep acting as a declarative runtime locally.
Outputs
One of the things I wanted from the beginning was proper deployment feedback — not just “it worked” or “it failed,” but meaningful outputs that reflect the observed state of the device. The Bicep template defines explicit outputs:
output currentState string = aqaraLight.currentState
output friendlyName string = aqaraLight.friendlyName
output entityId string = aqaraLight.entityIdThey are returned by the extension itself after it completes the convergence logic. Inside the handler, after calling the appropriate Home Assistant service (light.turn_on or light.turn_off), the extension reads the actual entity state from Home Assistant and maps relevant fields back into the resource outputs.
- currentState reflects the actual final state reported by Home Assistant.
- entityId confirms the exact resource targeted.
- friendlyName is pulled from the entity attributes for human-readable confirmation.
Surfacing real outputs transforms this from “a script that flips a light” into a proper resource model.
The extension applies the declared desired state, reads back the actual state from the source system, and returns that state as outputs to the Bicep runtime.
It’s being declared, converged, and observed.
And It Doesn’t Stop There
The light was just the smallest possible proof. Through Home Assistant, I already have dishwashers, washing machines, printers, motion sensors, smart plugs, and other appliances integrated behind the same API surface. They all expose the state. They all expose actions. They all have configuration that could be expressed declaratively. More fun challenges ahead!
The extension currently supports lights. But there’s nothing fundamentally preventing it from expanding to switches, scenes, or anything else. Once the extension model exists, the boundary moves.
We’ve been thinking about Infrastructure as Code in terms of where it runs: the cloud, the data center, the platform. Maybe we should start thinking about it in terms of what can converge.
Watching a Bicep deployment turn on a light is fun. Realizing that the same pattern could declaratively manage an entire physical environment is the interesting part.
What’s the most unexpected thing you’d model declaratively?
More info and shout-outs to other Bicep Local Deploy resources: