đź‘‹ [Sept 2024] This post was updated with improvements to both the service (now hosted as an Azure Function) and the ESP module programming (using an esphome config). See the original post via its git history.

The project from the original post may be easier to set up independently and provides a simple express web service that does not depend on Azure.

This past Christmas I set out to make a DIY “lovebox” as a gift. These devices come in pairs - each device at a different location. When one person performs some trigger, the peer device lights up. It’s a fun and passive way to let your partner know you’re thinking of them.

I thought it would be fun (and way cheaper!) to try and build the product myself. Below are the steps and associated code if you’d like to create one yourself!

2

Parts

  • 2x ESP8266 boards (I used these) because they were a good size and required minimal soldering.
  • LEDs to solder onto the boards (I used red)
  • Some kind of transparent film to place over the hearts (the material I had wasn’t great, open to suggestions!)
  • 3D printed box (see below)
  • Soldering iron

Hardware and 3D Model

For the 3D model, I had in mind a “companion cube” from Portal. I found a model by mooraayeelz online that fit the bill perfectly! I printed two on my Ender 3 with default PLA settings. I printed the base separately in a different color for some contrast.

As shown in the parts list, I used ESP8266 boards. I soldered the LEDs to GPIO pin 2 on the boards with a resistor appropriate for the LEDs I had on hand. I used a hot glue gun and some screws to attach things in place. I also snaked through a USB cable that sneaks out the back of the box for power.

Firmware

Each box is assigned an identifier (0 or 1) and a peer (the other box). The boxes are programmed with an esphome configuration.

# (c) 2024 Josh Spicer <hello@joshspicer.com>
# https://joshspicer.com/heartbox
#
# compile with esphome 2024.4.2
# https://github.com/esphome/esphome-docs/blob/2024.4.2/components/http_request.rst#id12

esphome:
  name: heartbox-<0|1>

esp8266:
  board: d1_mini

# Enable logging
logger:

# Enable Home Assistant API
api:
  password: "<TODO>"

ota:
  password: "<TODO>"

wifi:
  ssid: "<TODO>"
  password: "<TODO>"

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Heartbox"
    password: "<TODO>"

captive_portal:

# Define the LED on pin 2
output:
  - platform: gpio
    pin: GPIO2
    id: led_output

# HTTP Request component
http_request:
  id: "send_request"
  # verify_ssl: False
  timeout: 10s

# Make HTTP request and process the response
interval:
  - interval: 60s
    then:
      - http_request.get:
          id: "send_request"
          url: https://<TODO>/api/heartbox?deviceId=<0|1>
          verify_ssl: False
          headers: 
            x-functions-key: "<TODO>"
          on_response: 
            then:
              - logger.log:
                  format: 'Response status: %d, Duration: %u ms'
                  args: 
                    - status_code
                    - duration_ms
              - lambda: |-
                  auto body = id(send_request).get_string();
                  ESP_LOGD("Reponse Lambda:", "%s", body);

                  json::parse_json(body, [](JsonObject root) {
                    auto self = root["self"].as<char*>();
                    auto peer = root["peer"].as<char*>();

                    ESP_LOGD("Reponse Lambda:", "self: %s, peer: %s", self, peer);

                    if (strcmp(peer, "on") == 0) {
                      // Turn on LED
                      ESP_LOGD("Handle Lambda:", "peer=on");
                      id(led_output).turn_on();
                    } else {
                      // Turn off LED
                      ESP_LOGD("Handle Lambda:", "Off");
                      id(led_output).turn_off();
                    }
                  });

Web Service

The boxes are coordinated by a web service, implemented as an Azure Function for simplicity and cost (free!).

// (c) 2024 Josh Spicer <hello@joshspicer.com>
// https://joshspicer.com/heartbox

import { app, HttpRequest, HttpResponseInit, InvocationContext, output, input } from "@azure/functions";
import moment from 'moment';

interface StateTransition {
    PartitionKey: string;
    RowKey: string; // Timestamp
    Status: string;
}

interface HeartBoxRequest {
    deviceId: number;
    verbose?: boolean;
}

interface HeartBoxPut extends HeartBoxRequest {
    self: string | null;
    peer: string | null;
}

// The device always sends THEIR deviceId
// Eg: Box 1 with append ?deviceId=1 to every request
function getPeerDeviceId(myDeviceId: number) {
    return myDeviceId === 0 ? 1 : 0;
}

export async function heartbox(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log(`Http function processed request for url "${request.url}"`);
    try {
        const params = parseAndValidateParams(request.query);
        switch (request.method.toUpperCase()) {
            case 'GET':
                // Polls my state and my peer's state
                // If ?verbose=true is set, also return a list of all past states for both participants
                return handleGet(params, context);
            case 'PUT':

                if ((params.self !== null && params.peer !== null) || (params.self === null && params.peer === null)) {
                    throw new Error(`Invalid request. Must set either 'self' or 'peer', but not both.`);
                }

                const valid_values = ['on', 'off'];
                if (params.self !== null && !valid_values.includes(params.self)) {
                    throw new Error(`Invalid 'self' value`);
                }
                if (params.peer !== null && !valid_values.includes(params.peer)) {
                    throw new Error(`Invalid 'peer' value`);
                }

                return handlePut(params, context);
            default:
                return {
                    status: 405,
                    jsonBody: {
                        message: 'Method Not Allowed'
                    }
                };
        }
    } catch (error: any) {
        return {
            status: 400,
            jsonBody: {
                message: 'message' in error ? error.message : error?.toString()
            }
        };
    }
};

function parseAndValidateParams(queryParams: URLSearchParams) {
    if (!queryParams.has('deviceId')) {
        throw new Error('Missing deviceId');
    }

    const deviceId = parseInt(queryParams.get('deviceId'));
    const verbose = queryParams.get('verbose') === 'true'

    // 'self' and 'peer' are used for a PUT
    const self: string | null = queryParams.get('self');
    const peer: string | null = queryParams.get('peer');

    if (isNaN(deviceId)) {
        throw new Error('Invalid deviceId (NaN)');
    }

    return {
        deviceId,
        verbose,
        self,
        peer
    };
}

function handleGet(params: HeartBoxRequest, context: InvocationContext): HttpResponseInit {
    const { deviceId, verbose } = params;

    const device0 = context.extraInputs.get(device0Input) as StateTransition[];
    const device1 = context.extraInputs.get(device1Input) as StateTransition[];

    context.debug(`device0: ${JSON.stringify(device0)}`);
    context.debug(`device1: ${JSON.stringify(device1)}`);

    let device0LastState: StateTransition | undefined = undefined;
    for (const state of device0) {
        if (device0LastState === undefined || new Date(state.RowKey) > new Date(device0LastState.RowKey)) {
            device0LastState = state;
        }
    }

    let device1LastState: StateTransition | undefined = undefined;
    for (const state of device1) {
        if (device1LastState === undefined || new Date(state.RowKey) > new Date(device1LastState.RowKey)) {
            device1LastState = state;
        }
    }

    const transitions = { transitions: { device0, device1 } };

    const self = deviceId === 0 ? device0LastState : device1LastState;
    const peer = deviceId === 0 ? device1LastState : device0LastState;
    context.log(`[device${deviceId}] Polled self=${self.Status}, peer=${peer.Status}`);

    const selfAgo = moment(self.RowKey).fromNow();
    const peerAgo = moment(peer.RowKey).fromNow();
    context.log(`[device${deviceId}] Self was set on '${self.RowKey}' (${selfAgo})`);
    context.log(`[device${deviceId}] Peer was set on '${peer.RowKey}' (${peerAgo})`);

    return verbose ? {
        status: 200,
        jsonBody: {
            ...transitions,
            self: {
                ...self,
                lastChanged: selfAgo,
            },
            peer: {
                ...peer,
                lastChanged: peerAgo
            }
        },
    } : { status: 200, jsonBody: { self: self.Status, peer: peer.Status } }
}

function handlePut(params: HeartBoxPut, context: InvocationContext): HttpResponseInit {
    
    const { self, peer, deviceId } = params;

    let res = {};

    if (self !== null) {
        context.log(`[device${deviceId}] Setting self to '${self}'`);
        context.extraOutputs.set(tableOutput, {
            PartitionKey: deviceId.toString(),
            RowKey: new Date().toISOString(),
            Status: self,
        });
        res = { ...res, self }
    }

    if (peer !== null) {
        context.log(`[device${deviceId}] Setting peer to '${peer}'`);
        const peerDeviceId = getPeerDeviceId(params.deviceId);
        context.extraOutputs.set(tableOutput, {
            PartitionKey: peerDeviceId.toString(),
            RowKey: new Date().toISOString(),
            Status: peer,
        });
        res = { ...res, peer }
    }

    return {
        status: 200,
        jsonBody: res
    };
}


const device0Input = input.table({
    tableName: 'heartbox',
    connection: 'AzureWebJobsStorage',
    filter: `PartitionKey eq '0'`,
});

const device1Input = input.table({
    tableName: 'heartbox',
    connection: 'AzureWebJobsStorage',
    filter: `PartitionKey eq '1'`,
});

const tableOutput = output.table({
    tableName: 'heartbox',
    connection: 'AzureWebJobsStorage',
});

app.http('heartbox', {
    methods: ['GET', 'PUT'],
    authLevel: 'function',
    extraInputs: [device0Input, device1Input],
    extraOutputs: [tableOutput],
    handler: heartbox
});

I embed the function’s x-functions-key key in the box firmware and send it as a header to prevent unauthorized usage.

Triggering with a Siri Shortcut

I wanted to be able to trigger the box with a Siri Shortcut to trigger the partner’s device! I then added the shortcut to my home screen and added it to my home screen. These are easily shared though text - a great way to share with the gift’s recipient!

3