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:
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.
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.
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 00payload 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()