Step 12: Write Custom Scripts
Extend ControlBird with Go programs for advanced automation logic.
Beyond Visual Automation
Rule Chains handle straightforward logic well, but sometimes you need more: complex calculations, API integrations, or custom business rules that don't fit neatly into a flowchart.
ControlBird's Script Engine lets you write Go programs that run directly on your node. A program can read and write any entity it has been granted, react to changes, and run on timers and schedules.
- Visual, no-code approach
- Great for simple triggers
- Easy to understand at a glance
- Limited to predefined nodes
- Full programming flexibility
- Complex calculations
- Schedules and field reactions in code
- Requires Go knowledge
Opening the Plugin Editor
Click the ControlBird logo in the taskbar and select Plugin Editor. You'll see a list of existing programs and can create new ones. Selecting a program opens its Go source beside a Runtime panel that holds the program's switches and its latest status.
Creating Your First Program
Let's create a program that calculates a derived value: converting temperature from Celsius to Fahrenheit whenever the sensor changes.
- Click New program in the toolbar
- Enter a name: "Temperature Converter"
- Click Create
- In the Runtime panel, set Start mode to Leader, so the program starts by itself and keeps reacting
The editor opens with a starter program whose Run function does nothing. Replace it with the following code, then click Save:
package main
import (
"context"
"fmt"
"github.com/controlbird/platform/sdk/store"
"github.com/controlbird/platform/services/plugin-engine/program"
)
func Run(ctx context.Context, client *store.Client) error {
sensor, err := client.ResolvePath(ctx, "Root/Devices/TempSensor")
if err != nil {
return err
}
display, err := client.ResolvePath(ctx, "Root/Dashboard")
if err != nil {
return err
}
celsiusField, err := client.FieldType(ctx, "Temperature")
if err != nil {
return err
}
fahrenheitField, err := client.FieldType(ctx, "TemperatureF")
if err != nil {
return err
}
_, err = program.OnNotification(ctx, store.NotifyConfig{
TargetKind: store.NotifyEntity,
EntityID: sensor,
FieldType: celsiusField,
TriggerOnChange: true,
}, func(ctx context.Context, notification store.Notification) error {
if notification.Current.Value == nil {
return nil
}
celsius, err := notification.Current.Value.Float()
if err != nil {
return err
}
fahrenheit := celsius*9/5 + 32
if err := client.Write(ctx, display, fahrenheitField, store.Float(fahrenheit)); err != nil {
return err
}
fmt.Printf("Converted %.1f C to %.1f F\n", celsius, fahrenheit)
return nil
})
return err
}The first start compiles the program, which takes about a minute. After that, the program writes the Fahrenheit value each time the Celsius field changes, and prints one line to its log.
How a Program Runs
A program does not loop. The engine calls Run once. Run registers handlers, small functions that the engine calls when something happens, and returns. The converter above registers one handler with program.OnNotification. From then on the engine calls it for each change, one event at a time, and the program stays running because a handler is registered.
Because the engine calls one handler at a time, handlers never overlap and can share variables without locks. A handler should do its work and return. Programs have no goroutines and no blocking channels, and the compiler refuses code that uses them and names what to use instead.
The Store Client
Programs interact with ControlBird through the client that Run receives. Here are the most common operations:
client.ResolvePath(ctx, path)Find an entity by its path. Returns the entity's ID.
sensor, err := client.ResolvePath(ctx, "Root/Devices/Sensor1")client.FieldType(ctx, name)Resolve a field name once, then reuse the result for every read and write.
temperature, err := client.FieldType(ctx, "Temperature")client.Read(ctx, entity, field)Read a field value. The result's Value has typed accessors such as Float() and Bool().
reading, err := client.Read(ctx, sensor, temperature)client.Write(ctx, entity, field, value)Write a value built with a constructor such as store.Float. The field must exist and accept writes.
err = client.Write(ctx, thermostat, setpoint, store.Float(72.5))client.Find(ctx, entityType, filter)List the entities of a type, optionally narrowed with a CEL filter.
sensors, err := client.Find(ctx, sensorType, nil)Printing and the program log
Use fmt.Println in your programs for debugging. Output appears in the program's log, which you open from the Logs app. An error that a handler returns is written there too, as a line that starts with handler error:.
Triggering Programs
Programs can run in three ways:
On Change
Run a handler whenever a specific field changes. Register it in Run with program.OnNotification, pointing at the source field.
On Schedule
Run at an interval with program.Every, or at specific times with program.After and a cron expression. Well suited to periodic reports, daily calculations, or timed sequences.
On Demand
Leave the start mode on Trigger and click Run in the Plugin Editor, or write the program's Execute field from another program.
A Leader program starts by itself when its node becomes the leader, and keeps running while it has handlers registered. A Trigger program runs once for each click of Run and finishes when its Run function returns.
Example: Smart Averaging
Here's a more practical example: averaging multiple temperature sensors once a minute and detecting if any are offline:
package main
import (
"context"
"fmt"
"time"
"github.com/controlbird/platform/sdk/store"
"github.com/controlbird/platform/services/plugin-engine/program"
)
func Run(ctx context.Context, client *store.Client) error {
sensorType, err := client.EntityType(ctx, "TemperatureSensor")
if err != nil {
return err
}
zone, err := client.ResolvePath(ctx, "Root/Zones/LivingRoom")
if err != nil {
return err
}
temperatureField, err := client.FieldType(ctx, "Temperature")
if err != nil {
return err
}
onlineField, err := client.FieldType(ctx, "Online")
if err != nil {
return err
}
averageField, err := client.FieldType(ctx, "AverageTemp")
if err != nil {
return err
}
warningField, err := client.FieldType(ctx, "SensorWarning")
if err != nil {
return err
}
_, err = program.Every(time.Minute, func(ctx context.Context) error {
sensors, err := client.Find(ctx, sensorType, nil)
if err != nil {
return err
}
total, readings, offline := 0.0, 0, 0
for _, sensor := range sensors {
online, err := client.Read(ctx, sensor, onlineField)
if err != nil {
return err
}
isOnline, err := online.Value.Bool()
if err != nil {
return err
}
if !isOnline {
offline++
continue
}
reading, err := client.Read(ctx, sensor, temperatureField)
if err != nil {
return err
}
temperature, err := reading.Value.Float()
if err != nil {
return err
}
total += temperature
readings++
}
if readings > 0 {
if err := client.Write(ctx, zone, averageField, store.Float(total/float64(readings))); err != nil {
return err
}
}
if offline > 0 {
fmt.Println("Offline sensors:", offline)
}
return client.Write(ctx, zone, warningField, store.Bool(offline > 0))
})
return err
}program.Every registers the handler once, and the engine calls it every minute. If a store call fails, the handler returns the error. The engine logs it and calls the handler again a minute later.
Querying Historical Data
Programs can also query the Historian for time-series analysis. This one runs just after midnight UTC each day. It registers program.After for the next run before it does its work, so the schedule continues even when one run fails:
package main
import (
"context"
"fmt"
"time"
"github.com/controlbird/platform/sdk/store"
"github.com/controlbird/platform/services/plugin-engine/program"
"github.com/robfig/cron/v3"
)
func Run(ctx context.Context, client *store.Client) error {
meter, err := client.ResolvePath(ctx, "Root/Devices/PowerMeter")
if err != nil {
return err
}
consumptionField, err := client.FieldType(ctx, "DailyConsumption")
if err != nil {
return err
}
schedule, err := cron.ParseStandard("5 0 * * *")
if err != nil {
return err
}
var report program.TimerHandler
report = func(ctx context.Context) error {
if _, err := program.After(time.Until(schedule.Next(time.Now())), report); err != nil {
return err
}
end := time.Now().Unix()
records, err := client.QueryHistory(ctx, store.HistoryQuery{
TableName: "energy_table",
StartTime: end - 24*60*60,
EndTime: end,
Limit: 1024,
})
if err != nil {
return err
}
if len(records) < 2 {
return nil
}
first, err := records[0].Value.Float()
if err != nil {
return err
}
last, err := records[len(records)-1].Value.Float()
if err != nil {
return err
}
consumption := last - first
if err := client.Write(ctx, meter, consumptionField, store.Float(consumption)); err != nil {
return err
}
fmt.Printf("Daily energy consumption: %.1f kWh\n", consumption)
return nil
}
_, err = program.After(time.Until(schedule.Next(time.Now())), report)
return err
} Schedules evaluate in UTC. To run at midnight in a named time zone, import time/tzdata and start the cron expression with CRON_TZ=, for example CRON_TZ=Europe/Berlin 5 0 * * *.
Performance Considerations
Programs run on the node's compute resources, and a handler holds its whole program while it runs. Avoid very long handlers, excessive reads in tight loops, or very large historian queries, because other events wait behind them. For complex processing, break work into smaller scheduled chunks.
Debugging Programs
When things don't work as expected:
The Plugin Editor shows the last execution, its error text, and the compiler's diagnostic with the file, line and column of each error.
Open the Logs app and choose the Program tab. Everything the program printed appears here, with a handler error: line for each error a handler returned.
Use the Database Browser to confirm entity paths and field names match exactly what your program references.
Ensure the program has write access to target fields. Check Permissions Manager if writes fail.
Troubleshooting
My program runs but values aren't updating
- Check the path: Paths are case-sensitive and must match exactly
- Check field types: Writing a string to a numeric field will fail
- Check read-only: Some fields (like sensor inputs) can't be written
- Check permissions: The program may lack write access
- Check the log: A failed write in a handler appears as a
handler error:line
The program stopped after it started
A run lasts while at least one handler is registered. If Run returns without registering a handler, the run finishes at once. A Leader program that finishes stays stopped until the next leadership term, a change to Enabled, or an edit to its source.
The compiler refuses my code and names a replacement
Programs have no goroutines, blocking channels, time.NewTicker or ctx.Done(). Register program.Every, program.After or program.OnNotification instead. The Limits section lists every refused construct and its replacement.
How do I make a program run automatically?
Set Start mode to Leader and register handlers in Run: program.Every for an interval, program.After with a cron schedule for specific times, or program.OnNotification so the program reacts whenever a source field changes. See the Script Engine SDK for the full API.