« The importance of terminology | Details: OneWire bus enumeration » |
My primary architectural goal for this system was simplicity, followed by cost. After all it's a completely needless side project and I didn't want to spend any real money on it.
My target cost was order-of-magnitude a buck a month, and we're doing pretty well:
It's pretty miraculous what you can do with high-level PaaS cloud-hosted services that bill by use and have a pretty high free tier. At this point I cut could my costs in half by hosting my DNS elsewhere; I'm just using Route53 for ease of deployment since I automate DNS in my CI/CD.
Well, maybe three.
At its core, WarmAndFuzzy is a simple CRUD system with minor extras:
So it's basically a my first IoT system starter pack. And of course there's firmware too.
In order to achieve my goals for simplicity, I turned to what struck me as the sweet spot of advanced but boring technology: Advanced enough that a piece of tech could cover as many needs as possible at a time, and boring enough that I wasn't dogfooding some new craziness with zero answers on StackOverflow.
Everything is as stateless as possible. For example:
Any state is centralized:
This means that if our internet goes out, you can't update the thermostats' settings. On the other hand, as long as there's still power and the real-time clock of the thermostats isn't shot, they'll continue to faithfully execute their schedules and/or expire previously set holds (they do buffer their desired state in EEPROM).
This wasn't as popular a decision with the residents of the house as it was with my inner software architecture nerd, but it's made the system far easier than it might have otherwise been, and if all else fails, we can always blam a physical jumper into the control panel for the system.
Recall that Particle is the manufacturer I chose for the system-on-chip platform in the thermostats because their platform includes firmware-as-a-service management services as well as a device cloud that connects devices to webhooks. (The price point for their service at my scale is hard to argue with: it's free.)
One thing their platform is surprisingly bad at (compared to e.g. Balena) is secrets management: there is no way for me to configure a per-device secret that the firmware can use to authenticate to an API of my choosing.
I'd have preferred an MQTT-based approach, but without proper secrets management this was going to end poorly. Instead I have to rely on the Particle cloud to connect my devices to webhooks (because vendor lock-in is dope) so that's the one piece of complexity I just wasn't able to cut out. The Particle webhook configuration is where I get to specify the API key secret that Particle's cloud uses to talk to my API.
That said, the webhook-based approach has worked out just fine and I'm somewhat glad that I didn't need to take on the full burden of MQTT.
The firmware lives in the firmware package of the repo and is pretty basic.
Every so often (as configured through its cadence) the device wakes up, samples its sensors, reflects on its settings to figure out what the current actions should be, and sends a record of that to the cloud, receiving the latest configuration and settings in response.
When compared with the insanity of TypeScript, heap space is precious on the Particle Photon (32KB) and stack space even more so (5KB/thread). With the small amount of heap space, fragmentation becomes a concern so small stuff should really go on the stack, but larger stuff is too large to go on the stack so those should become fixed-size heap allocations. None of this is difficult or revolutionary, but it's quite the brain melter to go back-and-forth between TypeScript running on many-GB VMs and then count bytes in C++.
(For extra credit, ARM is an alignment-faulting architecture and the Particle OS doesn't do fixups, so that's another fun thing to consider when tweaking bytes out of structures.)
A few notable pieces of code that came in handy:
TraceLoggingActivity
because it seems that when I find one concept useful, I just can't stop replicating it.The cloud side of things (a.k.a. The API) lives in the API package of the repo.
serverless.yml
defines all the infrastructure, consisting of:
There's a shared package for code shared between the API and frontends, and a shared-client package for code shared just between the mobile and web frontends.
The shared
package provides yup-based schema definitions for every GraphQL type to provide more fine-grained definitions (e.g. value ranges). These are used by the frontend to validate form values before sending them across the wire, and by API code to validate any requested mutations.
In an ideal world I'd like articulate these as custom GraphQL directives and then extend the GraphQL code generator to emit a yup schema, but it wasn't a hill I felt like dying on. yup
is awesome, by the by, though questions of nullability/optionality are as convoluted as they ever are in JavaScript.
The shared
package also shares non-secret auth configuration data.
The shared-client
package provides shared data stores, unit-of-measurement management, and auth management services.
The mobile and web frontends are written in ReactNative and React, respectively.
The capabilities of both frontends are identical for normal users (e.g. inspecting system status, changing thermostat settings, ...), though administration (e.g. thermostat configuration) and data exploration is only in the web app.
The amount of code sharing (c.f. above) has been fantastic. Pretty much everything one would consider "business logic" in the old days is shared between the frontends.
Data and auth state is managed using MobX, which has been an absolute pleasure to work with. Separating data from React's state tree has allowed me to minimize app-specific code.
« The importance of terminology | Details: OneWire bus enumeration » |