retrospective
A year of automating a flat

I started this in January because four vendor apps had worn me down. It is now December, the flat locks itself when the last of us leaves, the lamps come on at dusk, and something switches the hallway light off at twenty past one every morning whether or not I asked it to.
Forty-six commits, every one on a Saturday or a Sunday, about three and a half thousand lines of Python. Here is what I would tell myself in January.
Two sources of truth, on purpose
The device shadow in IoT Core is what a device believes about itself. A DynamoDB table is what the flat believes. Keeping both looks like a mistake and is the decision I am most confident about.
Alexa expects a ReportState answered promptly, and "is the kitchen light on"
should not become a round trip to IoT Core to read a shadow document. The table
answers in single-digit milliseconds and holds exactly the shape the response
needs. The shadow is the transport, not the record.
They disagree, of course. A bulb switched at the wall never told anyone. The rule I settled on is that the shadow wins and the reported state is written back into the table — which is what the sensor ingest path does, and which I should have built in February instead of December. For most of this year, the flat was confidently wrong about any light somebody had touched by hand.
The race I found by reading, not by breaking
Two directives for the same device can land in two containers at once. "Alexa, turn off the kitchen light" twice in quick succession, or more realistically a voice command arriving while a rule is doing something to the same bulb.
Read state, merge the change, write it back — and the slower of the two writes
wins, regardless of which one was asked for last. I never observed this. I
found it by staring at apply() and feeling uneasy.
def conditional_put_state(device_id, state, updated_at):
try:
_table(STATE_TABLE).put_item(
Item={"deviceId": device_id, "state": state, "updatedAt": updated_at},
ConditionExpression="attribute_not_exists(updatedAt) OR updatedAt < :now",
ExpressionAttributeValues={":now": updated_at},
)
return True
except ClientError as error:
if error.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise
A conditional write and a timestamp. The caller treats a rejection as "someone newer already handled this" and re-reads rather than retrying. It is four lines of condition expression and it is the single most valuable thing DynamoDB gave me this year — a compare-and-set that costs nothing, in a system where every write is contended by definition and none of it is worth a lock.
The rule I got wrong twice
Away mode was the hardest thing here and it is not close.
The first version armed when a phone left the geofence. This switched the lights off on my partner, who was in the flat, roughly the first evening it ran.
The second version armed when the last phone left, tracked as a set. Better, and still wrong, because a geofence is not a sensor — it is a guess with a radius. Walking to the corner shop and back fires left and then arrived ninety seconds apart, and the flat would dutifully shut itself down and boot itself back up.
What it does now is start the routine optimistically and let a Choice state
at the front of the state machine cancel it if anybody turned out to be home.
The check moved from the trigger into the workflow, where it can be re-evaluated
after the fact.
I do not think this is finished. Presence is a probability and I am modelling it as a boolean, and the honest fix is a debounce with a few minutes of hysteresis. That is next year's problem.
Things that were cheaper than expected
Polling instead of scheduling. Two functions running every fifteen minutes to catch sunrise and sunset come to seventy thousand invocations a year, which is free-tier noise. I spent an evening designing something cleverer before working out what the clever version would save.
Fakes instead of a test suite. There is no test suite. There is a script that swaps DynamoDB, IoT, Parameter Store, SNS and Step Functions for dictionaries, and runs a real payload through the real handler:
python scripts/simulate_event.py events/presence_left.json --dry-run --show-effects
It prints the shadow updates, the bus events, the notifications and the resulting state. For a weekend project this has caught more than tests would have, because it exercises the wiring — and the wiring is where all the bugs were.
Everything on one bus. Every detail type the system emits is a constant in one module, and the EventBridge rules match those strings. When I wanted sensors to feed the rule engine, the rule engine did not change at all.
Things that were more expensive
Alexa's discovery contract. Advertising a capability the handler does not implement produces "the device is not responding", which is true and tells you nothing. I lost two evenings to it in March.
Quiet hours. Suppressing non-urgent notifications overnight is four lines. Deciding which notifications are urgent took considerably longer, and I got it wrong until a door sensor woke me at three to tell me a door had closed.
Unlocking by voice. Off by default, behind a parameter, and it is staying off. Ground-floor flat, and a window that opens onto a street where people talk.
What it costs
Under a euro a month, nearly all of it DynamoDB writes I could batch if I cared. The whole thing is on-demand billing and free-tier compute, which is the correct shape for a system that does nothing at all for twenty-three hours a day.
The code is at github.com/deyanpeev/home-automation if you want the details. It is a weekend project and reads like one, which I have decided to be at peace with.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


