Sub Zero integration with Home Assistant

I just got access to a slightly different model, the PUMA-250 PCB. From the PCB fabrication mark, it appears to have been manufactured in the 10th week of 2021, vs 23rd week of 2022 for @rlshaw23’s model. Note that all components on the top side of the board, including inside the wireless module are coated with a healthy amount of conformal coating to protect from moisture (which also frustrates buzzing things out with a multimeter).

I popped the lid off of the wireless module and found that they are using the following ICs:

  • Atheros AR9342-DL3A (2.4/5GHz WLAN SoC)
  • Winbond W9751G6NB-25 (512Mbit / 64MiB DDR2 SDRAM)
  • Atheros 3012-BL38 (Bluetooth 4.0 controller)
  • Skyworks SE5005L (5GHz Power Amplifier)

Based on similar Atheros chipset (AR9341), it is likely powered by a MIPS 74Kc core. There is no public data sheet or SDK for this part.

I have not yet been able to figure out what pin 3 is used for and I’m going to assume that it’s not connected at this point. I would not be surprised if it was some sort of device detection pin, but am unable to detect any resistance to GND, 3v3, 2v5, or 12v rails; nor to any exposed test point for SoC GPIOs.

Re-purposing existing hardware is probably not a productive way forward due to lack of SDKs and information on the Atheros SoC.

EDIT: There is also a weird rectangle of a type of putty-like substance underneath the wireless module which covers at least one IC. It is underneath the conformal coating, so is not easily removed. It is partially obscuring a SOP IC, but its ST Micro maker mark is visible along with a part number beginning with 148.

EDIT 2: This ST Micro IC is very likely an ST1480AB RS-485 interface IC.

2 Likes

wow, great information. admittedly, i’m not knowledgeable in this area, so most of it goes over my head :smile: . But, if you need another appliance wireless module to play around with, I’d happily ship you one as I was given an extra by mistake during my appliance install. Just lmk

1 Like

Thanks for the offer, I don’t believe I’ll need another board at this time. I’ll keep you in mind though!

I was able to attach a logic analyzer and captured some traffic with pulseview (I had the wifi module installed at the same time). It’s using 38400 baud, 8 data bits, no parity, 1 stop bit. Least significant bit first.

Here are 3 unique packets I captured:

data = [
    [0x1C, 0x02, 0x01, 0x03, 0x40, 0x00, 0x16, 0x00, 0xA3],
    [0x1C, 0x02, 0x01, 0x03, 0x40, 0x14, 0x01, 0x04, 0xA0],
    [0x1C, 0x02, 0x01, 0x11, 0x40, 0x00, 0x14, 0x26, 0x00, 0x44, 0x49, 0x00, 0xFF, 0xFF, 0x55, 0x46, 0x00, 0x54, 0x04, 0x00, 0x00, 0x02, 0xF1]
]

The format seems to be a 3 byte header, 1 byte length, n-byte payload, 2 byte footer.

I wasn’t using the subzero iphone app when I captured these, I’ll mess around with the app tomorrow and try to capture some packets for different app operations to see if theres any pattern.

3 Likes

I’d love to assist with this effort as well. I’m absolutely stunned that a company would be so anti-consumer when its primary means of profit is decidedly not software. We have a fridge; we just got it about six months ago. I also have a development background.

I wrote some python code that could be a starting point for others to help decode the data.

import socket
import struct

# Contains 1 message found over serial
class Packet:
    def __init__(self):
        self.header = []
        self.length = 0
        self.payload = []
        self.footer = []

    def __str__(self):
        return str(self.header) + "-" + str(self.length) + "-" + str(self.payload) + "-" + str(self.footer)

    def toBytes(self):
        return self.header + [self.length] + self.payload + self.footer

# State machine classes
class StaticMatchState:
    def __init__(self, packet, pattern):
        self.packet = packet
        self.pattern = pattern
        self.i = 0

    def consume(self, b):
        if b == self.pattern[self.i]:
            #print(f"{b} matches the next part of the pattern")
            self.packet.header.append(b)

            self.i += 1

            if self.i == len(self.pattern):
                #print(f"Found the whole pattern: {self.pattern}")
                return LengthState(self.packet)
            return self
        else:
            #print(f"{b} doesn't match {self.pattern[i]}, reseting state")
            return StaticMatchState(Packet(), self.pattern)

class LengthState:
    def __init__(self, packet):
        self.packet = packet

    def consume(self, b):
        self.packet.length = b
        return PayloadState(self.packet, b)

class PayloadState:
    def __init__(self, packet, length):
        self.packet = packet
        self.length = length
        self.consumed = 0

    def consume(self, b):
        self.packet.payload.append(b)
        self.consumed += 1
        if self.consumed >= self.length:
            return FooterState(self.packet, 2)
        return self

class FooterState:
    def __init__(self, packet, length):
        self.packet = packet
        self.length = length
        self.consumed = 0

    def consume(self, b):
        self.packet.footer.append(b)

        self.consumed += 1
        if self.consumed >= self.length:
            return self.packet
        return self

def startMatchingState():
    return StaticMatchState(Packet(), [0x1C, 0x02, 0x01])

# Convert TCP bytes into Packet objects
def getPackets(ip, port):
    clientsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    clientsocket.connect((ip, port))

    state = startMatchingState()
    while True:
        result = clientsocket.recv(16)
        for b in result:
            state = state.consume(b)
            if isinstance(state, Packet):
                yield state
                state = startMatchingState()

# Code that interprets Packet.payload data into something a human can understand (WIP)
def asHex(b):
    return hex(b)

def signedChar(b):
    return struct.unpack('>1b',bytes([b]))[0]

flags = {
    7: {
        "VacationFlag1?": 0b00000001
    },
    12: {
        "VacationFlag2?": 0b00000001
    },
    13: {
        "PureAir": 0b00010000,
        "IceMakerOn": 0b01000000
    },
    14: {
        "IceMakerMaxIce": 0b00000001,
        "AlarmBeeping": 0b00001000,
        "AlarmOn": 0b00000100
    },
    15: {
        "IceMakerNightMode": 0b00000001
    }
}

def decodePayload(old, new):
    for i in range(17):
        label = f"payload[{i}]"
        unpackData = asHex
        if i == 3:
            label = "Fridge Set Point"
            unpackData = signedChar
        elif i == 5 or i == 6:
            label = "Long Vacation timestamp?" # these change when setting long vacation mode, not sure what it is yet
        elif i == 8:
            label = "Freezer Set Point"
            unpackData = signedChar
        elif i in flags:
            label = f"Flags[{i}]"

        if old[i] == new[i]:
            print(f"{label}: {unpackData(old[i])}")
        else:
            print(f"{label}: {unpackData(old[i])} -> {unpackData(new[i])}")
            if i in flags:
                for flag in sorted(flags[i].keys()):
                    oldFlag = (old[i] & flags[i][flag]) > 0
                    newFlag = (new[i] & flags[i][flag]) > 0
                    if (oldFlag == newFlag):
                        print(f"    {flag}: {oldFlag}")
                    else:
                        print(f"    {flag}: {oldFlag} -> {newFlag}")
    print()



seenPackets = {}

# packet that arrived before this one, used for `uniq` style deduping based on payload bytes
previousPacketPayload = None

# previous status packet that arrived before this status packet, used for `uniq` style deduping of status packets
previousStatusPayload = None

def isStatusPacket(packet):
    payload = packet.payload
    # works at the moment, not sure if its correct over time or for other refridgerators
    return len(payload) == 17 and payload[0] == 0x40 and payload[1] == 0x00 and payload[2] == 0x14

for packet in getPackets('192.168.20.70', 8888):
    payload = packet.payload
    payloadBytes = " ".join(f"{n:#04x}" for n in payload)

    if previousPacketPayload is None or previousPacketPayload != payload:
        packetType = "Unknown"
        if payloadBytes.startswith("0x40 0x00 0x14"):
            packetType = "Status"
        elif payloadBytes == "0x40 0x07":
            packetType = "Door Open"

        print(f"New Payload ({packetType}): {payloadBytes}")
    previousPacketPayload = payload

    # special decoding for status packets with diffing against previous status
    if isStatusPacket(packet):
        if previousStatusPayload and previousStatusPayload != payload:
            decodePayload(previousStatusPayload, payload)
        previousStatusPayload = payload

    if str(packet) not in seenPackets:
        print(f"Found a new unique packet with payload: {payloadBytes}")
        seenPackets[str(packet)] = packet

Here’s an example of the output:

New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
Found a new unique packet with payload: 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x40 0x00 0x16
Found a new unique packet with payload: 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
Found a new unique packet with payload: 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Door Open): 0x40 0x07
Found a new unique packet with payload: 0x40 0x07
Found a new unique packet with payload: 0x40 0x07
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x80 0x07
Found a new unique packet with payload: 0x80 0x07
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xfe 0xff 0x55 0x41 0x04 0x54 0x04 0x00 0x50
payload[0]: 0x40
payload[1]: 0x0
payload[2]: 0x14
Fridge Set Point: 38
payload[4]: 0x0
Long Vacation timestamp?: 0x4c
Long Vacation timestamp?: 0x6e
Flags[7]: 0x0
Freezer Set Point: -1 -> -2
payload[9]: 0xff
payload[10]: 0x55
payload[11]: 0x41
Flags[12]: 0x0 -> 0x4
    VacationFlag2?: False
Flags[13]: 0x54
Flags[14]: 0x4
Flags[15]: 0x0
payload[16]: 0x50

Found a new unique packet with payload: 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xfe 0xff 0x55 0x41 0x04 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x80 0x07
New Payload (Door Open): 0x40 0x07
New Payload (Unknown): 0x80 0x07
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x04 0x54 0x04 0x00 0x50
payload[0]: 0x40
payload[1]: 0x0
payload[2]: 0x14
Fridge Set Point: 38
payload[4]: 0x0
Long Vacation timestamp?: 0x4c
Long Vacation timestamp?: 0x6e
Flags[7]: 0x0
Freezer Set Point: -2 -> -1
payload[9]: 0xff
payload[10]: 0x55
payload[11]: 0x41
Flags[12]: 0x4
Flags[13]: 0x54
Flags[14]: 0x4
Flags[15]: 0x0
payload[16]: 0x50

Found a new unique packet with payload: 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x04 0x54 0x04 0x00 0x50
New Payload (Unknown): 0x80 0x07
New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x04 0x54 0x04 0x00 0x50
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50
payload[0]: 0x40
payload[1]: 0x0
payload[2]: 0x14
Fridge Set Point: 38
payload[4]: 0x0
Long Vacation timestamp?: 0x4c
Long Vacation timestamp?: 0x6e
Flags[7]: 0x0
Freezer Set Point: -1
payload[9]: 0xff
payload[10]: 0x55
payload[11]: 0x41
Flags[12]: 0x4 -> 0x0
    VacationFlag2?: False
Flags[13]: 0x54
Flags[14]: 0x4
Flags[15]: 0x0
payload[16]: 0x50

New Payload (Unknown): 0x40 0x00 0x16
New Payload (Unknown): 0x40 0x14 0x01
New Payload (Status): 0x40 0x00 0x14 0x26 0x00 0x4c 0x6e 0x00 0xff 0xff 0x55 0x41 0x00 0x54 0x04 0x00 0x50

I’ve filled in some of the easy parts (things I can change in the app or touching the fridge control panel), but there’s more data to “decode.”

I am using an ESP32 running tasmota along with this board Amazon.com: Teyleten Robot TTL to RS485 Module 485 to Serial UART Level Mutual Conversion Hardware Automatic Flow Control Module TTL Turn to RS485 Module 3.3V-5.5V (5pcs) : Electronics

Tasmota has a serial to TCP bridge functionality, which I enabled using these commands in the Tasmota console:

TCPBaudRate 38400
TCPStart 8888
2 Likes

I have a couple more findings:

  1. The 3 byte header starts with 0x1C, then it seems the second byte is the source of the message and the third byte is the destination of the message.
  2. So far I have observed these sources and destinations 0x01, 0x2, 0x06, 0x023
  3. The first byte of the payload appears to be an opcode 0x40 or 0x80.
    A. When the src is 0x01, opcode 0x40 behaves like a “query” and the remaining 1 byte of the payload seems to be a register address. The destination device response to the query with opcode 0x80 and the remaining bytes of that payload are the register address and value.
    B. When the dst is 0x01, opcode 0x40 seems to behave like an advertisement of state from the src to 0x01.
1 Like

That’s great progress on the reverse engineering!

Do you see periodic status updates (and possibly queries) when the app isn’t running?

If so look for changing/cycling values that might indicate the internal temperature. Don’t know if it would actually send that or just range / out of range.

The 2 byte “footer” might be a 16-bit CRC. Reveng can be good at figuring out the CRC type, init, and polynomial. It tends to work best if you have some good hunches about which packets are known good. (That should be a lot easier with a serial connection than RF)

1 Like

Yes, I see a lot of traffic even when the app isn’t opened. Most packets are repeated 4 times in a row, which gives me confidence all 4 packets are “known good” - I will try out your CRC tool, thanks!

There is a relatively large message (~60 bytes) that seems to represent the internal state of the fridge. The “medium” sized message I worked towards decoding yesterday seems to be the state of the user’s configuration (temperate set point, ice mode, etc). I haven’t started decoding the larger message except noticed the first 4 bytes seem to be epoch seconds (and my fridge seems to be living a few years in the past :D).

My code has some changes, I will try to post it on github in the next couple of days.

Just FYI, I don’t really have any interest in controlling my fridge remotely. I am mostly interested in getting temperatures, door open/close events, filter life, water filter usage (if this data exists) into home assistant. After that I will probably lose interest (but will post all my code in case others have an interest in continuing).

Also, I ordered Tiny Matter-Native Board for Smart Home - XIAO ESP32C6 and Seeed Studio RS-485 Breakout Board for XIAO and QT Py, with half-duplex tranceiver converting UART Serial to long-distance high-speed RS-485 transmission. I haven’t used them before, but the form factor looks nice and it implies I can power the whole thing off the fridge’s 12V. They arrive at the end of the month.

1 Like

This is great work. I have a custom ESP32 board mostly designed that will plug in and allow whatever environment folks want. Personally, I was planning on dropping Rust in there and exposing things through MQTT.

@gpearman did you use Pin 3 as RS-485 earth or did you just use the GND pin?

I used the GND pin everywhere and didn’t use pin 3.

Right now I have everything powered off the fridge, currently hacked together on a breadboard.

I’m using a buck converter to create 5V using the 12V pin and the ground pin (Amazon.com: Diitao 3pcs LM2596 LM2596S Buck Converter DC-DC 4-40V to 1.25-37V 2A Reduced Voltage Regulator Power Modules Adjustable Board Step Down Module with LED Display : Electronics)

The ESP32 I am using is this one which has a 5V → 3.3V chip: (Amazon.com: FREENOVE ESP32-WROOM Board (2 Pack), Dual-core 32-bit 240 MHz Microcontroller, Onboard Wireless, Python C Code, Example Projects Tutorial : Office Products). This board has worked well for me generally, but I don’t think I would recommend buying it for this project because UART2 is connected to a WS2811 LED that shines very bright when receiving serial data.

I am using 3.3V on the ESP side of the RS485 to UART board (see previous post, I am a new user and the forum won’t let me include a 3rd link in this post). I’m using GND from the fridge on both sides of the RS485 board

I put my latest code here: GitHub - georgepearman/subzero-mqtt
It’s very much a work in progress right now.

It supports 3 modes:
0 - print packet bytes, 1 packet per line
1 - print packet bytes with the header and first few bytes of the payload interpreted (opcode, register)
2 - Further decode some of the data. So far I have found a handful of internal temperature sensors, door status, water dispensing, ice machine settings, temperature set points

I plan to log the temperature sensors over time for a couple days to try to figure out what each one represents

5 Likes

I believe the checksum algorithm is:
(sum(all bytes in packet) + 229) % 256 = 0

Or, if you exclude the start byte 0x1c, its:
(sum(all bytes in packet) + 1) % 256 = 0

Do you happen to know what the conversation is for the temperature? And whether it’s in Celsius or Fahrenheit?
I’m getting -102 or so for the freezer.
I’ve got some capture from a different model I believe and am slowly going through it to determine the differences compared to the GitHub code you posted (super helpful by the way. Thanks for doing all that!). I can share once I get farther, but currently the doors opening and closing are properly getting set (they were slightly different than your model)

1 Like