All writing

Writing / IoT · WebSockets · Rails

Starting laundry machines from the cloud: four versions of one command

How Quicklean's washers and dryers went from a relay computer in every branch to chips that connect directly, confirm each start and report a heartbeat.

Quicklean is a chain of self-service laundromats. Nobody drops a coin into its washers and dryers. A customer or a staff member pays for a load, and the platform tells that one machine to run. Today it tracks more than 2,000 machines in over 50 branches.

This article is about the smallest message in that system: "start this machine". I have rewritten it four times since 2024. All four versions still run in production, because machines in the field cannot all be upgraded on the same day.

What "start" means

Commercial washers and dryers are built to be started by a coin or card reader, which signals the machine with electrical pulses. A chip wired to each machine sends those pulses instead.

In the platform, every machine type has a list of loads. A load has a name, a price, a duration and a number of pulses. Starting a machine means sending it that number of pulses.

Every start is written to the database before anything is sent: which machine, which load, who asked for it, and which customer and order it belongs to. That table is the audit trail. The usage reports and the per-machine billing are computed from it.

Version 1: a relay in every branch

In 2024 each branch had a local computer with a MySQL database holding one row per machine: its status, whether it was running, whether it was locked, and a pulse count waiting to be sent.

I wrote a small Node.js service to sit beside that database and connect it to the cloud:

  • It reads MySQL's binary log. When a machine's status changes, it sends the branch's machine list to the cloud over a WebSocket.
  • The cloud, a Rails application using Action Cable, identifies the branch by its API key and creates or updates its machines.
  • To start a machine, the cloud sends a machine number and a pulse count down the same socket. The service writes them into that machine's row, and the local system does the rest.

Reading the binary log meant I did not have to touch the software that was already there. Nothing polls the table, and nothing that writes to it had to change.

The reconnect rule was the part I kept in every later version. When the socket drops, the service tries again every second. Each time it connects, the cloud asks for the full machine list. There is no queue of missed changes to replay. The current state replaces whatever the cloud had.

It worked, and it had two weaknesses. Every branch had one more computer to keep alive. And the cloud never learned whether a machine had started. It marked a start as done the moment the message was sent. A heartbeat check was planned for the relay and never built.

Version 2: the chip connects by itself

In July 2025 a new chip removed the branch computer. Each chip opens its own WebSocket to the platform and listens on a stream named after itself. A start command goes straight to that chip.

It was simpler to install and it had the same blind spot. The command was still sent and forgotten.

Heartbeats that do not flood the dashboard

A chip with its own connection can say "I am alive", and these chips do, with a watchdog message.

Thousands of chips repeating that message is most of the traffic in the system. Writing each one to MySQL, or pushing each one to every open dashboard, would cost more than everything else the platform does. So since September 2025 heartbeats never reach the main database:

  1. Each message goes to Redis. It replaces that device's latest message and joins a short history, capped at 1,000 messages per device and kept for seven days.
  2. The device's code is added to a set of devices that have changed.
  3. Every five seconds a scheduled job takes a short lock so two workers cannot overlap, reads the set, fetches the latest message for those devices in one call, and sends the dashboard one combined update. Then it removes them from the set.

A dashboard receives at most one message every five seconds, however many machines are talking.

Whether a machine is online is one small check:

HEARTBEAT_WINDOW = 1.minute

def online?
  message = latest_message_for(code)
  return false unless message && message["content"].start_with?("WDT")

  Time.parse(message["ts"]) >= HEARTBEAT_WINDOW.ago
end

The API returns that answer with every machine, so an app can show a machine as offline before anyone pays for it.

Version 3: the machine answers

In October 2025 the start command began to carry one more value: the id of its own row in the database.

The chip runs the command and replies that it executed that id. Only then is the row marked as executed. If the chip replies that it failed, the row keeps the reason. Until an answer arrives, the start shows as pending, and staff can see it.

A background job resends the command when no confirmation comes back. The first version resent it up to 12 times, five seconds apart. In February 2026 I reduced that to a single resend, and staff can retry a start from the branch app.

Confirmation made a second feature possible. Since December 2025 each load has a duration. When a chip confirms a start, the platform records when that machine will be free, and the API reports the seconds remaining. An app can show a machine as busy instead of letting someone pay for one that is mid-cycle.

Version 4: one controller, several machines

In January 2026 a new board arrived that drives several machines from one controller.

A machine can now have a parent. The start command goes to the parent's connection and carries the child's device number, the pulse count and the row id. The parent answers for the child, and a child counts as online when its parent is.

Four protocols behind one switch

Every machine record stores its chip type, and the code that sends a start picks the protocol from it:

  • The relay and the first direct chips cannot confirm, so their starts are marked as executed when they are sent.
  • The two newer types stay pending until the chip answers.

Everything above that switch is the same for all four: the order, the payment, the audit row and the reports. When one paid order includes several machines, their starts go to a queue of their own, three seconds apart.

What it does not do

  • This article does not cover the firmware on the chips, only the platform side of the conversation.
  • For the two older chip types, "executed" still means "sent".
  • The retry is one resend. It is not a schedule with increasing delays.
  • Only chips with their own connection report a heartbeat. The relay never got one.

What I would tell someone building the same thing

  • Write the command to the database before you send it. The row's id is what makes a confirmation possible later.
  • "Sent" is not "done". Make the device answer, and make it answer with the id.
  • Keep heartbeats out of your main database, and batch what you show on dashboards.
  • On reconnect, ask for the full state. Do not try to replay what was missed.
  • Expect the old protocol to live for years. Choose the protocol per device, not per release.