This is the replication guide. The story of how we got here, including the two walls that turned out not to be there, is at jan.zorz.si/HA-automations/washer.html; this page is only the part you can type.
Where things stand, September 2026: reading works in full. The machine connects to a box in the flat, tells it everything it would have told Amazon, and we understand the half of it that matters: state, phase, time, door, programme and every setting on the panel. Commands are unproven. The machine appears to accept orders only while a programme is running (seen once, in the cloud's own history), and every test so far was sent to an idle drum, which ignored us with great dignity. Ours is an AEG 9000-series washer-dryer. AEG is an Electrolux brand, so the dialect is probably shared, but the tags, bits and enums below are what one machine told us over three sessions; yours will overlap and differ, and the method survives where the numbers do not.
What you need
| Thing | Why |
|---|---|
| A router that can NAT by source MAC (ours: RouterOS 6) | The machine's IP is dynamic; its MAC is not |
| A Linux box on the LAN with Python 3 | The fake broker lives here |
| The machine's MAC address | From your router's DHCP leases |
| A self-signed certificate on an ECDSA P-256 key | RSA gets "no shared cipher"; the machine offers elliptic-curve suites only |
| An empty drum | You will be pressing buttons on the panel. Do not press Start |
Two facts make this possible, and both are the manufacturer's choice, not ours: the machine never verifies the server certificate, and it sends no MQTT username or password. It proves who it is with a client certificate and then takes your word for who you are.
So the certificate is the only thing you need to make, and it takes two commands:
openssl ecparam -name prime256v1 -genkey -noout -out local_ec.key
openssl req -new -x509 -days 3650 -key local_ec.key -out local_ec.crt \
-subj "/CN=<the broker hostname your machine resolves; ours was the maker's mqtt-ecc… host>"
We only ever tested it with the broker's real name, so use that. Whether the machine checks the common name is untested, and this is not the place to find out.
Step 1: redirect
The machine opens TCP 8883 to the maker's broker. Two NAT rules turn that into TCP 8899 on your box. Both carry a comment so they can be removed in one go. Arm the timer first; the rules come second.
/system scheduler add name=AEG-LOCAL-revert start-time=([/system clock get time] + 5h) interval=0 \
on-event="/ip firewall nat remove [find comment=AEG-LOCAL]; /system scheduler remove AEG-LOCAL-revert"
/ip firewall nat add chain=dstnat protocol=tcp src-mac-address=<MACHINE-MAC> dst-port=8883 \
action=dst-nat to-addresses=<BOX-LAN-ADDRESS> to-ports=8899 comment=AEG-LOCAL place-before=0
/ip firewall nat add chain=srcnat protocol=tcp dst-address=<BOX-LAN-ADDRESS> dst-port=8899 \
action=masquerade comment=AEG-LOCAL place-before=0
The timer lives on the router, not the box, because the box is the thing you will be restarting and editing all afternoon. place-before=0 puts a rule on top; on an empty table omit it. Source-MAC matching needs the router to see the machine's own frames; if an access point or ISP box does NAT in between, match on src-address with a static lease instead. The box must accept TCP 8899 on its own firewall, and if your forward chain drops LAN-to-LAN by default, allow this one.
The second rule is the one we forgot the first time. On one subnet the box answers the machine directly, the router never gets to undo its rewrite, and the machine discards a reply from a stranger; the masquerade sends it back the long way. The story page has the evening this cost.
The rules affect new connections only. The machine's existing session to Amazon carries on until it drops, so expect the first knock on your door within a few minutes; to hurry it, kill the tracked flow:
/ip firewall connection remove [find src-address=<MACHINE-IP> dst-port=8883]
Rollback is the mirror image. Remove the rules, then stop the sink, because an established connection stays translated until it closes and the machine will otherwise keep talking to you, quite happily, for hours:
/ip firewall nat remove [find comment=AEG-LOCAL]
/ip firewall connection remove [find dst-port=8899]
Once the socket is gone the machine dials home within seconds. A running wash never notices either way, because the drum does not consult the network.
Step 2: the sink
You need something on port 8899 that speaks MQTT 3.1.1 over TLS and says yes. A real broker would do; we wrote a couple of hundred lines of Python instead, because a broker that writes down everything it hears is the point, and configuring mosquitto to accept a client with no password and an unchecked certificate is more lines than writing one. The script is published next to this guide as aeg_wash_sink.py; run it as AEG_SESS=<session dir> python3 aeg_wash_sink.py 8899, with the certificate files where the top of the script expects them.
The TLS side is three settings:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("local_ec.crt", "local_ec.key")
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("ALL:@SECLEVEL=0")
conn = ctx.wrap_socket(sock, server_side=True)
An RSA certificate may sit alongside; it will never be picked. The MQTT side is a handful of replies. Anything else can be logged and ignored.
| Machine sends | You answer | Bytes |
|---|---|---|
| CONNECT | CONNACK, accepted | 20 02 00 00 |
| SUBSCRIBE (packet id, topic, qos) | SUBACK, granted | 90 03 <id-hi> <id-lo> <granted-qos> |
| PUBLISH | nothing (ours: qos 0); if qos 1, PUBACK | 40 02 <id-hi> <id-lo> |
| UNSUBSCRIBE | UNSUBACK | B0 02 <id-hi> <id-lo> |
| PINGREQ | PINGRESP | D0 00 |
The machine reconnects about every seventeen seconds until something answers, which makes every mistake free: fix the code, wait, it knocks again. Once connected it does two things. It subscribes to one command topic, cmd/<PNC>/00/<SERIAL>/<MAC>, which is the channel it waits on for orders. And it publishes its state to another, $aws/rules/appliances_events/evt/<PNC>/00/<SERIAL>/<MAC>. The sink keeps the subscribed topic that starts with cmd/; that is where commands go later.
Did it work? On the router, /ip firewall connection print where dst-port=8899 shows a flow if the redirect is doing its job. In the sink log, TLS ok (TLSv1.2 ECDHE-ECDSA-AES128-GCM-SHA256) means you are done. If instead the sink says NO_SHARED_CIPHER, your certificate is RSA. If the handshake never completes and the machine's Wi-Fi icon dies within a minute, the masquerade is missing. If nothing at all happens in five minutes, the MAC is wrong, an earlier dstnat rule is catching the packet first, or the old Amazon session is still alive; see the connection-remove line above.
Our sink keeps five files per session, and we recommend all five:
| File | What |
|---|---|
raw.log |
every MQTT packet in hex, so a decoder bug loses nothing |
frames.jsonl |
one decoded frame per line; tail this for anything live |
live.log |
readable, with value changes highlighted; this is the file you watch |
params.json |
last value of every parameter and how often it moved; written every 20 frames and on disconnect |
cmd_queue.txt |
write a hex frame on a line, the sink publishes it to the command topic |
Step 3: wire format
Each MQTT payload is a thin JSON envelope around hex:
{"version":"1.0.0","data":[{"payload":"<HEX>","timestamp":<ms since epoch>}]}
Each payload is one frame:
AD | module (6 ASCII) | container (1) | field (1) | type 0x06 | value (rest)
Values are ASCII or big-endian integers, except when they are nested TLVs: 64 | (tag, length, value)*. Most of the interesting settings live in TLVs, and a TLV read as a decimal integer looks like a random ten-digit number that changes by one, which fooled us for a moment. Read hex.
A worked example, the temperature turned from 40 to 30 °C on the panel:
AD 525031574431 1C 09 06 64 02 01 28 04 01 20 06 06 000100000000 11 01 00 0B 01 00 32 02 0000 0C 01 00 0A 02 FFFF 13 01 40
AD 525031574431 1C 09 06 64 02 01 1E 04 01 20 06 06 000100000000 11 01 00 0B 01 00 32 02 0000 0C 01 00 0A 02 FFFF 13 01 40
Module RP1WD1, container 1C, field 09, then a TLV. Tag 02 is the temperature, in degrees, exactly: 0x28 is 40, 0x1E is 30. Nothing else moved. That is the whole method: change one thing on the panel, diff the frame, name the tag.
Step 4: the field map
The machine sorts its fields by meaning. Container 04 is the device and its clocks; container 1C is the programme. This held for every field we decoded and saved us a great deal of guessing.
Container 04, device and time (module RP1WD1):
| Field | Meaning | Values |
|---|---|---|
01 |
appliance state | 0 off, 2 running, 3 ready, 6 end of cycle |
02 |
UI/menu state | 1 running, 2/3 flip on menu visits; use 01 instead |
11 |
time to end | seconds; FFFFFFFF means no data |
12 |
delay start | seconds; FFFFFFFF means not set |
22 |
language index | 21 Slovenian, 0 English on ours |
29 |
display brightness | 1 to 5 |
2A |
end-of-cycle sound | 0 off, 1 short, 2 medium, 3 long |
60 |
door | 1 open, 0 closed |
Container 1C, programme:
| Field | Meaning |
|---|---|
09 |
selection TLV: what is chosen now |
0A |
limits TLV: what this programme allows |
10 |
cycle phase: 2 wash, 3 rinse, 4 spin, 6 dry, 9 drain |
20 |
soft-plus TLV, tag 01, 0 or 1 (a permanent setting) |
22 |
permanent extra rinse, 0 or 1 |
Tags inside the selection TLV (1C 09):
| Tag | Meaning | Encoding |
|---|---|---|
02 |
temperature | °C |
04 |
spin | rpm / 50 (0x20 = 1600) |
06 |
options | 6-byte bitfield, bits below |
0B |
extra rinse count | 1, 2, 3 (with bit 12 set) |
13 |
time save | enum: 0x40 off, 0x20 quick, 0x04 extra quick |
0C |
dryness | enum: 0x01 timed, 0x08 iron-dry, 0x20 cupboard, 0x80 extra |
0A |
drying time | minutes (only with dryness 0x01) |
32 |
programme code | 2 bytes; our dial has 17 positions, one of them Settings |
Bits in tag 06:
| Bit | Option |
|---|---|
| 7 | prewash |
| 10 | stains |
| 11 | soak |
| 12 | extra rinse on |
| 16 | anti-crease |
| 17 | steam |
| 29 / 30 / 31 | steam level low / mid / max (steam programme only, exactly one set) |
| 32 | wash mode |
| 33 | dry mode |
Tags inside the limits TLV (1C 0A):
| Tag | Meaning |
|---|---|
14 / 15 |
temperature max / min |
16 / 17 |
spin max / min (rpm / 50) |
06 |
supported options, same bit layout |
13 |
allowed time-save values |
0C |
allowed dryness values |
The rule that turned out to matter more than any single tag: never guess a value set, read the mask. Limits tag X lists exactly the values selection tag X may take. We guessed the dryness levels as 0x20/0x40/0x80; the mask said 0x01/0x08/0x20/0x80, and the mask was right. Wool has a lower spin ceiling than cotton, dry-only sets every wash limit to FF, and the machine itself clamps the selection when you change programme. It tells you what it will accept before you ask.
Step 5: commands
The machine subscribed to a command topic in step 2. A command is, as far as we can tell, the same frame shape with the changed tag inside the selection TLV, wrapped in the same JSON envelope, published to that topic. Write the hex line into cmd_queue.txt; the sink does the rest.
The catch is permission. In the cloud entity, before the redirect, the machine's remote-control flag read not safety relevant enabled whenever the drum was idle, and in that state it drops every command without a reply. We saw it flip to enabled by itself when a cycle started and back off at the end, once, in the cloud history of the 15 September wash. We have not found the local field for that flag; locally, use cycle phase (1C 10 not zero) as the proxy and treat the window as unverified.
The test, then: start a spin-only or rinse-only programme with an empty drum from the panel, never remotely; wait for 1C 10 to move; queue one frame with only tag 04 changed. Do not use the panel's delay-plus-start trick to enable remote start on an idle machine; that arms it for remote START, which is the one command never to send.
If the next wash still ignores the frame, the suspects are the leading byte (AD may mean reporting, with a different byte for requesting) and the envelope.
Step 6: Home Assistant
We have not written this yet; today HA sees the washer only through its power plug. The plan is a small daemon that tails frames.jsonl, translates the fields above, and publishes MQTT Discovery entities to your own broker. It is a hundred lines the reader can type as fast as we can, and the first of them looks like this:
homeassistant/sensor/washer_time_to_end/config
{"name":"Washer time to end","state_topic":"washer/time_to_end",
"unit_of_measurement":"s","device_class":"duration","unique_id":"washer_time_to_end"}
Then publish the value of 04 11 to washer/time_to_end whenever it changes. Time to end, phase, door, state and the chosen programme are enough for a dashboard and every automation we have wanted.
Two consequences follow and neither is a bug. While the redirect is active the vendor app is blind: one connection, one owner, and it cannot be proxied because the leg to the cloud is mutual TLS with a key that never leaves the machine. Going local for good, with a permanent NAT rule or a DNS rewrite, kills the app permanently. That is a choice. We have not made it yet, because the app still has the twelve programmes that are not on the dial.
Safety, and a confession
- Arm the auto-revert before the rules. A router rule with no timer is a laundry room with no cloud, forever, at three in the morning.
- Do not publish your MAC, LAN addresses, client ID or topic strings; the last two contain the serial. Every placeholder above was real once.
- Never send START. Empty drum, one tag at a time, spin speed first.