Skip to content

Commit

Permalink
Merge pull request #67 from balena-io-modules/dynamic-sensors
Browse files Browse the repository at this point in the history
Add support for dynamically created sensors
  • Loading branch information
flowzone-app[bot] authored May 10, 2024
2 parents 5e8d54c + db77732 commit db35d6d
Show file tree
Hide file tree
Showing 13 changed files with 801 additions and 94 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,6 @@ Now, these tasks define the behaviour of our agent under certain conditions, but
// Sensor.of<Heater>.from works here too
const temperature = Heater.sensor({
// The sensor returns values to set the path given by the lens below
// (no lens variables supported in this case)
lens: '/roomTemp',
// The sensor is a generator function that yields values obtained from
// the measuring hardware
Expand Down Expand Up @@ -1214,7 +1213,7 @@ const agent = Agent.from({
});
```

Passing a `trace` function to the agent will allow to get structured feedback of the planning progress. The rest of the functions (debug, info, warn,error) are called only by the agent runtime with textual information with different levels of detail. We intend to move to an [open telemetry](https://github.com/balena-io-modules/mahler/issues/40) for standardized logging.
Passing a `trace` function to the agent will allow to get structured feedback of the planning progress. The rest of the functions (debug, info, warn,error) are called only by the agent runtime with textual information with different levels of detail.

## Troubleshooting

Expand Down
228 changes: 228 additions & 0 deletions lib/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { Sensor } from './sensor';
import { stub } from 'sinon';

import { setTimeout } from 'timers/promises';
import { Observable } from './observable';
import * as memoizee from 'memoizee';
import { UNDEFINED } from './target';

describe('Agent', () => {
describe('basic operations', () => {
Expand Down Expand Up @@ -389,4 +392,229 @@ describe('Agent', () => {
agent.stop();
});
});

// A more complex example that the heater, for a multi-room
// temperature control system
describe('Climate controller', () => {
type ClimateControl = {
[room: string]: { temperature: number; heaterOn: boolean };
};
const INITIAL_STATE: ClimateControl = {
office: { temperature: 15, heaterOn: false },
bedroom: { temperature: 15, heaterOn: false },
};
const buildingState = structuredClone(INITIAL_STATE);

// Reset the state before each test
beforeEach(() => {
Object.assign(buildingState, INITIAL_STATE);
});

// Memoize the update function so it's called at most per counter
// eslint-disable-next-line
const updateTemp = memoizee((_) =>
Object.fromEntries(
Object.entries(buildingState).map(([roomName, roomState]) =>
roomState.heaterOn
? [roomName, ++roomState.temperature]
: [roomName, --roomState.temperature],
),
),
);

// Global monitor of temperature
// this simulates temperature change on rooms of a building
// the temperature of each room will drop 1 degree every
// 10ms if the heater is off and increase 1 degree if heater is on
const climateMonitor = Observable.interval(10).map(updateTemp);

const roomSensor = Sensor.of<ClimateControl>().from({
lens: '/:room/temperature',
sensor: ({ room }) => climateMonitor.map((climate) => climate[room]),
});

const turnOn = Task.of<ClimateControl>().from({
lens: '/:room',
condition: (room, { target }) =>
room.temperature < target.temperature && !room.heaterOn,
effect(room, { target }) {
// Turning the resistor on does not change the temperature
// immediately, but the effect is that the temperature eventually
// will reach that point
room._.temperature = target.temperature;
room._.heaterOn = true;
},
async action(room) {
room._.heaterOn = true;
},
description: ({ room }) => `turn heater on in ${room}`,
});

const turnOff = Task.of<ClimateControl>().from({
lens: '/:room',
condition: (room, { target }) =>
room.temperature > target.temperature && room.heaterOn,
effect(room, { target }) {
// Turning the resistor on does not change the temperature
// immediately, but the effect is that the temperature eventually
// will reach that point
room._.temperature = target.temperature;
room._.heaterOn = false;
},
async action(room) {
room._.heaterOn = false;
},
description: ({ room }) => `turn heater off in ${room}`,
});

const wait = Task.of<ClimateControl>().from({
lens: '/:room',
condition: (room, { target }) =>
// We have not reached the target but the resistor is already off
(room.temperature > target.temperature && !room.heaterOn) ||
// We have not reached the target but the resistor is already on
(room.temperature < target.temperature && room.heaterOn),
effect: (room, { target }) => {
room._.temperature = target.temperature;
},
action: NoAction,
description: ({ room, target }) =>
`wait for temperature in ${room} to reach ${target.temperature}`,
});

const addRoom = Task.of<ClimateControl>().from({
op: 'create',
lens: '/:room',
effect(room, { target }) {
room._ = target;
},
});

const removeRoom = Task.of<ClimateControl>().from({
op: 'delete',
lens: '/:room',
effect() {
/* noop */
},
});

it('should allow controlling the tempereture of a single room', async () => {
const climateControl = Agent.from({
initial: INITIAL_STATE,
tasks: [turnOn, turnOff, wait, addRoom],
sensors: [roomSensor],
opts: { minWaitMs: 10, logger },
});

climateControl.subscribe((s) => {
// Update the building state when
// the agent state changes
Object.assign(buildingState, s);
});

climateControl.seek({ bedroom: { temperature: 20 } });
await expect(climateControl.wait(300)).to.be.fulfilled;
expect(climateControl.state().bedroom).to.deep.equal({
temperature: 20,
heaterOn: true,
});

climateControl.stop();
await setTimeout(50);
});

it('should allow controlling the temperature of multiple rooms', async () => {
const climateControl = Agent.from({
initial: INITIAL_STATE,
tasks: [turnOn, turnOff, wait, addRoom],
sensors: [roomSensor],
opts: { minWaitMs: 10, logger },
});

climateControl.subscribe((s) => {
// Update the building state when
// the agent state changes
Object.assign(buildingState, s);
});

// This is not a great example, because if the target for both
// rooms is not the same, then the controller will keep iterating
// as temperature will never settle
climateControl.seek({
bedroom: { temperature: 20 },
office: { temperature: 20 },
});
await expect(climateControl.wait(300)).to.be.fulfilled;
expect(climateControl.state()).to.deep.equal({
bedroom: { temperature: 20, heaterOn: true },
office: { temperature: 20, heaterOn: true },
});

climateControl.stop();
await setTimeout(50);
});

it('should allow controlling the temperature of a new room', async () => {
const climateControl = Agent.from({
initial: INITIAL_STATE,
tasks: [turnOn, turnOff, wait, addRoom],
sensors: [roomSensor],
opts: { minWaitMs: 10, logger },
});

climateControl.subscribe((s) => {
// Update the building state when
// the agent state changes
Object.assign(buildingState, s);
});

// This is not a great example, because if the target for both
// rooms is not the same, then the controller will keep iterating
// as temperature will never settle
climateControl.seek({
studio: { temperature: 20 },
});
await expect(climateControl.wait(300)).to.be.fulfilled;
expect(climateControl.state().studio).to.deep.equal({
temperature: 20,
heaterOn: true,
});

climateControl.stop();
await setTimeout(50);
});

it('should allow removing a room and still control temperature', async () => {
const climateControl = Agent.from({
initial: INITIAL_STATE,
tasks: [turnOn, turnOff, wait, addRoom, removeRoom],
sensors: [roomSensor],
opts: { minWaitMs: 10, logger },
});

climateControl.subscribe((s) => {
// Update the building state when
// the agent state changes
Object.assign(buildingState, s);
});

// This is not a great example, because if the target for both
// rooms is not the same, then the controller will keep iterating
// as temperature will never settle
climateControl.seek({
bedroom: { temperature: 20 },
office: UNDEFINED,
});
await expect(climateControl.wait(300)).to.be.fulfilled;
expect(climateControl.state()).to.deep.equal({
bedroom: {
temperature: 20,
heaterOn: true,
},
});

climateControl.stop();
await setTimeout(50);
});
});
});
11 changes: 7 additions & 4 deletions lib/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Task } from '../task';
import { Runtime } from './runtime';
import type { AgentOpts, Result } from './types';
import { NotStarted } from './types';
import type { Path } from '../path';

export * from './types';

Expand Down Expand Up @@ -176,13 +177,13 @@ function from<TState>(
| {
initial: TState;
planner?: Planner<TState>;
sensors?: Array<Sensor<TState>>;
sensors?: Array<Sensor<TState, Path>>;
opts?: DeepPartial<AgentOpts>;
}
| {
initial: TState;
tasks?: Array<Task<TState, any, any>>;
sensors?: Array<Sensor<TState>>;
sensors?: Array<Sensor<TState, Path>>;
opts?: DeepPartial<AgentOpts>;
},
): Agent<TState>;
Expand All @@ -199,7 +200,7 @@ function from<TState>({
initial: TState;
tasks?: Array<Task<TState, any, any>>;
planner?: Planner<TState>;
sensors?: Array<Sensor<TState>>;
sensors?: Array<Sensor<TState, Path>>;
opts?: DeepPartial<AgentOpts>;
}): Agent<TState> {
const opts: AgentOpts = {
Expand All @@ -220,10 +221,12 @@ function from<TState>({
assert(opts.maxWaitMs > 0, 'opts.maxWaitMs must be greater than 0');
assert(opts.minWaitMs > 0, 'opts.minWaitMs must be greater than 0');

const subject: Subject<TState> = new Subject();
// Isolate the local state from the user input
state = structuredClone(state);

// Subscribe to runtime changes to keep
// the local copy of state up-to-date
const subject: Subject<TState> = new Subject();
subject.subscribe((s) => {
state = s;
});
Expand Down
Loading

0 comments on commit db35d6d

Please sign in to comment.