Back to projects

a build log

Darklands
Control System

A whole remote-control setup that lets people at Darklands drive my wearable gear from their phone: LEDs, collars, and a few things that bite back.

ElectronicsDesignCAD
Open the live control panel fen.bio/dl/control · drive it yourself

the idea

Say nothing, do something

Darklands is huge, and I am shy, so I wanted a way for people to interact with me without either of us having to say a word. The answer was a control panel on my website. Anyone can open it, pick one of my wearable devices, and set it off from across the room: change the color of my harness, scroll a message on my collar, play a GIF on my pendant, or send a shock to something a lot more personal.

It is one small web page sitting on top of a chain of servers, radios and microcontrollers, and this is how the whole thing hangs together.

the plumbing

How it is wired up

A rough map of the system, still being drawn up properly.

Anyone's phone
the control panel at fen.bio/dl/control
HTTPS
fen.bio API
validates the request and drops it on a command queue
poll every 0.5s
Home server
a Node.js box in my apartment that pulls commands and routes each one to the right transport
split by device
WiFi · ESP32
harness · pendulum · collar
Sub-GHz RF · Flipper Zero
shock collar · plug · cage

the gear

The devices

Six things you can drive from the panel, from harmless and pretty to distinctly not.

LED Harness WiFi / ESP32

RGB matrix harness. Solid colors, rainbow waves, scrolling text, pulse and fire effects.

Pendulum WiFi / ESP32

A small LED display pendant that plays looping GIFs (hypno spiral, fire, paw print, and more).

Collar Display WiFi / ESP32

A scrolling-text collar. Type up to 64 characters and it runs across the front.

Shock Collar Sub-GHz RF (Flipper)

Shock, vibration and beep, with adjustable intensity and duration.

Shock Plug Sub-GHz RF (Flipper)

Electroshock plus vibration, with intensity, patterns and duration.

Chastity Cage Sub-GHz RF (Flipper)

Shock-enabled cage. Pick an intensity and send a zap.

build notes

How it actually holds together

The command flow

Every button on the panel does the same basic thing: it posts a small JSON command to the API, which drops it on a queue. My home server is constantly polling that queue, and the moment a command shows up it figures out which transport that device uses and fires it off. The LED gear listens over WiFi, the shock gear listens over radio. The panel then flips its status dot to green if it hears back.

Why a home server in the middle

The website cannot talk to a microcontroller or a Flipper Zero directly, and I did not want to expose any of that hardware to the open internet. So the website only ever writes to a queue, and a trusted little server I control does the actual talking. If my phone loses signal at the venue, the queue just backs up harmlessly instead of anything misbehaving.

Safety and consent

The spicy devices all cap their intensity and duration in firmware, not just in the interface, so a maxed-out slider still stays inside a range I set. Everything is opt in, it is my own gear on my own body, and if the connection drops you can always just come find me and use my phone instead.

a look at the code

From a tap to a zap, in four hops

1 The website posts a command to the API
// fen.bio API  ·  queue a command for a device
app.post('/api/devices/:id', async (req, reply) => {
  const { id } = req.params;         // 'harness' | 'shock' | 'cage' | ...
  const cmd = req.body;              // { action, intensity, duration, ... }

  if (!ALLOWED.has(id)) return reply.code(404).send();
  await queue.push({ device: id, cmd, at: Date.now() });
  return { ok: true, queued: true };
});
2 The home server pulls the queue and routes each command
// home server (runs in my apartment)  ·  pull + route
setInterval(async () => {
  const jobs = await api('/api/devices/pending');   // grab what's waiting
  for (const job of jobs) {
    switch (transportFor(job.device)) {
      case 'esp32':  sockets[job.device].send(JSON.stringify(job.cmd)); break;
      case 'subghz': flipper.tx(signalFor(job.device, job.cmd));        break;
    }
  }
}, 500);   // poll twice a second
3 The ESP32 gear receives it over WebSocket
// ESP32 firmware (harness / pendulum / collar)  ·  receive over WebSocket
void onMessage(uint8_t *payload) {
  StaticJsonDocument<256> doc;
  deserializeJson(doc, payload);
  const char *mode = doc["mode"];          // "color" | "text" | "rainbow"

  if      (!strcmp(mode, "color"))  setAll(doc["r"], doc["g"], doc["b"]);
  else if (!strcmp(mode, "text"))   scrollText(doc["text"]);
  else if (!strcmp(mode, "rainbow")) rainbowWave();

  FastLED.show();
}
4 The RF gear is driven by replaying captured signals
# Sub-GHz replay for the RF shock devices
# each button on the original remote was captured to its own .sub file
flipper subghz tx  shock_collar_zap.sub    # 433 MHz, decoded from the remote
flipper subghz tx  cage_zap.sub
flipper subghz tx  plug_vibe_pulse.sub

These are simplified for the writeup. The real thing has a lot more error handling, reconnection logic, and duct tape.

take it home

Get the files

Everything to build your own: the server code, the ESP32 firmware, the captured RF signals, wiring notes, and the 3D models for the harness, pendant and mounts.

files and models coming soon, poke me if you want them now

Or skip the reading and go drive it
Fen is at Darklands!
Check out the live schedule, gear map & control panel
Go