A few months ago, I decided that it’s time I started to become more responsible and to start drinking more water. Unfortunately, the tap water where I live has a very high calcium content, which makes it a lot better when filtered and cooled first. To solve both issues, I picked up a second-hand professional water dispenser:
ION 900 series water dispenser
This thing is amazing. It’s connected to the water supply, has power save features when it detects it’s in the dark for a while, the filter lasts for years, …
Along with the cooler, I also bought thermally-isolated 1L bottles, which take about 50 seconds to fill. Unfortunately, you have to keep pressing the “dispense cold water”-button while filling 😑.
Since I’m lazy and not very rational in my decision making, I decided it’s time to fix that!
What makes it tick?
It’s pretty simple! The main part of the cooler is an insulated vat of chilled water (cooled by a compressor like in a fridge), through which the stainless steel tube of fresh filtered water flows:
Water vat with the insulation removed
To control the water flow, it contains multiple 24VDC valves. Other than that, the cooler contains some temperature sensors, a stirring pump, a fan, …
This all is controlled by the main PCB, containing an 8-bit PIC18F6527 microcontroller. In the front panel, there’s another PCB with a PIC16F630 to handle the LEDs and light sensor.
The two PCBs in the water dispenser
Dumping the binary
To modify the code running on the microcontrollers, we first have to get our hands on a copy of the binary. Keen-eyed readers might have already spotted the ICSP headers on both PCBs, which can be connected directly to a PICkit in-circuit programmer!
When starting this project, my PICkit was still on order, so I implemented a dumper based on the ICSP logic of the PIC-18 by using an Arduino:
Arduino PIC dumping setup, with external MOSFET to power the board
The resulting dumps can be found in the public GitHub repo of this project here.
Reverse engineering the firmware
To be able to implement a new feature in this firmware, we first need to have an idea of what that binary is actually doing. Of course, having the binary doesn’t give you the source code, but by disassembling it you get the assembly instructions and can even get an approximate version of the source code that might have produced this assembly. For this, I used Ghidra, a tool developed and open-sourced by the NSA. Luckily, Ghidra already supports the PIC-18 architecture. Opening up the binary and running all the standard analysis tool, the project contains a lot of detected functions which look a bit like this:
On the left hand side of the screen, the binary data and disassembled instructions are shown, while on the right Ghidra has done it’s best decompilation effort:
Since no debug symbols are present in this binary, all variables and functions have been given generic names like
bVar3. Let’s explain a bit what these refer to:
FUN_CODE_0067d2: This is a generated label referring to the detected function at address
0x67d2. Since the PIC18 is based on the Harvard architecture with separate instruction an data memory spaces, Ghidra denotes this address as
CODE_0067d2since it’s in the
DAT_DATA_0013: This label denotes a 8-bit data variable located in the
DATAmemory space at address
0x0013. The CPU in the PIC18 does not have general-purpose registers (except for
WREG), so it uses variables stored in data memory for most of its computations and function arguments. Since it re-uses most of these addresses so often, it’s really annoying to annotate the global variables used in the reverse engineered code.
bVar3: This denotes byte-sized variable number 3, used locally. Variables like these are inserted in the decompilation, but are not actually represented in the data memory anywhere.
Looking at what this decompiled code of this function is doing, we can see that it sets the
TRISE registers, after which it reads out
PORTE and does some processing with it. Referencing the PIC18F6527 datasheet, we can see that this sets the direction of the GPIO port B and E pins, after which it reads the values.
Using this knowledge, we can rename the labels used in the code and add some comments to make it a bit more readable:
By tracing the buttons on the front panel to the PIC on the main PCB, we find following connections:
B0: Room temperature water dispense
B1: Cold water dispense
We also find that this is the only function in the binary to reference
PORTB, which makes us sure that this is the function which will be called to check the status of the front panel buttons.
Using this process, I decompiled and found most of the functions we’re interested in, under which for example:
0x7118: Like the name suggests, calling this function with the desired duration in
DAT_DATA_0001(MSB) makes the beeper beep!
0x75c8: Regular delay function, with the desired duration in
0x62e8: Closes all dispensing valves
0x6332: Opens the inlet valve and the cold water dispensing valve
0x639c: This function is called in the main program loop and checks the button state through
read_port_b_eand calls the appropriate dispensing functions.
The Ghidra project with all the labels I added can be found here.
Ok, time to get our hands dirty and let’s see how we can add the hands-free functionality we want.
After some consideration, I settled on the following simple patches:
- Replace the call to
handle_button_presses_and_dispense-function with a call to my own
read_port_b_e(), and with the returned “actual” button states perform the added function
- Return a “faked” button state based on this added logic
Building a patch (C code)
I don’t like writing assembly, especially in cases like this when there is some trial-and-error going on. Instead, I opted to download Microchip’s XC8 compiler and use it to compile the binary patches straight from C code.
In the end, I settled on this code:
While writing and compiling something like this, there are a few caveats we need to be aware of, and work around.
First of all, there’s the issue that the functions will be packed into the smallest binary possible, with the function addresses being close to
0x0000. To address this (pun intended), we added the
__at(<address>) directives to force the compiler to put the code in a certain memory location. Since the original code stops at
0x8000 seemed like a nice spot to put our
Second issue we encounter is that we have to make the compiler aware to stay clear of the
DATA variables used by the original code. In Ghidra, we can see that the original code only uses
DATA variables with addresses below
0x100, hence we call the compiler with the argument to reserve that area. This option is intended to be used for code that’s used with bootloaders, but comes in handy in our specific case as well. We invoke the compiler as such:
Finally, we need to be aware that we can’t rely on the global variables to be initialized with a known value. Initializing it globally in this code would result in the compiler generating some instructions to be run before
main() is called, which we can’t very easily patch into the original binary. A more simple way was used, namely by dedicating a global
uint16_t as a flag with a magic value to check the initialization status.
Let’s look at the generated binary
Opening up and analyzing the freshly compiled
hook binary in Ghidra, we can see that our code is indeed in the intended locations, and that it only uses
DATA variables over
0x100, except when needed as arguments or return values. This is the decompiled version of the
Patching the original binary
Now that we have a binary version of the
patch(), we can just copy that part of the binary over to the original code at address
After this, there’s only one thing left: replacing the call in
0x67d2 to the new
patch() function at
To do this, we could do this by hand by recalculating and replacing the argument bytes in the CALL-instruction, but Ghidra has a nice feature for this! Right-clicking on the instruction you want to patch and selecting ‘Patch Instruction’ allows you to edit the assembly line directly, and it’ll automagically recalculate the binary representation. How awesome!
I had a proper PICkit for flashing now!
Results and conclusions!
Et voila! A working patch for hands-free mode, complete with cancel functionality and annoying beeps!
Added hands-free dispensing! Can even cancel the pour mid-way by pressing a button again.— Robbe Derks (@robbederks) December 21, 2021
Timer needs some tweaking to fill my bottle, but this will definitely save me 30s of button pressing a day! pic.twitter.com/90bHw7xXmo
In the end, it took about 8 hours to get this project finished, which was spread out over multiple weeks. Considering that I usually fill my bottle once a day, and it takes about 50 seconds to do so, this will have a positive return-on-investment after only about 1.5 years!