Bad things happen when programmer finds a soldering iron

Pulling Rotary Encoder State

Illustration

When it comes to microcontrollers reading a rotary encoder, I often see it being done using interrupts. While that is not necessarily the wrong thing to do, it can crowd the interrupt handler with something that’s not really critical functionality, especially if microcontroller can handle it within its base loop just as effectively.

To start with, the code is available here and consists of two exported functions:

void rotary_init(void);
enum ROTARY_DIRECTION rotary_getDirection(void);

If we disregards rotary_init which just initializes a few variables, all the significant code is in rotary_getDirection function. To use it, simply call this function once in a while from the main loop, and it will return one of three values (ROTARY_DIRECTION_NONE, ROTARY_DIRECTION_LEFT, or ROTARY_DIRECTION_RIGHT) corresponding to the detected movement.

The heart of the functionality is in the following code:

    uint8_t currRotState = getRotaryState();
    if (currRotState != lastRotState) {
        histRotState = (uint8_t)(histRotState << 2) | currRotState;
        lastRotState = currRotState;
        uint8_t filteredRotState = histRotState & 0x3F;
        if (filteredRotState == 0x07) { return ROTARY_DIRECTION_LEFT; }
        if (filteredRotState == 0x0B) { return ROTARY_DIRECTION_RIGHT; }
    }
    return ROTARY_DIRECTION_NONE;

First, we get the state in binary for each of the 2 contacts available. Thus, the output can be either 0x00, 0x01, 0b10, or 0b11, depending on which contacts are closed. If the state has changed compared to the previous reading, we append it to the variable used to store previous states. As each state is 2 bits long, this means we can easily fit all 4 states in a single 8-bit value by shifting it 2 bits at a time.

If your switch is new and shiny, this miis where you might stop. However, in the real world, switches are dirty and thus can skip a state or two. For example, one of my rotary encoders would skip a state every once in a while. This means that a proper code would simply make experience worse as time goes by.

However, due to redundancies in how the encoder functions, detecting three neighboring states still gives you plenty of information to go by without misidentifying the direction of scrolling. In my code, detection starts when both encoder contacts are active (i.e. 0b11 state). On all encoders I worked with (admitely, mostly PEC12R series from Bourns), this actually nicely aligns with steps and almost perfectly filters wear-induced noise.

And yes, you can adjust the code slightly to make it run in an interrupt routine if you are so inclined.

Driving the Plunger (part 3)

This is part 3 out of 3 in the series (see part 1 or 2).


Well, if after reading previous posts you were curious as to how it all looks, wait no more. Below is a quick video showing its usage. And yes, it was a minimum effort video so sound is not there.

First is the mounting method. Just place syringe around the plunger and rotate to fix in place. If you used another syringe before this, you might need to move the plunger ahead until it makes contact. In the video, you can see that I reverse it a bit and then I move it forward just to illustrate this point.

After dealing with the initial paste surge, you get to control the syringe using either the button in the top-left corner or the foot pedal. As I use a foot pedal, you won’t directly see my presses, but you’ll notice the LCD flashes.

As you can see in the video, this thing actually works. However, it’s not without its issues. The most noticeable one is speed and drooping. Every push creates pressure and, no matter how many pull-back tricks I’ve tried, it will not squirt paste fast and without excess. Each profile was essentially a balancing act between how much paste I push and how quickly.

Firmware development for this was actually quite easy and really depended on two programming exercises I already had made from before: OLED and rotary button. The rest of the firmware was just reading the button status and pushing text to an OLED. You can check how I did it on GitHub but I will probably also make a separate post on each.

Would I use it? Well, maybe. While it actually does what I intended to do, the whole process is just too slow. I can do non-paste soldering much faster. Also, different paste will react differently and will need adjustments.

It’s not great for regular work but it’s actually quite okay if you need repeatable solder paste dispensing for special components.

Driving the Plunger (part 2)

This is part 2 out of 3 in the series (see part 1 or 3).


So, we’re continuing on PlungerDriver development. If you’re not up-to-date, do check part 1.

For electronics, I decided early to keep it rather simple and use only components I had lying around. I mean, I was waiting for motors but there was no reason why I couldn’t just cobble something up.

The first decision I almost always like to make is how I will control the darn thing. In this case I saw myself using combination of a rotary button and a small OLED screen.

Illustration

The rotary button I had was the one I used for a few projects in the past, which was Bourns PEC12R, more precisely its PEC12R-4220F-S0024 variant. It’s 12mm square, has detents (which I love), and you can also press it to get button functionality. Essentially, you have left/right/button controls. Ideal for something like this.

The screen I also had decided early on. Small 128x64 I2C OLEDs are easy to obtain and easy to drive. Even better, I had quite a few of different ones laying around from previous projects (e.g., USB OLED).

Input-wise, I wanted to connect it all via just a standard 3.81 mm Phoenix-style MC connector. These are really easy to work with, available from multiple manufacturers, and I have a load of these always in my storage.

For output, I decided to use JST XH connector. Using a different connector here was just to make it really clear what goes where. If I can do it, I always try to do different connectors for each functionality board might have. Makes mistake a lot less likely to happen.

As a trigger for paste dispensing, I decided to use both an on-board button and an external connector. While having a button on board was a handy thing to have during the testing, I really wanted an external connection for a foot pedal. Not only are they easy and cheap to get, but they’re the only practical way to trigger something while keeping both of your hands free. And yes, in order to avoid potential errors, I used a JST PH connector here.

The last selection was a PIC microcontroller. And yes, I knew it would be a PIC since those are the only ones I have in my storage. I did play with some other microcontrollers out there, but my pipeline and knowledge are so heavily skewed toward PIC that I always select it for projects like this. And here I opted for PIC16F1454. While many other Microchip devices would work equally well, I had a load of PIC16F1454 in my storage.

Illustration

PCBs were made by PCBWay and provided to me for free. While they do sponsor me occasionally, I actually did quite a few orders with them where I paid my own money to get stuff made. They were fast (well, much faster than me doing my part) and boards ended up being exactly what I ordered. Unfortunately, that meant they had a few errors - none of which were PCBWay’s fault.

The first issue I noticed was actually reaching programming ICSP header. OLED simply didn’t leave enough space to reach the darn thing. Fortunately, I use Pogo pins with my PICkit so I could reach the header from the bottom. Had I been using just a standard header, it would have been really a deal breaker.

Second, a more serious issue was with the motor driver simply not working. All voltages were just fine when the motor was not connected. However, connect the motor and it would go nowhere. It took me a bit of troubleshooting to notice I messed up Vref voltage divider and that was causing any load to trip the current limit function on my motor driver.

As I already soldered the whole board, I had to unsolder the OLED to reach resistors underneath and place a bodge wire. Since I use lead-free solder, it took a considerable amount of heat to fix that and the board survived that without any issues. The same couldn’t be said for my OLED display which didn’t work after I placed it back. So I had to unsolder it again in order to solder a replacement.

Why I mention this? Well, playing with higher temperature quite often damages the soldermask. And I had that experience with many PCB manufacturers, including PCBWay. However, either PCBWay changed their soldermask formulation to handle the higher temperature or this red soldermask I selected is a bit more resilient as after cleaning it with alcohol, I couldn’t even tell it was ever worked on.

And that’s all for this installment of the project. See you next time when I get the firmware running and see how this works in practice.

Driving the Plunger (part 1)

This is part 1 out of 3 in the series (see part 2).


Illustration

After playing with TB67H450FNG motor driver for a project of mine, I was really taken away by this little jelly bean chip. While rather basic, it’s really protective of its user. Not only does it offer a nice current limiting functionality defined by a single resistor, but it also has basic back-EMF current sink built in. Even if that goes wrong, there is a thermal shutdown circuit to save the day. And all that in the small 9-pin SOP package.

Yes, you read it right - 9 pin. While 8 pins are exactly where you expect them, the 9th pin is exposed beneath the chip itself and it’s pain to solder. Before you ask, yes, solder it you must.

At first glance, soldering it should not be too difficult. Just put some paste on pad, hit it from above (or below) with some heat and things will work out. And that is true if you place just enough solder paste. Place too much and you’ll get shorted pins from the back that are really annoying to get rid of. Place too little and it will not make contact thus leading to overheating and thermal shutdown. While not catastrophic, I can assure you troubleshooting this is not fun.

What I needed for this was a way to always place the same amount of paste on the bottom pad. My hands, while marvelous in their own right, were just not precise enough for this duty. What I needed was a way to push some paste out from a syringe in a repeatable manner.

As I was playing with a motor driver, thoughts immediately went toward using it to drive some gears and push a small rod into the back of the syringe in order to get its rotation into a linear motion.

With that idea on my mind, I went on to selecting components. I already knew which motor driver I wanted to use but it took me a while to decide on the motor.

Illustration

The first thought was to use a 3D printer stepper driver I had around. It would be ideal for this as you can control it really precisely, it holds position well, and it can handle some torque even without gearing. That idea lasted until I took it into my hand. That darn thing was heavy. When I accounted for all parts I needed for pushing into the syringe, darn things would be way too top heavy for my taste. And that’s even without taking bulkiness into account.

No, I wanted something a bit slender and thankfully, such motors exist. Even more importantly for my case, they already had a shaft on saving me an extra step. After measuring my paste syringes, I decided onto 6V 15 rpm motor with 55 mm M3 thread.

With an M3 rod integrated into the motor, my only task left for the mechanical portion of the project was to design a plunger. And I did so in TinkerCAD. It was just a simple body taking the motor from one side and taking a custom-designed plunger from the other.

With all the mechanical stuff out of the way, it was time to design the electronics. But that’s a story for the next week.

Counting Geigers

Illustration

A long while ago I got myself a Geiger counter, soldered it together, stored it in a drawer, and forgot about it for literally more than a year. However, once I found it again, I did the only thing that could be done - I connected it to a computer and decided to track its values.

My default approach would usually be to create an application to track it. But, since I connected it to a Linux server, it seemed appropriate to merge this functionality into a bash script that already sends ZFS data to my telegraf server.

Since my Geiger counter has UART output, the natural way of collecting its output under Linux was a simple cat command:

cat /dev/ttyUSB0

That gave me a constant output of readings, about once a second:

CPS, 0, CPM, 12, uSv/hr, 0.06, SLOW

CPS, 1, CPM, 13, uSv/hr, 0.07, SLOW

CPS, 0, CPM, 13, uSv/hr, 0.07, SLOW

CPS, 1, CPM, 14, uSv/hr, 0.07, SLOW

CPS, 1, CPM, 15, uSv/hr, 0.08, SLOW

CPS, 0, CPM, 15, uSv/hr, 0.08, SLOW

In order to parse this line easier, I wanted two things. First, to remove extra empty lines caused by CRLF line ending, and secondly to have all values separated by a single space. Simple adjustment sorted that out:

cat /dev/ttyUSB0 | sed '/^$/d' | tr -d ','

While this actually gave me a perfectly usable stream of data, it would never exit. It would just keep showing new data. Perfectly suitable for some uses, but I wanted my script just to take the last data once a minute and be done with it. And no, you cannot just use tail command alone for this - it needs to be combined with something that will stop the stream - like timeout.

timeout --foreground 1 cat /dev/ttyUSB0 | sed '/^$/d' | tr -d ',' | tail -1

If we place this into a variable, we can extract specific values - I just ended up using uSv/h but the exact value might depend on your use case.

OUTPUT=`timeout --foreground 1 cat /dev/ttyUSB0 | sed '/^$/d' | tr -d ',' | tail -1`
CPS=`echo $OUTPUT | awk '{print $2}'`
CPM=`echo $OUTPUT | awk '{print $4}'`
USVHR=`echo $OUTPUT | awk '{print $6}'`
MODE=`echo $OUTPUT | awk '{print $7}'`

With those variables in hand, you can feed whatever upstream data sink you want.

Randomizing Serial Number During MPLAB X Build

Illustration

Quite often, especially when dealing with USB devices, a serial number comes really handy. One example is my TmpUsb project. If you don’t update its serial number, it will still work when only one is plugged in. But plug in two, and all kinds of shenanigans will ensue.

This is the exact reason why I already created a script to randomize its serial number. However, that script had one major fault - it didn’t work under Linux. So, it came time to rewrite the thing and maybe adjust it a bit.

The way the original script worked was as a post-build step in MPLAB X project that would patch the Intel Hex object file and that’s something I won’t change as it integrates flawlessly with programming steps.

The resulting serial number was 12 hexadecimal digits in length (48 bits or random data) and that was probably excessive. Even worse, that led to an overly complicated script that was essentially a C# program to patch hex after the modification was done since any randomization always impacted more than 1 line. As I wanted a solution that could work anywhere the Bash can run (even Windows), I wanted to make my life easier by limiting any change to a single line.

Well, first things first, to limit serial number length, I had to figure out how much of a serial number can fit in one. Looking at the Intel hex produced by MPLAB X, we can see that each line is 16 bytes, which means any serial number intended for USB consumption can be up to 8 characters. However, what if other code pushes the serial number further down the line? Well, now you get only a single character.

What we need is a way to fix the serial number location. The solution to this is in the __at keyword. Using it, we can align our string descriptors wherever we want them. In the TmpUsb example, that would be something like this

const struct {
    uint8_t bLength;
    uint8_t bDscType;
    uint16_t string[7];
} sd003 __at(0x1000) = {
    sizeof(sd003),
    USB_DESCRIPTOR_STRING,
    { '2','8','4','4','3','4','2' }
};

The whole USB descriptor structure has to fit into 16 bytes as to limit any subsequent modification to the single line. The first 2 bytes are length and type bytes, leaving us with 14 bytes for our serial. Since USB likes 16-bit unicode, this means we have 7 characters to play with. If we stay in the hexadecimal realm, this provides 28 bits of randomization. Not bad, but we can slightly improve on it by expanding character selection a bit.

That’s where base32 comes in. It’s a nice enough encoding that isn’t case sensitive and it omits most easily confused characters. And yes, it would take 40 bits to fully utilize base32 but trimming it at 7 will leave you with 35 bits which is plenty.

How do I get this serial number in an easy way? Well, getting 5 bytes using dd and then passing it to base32 will do.

NEW_SERIAL=`dd if=/dev/urandom bs=5 count=1 2>/dev/null | base32 | cut -c 1-7`

If we pass the non-random serial number in, with some mangling to get it expanded, it is trivial to swap it with the new one using sed:

LINE=`cat "$INPUT" | grep "$SERIAL_UNICODE"`
NEW_LINE=`echo -n "$LINE" | sed "s/$SERIAL_UNICODE/$NEW_SERIAL_UNICODE/g"`
sed -i "s/$LINE/$NEW_NEW_LINE/g" "$INPUT"

But no, this is no good. Changing content of a line will invalidate checksum character that comes at the end. To make this work we need to adjust that checksum. Thankfully, checksum is just a sum of all characters preceding with a bit of inversion as a last step. Something like this:

CHECKSUM=0
for ((i = 1; i < $(( ${#NEW_LINE} - 2 )); i += 2)); do
    BYTE_HEX="${NEW_LINE:$i:2}"
    NEW_NEW_LINE="$NEW_NEW_LINE$BYTE_HEX"
    BYTE_VALUE=$(printf "%d" 0x$BYTE_HEX)
    CHECKSUM=$(( (CHECKSUM + BYTE_VALUE) % 256 ))
done
CHECKSUM_HEX=`printf "%02X" $(( (~CHECKSUM + 1) % 256 )) | tail -c 2`

Once all this is combined into a script, we can call it by giving it a few arguments, most notably, project directory (${ProjectDir}), image path (${ImagePath}), and our predefined serial number (2844342):

bash "${ProjectDir}/../package/randomize-usb-serial.sh"
  "${ProjectDir}"
  "${ImagePath}"
  "2844342"

Script is available for download here but you can also see it in action as part of TmpUsb.

MPLABX and the Symbol Lookup Error

After performing the latest Ubuntu upgrade, my MPLAB X v6.15 installation stopped working. It would briefly display the splash screen and then crash. When attempting to run it manually, I encountered a symbol lookup error in libUSBAccessLink_3_38.so.

$ /opt/microchip/mplabx/v6.15/mplab_platform/bin/mplab_ide

/opt/microchip/mplabx/v6.15/sys/java/zulu8.64.0.19-ca-fx-jre8.0.345-linux_x64/bin/java: symbol lookup error: /tmp/mplab_ide/mplabcomm4864006927221691126/mplabcomm5312997795113373971libUSBAccessLink_3_38.so: undefined symbol: libusb_handle_events_timeout_completed

Since there were a few links to the old MPLAB directory, I cleaned up /usr/lib a bit, but that didn’t help. What did help was removing the older libusb altogether.

sudo rm /usr/local/lib/libusb-1.0.so.0

With that link out of the way, everything started working once again.

Overthinking the LED Blinking

The very first thing I add to every piece of hardware I make is an LED. It not only signals that the device is on, but you can also use one to show processing or even do some simple debugging. What I often go for is a simple resistor+LED pair. And here is where the first decision comes - do I go for high-side or low-side drive?

Illustration

High side drive is when your output pin goes into the anode of the LED. The exact resistor location doesn’t really matter, and either before or after the LED results in the same behavior. When your pin goes high, the LED lights up. When the pin goes low, the LED turns off.

Illustration

Low side drive is pretty much the same idea but with logic reversed. When the pin goes low, the LED turns on, while the pin going high turns it off.

With both being essentially the same, why would you select one over the other? Honestly, there is no difference worth mentioning. It all goes to personal preference and/or what is easier to route on the PCB.

Illustration

For open-drain LED control we make use of the tri-state output available on most microcontrollers (high-Z state simulated using a switch). In addition to the standard low and high setup, we have an additional one sometimes called high-Z (or high impedance). In Microchip PIC, this is done by manipulating the TRIS pin. If the output TRIS is 1 (high-Z), the LED will be off. If TRIS is 0 and the output is low (the only valid open-drain state), the LED has a path to the ground and lights up.

This is quite similar to the previously mentioned low-side drive and has pretty much the same effect but with a slightly different way of controlling the pin. I would argue that both are essentially the same if your circuit uses just a single voltage. Where things change is when you have different voltages around as open-drain will work for driving LED on any rail (ok, some limits apply) while low side drive will not turn off fully if, e.g., you are controlling 12V LED with a 5V microcontroller.

But this open-drain setup got me thinking whether I can have use something similar to have LED be on by default? Why, you wonder? Well, my first troubleshooting step is to see if input voltage is present. My second troubleshooting step is to see if microcontroller is booting. Quite often, those tasks fall onto two different LEDs. If I could have LED on by default, that would tell me if there’s any voltage to start with and then blinking boot sequence would tell me microcontroller is alive and programmed.

Illustration

Well, there is one way to do it, and I call it default on open-drain. Unprogrammed Microchip PIC has pins in tri-state (high-Z) configuration. Thus, current will flow through LED as soon as voltage is applied. However, if pin goes low, current will flow to the pin and thus LED will go off. Exactly what we wanted.

Albeit, nothing comes without the cost and here “cost” is twofold. The first issue is current consumption. While our “on” state is comparable to what we had with other setups, our “off” state uses even more current. For blinking it might not matter but it should be accounted for if you have it off for longer duration.

Second potential issue comes if our ping ever goes high since that will have LED pull as much current as pin can provide. While Microchip’s current limiting is usually quite capable, you are exceeding current limit and that’s never good for a long-term PIC health. And chips that have no current limiting are going to fry outright. It is critical for functioning of this LED setup that the only two states are ever present: either low or high-Z.

Illustration

To avoid this danger, we can do the same setup using two resistor split. Assuming both resistors together are about the same overall value, in “on” state this circuit matches what we had before. In “off” state, we unfortunately increased current even further but, if we ever switch pin to high, we are fully protected. Even better, this setup allows for three LED states: “on”, “off”, and “bright”. This feature alone might be worth increased current consumption.

In order to deal with higher current consumption, we could reorganize resistors a bit. This circuit behaves the same as the previous one if microcontroller’s pin is high-Z (or not present). If microcontroller pin goes high, “bright” state gets even brighter as you’re essentially putting two resistors in parallel.

Illustration

When pin goes low, we have those resistors in series and thus current consumption, while still present, is not exceedingly higher than what “on” state requires. And yes, having made what’s essentially a voltage divider does mean that LED is not really fully off. However, it goes dark enough that nobody will be able to tell the difference when blinking starts.

Which one is best?

Well, I still use a bog-standard resistor-LED drive the most (either low, high, or open-drain). It’s simple, it’s fool-proof and definitely has the lowest current consumption among examples given. However, for boards where remote debugging is a premium but space doesn’t allow for bigger display, I find that having three brightness settings at a cost of a single resistor is quite a useful bargain.

Type-C Power Delivery as Passive PoE Source - Firmware

This is part 3 out of 3 in the series.


The firmware for the old ResetBox had a simple setup. You press the button shortly, it gets ignored; you press it for a second or two, and you reboot the devices. However, the introduction of the PD negotiation required a way to configure the voltage.

The easy way would be simply to use the existing external button for configuration. And that would work just fine. However, this would also mean that one could accidentally (or more likely by messing with the front panel) change the voltage. I didn’t want that. Thus the second, internal, button was born.

With it, the second question popped - why not use the internal button to set the voltage and the external button to change the setting. Since two buttons are there, both can be used. Except there aren’t two buttons available at all times. If we go with my rack-mounting case, the button is mounted in the rack using a rather short cable. If we get the device on the table, we don’t want to unmount the button. So, it has to be a single button setup.

Thus, the first thing that firmware checks during boot time is the state of the button. If the button is pressed, the boot process waits for either 3 seconds to elapse or for the button to be released. If the button is released within that time, just proceed with the normal boot. If the button is still pressed, go into configuration.

While I use TIMER0 for time management, I avoid using interrupts for just measuring the duration of time presses. The reason behind this is that I like to have interrupts available for the stuff that matters (e.g., change on the external pin). It doesn’t matter if the press is 3 or 3.01 seconds long, and thus, just checking the timer state is good enough.

Essentially, all time loops are as follows:

while {
    if (timer0_wasTriggered()) {
        counter++;
        if (counter == COUNTER_MAX) {  // held it long enough
            // do something for holding
        }
    }
}

As I set up timer pre/post-scalers in such a manner that I have exactly 24 “triggers” per second, an 8-bit counter variable allows for 10 seconds to pass. If a longer duration is needed, you can use 16-bit integers, but I usually find 10 seconds to be plenty.

Another change I made to the firmware was the ability to cancel the reset. In the old version, once you press the button for 3 seconds, it will reset the device when you release the button. Well, it happened at least once that I pressed the button for 3 seconds and then discovered that I pressed the wrong one. However, there was no way to go back.

In the new firmware, I lowered the duration needed to enter into the reboot mode to 2 seconds, but I also allowed for cancel if one holds it for an additional 10 seconds. As before, one can know if the release will reset the device by the LED brightness. If the LED gets dim, the reset is imminent.

For a simple device like this one, this is more than enough flexibility.

Source code is available on GitHub. And you can also check previous posts about design and its fixes.

Type-C Power Delivery as Passive PoE Source - Fixes

This is part 2 out of 3 in the series.


The first version of any project is rarely without faults. And this one is not an exception.

Illustration

The simplest issue has been me simply forgetting to wire up enable lines on 74HC238D decoder. I assumed, for no good reason, that lines were enabled by default. Thus, no matter what my code did, LEDs stayed dark. After a bit of probing and seeing correct voltage on input, I decided to check the datasheet again and saw the issue almost immediately. A fix required two wires though, as G2A and G2B lines required pull-down while pull-up was required for G1.

The next issue I noticed was that my internal button wouldn’t work. I knew of this risk since, due to pin shortage on PIC12F1501, I ended up using this pin as both input and output. As the microcontroller was booting, I wanted to detect button presses so I can put it into configuration mode. If the button wasn’t pressed during configuration, I would simply use the same pin as power-on signal input towards the transistor. Well, in theory, that should have worked. In practice, I had it pull to ground via an external base resistor. In order to keep the transistor at a known level when the line is input, I used my bodge powers to make the button pull the line high when pressed. Here is where my habit of connecting only two legs of the four-legged button actually helped as I was able to simply snip off one leg and solder 3V3 to another.

With the configuration mode sorted out, I started switching voltages only to find out that I am maxing at 9 V. I could select 5 V just fine but no matter what higher voltage I selected, CH224K would give me 9 V. It took me a while and a lot of datasheet deciphering (Chinese-only datasheet didn’t help) to understand what was happening.

You see, there are two ways you can configure CH224K. You can either use 3 CFG lines to set the desired input state in binary or you can use a resistor on CFG1 line alone. My 10K series resistor made the chip think I was using a single-resistor setting. 10K was close enough to 6.8K so that’s where 9 V came from. In 5 V mode, I had that pin pulled high, and that was enough to stop the single-resistor setup. The solution was to remove series resistors altogether. Ironically, I didn’t actually need them but decided to place them anyhow to help with troubleshooting.

One thing that definitely did help with this troubleshooting was the quality of the PCB. For example, while trying to troubleshoot my voltage settings, I soldered and resoldered CH221K multiple times. Since ground is on the pad that is inaccessible to a soldering iron, I had to reheat the board multiple times. I honestly expected to damage the solder mask after the second time getting it to 300 °C. And while the smell wasn’t the best, the PCB handled it without problems. Yes, you can damage the solder mask if you really want to but it will take a lot more than heavy-handed rework to do so.

With bodges sorted out, the next step will be to update the firmware.