Monitoring Power with the AVHzY CT-3 on Linux

· Series: pvg100-upgrades · Tags: [pvg100]

I’ve been bringing up my Palm PVG100 to run on Android 16 using a custom ROM.

In doing so, I’ve been wondering how good I could get its tiny tiny tiny battery to last.

Also, I wanted to make sure my new ROM didn’t have any serious battery life regressions compared to the stock Android 8 OS.

So I decided to be scientific about it and actually measure.

Phone Coulomb Counters

All mobile phones have a Coulomb counter so that they can measure how much battery life they have.

In Linux this is exposed as /sys/class/power_supply/battery/charge_counter (µAh remaining).

This alone doesn’t give you power of course. Power is V * A, so you have to multiply by the voltage, which is at /sys/class/power_supply/battery/voltage_now (µV).

With my three (!) phones available, I could do some serious cross-checking against different hardware and different software:

Three pvg100s on my nightstand

Like they say, a man with one watch knows the time. A man with two watches is never sure. A man with three watches… is able to cross reference them and come up with statistical averages.

Aside

This kinda reminds me of the Whitworth three plates method, where one can use three rough plates, and through a particular combination of rubbing particular pairs in a certain order, you can achieve very good flatness, without any special tools.

Pretty neat!

The Problem

The problem is, as I learned, these Coulomb counters on this phone are NOT very accurate.

I wouldn’t know unless I had a couple of them to cross-reference of course.

But yea, I don’t trust them for anything other than a guess.

I need a real power meter.

A Real Power Meter

I have an AVHzY CT-3. It’s a great little USB power meter. It sits inline between a charger and a device and measures voltage, current, power, D+/D- line voltages, and can trigger and analyze fast-charge protocols.

AVHzY CT-3 inline between a charger and the PVG100, showing live voltage, current, and accumulated mAh/mWh

The official desktop software is Windows-only. The developer is not interested in supporting Linux.

Really, all I want is a Python script to record the values.

Using an external tool instead of the phone’s internal Coulomb counter will give me some assurance about the data I’m getting.

Linux Stuff

Plug the CT-3 in and check lsusb:

Bus 003 Device 033: ID 0483:ffff STMicroelectronics Shizuku in Application Mode

The device shows up as both a storage device (/dev/sda) and a tty (/dev/ttyACM0).

Using It

(See the script source code at the end of the blog post)

Stream everything to stdout at the default 100ms interval:

$ ./AVHzY.py read

Log voltage and current to a file every second until interrupted:

$ ./AVHzY.py read -t 1000 -g voltage current -o charge-test.csv

Sample output with nothing plugged into the meter (floating bus, µA leakage):

time,voltage,current,power,voltageDP,voltageDM,energy
1400182,0.832687497138977,3.556491719791666e-05,2.9614462619065307e-05,0.06709318608045578,0.12153557687997818,0
1600204,0.9095437526702881,2.6150675694225356e-05,2.3785183657309972e-05,0.06712532788515091,0.12180139869451523,1.3215444459729042e-09
1800184,0.9385125041007996,5.857751239091158e-05,5.497572783497162e-05,0.06737635284662247,0.12187599390745163,4.375446127205578e-09

The time column is the meter’s own microsecond timestamp, so intervals are exact regardless of host-side jitter, and the energy column integrates watt-hours from it.

Notes

  • The meter occasionally repeats the previous sample if you poll faster than its internal sample rate. Harmless, and easy to dedupe on the timestamp column.
  • There is a lot more in this protocol than the script uses (the official software does PD triggering, protocol enumeration, etc. over the same link). The 04 00 0b 00 payload header suggests other payload types exist, if you feel like exploring.

The Script

Here is the full script for the CT-3:

#!/usr/bin/env python
from serial import Serial as Serial

from struct import unpack as unpack
from struct import pack_into as pack_into
from argparse import ArgumentParser as ArgumentParser
from argparse import FileType as FileType

from time import sleep as sleep

from sys import exit as exit

# actions along with their description
__actions = ["list", "read"]
__action_descriptions = {
    "list": "Show this list and exit",
    "read": "Reads energy value from the meter",
}

# values for read operation
__gets = ["all", "voltage", "current", "power", "voltageDP", "voltageDM", "energy"]


class AVHzY_CT3:

    def __init__(self, device, action, repeat, time, reads, output):
        self.__action = action
        self.__repeat = repeat
        self.__time = time
        self.__output = output
        self.__ser = Serial(device, timeout=3)

        self.__first_exec = True
        self.__timestamp = 0
        self.__energy = 0
        self.__prev_timestamp = 0

        if reads == "all":
            self.__reads = [
                "voltage",
                "current",
                "power",
                "voltageDP",
                "voltageDM",
                "energy",
            ]
        else:
            self.__reads = reads

        # action handlers in a dictionary
        self.__action_handlers = {
            "list": self.__action_list,
            "read": self.__action_read,
        }

    def __del__(self):
        self.__ser.close()
        self.__output.close()

    def __action_list(self):
        print("Actions list:")
        for action in __action_descriptions:
            print("  ", action, "\t", __action_descriptions[action])

    def xor_checksum(self, byte_array, start, end):
        checksum = 0
        for byte in byte_array[start : end + 1]:
            checksum ^= byte
        return checksum

    # Frame layout: A5 <len> <3 header bytes> <payload: len bytes> <xor checksum> 5A
    # Total size = len + 7. CT-3 data packets have len == 36.
    def read_frame(self, want_len=None):
        while True:
            byte = self.__ser.read(1)
            if len(byte) == 0:
                raise TimeoutError("no data from meter")
            if byte[0] != 0xA5:
                continue
            length_byte = self.__ser.read(1)
            if len(length_byte) == 0:
                raise TimeoutError("no data from meter")
            length = length_byte[0]
            rest = self.__ser.read(3 + length + 2)
            if len(rest) < 3 + length + 2:
                continue
            frame = byte + length_byte + rest
            if frame[-1] != 0x5A:
                continue
            if self.xor_checksum(frame, 5, 4 + length) != frame[5 + length]:
                continue
            if want_len is not None and length != want_len:
                continue
            return frame

    def __action_read(self):

        if self.__first_exec:
            self.__output.write("time")
            for r in self.__reads:
                self.__output.write(",{0}".format(r))
            self.__output.write("\n")
            self.__first_exec = False

        # CT-3 data payload: 04 00 0b 00, then 6 floats
        # (voltage, current, power, D+, D-, signed current), then u64 microseconds
        packet = self.read_frame(want_len=36)
        packetData = unpack("ffffffQ", packet[9:41])

        voltage = packetData[0]
        current = abs(packetData[1])
        power = packetData[2]
        voltageDP = packetData[3]
        voltageDM = packetData[4]
        self.__timestamp = packetData[6]

        self.__output.write("{0}".format(self.__timestamp))
        if "voltage" in self.__reads:
            self.__output.write(",{0}".format(voltage))
        if "current" in self.__reads:
            self.__output.write(",{0}".format(current))
        if "power" in self.__reads:
            self.__output.write(",{0}".format(power))
        if "voltageDP" in self.__reads:
            self.__output.write(",{0}".format(voltageDP))
        if "voltageDM" in self.__reads:
            self.__output.write(",{0}".format(voltageDM))
        if "energy" in self.__reads:
            if self.__prev_timestamp == 0 or self.__timestamp < self.__prev_timestamp:
                self.__output.write(",{0}".format(self.__energy))
            else:
                self.__energy += (
                    (self.__timestamp - self.__prev_timestamp) * power
                ) / 3600000000
                self.__output.write(",{0}".format(self.__energy))
        self.__output.write("\n")
        self.__output.flush()

        self.__prev_timestamp = self.__timestamp

    def perform_action(self):
        if self.__action == "list":
            self.__action_list()
            return

        # Clear input buffer
        self.__ser.reset_input_buffer()

        # Reset device
        resetCommand = bytearray.fromhex("A5 04 00 00 00 01 07 00 00 06 5A")
        self.__ser.write(resetCommand)
        self.read_frame()  # Clear response to command

        # Reset record timer
        resetRecordTimerCommand = bytearray.fromhex("A5 04 00 00 00 01 0C 0A 00 07 5A")
        self.__ser.write(resetRecordTimerCommand)
        self.read_frame()  # Clear response to command

        # Start reading
        startReadCommand = bytearray.fromhex(
            "A5 08 00 00 00 01 09 0B 00 00 00 00 00 00 5A"
        )
        pack_into("I", startReadCommand, 9, self.__time)
        startReadCommand[13] = self.xor_checksum(startReadCommand, 5, 12)
        self.__ser.write(startReadCommand)

        count = 0
        while count != self.__repeat:

            try:
                self.__action_handlers[self.__action]()
            except KeyboardInterrupt:
                break

            if self.__repeat != -1:
                count += 1

    def change_output(self, output):
        self.__output.close()
        self.__output = output


def main():

    # -------------------------------------- OPTION PARSING
    parser = ArgumentParser(
        description="Program to interact with the AVHzY CT-3 power meter"
    )
    parser.add_argument(
        "action",
        metavar="action",
        choices=__actions,
        help="The action to perform [choices: %(choices)s]",
    )
    parser.add_argument(
        "-d",
        "--device",
        default="/dev/ttyACM0",
        help="Path to the device [default: %(default)s]",
    )
    parser.add_argument(
        "-r",
        "--repeat",
        type=int,
        default=-1,
        help="How many times to repeat the operation. Must be in [-1, inf[ (-1: infinite) [default: %(default)s]",
    )
    parser.add_argument(
        "-t",
        "--time",
        type=int,
        default=100,
        help="The time (in miliseconds) to wait between each action iteration.",
    )
    parser.add_argument(
        "-o",
        "--output",
        default="-",
        type=FileType("w"),
        help="Where to write output of the action [default: stdout]",
    )
    parser.add_argument(
        "-g",
        "--get",
        default="all",
        choices=__gets,
        nargs="+",
        help="For read operation: what to get from power meter [choices: %(choices)s default: %(default)s",
    )
    args = parser.parse_args()

    if args.repeat < -1:
        print("ERROR: repeat must be in >= -1")
        parser.print_usage()
        exit(1)

    if args.time < 1:
        print("ERROR: time must be > 1")
        parser.print_usage()
        exit(1)

    AVHzY_CT3(
        args.device, args.action, args.repeat, args.time, args.get, args.output
    ).perform_action()
    exit(0)


if __name__ == "__main__":
    main()
Comment via email