I wrote an API client for my water-cooled bed
Posted on
Table of Contents:
Now that I have your attention, let's break that down a little bit.
I recently purchased a Sleep.me Chilipad 2.0, which is a system that allows you to control the temperature of your bed using a thin, water-filled mattress topper that's connected to a device that contains a pump, reservoir, and thermostat. It was $17001. Yes it's ridiculous. I know. I make software engineer money, I'm single, I live alone, and I don't have kids. Sue me. I'll provide a serious, grown-up justification in just a second, but for now "la la la can't hear you."
The idea is that it helps regulate your body temperature while you sleep if you have trouble doing it yourself, don't have AC in your bedroom, etc. The device allows you to set desired temperatures throughout the night, put this on schedules that apply on certain days of the week, etc. The manufacturer also provides a developer API, so immediately there was no world in which I didn't do some bullshittery with this.
The result is a tool called bedctl2. I named it that way for two reasons:
- There's a long history of "control" programs having names that end with
-ctl. - In my head
bedctlis pronounced "bed cuttle"3, which sounds like "bed cuddle", which is cute.
Sleep
Our understanding of how the human body works is constantly evolving. This is good. However, between grifters on social media, biased research funded by industry, and the evolving understanding provided by science I mentioned earlier, you could sometimes reasonably convince yourself that there's evidence to support the claim that juggling live salmon cures male pattern baldness.4
I have a PhD in physics. I built lasers that spit out 80 femtosecond pulses, and I did quantum mechanics simulations. I am so scarred by that experience that my default stance on science-related (which includes health) headlines and social media posts is "bullshit until proven otherwise", where "proven" includes but is not limited to "corroborated by my doctor or a consensus of medical professionals." And to be clear, that distrust is not distrust of science, it's distrust that nuance has been stripped away to fit into a headline, essentially. I am not a doctor, and I'm very aware of that.
One piece of knowledge I feel safe clinging to and saying out loud to the internet is that sleep is probably good for you. Everything seems to get better with sleep: your immune system, your cognitive function, recovery from physical activity, etc.
Unfortunately, I've always had a complicated relationship with sleep. Namely because my neuro is divergent.
Neurodivergent people tend to be night owls as a survival/recovery mechanism. Having some time to ourselves where no one is placing demands on our attention, behavior, etc, gives us some space to exist as our actual selves, not our "capitalism demands that I perform on this schedule, in this manner, to these people, otherwise I can't feed myself" selves. This means that our sleep routines can be either misaligned with our work schedules, or just irregular in general.
If it's not clear yet, I have ADHD. This means that my brain never shuts up. Like, ever. This is not conducive to "winding down for bed." I developed a reading in bed routine from a pretty early age, which cuts down on distractions and acts as a signpost my nervous system can recognize as "ah, we typically fall asleep after doing this for a while, let's start that process."
Luckily I was diagnosed with ADHD pretty young, and I've been medicated since childhood. For people that are diagnosed as adults, I see one of two reactions to starting ADHD medication:
- Holy shit, is this how normal people feel all the time? It's so much quieter!
- Ah fuck, I'm autistic too.
If it's not clear yet, I'm autistic. The overlap between autism and ADHD is pretty significant, and in many cases the ADHD is just masking the autism. That's why some people go undiagnosed until they start medication. Not me though, I happen to be exceptionally good at burning mental energy in exchange for social skills. I was only diagnosed about two years ago, but there were signs.5
Many people with autism have sensory issues, as in certain textures or sensations are abnormally bothersome. Imagine the reaction that some people have to nails on a chalkboard, or the sound of someone chewing, etc, but getting that reaction to a texture, certain kinds of touch, etc. Similarly, many people with autism have trouble regulating their body temperature to the same degree as neurotypical people. When it comes to sleep, this means that I have trouble feeling comfortable unless the temperature is just right, the weight of the comforter/duvet is high enough, etc.
I also just moved to Portland (the cool one, not the lobster one).
This is one of the rare cities in the US where air conditioning isn't a given.
For most of the US this is like living in the stone age.
I guess for a long time the climate was temperate enough that it wasn't needed, but the climate is changing has changed.
It now gets certifiably hot as fuck in Portland.
My new apartment at least has a window unit, but it's in the living room, not the bedroom. There's also no ceiling fans. I also live in a high-rise, and heat rises. This means that my bedroom gets pretty stuffy at night.6 I bought temperature sensors to prove that my bedroom is the hottest room in the apartment and everything.
So, to recap:
- Brain never shuts up
- I have medical grade stimulants coursing through my veins
- Me no likey stuffy bedroom
- Not sleeping well is pretty bad for you
- Please, PLEASE help me sleep better
That's why I said fuck it and dropped RAM money on a temperature control system for my bed. Spoiler: it's good.
Chilipad
Now we'll talk about the device itself.
Why this device
Naturally I did a bunch of research into the "sleep cooler" landscape before buying this.
I didn't want a fan-based solution like BedJet because I didn't want it blowing cold air at my feet in the summer, even if blowing warm air on my feet during the winter sounds nice.
I think the closest competitor is Eight Sleep, but I dismissed it pretty much immediately. The cheapest model is $3000, it requires a subscription, there's some AI bullshit built-in, etc. PASS.7 Seriously, fuck off with a subscription in order to keep using the $3000 device you just bought. Do not buy that device.
The Chilipad 2.0, which I'll just call the Chilipad going forward out of laziness, starts cheaper because it's modular. You can buy a unit for just one side of the bed to save some money, which is what I did. This also means that if you buy two of them you can set them to different temperatures. Every partner I've ever lived with has been a human furnace while sleeping, so I can see this being helpful for couples.
There's a bunch of other very polite things that the company has done that sealed the deal for me:
- Even though it's WiFi connected, there's an airplane mode.
- You can control the device with an included Bluetooth remote, but I haven't checked or tested whether airplane mode is actual airplane mode or just "disable WiFi."
- They provide a developer API.8
- They provide a Postman "collection" for playing around with the API.
- They provide an online Swagger UI and OpenAPI schema.
The ability to put the device into airplane mode tells me that they're conscious of the fact that it can be creepy to have an internet-connected device built into your bed, and that you may want a failsafe if your bed joins a robocult botnet.
Construction
The device consists of two main parts:
- The "dock", which is the control unit, thermostat, pump, etc.
- The cover, which contains the "hydrolayer".
I'll let the photos on the website speak for themselves for the most part. The dock sits under the foot of my bed, which is close to the door to my bedroom so the heat exchanger isn't just spewing hot air back into my room.
The cover itself is actually three layers:
- The bottom layer
- The hydrolayer (a TPU membrane that water circulates through)
- The top cover
The top cover zips open so you can install the hydrolayer on the side of the bed that you sleep on. The top cover is made of some fabric that's cool to the touch and is lightly padded so you don't feel like you're laying directly on the hydrolayer.
API
Let's talk about the API so we can talk about the API client. It looks like the documentation is gated by having an account, so I'll describe it for you. First you need to generate a token for yourself, like with most APIs.
A note about the Postman examples
I'm not going to talk about these.
They're helpful when the documentation is lacking detail (which is rare), but I wish this wasn't tied to Postman.
I think it's overkill, and a bit cluttered.
I also don't love that you need an account with some other company.
I'd prefer something open source, like hurl scripts or whatever.
Bah humbug.
There's also typos in a variable name in all examples except for one: {{SleepMeAPI_URL}} should be {{SleepMeAPI_Url}} (notice the casing of Url).
On the upside, the provided body of the "Set Device Status" example is
{
"set_temperature_f": 69,
"thermal_control_status": "standby"
}
Nice9
Endpoints
The API is pretty simple, you get two endpoints:
/devicesGET: list the devices associated with the token
/devices/{:id}GET: gets all information about the devicePATCH: set device properties
The PATCH request is how you control the device remotely.
We'll discuss that in a second.
Here's what a call to the /devices endpoint gets you (I'll be redacting some things10):
[
{
"id": "<redacted>",
"name": "Dock",
"attachments": []
}
]
This is a list of devices, where each object contains the device ID, user-assigned name, and any other devices attached to it (I think, not sure about the last one).
Here's what you get back from /devices/{:id}:
{
"about": {
"firmware_version": "1.12.1358",
"ip_address": "<redacted>",
"lan_address": "192.168.8.69",
"mac_address": "<redacted>",
"model": "DP723NA",
"serial_number": "<redacted>"
},
"control": {
"brightness_level": 100,
"display_temperature_unit": "f",
"set_temperature_c": 21.5,
"set_temperature_f": 71,
"thermal_control_status": "standby",
"time_zone": "America/Los_Angeles"
},
"status": {
"is_connected": true,
"is_water_low": false,
"water_level": 100,
"water_temperature_f": -1,
"water_temperature_c": -1
}
}
The about object contains read-only data about the device identity and definition. ip_address is the external IP and lan_address is the IP on your local network.
The control object describes the current settings of all controllable properties.
This is device specific, so some devices may have a subset of these, or may have keys not listed in the documentation (the documentation doesn't list the brightness_level key, for example).
thermal_control_status is either standby or active, and is just whether the device is actively controlling the temperature.
The status object contains read-only data about the status of the device. is_connected is whether the device is reachable from the API. water_level is a percentage (0-100).
Temperatures are a little funky. The Fahrenheit temperatures seem to be integers whereas the Celsius temperatures are floats (Javascript "all numbers are floats" aside). The allowed Fahrenheit range is 55-115, and the Celsius range is 13.0-48.0. In control.set_temperature_* you can set the temperature to -1/-1.0 for MAX_COLD, or 999/999.0 for MAX_HEAT. In status.water_temperature_* presumably -1 is a sentinel value while the device is in standby. That's not documented, but seems reasonable.
Aside: Fahrenheit vs. Celsius
To my dying breath I will fight Canadians and Europeans that Celsius is the better temperature unit for human-facing temperatures. Example: air temperature. 0F is really cold (0% hot) and 100F is really hot (100% hot). 0C is cold but not outrageous, but 100C is "100% dead." Also, as an AC connosieur, sometimes one degree F really does take you from "almost there" to "perfect." Throw off your chains. Be free of the coarse grained tyranny of Celsius.
Device control
You set the properties of the device by sending a control object with just the keys that you want to set. For example, if you only want to set the water temperature, you'd send
{
"set_temperature_f": 73
}
This isn't actually spelled out in the documentation, but I've gathered it from the Postman collection. The important bit is that you don't need to include all of the keys, and you don't need to wrap it in a { "control": ... }.
API Client
I wrote bedctl in Zig, because that's my language of choice these days.
I've used Rust professionally for about 5 years, and as a hobbyist since 2018.
No shade, I just want instant feedback and low mental overhead.
zig build check --watch -fincremental updates in 75ms on my M1 Max Macbook Pro, which means that it's not even using the faster self-hosted x86_64 backend.
The struggles I have are struggles of learning a new tool, not "how do I even read this" or "how do I even track down this trait method implementation."
Anyway.
Here's some examples of what it looks like to use:
$ bedctl devices
Dock: id=<redacted>
$ bedctl status
About:
Firmware version: 1.12.1358
External IPv4: <redacted>
Internal IPv4: 192.168.8.121
MAC Address: <redacted>
Model: DP723NA
Serial Number: <redacted>
Settings:
Brightness: 100/100
Temperature Unit: F
Temperature: 78F / 25.5C
Thermal Controls: Off
Time Zone: America/Los_Angeles
Status:
Connected: Yes
Water Low: No
Water Level: 100/100
Observed Temperature: 72F / 22.0C
$ bedctl set temp 74
In those examples I've set the token and device ID with environment variables, but you can also pass them with --token and --device flags.
bedctl is mainly a library, and the CLI is a very thin wrapper around that library.
The current structure of the project looks like this:
bedctlZig modulebedctlCLI that consumes the Zig module
My ultimate goal is for bedctl to be consumed as a zero-dependency dynamic library from higher level languages.
I would love for someone to write a Python wrapper for use in Home Assistant.
I'm not going to do that, but I would use it if someone did.
I don't have the build or interface set up for the dynamic library yet, but that will come at some point in the near future.
At that point I'll have a separate build step for a CLI that consumes it as a dynamic library for dogfooding the C interface.
At that point the project structure will look like this:
bedctlZig modulebedctlCLI that consumes the Zig modulelibbedctldynamic library- Provides a C interface to the Zig module
bedctl-dlCLI that consumes the dynamic library
The CLI only has one dependency: an argument parser I extracted from the Tigerbeetle codebase, tweaked, and updated for compatibility with Zig 0.16.0.
The library has no dependencies outside of the Zig standard library, using std.http.Client for requests.
The CLI also doesn't link against libc, which is some magic made possible by Zig.
Once the dynamic library is set up, it won't link against libc either.
Also, since Zig natively supports cross-compilation, I can build for any supported system without needing access to it. Love it.
These are the central data structures that the library operates on:
pub const Device = struct {
/// The device's unique identifier.
id: []const u8,
/// The user-provided name for the device
name: []const u8,
/// The devices attached to this one, I think.
attachments: []const []const u8,
/// Read-only data about the identity and definition of the device.
pub const About = struct {
/// The current loaded firmware of the device.
firmware_version: []const u8,
/// The public IPv4 address of the WiFi the device is connected to.
ip_address: []const u8,
/// The LAN IP given to the device by the router.
lan_address: []const u8,
/// The MAC address of the control board inside the device.
mac_address: []const u8,
/// The model of the device.
model: []const u8,
/// The serial number of the device.
serial_number: []const u8,
};
/// The current state of the device's controllable properties,
/// indicating that you can request to change them.
///
/// Availability may vary depending on device model.
pub const Control = struct {
/// The brightness level of the device's display
/// as a percentage (range: 0-100).
brightness_level: ?u8,
/// How the temperature is shown on the device's display.
display_temperature_unit: ?enum { c, f },
/// The desired water temperature setting in Celsius.
///
/// The allowed values are as follows:
/// - MAX_COLD is -1.0
/// - MAX_HEAT is 999.0
/// - Normal temperature range is 13.0-48.0 inclusive
///
/// The value will not be validated before sending the request.
set_temperature_c: ?f32,
/// The desired water temperature setting in Fahrenheit.
///
/// The temperature may only be set between 55-115 inclusive.
/// This will not be validated before sending the request.
set_temperature_f: ?enum {
max_cold = -1,
max_heat = 999,
_,
},
/// The state of the system's thermal control systems.
///
/// Changing to "active" will turn the systems on, and
/// a change to "standby" will turn them off.
thermal_control_status: ?enum { active, standby },
/// The time zone set on the device.
///
/// May be set to any IANA time zone name.
time_zone: ?[]const u8,
};
/// Read-only data about the status of the device.
pub const Status = struct {
/// Indicates if the device is actively connected and reachable.
is_connected: bool,
/// Indicates if the water level is low enough that it needs to be refilled.
is_water_low: bool,
/// Indicates the fill level of the resevoir as a percentage (range: 0-100).
water_level: u8,
/// Indicates the water temperature in degrees Fahrenheit (range: 55-115).
///
/// If the device is in standby, this will be -1.
water_temperature_f: i8,
/// Indicates the water temperature in degrees Celsius (range: 13.0-48.0).
///
/// If the device is in standby, this will be -1.
water_temperature_c: f32,
};
};
/// A device status response.
pub const DeviceStatus = struct {
about: Device.About,
control: Device.Control,
status: Device.Status,
};
These are largely translations of the JSON objects, but we get to be fancy in a couple of cases.
Rather than strings for control.display_temperature_unit we get an anonymous enum with .c and .f variants, same thing for control.thermal_control_status with .active and .standby variants. Anonymous structs and enums are one of my favorite syntax features in Zig. Being able to avoid naming something that's clear from context cuts down on visual noise and the mental overhead of coming up with a concise, unambiguous name.11
We also get a little bit of extra type-system assistance when setting temperatures in Fahrenheit.
I forget the name of this particular feature, but when defining an enum you can give a semantic name to specific integer values, and let the rest just pass through as their literal integer values.
This allows you to set both .max_cold and 78 as literal values, rather than needing to remember that -1 is the magic value for MAX_COLD.
Unfortunately there's no way (that I'm aware of) to encode the additional restriction that the valid range of temperatures is 55-115F.
For Celsius we're pretty much in YOLO mode because it's a float.
The experience of writing it was pretty pleasant.
It was my first time using the Io interface for HTTP operations, so there was a little bit of floundering at first.
I think most people's mental model of sending a request is something like:
- Provide a URI
- Provide a body either as bytes or a string
send- Get either bytes or a string back
Since Zig is lower level, and the HTTP client in the standard library isn't intended to be the Easy Button of HTTP clients, things look pretty different. The process looks like this:
- Create a URI object
- Mutate the URI object to set the path
- Create a request object with the base URI, headers, and connection settings
- (In this case) Send the request head
- Receive the response head
- This allows you to get a reader for the body
- Consume from the reader to get the body
It does feel a little bit like using scalpel-level precision for all operations, but that's okay. I do like how with Rust you get to be high level in many places, and then drop down to low-level, nitty-gritty sweat mode when you feel like it. In Zig it feels like I'm always in sweat mode.
The methods for sending the GET request to the /devices endpoint look like this:
/// List the devices registered to the provided token.
pub fn devices(self: *Self) Error![]const Device {
var request = try self.requestGetPath(DEVICES);
defer request.deinit();
const response = try self.fetch(&request);
const parsed = try std.json.parseFromSliceLeaky([]const Device, self.gpa, response, .{
.allocate = .alloc_always,
});
return parsed;
}
/// Constructs a GET request configured with the authorization header.
///
/// Caller must deinit the request.
fn requestGetPath(self: *Self, path: []const u8) Error!Client.Request {
var endpoint = self.uri;
endpoint.path = .{ .percent_encoded = path };
const request = try self.client.request(http.Method.GET, endpoint, .{
.keep_alive = true,
.headers = .{
.authorization = .{ .override = self.token },
},
});
return request;
}
/// Sends the request and reads the response. Caller is response for
/// freeing the returned slice.
fn fetch(self: *Self, request: *Client.Request) Error![]const u8 {
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
var response = try request.receiveHead(&redirect_buffer);
var response_buffer: [4096]u8 = undefined;
const response_body = try response.reader(&response_buffer).allocRemaining(self.gpa, .limited(64_000));
return response_body;
}
Ignore the Leaky in parseFromSliceLeaky, I'm doing some stuff with arena allocators not shown here that makes this a non-issue. Trust me bro.
Sleeping on it
So, what's it like sleeping on my $1700 gadget?
I've slept really well for the last couple of nights. I wear a fitness tracker, so I can also see quantitatively that my sleep is much better. I've spent more time in the "restorative sleep" phases, and I've had fewer "wake events" over the course of the night. Subjectively, I also feel pretty well rested. Another data point is that one of the nights I've slept on it was after having some whiskey, which I've learned from my fitness tracker typically makes my sleep much worse, but I actually slept pretty well that night.12
I've only had it a few days (I wrote bedctl over the course of about 3 nights), so we're still squarely in the honeymoon phase.
The first time I got into the bed with the temperature set a bit too low it almost felt like I was laying in a wet spot because of the cooling sensation. Adjusting the temperature helps, and I don't feel like I'm laying in a wet spot anymore. I'll also say that the temperature adjusts pretty quickly. I haven't timed it, but I'd put it closer to 1 minute than I would 10 minutes. The device took less than 1 gallon of water to fill, so there's not a ton of thermal mass to heat/cool.
I'm still dialing in my preferred, scheduled temperatures, but the included remote (which is basically just a big dial with a touch screen) makes it really easy to adjust the temperature on the fly. The dial on the remote is not the most satisfying tactile experience, but at least it vibrates at temperature increments so you don't have to look at it to know how much you're adjusting it. The haptic feedback is helpful for making adjustments if you have to wear a sleep mask because your bedroom has gorgeous floor to ceiling windows that are too tall to reasonably install blackout curtains on, and if there's a big frosted glass window built into your bedroom door that lets in light from the living room.13
Because my brain is different, I still need a slight amount of airflow from a fan to feel comfy. Previously I would have to balance these things:
- Enough airflow to keep the room cool
- Direct airflow on me because brain needs that sensation
- Not so much air directly on me that it dries out my airways and makes me cough all day
Now I just have the fan on the lowest setting.
Setting the temperature slightly warm in the morning feels pretty cozy, and I'm really looking forward to it pre-warming my bed in the winter. That said, I can tell that the breathability of the "thing I'm laying on top of, mattress or otherwise" is reduced. To be clear, it doesn't feel like I'm laying on plastic or anything like that, just that the skin in contact with the bed was like 1% sweaty with the temperature cranked a little bit. Also keep in mind that my body is apparently a weak bitch that can't control its own temperature very well, so you may be blissfully dry in the same scenario.
On that note, that's about all I have to say. Positive experiences so far. I'll probably post an update when I get around to setting up the dynamic library. Night night.
Some might say that it's eye wateringly expensive lolololol.
It's pronounced "bedctl".
Ok, I lied.
My hairline is doing great, thanks for asking.
I'll take my water with exactly 6 ice cubes, please. And make sure the glass doesn't smell bad. And make sure the rim isn't too thick.
Pre-Chilipad solution: put a tower fan outside the bedroom and have it blow lukewarm air into the stuffy bedroom.
There's apparently a jailbreaking community out there, which is awesome, but I didn't want to do that out of the box.
Did I misread and think that the API was local instead of talking to their cloud service? Yes. ADHD in action, folks.
If I just blew up an engineer's spot, sorry. Sleep.me if you're reading this be cool.
Please don't ruin my life hackerman.
I'm anal about names. I held the title of High Kind of Bikeshedding at my last company. I'm not making that up.
Don't misinterpret this. Alcohol is still bad for you, and bad for your sleep. At best this is probably just helping you sleep less bad.
A+ design.