Brute Forcing Pentair RS-485
Several open source projects have implemented portion of Pentair’s RS-485 protocol over the years, but none of them mesh with my setup and way of thinking. On the setup side, I only have a Pentair pump – I do not have any of the magic controllers or anything else that’s generally found on the RS-485 bus. For this project, I have a Raspberry Pi with an RS-485 adapter wired up directly to the pump. On the way-of-thinking side, most of the projects out there implement the minimum viable functionality and have magic bytes scattered around the code with the hows and whys buried in years of forum posts and chat logs. My goal is to publish a map of my pump’s complete API and implement a straight-forward class to interface with it.
Getting Started
In general, Pentair packets look like this:
[ [PACKET_HEADER], PAYLOAD_HEADER, VERSION, DESTINATION, SOURCE, ACTION, DATA_LENGTH, [DATA], [CHECKSUM] ]
[PACKET_HEADER]is always0xFF, 0x00, 0xFF.PAYLOAD_HEADERis always0xA5.- There’s some discussion around what
VERSIONis all about, but I’ve found that for my use case, it’s safe to always have it be0x00. DESTINATIONis the address of the receiver. RS-485 is a broadcast bus and is not routed. That means that every packet will go to every device connected to the bus. TheDESTINATIONaddress tells the receiver that they should care about that packet.SOURCEis the address of the sender. When the receiver responds, it will reverseSOURCEandDESTINATION. If you lie about theSOURCE, the receiver will happily set the responseDESTINATIONto whateverSOURCEyou passed in the request.ACTIONis the basic action or class of actions you want to perform. This will make more sense later on.DATA_LENGTHis the number of bytes in[DATA]. It can be 0.[DATA]contains different things depending on theACTION:- If the
ACTIONdoes not take any parameters,DATA_LENGTHwill be0and will be followed directly by the[CHECKSUM]. For example, to request pump status,ACTIONis0x07(get pump status),DATA_LENGTHis0x00and[DATA]doesn’t exist in the packet at all.Request: [255, 0, 255, 165, 0, 96, 33, 7, 0, 1, 45] - If the
ACTIONtakes a single byte argument,DATA_LENGTHwill be1and[DATA]will contain the relevant argument. For example, to ready the pump for remote control,ACTIONis0x04(set remote control),DATA_LENGTHis1and[DATA]is0xFF(on).Request: [255, 0, 255, 165, 0, 96, 33, 4, 1, 255, 2, 42] - Going on down the rabbit hole, some
ACTIONShave sub-actions which are defined in[data]. For example, to ask the pump to run at 1500 rpm,ACTIONis0x01(set settings) and[DATA]is0x02, 0xC4, 0x05, 0xDCwhere0x02, 0xC4is (set rpm) and0x05, 0xDCis the hex byte representation of 1500.Request: [255, 0, 255, 165, 0, 96, 33, 1, 4, 2, 196, 5, 220, 2, 210]
- If the
[CHECKSUM]is a 2-byte (big-endian) representation of the sum of bytes beginning with and includingPAYLOAD_HEADERand ending and including the last byte in[DATA].
Responses
As a general rule, response packets start out similar to the request but swap SOURCE and DESTINATION. Depending on the situation the response [DATA] could take a few forms:
- If the request asked for data, those data will be returned. For example, if the request
ACTIONis0x07(get pump status), the responseACTIONwill stay0x07but its[DATA]will be an array of bytes representing various status elements.Request: [255, 0, 255, 165, 0, 96, 33, 7, 0, 1, 45] Response: [255, 0, 255, 165, 0, 33, 96, 7, 15, 10, 0, 0, 1, 25, 5, 220, 0, 0, 0, 0, 0, 1, 16, 52, 2, 134] - If the request was a setter, the data that were set will be returned. For example, if the request
ACTIONis0x04(set remote control) and[DATA]is0xFF, the responseACTIONwill stay0x04and its[DATA]will be0xFFas well, confirming the value set.Request: [255, 0, 255, 165, 0, 96, 33, 4, 1, 255, 2, 42] Response: [255, 0, 255, 165, 0, 33, 96, 4, 1, 255, 2, 42] - If the request
[DATA]includes sub-actions / routing, only the final bytes of data actually set will be returned. For example, when the pump is set to 1500 rpm as above, the responseACTIONwill be0x01(set mode) but[DATA]will contain only0x05, 0xDCrepresenting 1500 with no reference to the0x02, 0xC4(set rpm) sub-action / route.Request: [255, 0, 255, 165, 0, 96, 33, 1, 4, 2, 196, 5, 220, 2, 210] Response: [255, 0, 255, 165, 0, 33, 96, 1, 2, 5, 220, 2, 10]
Trying to Break Things
From previous research, we already have some good leads:
ACTIONS = {
'ACK': 0x00,
'PROGRAM': 0x01,
'REMOTE_CONTROL': 0x04,
'SPEED': 0x05,
'POWER': 0x06,
'STATUS': 0x07,
'UNKNOWN': 0xFF
}
Since ACTION is only one byte, let’s try throwing every possible ACTION with no [DATA] and see what we get back. (Note: While often effective, this sort of brute force testing can have lots of unintended consequences – like setting parameters, running functions, bricking the device you’re talking to, etc. Do this sort of thing at your own risk and preferably on equipment you don’t need in production.)
Req Action | Req Data | Res Action | Res Data
0x0 | | 0x0 | None
0x1 | | 0xff | [8]
0x2 | | 0xff | [8]
0x3 | | 0x3 | [13, 38]
0x4 | | 0x4 | [1]
0x5 | | 0x5 | [1]
0x6 | | 0x6 | [1]
0x7 | | 0x7 | [10, 0, 0, 2, 72, 7, 208, 0, 0, 0, 0, 4, 22, 13, 38]
0x8 | | 0xff | [8]
0x9 | | 0xff | [1]
...
0xff | | 0xff | [1]
That ... isn’t actually in the output, but I didn’t figure anyone wanted to scroll through ~200 lines that are all 0xff | [1]. Trust me, everything snipped out is uninteresting.
Our leads tell us that 0x01 should be a valid ACTION, but we get back 0xff. If we look over the rest of the tests, we can see that 0xff occasionally comes along with [DATA] being 8, but usually it’s 1. To my eyes, 0xff means ERROR with 1 being Command Not Found and 8 being Invalid Parameters.
Let’s run through the tests and see if this pans out comparing with the leads above:
- Sending an
ACTIONof0x00gets us0x00with no[DATA]back. This does indeed feel like an Acknowledgment and nothing more. - Sending an
ACTIONof0x01gets usInvalid Parameters, which makes sense because setting up pump programs requires additional parameters. - Sending an
ACTIONof0x02gets usInvalid Parameters. We don’t know anything about0x02yet, so¯\_(ツ)_/¯. - Sending an
ACTIONof0x03gets us 2-bytes of[DATA]. We don’t know anything about0x03yet either, so more¯\_(ツ)_/¯. - Sending an
ACTIONof0x04gets us a1. Not sure yet what this is telling us, but we got the0x04back, so probably not an error. - Sending an
ACTIONof0x05gets us a1. Not sure yet what this is telling us, but we got the0x05back, so probably not an error. - Sending an
ACTIONof0x06gets us a1. Not sure yet what this is telling us, but we got the0x06back, so probably not an error. - Sending an
ACTIONof0x07gets us back a bunch of[DATA]as would be expected for a status report. Haven’t we seen[13, 38]before? Interesting. - Sending an
ACTIONof0x08gets usInvalid Parameters. We don’t know anything about0x08yet, so¯\_(ツ)_/¯. - Sending anything else for
ACTIONgets us0xffand1which I think has to meanCommand Not Found. This means that moving forward we can reduce our brute force blast radius to0x00-0x08which should cut down on time considerably.
Get Time
Going back to previous research, we know that the pump status fields look like this:
PUMP_STATUS_FIELDS = {
'RUN': 0,
'MODE': 1,
'DRIVE_STATE': 2,
'WATTS_H': 3,
'WATTS_L': 4,
'RPM_H': 5,
'RPM_L': 6,
'GPM': 7,
'PPC': 8,
'UNKNOWN': 9,
'ERROR': 10,
'REMAINING_TIME_H': 11,
'REMAINING_TIME_M': 12,
'CLOCK_TIME_H': 13,
'CLOCK_TIME_M': 14
}
That means those last two bytes – [13, 38] in our example – are the time. It seems pretty clear that an ACTION of 0x03 with no [DATA] gives us back the current clock time. We’ll have to poke around more to see if parameters do anything.
Progress
This gives us:
ACTIONS = {
'ACK_MESSAGE': 0x00,
'PROGRAM': 0x01,
'UNKNOWN_2': 0x02,
'TIME': 0x03,
'REMOTE_CONTROL': 0x04,
'SPEED': 0x05,
'POWER': 0x06,
'STATUS': 0x07,
'UNKNOWN_8': 0x08,
'ERROR': 0xFF
}
ERROR_CODES = {
1: 'Unknown Command',
8: 'Invalid Parameters'
}
Add Some Data
Let’s try throwing some data at each of our viable ACTIONS:
0x0 | 0x0 | 0x0 | None
...
0x0 | 0xff | 0x0 | None
0x1 | 0x0 | 0xff | [8]
...
0x1 | 0xff | 0xff | [8]
0x2 | 0x0 | 0xff | [8]
...
0x2 | 0xff | 0xff | [8]
0x3 | 0x0 | 0xff | [7]
...
0x3 | 0xff | 0xff | [7]
0x4 | 0x0 | 0x4 | [0]
0x4 | 0x1 | 0x4 | [1]
...
0x4 | 0xfe | 0x4 | [254]
0x4 | 0xff | 0x4 | [255]
0x5 | 0x0 | 0x5 | [0]
0x5 | 0x1 | 0x5 | [1]
...
0x5 | 0xfe | 0x5 | [254]
0x5 | 0xff | 0x5 | [255]
0x6 | 0x0 | 0x6 | [0]
0x6 | 0x1 | 0x6 | [1]
...
0x6 | 0xfe | 0x6 | [254]
0x6 | 0xff | 0x6 | [255]
0x7 | 0x0 | 0x7 | [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 59, 18, 53]
...
0x7 | 0xff | 0x7 | [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 59, 18, 53]
0x8 | 0x0 | 0xff | [8]
...
0x8 | 0xff | 0xff | [8]
Looking at the output, the first thing I notice is that 0x00 (acknowledgment) and 0x07 (get pump status) both acted exactly the same as before. This tells me that at least for simple ACTIONS, the pump is just throwing away extraneous bytes. Given that we know pretty well what 0x00 and 0x07 ACTIONS do, I think we can stop testing them moving forward.
The next interesting thing is that 0x03 is now throwing a 0xff and 7 is an error code we haven’t seen before. Being that 0x00 and 0x07 didn’t throw errors, it’s probably not “Got arguments but wasn’t expecting any”, but it’s likely something similar. Add it to the ¯\_(ツ)_/¯ list.
0x01, 0x02, and 0x08 all threw error code 8 across the board, so we probably need more parameters.
Now, for the interesting stuff. 0x04, 0x05, and 0x06 all gave me back the [DATA] byte I gave them. As for real life data, somewhere in 0x05 I heard the pump turn on and right at the beginning of 0x05, the pump turned off.
Speed
Let’s play with 0x05 (speed)
Req Action | Req Data | Res Action | Res Data | RPM
0x5 | 0x0 | 0x5 | [0] | 0
0x5 | 0x1 | 0x5 | [1] | 0
0x5 | 0x2 | 0x5 | [2] | 450
0x5 | 0x3 | 0x5 | [3] | 1150
0x5 | 0x4 | 0x5 | [4] | 1800
0x5 | 0x5 | 0x5 | [5] | 1800
0x5 | 0x6 | 0x5 | [6] | 1800
0x5 | 0x7 | 0x5 | [7] | 1800
0x5 | 0x8 | 0x5 | [8] | 1800
0x5 | 0x9 | 0x5 | [9] | 1800
0x5 | 0xa | 0x5 | [10] | 1800
0x5 | 0xb | 0x5 | [11] | 1800
0x5 | 0xc | 0x5 | [12] | 1800
0x5 | 0xd | 0x5 | [13] | 1800
0x5 | 0xe | 0x5 | [14] | 2180
0x5 | 0xf | 0x5 | [15] | 2880
0x5 | 0x10 | 0x5 | [16] | 3450
...
0x5 | 0x4e | 0x5 | [78] | 3450
0x5 | 0x4f | 0x5 | [79] | 0
...
0x5 | 0xff | 0x5 | [255] | 0
Trolling around the forums and such, I’ve found several bytes that could potentially be useful:
SPEED = {
'FILTER': 0x00,
'MANUAL': 0x01,
'SPEED_1': 0x02, # Backwash on some pumps?
'SPEED_2': 0x03,
'SPEED_3': 0x04,
'SPEED_4': 0x05,
'FEATURE_1': 0x06,
'EXTERNAL_1': 0x09,
'EXTERNAL_2': 0x0a,
'EXTERNAL_3': 0x0b,
'EXTERNAL_4': 0x0c,
}
Testing a little more deliberately, I find the following:
0x00has no effect0x01has no effect0x02sets the RPM to 1100 (min) and the timer to 24-hours0x03sets the RPM to 2000 and the timer to 24-hours0x04sets the RPM to 2350 and the timer to 24-hours0x05sets the RPM to 3110 and the timer to 24-hours0x06has no effect – likely Speed 5 which is not set by default0x07has no effect – likely Speed 6 which is not set by default0x08has no effect – likely Speed 7 which is not set by default0x09has no effect – likely Speed 8 which is not set by default0x0asets the RPM to 3450 (max) and the timer to 24-hours. This may be the documented Quick Clean feature?0x0bsets the RPM to 0 and the timer to 3-hours. This state also seems to block the pump from being remotely turned off or setting RPM manually until0x02-0x05are run. This may be the documented Time Out feature?0x0chas no effect- Passing extra
[DATA]bytes have no effect. Everything acts the same as with the single byte, similar to[0x00]and[0x07]when they received extra bytes.
This leaves us at:
SPEED = {
'SPEED_1': 0x02,
'SPEED_2': 0x03,
'SPEED_3': 0x04,
'SPEED_4': 0x05,
'QUICK_CLEAN': 0x0b,
'TIME_OUT': 0x0c,
}
YouTube!