Home automation field notes All concepts

protocols

Talking to an AEG washer-dryer without the cloud

The replication guide: redirect the machine at the router, answer it with a hundred lines of Python, read the wire format, use the field map, and know exactly when it will and will not take an order.

Two files travel with this page: the guide itself as raw Markdown, aeg-local-control.md, for people who would rather copy than read, and the sink, aeg_wash_sink.py, published as-is from a working laundry room. The story of how any of this became necessary is on the washer page.

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