element14 Community
element14 Community
    Register Log In
  • Site
  • Search
  • Log In Register
  • Community Hub
    Community Hub
    • What's New on element14
    • Feedback and Support
    • Benefits of Membership
    • Personal Blogs
    • Members Area
    • Achievement Levels
  • Learn
    Learn
    • Ask an Expert
    • eBooks
    • element14 presents
    • Learning Center
    • Tech Spotlight
    • STEM Academy
    • Webinars, Training and Events
    • Learning Groups
  • Technologies
    Technologies
    • 3D Printing
    • FPGA
    • Industrial Automation
    • Internet of Things
    • Power & Energy
    • Sensors
    • Technology Groups
  • Challenges & Projects
    Challenges & Projects
    • Design Challenges
    • element14 presents Projects
    • Project14
    • Arduino Projects
    • Raspberry Pi Projects
    • Project Groups
  • Products
    Products
    • Arduino
    • Avnet & Tria Boards Community
    • Dev Tools
    • Manufacturers
    • Multicomp Pro
    • Product Groups
    • Raspberry Pi
    • RoadTests & Reviews
  • About Us
    About the element14 Community
  • Store
    Store
    • Visit Your Store
    • Choose another store...
      • Europe
      •  Austria (German)
      •  Belgium (Dutch, French)
      •  Bulgaria (Bulgarian)
      •  Czech Republic (Czech)
      •  Denmark (Danish)
      •  Estonia (Estonian)
      •  Finland (Finnish)
      •  France (French)
      •  Germany (German)
      •  Hungary (Hungarian)
      •  Ireland
      •  Israel
      •  Italy (Italian)
      •  Latvia (Latvian)
      •  
      •  Lithuania (Lithuanian)
      •  Netherlands (Dutch)
      •  Norway (Norwegian)
      •  Poland (Polish)
      •  Portugal (Portuguese)
      •  Romania (Romanian)
      •  Russia (Russian)
      •  Slovakia (Slovak)
      •  Slovenia (Slovenian)
      •  Spain (Spanish)
      •  Sweden (Swedish)
      •  Switzerland(German, French)
      •  Turkey (Turkish)
      •  United Kingdom
      • Asia Pacific
      •  Australia
      •  China
      •  Hong Kong
      •  India
      •  Japan
      •  Korea (Korean)
      •  Malaysia
      •  New Zealand
      •  Philippines
      •  Singapore
      •  Taiwan
      •  Thailand (Thai)
      •  Vietnam
      • Americas
      •  Brazil (Portuguese)
      •  Canada
      •  Mexico (Spanish)
      •  United States
      Can't find the country/region you're looking for? Visit our export site or find a local distributor.
  • Translate
  • Profile
  • Settings
RoadTests & Reviews
  • Products
  • More
RoadTests & Reviews
Review Blogs Raspberry Pi CM5 - Frigate NVR Benchmark Preparation Part 2
  • Blogs
  • Forum
  • Documents
  • RoadTests
  • Reviews
  • Polls
  • Files
  • Members
  • Sub-Groups
  • More
  • Cancel
  • New
Join RoadTests & Reviews to participate - click to join for free!
  • Share
  • More
  • Cancel
  • Author Author: ralphjy
  • Date Created: 14 Sep 2026 3:35 PM Date Created
  • Views 40 views
  • Likes 3 likes
  • Comments 0 comments
Related
Recommended
  • rpi cm5 io board
  • fnirsi fnb58
  • Raspberry Pi CM5
  • rpi cm5 development kit roadtest

Raspberry Pi CM5 - Frigate NVR Benchmark Preparation Part 2

ralphjy
ralphjy
14 Sep 2026

Thermal Tests continued

I had forgotten to mention in my previous post that after the roadtest benchmarking is complete that I still want to monitor thermal and power parameters on the Frigate NVR.  To that end, I'm going to create MQTT sensor entities for those parameters so that I can monitor them in Home Assistant and also display them on a Node-Red dashboard.

I installed the Mosquitto MQTT client on the CM5 and added a crontab entry to publish the CM5 CPU Temperature once a minute

.The crontab syntax is:

* * * * * mosquitto_pub -h localhost -t "mytopic" -m "my_message"

mytopic is cm5/hardware/cpu_temp
my_message is $(( $(cat /sys/class/thermal/thermal_zone0/temp) / 1000 ))

Over on my RPi CM4 which is running Home Assistant, I added an mqtt sensor entity in my configuration.yaml

mqtt:
 sensor:
 - name: "CM5 CPU Temperature"
   state_topic: "cm5/hardware/cpu_temp"
   unit_of_measurement: "°C"
   device_class: temperature
   state_class: measurement

And I can then view the sensor data directly by name.

image

The plot is showing the temperature over a couple of days.  In the first third of the plot, Frigate is running initially with a single camera.  Then I stop Frigate and run the stress test (the large spike).  Then the system is just idling.  The flat line is a period when the system was turned off (it retains the last received value).  Then system turns on, Frigate runs single camera then back to idle.

Sensor entities are pretty useful to examine data timelines independently from a dashboard, so I'll create them for parameters that I want to watch, but I'll also create a Node-Red dashboard for real-time monitoring.

image

 

Power Logging

To do the power logging using the FNIRSI FNB58 I am going to use OpenFNB58 which is an open-source logger written by Libor Tomsik.  In particular I am going to use the Python CLI reader fnb58.py.  .This reader can interact with the FNB58 using USB HID or Bluetooth LE, but I'm going to use USB for speed and reliability.  The FNB58 is quite a capable tool, it can sample at 100Hz which might be good for looking at quick events but I don't expect to run faster than 1 Hz.

First thing to do is to verify that Linux can see the FNB58 when you plug it in.

image

image

Everything looks good, so any required drivers are already available.

Clone the OpenFNB58 repo from github.

image

All FNB58 programs will be run from this directory to insure we have the correct path to required files.

Quick test of fnb58.py to verify it is operating okay with a single measurement test.

image

For reference here is the fnb58.py file from the repo:

#!/usr/bin/env python3
"""
FNB58 USB Power Meter reader

Supports two transport modes:
  --usb  (default) USB HID via pyusb (interface 3, endpoints 0x03/0x83)
  --ble            Bluetooth LE via BlueZ D-Bus (ffe0 GATT service)

=== USB HID protocol (64-byte reports) ===
  Init: aa 81 00..00 8e  / aa 82 00..00 96 (×2 for FNB58)
  Poll: aa 83 00..00 9e  (every ~1 s)
  Data packet [0]=0xaa [1]=0x04, then 4×15-byte samples:
    [0..3]   voltage  uint32-LE / 100000  V
    [4..7]   current  uint32-LE / 100000  A
    [8..9]   D+       uint16-LE / 1000    V
    [10..11] D-       uint16-LE / 1000    V
    [12]     unknown
    [13..14] temp     uint16-LE / 10      °C

=== BLE protocol (GATT, service ffe0, RFstar transparent UART) ===
  Device: FNB58-XXXXXX  (default MAC set by BLE_DEFAULT_MAC below)
  Char ffe9 (write / write-without-response): send commands
  Char ffe4 (notify): receive streaming data

  Init sequence (4-byte commands sent to ffe9):
    aa 81 00 f4   — wake up / enter measurement mode
    aa 82 00 a7   — start data stream  (send ~2 s after first command)

  Stream packet framing:  aa [type] [data_len] [data...] [checksum]
    total bytes = data_len + 4

  Packet types in the stream:
    0x07  VBUS/IBUS — data_len=4
            data[0..1] uint16-LE / 1000  VBUS (V)
            data[2..3] uint16-LE / 1000  IBUS (A)
    0x06  D+/D-     — data_len=6
            data[0..1] uint16-LE / 1000  D+ (V)
            data[2..3] uint16-LE / 1000  D- (V)
            data[4..5] unknown
    0x04  Accumulated — data_len=12
            data[0..3] uint32-LE  accumulated charge (unit TBD)
    0x03  Device info — data_len=14  (sent once on init)
    0x05  Status/misc — data_len=7
    0x08  Config/misc — data_len=17

  Multiple packet types may be concatenated in a single BLE notification chunk.
"""

import sys
import os
import errno
import glob
import select
import subprocess
import time
import struct
import argparse

VID_FNB58 = "2e3c"
PID_FNB58 = "5558"

SAMPLES_PER_SECOND = 100
TIME_INTERVAL = 1.0 / SAMPLES_PER_SECOND

CMD_INIT1  = b"\xaa\x81" + b"\x00" * 61 + b"\x8e"
CMD_INIT2  = b"\xaa\x82" + b"\x00" * 61 + b"\x96"
CMD_POLL   = b"\xaa\x83" + b"\x00" * 61 + b"\x9e"

# ── Protocol definitions ──────────────────────────────────────────────────
# Matching the FNIRSI Windows app's protocol list.
# Protocol IDs are used by the trigger command (cmd 0x85).
# NOTE: The trigger command byte (0x85) and payload format are derived from
#       reverse engineering; verify with a QC/PD-capable charger.
PROTOCOLS = {
    # id: (name, default_voltage_mv, [available_mv])
     1: ("QC 2.0 5V",    5000,  [5000]),
     2: ("QC 2.0 9V",    9000,  [9000]),
     3: ("QC 2.0 12V",  12000,  [12000]),
     4: ("QC 2.0 20V",  20000,  [20000]),
     5: ("QC 3.0",       9000,  list(range(3600, 20001, 200))),
     6: ("FCP/AFC 9V",   9000,  [9000]),
     7: ("FCP/AFC 12V", 12000,  [12000]),
     8: ("Huawei FCP",   9000,  [9000, 12000]),
     9: ("Huawei SCP",   5000,  list(range(4000, 8001, 100))),
    10: ("Samsung 2A",   5000,  [5000]),
    11: ("Samsung AFC",  9000,  [9000, 12000]),
    12: ("Apple 2.1A",   5000,  [5000]),
    13: ("Apple 2.4A",   5000,  [5000]),
    14: ("PD/MTK",       9000,  [5000, 9000, 12000, 15000, 20000]),
}

CMD_TRIGGER_BYTE = 0x85   # single trigger (best-guess; not officially documented)
CMD_RELEASE_BYTE = 0x86   # release trigger (best-guess)


def crc8_fnb58(data: bytes) -> int:
    """
    CRC-8 over the given bytes.
    Parameters: poly=0x39, init=0x42, refin=False, refout=False, xorout=0x00.
    For a 64-byte USB HID frame, pass bytes[1:63].
    """
    poly, crc = 0x39, 0x42
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = ((crc << 1) ^ poly) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
    return crc


def make_hid_cmd(cmd_byte: int, payload: bytes = b"") -> bytes:
    """Build a 64-byte USB HID command with the correct CRC-8 checksum."""
    inner = bytes([cmd_byte]) + bytes(payload) + bytes(61 - len(payload))
    return bytes([0xAA]) + inner + bytes([crc8_fnb58(inner)])


def make_trigger_cmd(proto_id: int, voltage_mv: int = 0) -> bytes:
    """
    Build a trigger command for the given protocol ID and target voltage.
    voltage_mv is encoded as a LE uint16 in 10 mV units (e.g. 9000 mV → 0x0384).
    """
    v10 = voltage_mv // 10  # 10 mV resolution
    payload = bytes([proto_id, v10 & 0xFF, (v10 >> 8) & 0xFF])
    return make_hid_cmd(CMD_TRIGGER_BYTE, payload)


def make_release_cmd() -> bytes:
    """Build the trigger release command."""
    return make_hid_cmd(CMD_RELEASE_BYTE)


def find_hidraw(wait: float = 10.0) -> str:
    """
    Find the /dev/hidrawN node for the FNB58 by scanning sysfs.
    Waits up to `wait` seconds for the device to appear (it may be rebooting).
    Returns the device path, or exits with an error if not found.
    """
    deadline = time.time() + wait
    while True:
        for hidraw_dir in glob.glob("/sys/class/hidraw/hidraw*"):
            uevent_path = os.path.join(hidraw_dir, "device", "..", "uevent")
            try:
                uevent = open(uevent_path).read()
            except OSError:
                continue
            product = ""
            for line in uevent.splitlines():
                if line.startswith("PRODUCT="):
                    product = line.split("=", 1)[1].lower()
            if product.startswith(f"{VID_FNB58}/{PID_FNB58}/"):
                return f"/dev/{os.path.basename(hidraw_dir)}"

        if time.time() >= deadline:
            break
        print("Waiting for FNB58 HID device...", file=sys.stderr)
        time.sleep(1.0)

    print(
        "FNB58 HID device not found after waiting. Is it connected?",
        file=sys.stderr,
    )
    sys.exit(1)


def open_hid(path: str) -> int:
    """Open the hidraw device; returns a file descriptor, or exits on error."""
    try:
        return os.open(path, os.O_RDWR)
    except PermissionError:
        print(f"Cannot open {path}: permission denied.", file=sys.stderr)
        sys.exit(1)
    except OSError as e:
        print(f"Cannot open {path}: {e}", file=sys.stderr)
        sys.exit(1)


def hid_write(fd: int, cmd: bytes, retries: int = 5) -> bool:
    """
    Write a HID command.  Retries on ETIMEDOUT (device busy streaming).
    Returns True on success, False on hard failure.
    """
    for _ in range(retries):
        try:
            os.write(fd, cmd)
            return True
        except OSError as e:
            if e.errno in (errno.ETIMEDOUT, errno.EPROTO, errno.ENODEV):
                time.sleep(0.15)
                continue
            return False
    return False


def hid_drain(fd: int, max_packets: int = 200):
    """
    Drain pending HID input reports so the device isn't backlogged when we write.
    Stops when no packet arrives within 20 ms or max_packets reached.
    """
    for _ in range(max_packets):
        r, _, _ = select.select([fd], [], [], 0.02)
        if not r:
            break
        try:
            os.read(fd, 64)
        except OSError:
            break


def infer_protocol(dp: float, dn: float, vbus: float = 5.0) -> str:
    """
    Infer the charging protocol from D+/D- voltages measured by the FNB58.
    These voltages reflect what the charger (upstream side) is asserting.

    Returns a short protocol name string.
    """
    tol = 0.18  # ±180 mV tolerance

    def near(v, target):
        return abs(v - target) < tol

    def near2(v1, t1, v2, t2):
        return near(v1, t1) and near(v2, t2)

    # No voltage on either line → SDP or no charger detected
    if dp < 0.35 and dn < 0.35:
        return "USB SDP"

    # Both lines equal and low → BC 1.2 DCP / Samsung-style charger
    if near(dp, dn) and 0.5 < dp < 2.5:
        if near2(dp, 2.0, dn, 2.0):
            return "Apple 1A"
        if near2(dp, 2.7, dn, 2.0):
            return "Apple 2.1A"
        if near2(dp, 2.0, dn, 2.7):
            return "Apple 2.4A"
        if 1.0 < dp < 1.8:
            return "DCP / Samsung"
        if 1.8 < dp < 2.3:
            return "Apple 1A"
        return f"DCP ({dp:.2f}V)"

    # QC 2.0 voltage codes (D+, D-)
    if near2(dp, 0.6, dn, 0.0):
        return "QC 2.0 5V"
    if near2(dp, 3.3, dn, 0.6):
        return "QC 2.0 9V"
    if near2(dp, 0.6, dn, 0.6):
        return "QC 2.0 12V / CDP"
    if near2(dp, 3.3, dn, 3.3):
        return "QC 2.0 20V"
    # QC 3.0 continuous mode: D+ = 0.6 V, D- toggling
    if near(dp, 0.6) and 0.0 < dn < 3.6:
        return "QC 3.0"

    # Huawei FCP: D+ ≈ 0.325 V used as handshake initiation signal
    if 0.2 < dp < 0.45 and dn < 0.3:
        return "Huawei FCP"

    # If VBUS is elevated but D+/D- still show 5V pattern → USB PD (CC pins used)
    if vbus > 5.5 and dp < 0.5 and dn < 0.5:
        return f"USB PD ({vbus:.0f}V)"
    if vbus > 5.5:
        return f"Fast Charge ({vbus:.0f}V)"

    return f"Unknown (D+={dp:.2f}V D-={dn:.2f}V)"


def decode_packet(data):
    """Decode a 64-byte data packet into a list of sample dicts."""
    if data[0] != 0xAA or data[1] != 0x04:
        return None  # not a data packet

    samples = []
    for i in range(4):
        off = 2 + 15 * i
        voltage = struct.unpack_from("<I", data, off)[0] / 100000.0
        current = struct.unpack_from("<I", data, off + 4)[0] / 100000.0
        dp      = struct.unpack_from("<H", data, off + 8)[0] / 1000.0
        dn      = struct.unpack_from("<H", data, off + 10)[0] / 1000.0
        temp    = struct.unpack_from("<H", data, off + 13)[0] / 10.0
        power   = voltage * current
        samples.append({
            "voltage_V": voltage,
            "current_A": current,
            "power_W":   power,
            "dp_V":      dp,
            "dn_V":      dn,
            "temp_C":    temp,
            "protocol":  infer_protocol(dp, dn, voltage),
        })
    return samples


def hid_read(fd: int, timeout: float = 2.0) -> bytes | None:
    """Read one 64-byte HID report with a timeout. Returns None on timeout."""
    r, _, _ = select.select([fd], [], [], timeout)
    if not r:
        return None
    try:
        return os.read(fd, 64)
    except OSError:
        return None


def _hid_init(fd: int) -> bool:
    """
    Drain any pending data, then send the init sequence.
    Returns True if init commands were written successfully.
    """
    hid_drain(fd)
    if not hid_write(fd, CMD_INIT1):
        return False
    time.sleep(0.05)
    if not hid_write(fd, CMD_INIT2):
        return False
    time.sleep(0.05)
    if not hid_write(fd, CMD_INIT2):
        return False
    return True


def read_once(fd: int) -> list | None:
    """
    Read one measurement.  Tries progressively more aggressive init sequences:
      1. Drain + poll only  (device already streaming from a prior run)
      2. Drain + INIT2×2   (gentle restart, avoids the aa81 reboot risk)
      3. Drain + full INIT  (aa81 + aa82×2, only if gentler methods fail)
    """
    for phase, cmds in enumerate([
        [CMD_POLL],
        [CMD_INIT2, CMD_INIT2],
        [CMD_INIT1, CMD_INIT2, CMD_INIT2],
    ]):
        hid_drain(fd)
        for cmd in cmds:
            if not hid_write(fd, cmd):
                return None
            time.sleep(0.05)

        deadline = time.time() + (1.5 if phase == 0 else 4.0)
        while time.time() < deadline:
            data = hid_read(fd, timeout=min(1.5, deadline - time.time()))
            if data is None:
                break
            samples = decode_packet(data)
            if samples:
                return samples

    return None


def run_monitor(fd: int, interval=1.0, count=None):
    """Continuously poll and print measurements."""
    hid_drain(fd)
    if not hid_write(fd, CMD_INIT1) or not hid_write(fd, CMD_INIT2) or not hid_write(fd, CMD_INIT2):
        print("Failed to initialize device.", file=sys.stderr)
        sys.exit(1)

    print(f"{'Time':>10}  {'Voltage':>9}  {'Current':>9}  {'Power':>8}  {'D+':>6}  {'D-':>6}  {'Temp':>7}  Protocol")
    print(f"{'(s)':>10}  {'(V)':>9}  {'(A)':>9}  {'(W)':>8}  {'(V)':>6}  {'(V)':>6}  {'(°C)':>7}")
    print("-" * 90)

    next_poll = time.time() + interval
    start = time.time()
    n = 0

    try:
        while count is None or n < count:
            now = time.time()
            if now >= next_poll:
                hid_write(fd, CMD_POLL)
                next_poll = now + interval

            data = hid_read(fd, timeout=min(1.0, max(0.05, next_poll - time.time())))
            if data is None:
                continue

            samples = decode_packet(data)
            if samples is None:
                continue

            t = time.time() - start
            s = samples[0]
            print(
                f"{t:10.2f}  {s['voltage_V']:9.5f}  {s['current_A']:9.5f}"
                f"  {s['power_W']:8.5f}  {s['dp_V']:6.3f}  {s['dn_V']:6.3f}  {s['temp_C']:7.1f}"
                f"  {s['protocol']}"
            )
            sys.stdout.flush()
            n += 1

    except KeyboardInterrupt:
        print("\nStopped.", file=sys.stderr)


def trigger(fd: int, proto_id: int, voltage_mv: int = 0, hold_secs: float = 3.0):
    """
    Send a single trigger command to the FNB58 to negotiate a fast-charge protocol.

    proto_id    : protocol ID (1–14, see PROTOCOLS dict)
    voltage_mv  : target voltage in millivolts (0 = use protocol default)
    hold_secs   : seconds to hold the trigger before releasing (0 = no release)

    NOTE: Trigger command bytes are reverse-engineered / best-guess.
          Test with a QC/PD-capable charger and report results.
    """
    proto = PROTOCOLS.get(proto_id)
    if proto is None:
        print(f"Unknown protocol ID {proto_id}. Valid IDs: {list(PROTOCOLS.keys())}", file=sys.stderr)
        sys.exit(1)

    name, default_mv, _ = proto
    if voltage_mv == 0:
        voltage_mv = default_mv

    print(f"Triggering: {name} @ {voltage_mv/1000:.1f} V (proto_id={proto_id})", file=sys.stderr)
    hid_write(fd, make_trigger_cmd(proto_id, voltage_mv))

    if hold_secs > 0:
        time.sleep(hold_secs)
        hid_write(fd, make_release_cmd())
        print("Trigger released.", file=sys.stderr)


# ──────────────────────────────────────────────────────────────────────────────
# BLE transport (BlueZ D-Bus, service ffe0 / RFstar transparent UART)
# ──────────────────────────────────────────────────────────────────────────────

BLE_DEFAULT_MAC = "BA:03:18:7A:23:DF"

# UUIDs for the transparent UART characteristics
UUID_NOTIFY = "0000ffe4-0000-1000-8000-00805f9b34fb"  # ffe4 — notifications
UUID_WRITE  = "0000ffe9-0000-1000-8000-00805f9b34fb"  # ffe9 — write

# 4-byte init commands sent over ffe9 to start the measurement stream
BLE_CMD_INIT1 = bytes([0xaa, 0x81, 0x00, 0xf4])
BLE_CMD_INIT2 = bytes([0xaa, 0x82, 0x00, 0xa7])

# Stream packet data_len by type (used for framing)
_BLE_PKT_LENS = {0x03: 14, 0x04: 12, 0x05: 7, 0x06: 6, 0x07: 4, 0x08: 17}


def _ble_ensure_connected(mac: str):
    """Connect to BLE device via bluetoothctl if not already connected."""
    import dbus
    try:
        bus = dbus.SystemBus()
        dev_path = f"/org/bluez/hci0/dev_{mac.replace(':', '_').upper()}"
        props = dbus.Interface(bus.get_object("org.bluez", dev_path), "org.freedesktop.DBus.Properties")
        if props.Get("org.bluez.Device1", "Connected"):
            return  # already connected
    except Exception:
        pass

    print(f"Connecting to {mac}...", file=sys.stderr)
    result = subprocess.run(
        ["bluetoothctl", "connect", mac],
        capture_output=True, text=True, timeout=15
    )
    if "Connection successful" not in result.stdout:
        print(f"Could not connect: {result.stdout.strip()}", file=sys.stderr)
        sys.exit(1)
    time.sleep(1.5)  # give BlueZ time to resolve GATT services


def _ble_find_chars(bus, mac: str) -> tuple[str, str]:
    """
    Enumerate BlueZ GATT objects to find the D-Bus paths for ffe4 and ffe9
    characteristics under the given device MAC. Returns (notify_path, write_path).
    """
    import dbus
    mgr = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager")
    objs = mgr.GetManagedObjects()
    dev_prefix = f"/org/bluez/hci0/dev_{mac.replace(':', '_').upper()}"
    notify_path = write_path = None
    for path, ifaces in objs.items():
        if not str(path).startswith(dev_prefix):
            continue
        char = ifaces.get("org.bluez.GattCharacteristic1")
        if not char:
            continue
        uuid = str(char.get("UUID", ""))
        if uuid == UUID_NOTIFY:
            notify_path = str(path)
        elif uuid == UUID_WRITE:
            write_path = str(path)
    if not notify_path or not write_path:
        print(
            f"Could not find ffe4/ffe9 characteristics for {mac}.\n"
            "Is the device connected?  Try: bluetoothctl connect " + mac,
            file=sys.stderr,
        )
        sys.exit(1)
    return notify_path, write_path


def _parse_ble_stream(chunk: bytes) -> list[dict]:
    """
    Parse a BLE notification chunk that may contain multiple concatenated packets.
    Each packet: aa [type] [data_len] [data...] [checksum]
    Returns decoded temperature, D+/D-, and VBUS/IBUS measurement dicts.
    """
    results = []
    i = 0
    while i < len(chunk):
        if chunk[i] != 0xAA:
            i += 1
            continue
        if i + 2 >= len(chunk):
            break
        ptype = chunk[i + 1]
        dlen  = chunk[i + 2]
        expected = _BLE_PKT_LENS.get(ptype)
        if expected is None or dlen != expected:
            i += 1
            continue
        total = dlen + 4  # aa + type + len + data + checksum
        if i + total > len(chunk):
            break
        data = chunk[i + 3 : i + 3 + dlen]
        if ptype == 0x07:
            vbus = struct.unpack_from("<H", data, 0)[0] / 1000.0
            ibus = struct.unpack_from("<H", data, 2)[0] / 1000.0
            results.append({"type": "vi", "vbus_V": vbus, "ibus_A": ibus, "power_W": vbus * ibus})
        elif ptype == 0x05:
            temp = struct.unpack_from("<H", data, 5)[0] / 10.0
            results.append({"type": "temp", "temp_C": temp})
        elif ptype == 0x06:
            dp = struct.unpack_from("<H", data, 0)[0] / 1000.0
            dn = struct.unpack_from("<H", data, 2)[0] / 1000.0
            results.append({"type": "dp", "dp_V": dp, "dn_V": dn})
        i += total
    return results


def _ble_require_imports():
    try:
        import dbus
        import dbus.mainloop.glib
        from gi.repository import GLib
        return dbus, GLib
    except ImportError:
        print("BLE mode requires: sudo apt install python3-dbus python3-gi", file=sys.stderr)
        sys.exit(1)


def ble_read_once(mac: str) -> dict | None:
    """
    Start the BLE stream, collect the first VBUS/IBUS, D+/D-, and temperature
    readings, then return them.
    """
    dbus, GLib = _ble_require_imports()
    import dbus.mainloop.glib
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    _ble_ensure_connected(mac)
    bus = dbus.SystemBus()

    notify_path, write_path = _ble_find_chars(bus, mac)
    ni = dbus.Interface(bus.get_object("org.bluez", notify_path), "org.bluez.GattCharacteristic1")
    wi = dbus.Interface(bus.get_object("org.bluez", write_path),  "org.bluez.GattCharacteristic1")

    result = {}
    loop = GLib.MainLoop()

    def on_data(iface, changed, inv):
        if "Value" not in changed:
            return
        for pkt in _parse_ble_stream(bytes(changed["Value"])):
            result.update(pkt)
        if "vbus_V" in result and "dp_V" in result and "temp_C" in result:
            loop.quit()

    bus.add_signal_receiver(on_data,
        dbus_interface="org.freedesktop.DBus.Properties",
        signal_name="PropertiesChanged", path=notify_path)

    try: ni.StopNotify()
    except Exception: pass
    ni.StartNotify()

    def _send_init2():
        wi.WriteValue(dbus.Array([dbus.Byte(b) for b in BLE_CMD_INIT2], signature="y"), {})
    wi.WriteValue(dbus.Array([dbus.Byte(b) for b in BLE_CMD_INIT1], signature="y"), {})
    GLib.timeout_add(2000, lambda: _send_init2() or False)
    GLib.timeout_add(8000, lambda: loop.quit() or False)
    loop.run()

    try: ni.StopNotify()
    except Exception: pass

    return result if "vbus_V" in result else None


def ble_monitor(mac: str, count: int | None = None):
    """Stream measurements continuously from BLE and print each VBUS/IBUS packet."""
    dbus, GLib = _ble_require_imports()
    import dbus.mainloop.glib
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    _ble_ensure_connected(mac)
    bus = dbus.SystemBus()

    notify_path, write_path = _ble_find_chars(bus, mac)
    ni = dbus.Interface(bus.get_object("org.bluez", notify_path), "org.bluez.GattCharacteristic1")
    wi = dbus.Interface(bus.get_object("org.bluez", write_path),  "org.bluez.GattCharacteristic1")

    print(f"BLE mode — device {mac}")
    print(f"{'Time':>10}  {'VBUS':>9}  {'IBUS':>9}  {'Power':>9}  {'D+':>7}  {'D-':>7}  {'Temp':>7}")
    print(f"{'(s)':>10}  {'(V)':>9}  {'(A)':>9}  {'(W)':>9}  {'(V)':>7}  {'(V)':>7}  {'(°C)':>7}")
    print("-" * 77)

    start = time.time()
    n = [0]
    last_dp = [None]
    last_temp = [None]
    loop = GLib.MainLoop()

    def on_data(iface, changed, inv):
        if "Value" not in changed:
            return
        for pkt in _parse_ble_stream(bytes(changed["Value"])):
            if pkt["type"] == "dp":
                last_dp[0] = pkt
            elif pkt["type"] == "temp":
                last_temp[0] = pkt["temp_C"]
            elif pkt["type"] == "vi":
                t = time.time() - start
                dp = last_dp[0]["dp_V"] if last_dp[0] else float("nan")
                dn = last_dp[0]["dn_V"] if last_dp[0] else float("nan")
                temp = last_temp[0] if last_temp[0] is not None else float("nan")
                print(
                    f"{t:10.2f}  {pkt['vbus_V']:9.3f}  {pkt['ibus_A']:9.3f}"
                    f"  {pkt['power_W']:9.5f}  {dp:7.3f}  {dn:7.3f}  {temp:7.1f}"
                )
                sys.stdout.flush()
                n[0] += 1
                if count is not None and n[0] >= count:
                    loop.quit()

    bus.add_signal_receiver(on_data,
        dbus_interface="org.freedesktop.DBus.Properties",
        signal_name="PropertiesChanged", path=notify_path)

    try: ni.StopNotify()
    except Exception: pass
    ni.StartNotify()

    def _send_init2():
        wi.WriteValue(dbus.Array([dbus.Byte(b) for b in BLE_CMD_INIT2], signature="y"), {})
    wi.WriteValue(dbus.Array([dbus.Byte(b) for b in BLE_CMD_INIT1], signature="y"), {})
    GLib.timeout_add(2000, lambda: _send_init2() or False)

    try:
        loop.run()
    except KeyboardInterrupt:
        print("\nStopped.", file=sys.stderr)

    try: ni.StopNotify()
    except Exception: pass


# ──────────────────────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Read VBUS/IBUS and more from an FNIRSI FNB58 USB power meter.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    transport = parser.add_mutually_exclusive_group()
    transport.add_argument(
        "--usb", action="store_true", default=True,
        help="Use USB HID (default)"
    )
    transport.add_argument(
        "--ble", action="store_true",
        help="Use Bluetooth LE (requires python3-dbus, python3-gi)"
    )
    parser.add_argument(
        "--mac", default=BLE_DEFAULT_MAC,
        help="BLE device MAC address"
    )
    parser.add_argument(
        "-n", "--count", type=int, default=None,
        help="Number of readings (default: run forever, Ctrl-C to stop)"
    )
    parser.add_argument(
        "--once", action="store_true",
        help="Take a single snapshot and exit"
    )
    parser.add_argument(
        "--interval", type=float, default=1.0,
        help="Poll interval in seconds"
    )
    parser.add_argument(
        "--trigger", type=int, default=None, metavar="PROTO_ID",
        help=(
            "Send a fast-charge trigger and release it after --hold seconds. "
            "PROTO_ID: " + ", ".join(f"{k}={v[0]}" for k,v in PROTOCOLS.items())
        )
    )
    parser.add_argument(
        "--voltage", type=float, default=0,
        help="Trigger target voltage in V (0 = protocol default)"
    )
    parser.add_argument(
        "--hold", type=float, default=3.0,
        help="Seconds to hold the trigger before releasing (0 = no release)"
    )
    args = parser.parse_args()

    # ── BLE mode ──
    if args.ble:
        if args.once:
            s = ble_read_once(args.mac)
            if s:
                dp  = s.get('dp_V', float('nan'))
                dn  = s.get('dn_V', float('nan'))
                vbs = s.get('vbus_V', float('nan'))
                print(f"VBUS     : {vbs:.3f} V")
                print(f"IBUS     : {s.get('ibus_A', float('nan')):.3f} A")
                print(f"Power    : {s.get('power_W', float('nan')):.5f} W")
                print(f"D+       : {dp:.3f} V")
                print(f"D-       : {dn:.3f} V")
                print(f"Temp     : {s.get('temp_C', float('nan')):.1f} °C")
                print(f"Protocol : {infer_protocol(dp, dn, vbs)}")
            else:
                print("No BLE data received.", file=sys.stderr)
                sys.exit(1)
        else:
            ble_monitor(args.mac, count=args.count)
        return

    # ── USB HID mode ──
    path = find_hidraw()
    fd = open_hid(path)

    try:
        if args.trigger is not None:
            # Ensure device is streaming first
            read_once(fd)
            trigger(fd, args.trigger, int(args.voltage * 1000), args.hold)
        elif args.once:
            samples = read_once(fd)
            if samples is None:
                # Device may be mid-reboot; wait for it to come back and retry
                os.close(fd)
                path = find_hidraw(wait=20.0)
                fd = open_hid(path)
                samples = read_once(fd)
            if samples:
                s = samples[0]
                print(f"VBUS     : {s['voltage_V']:.5f} V")
                print(f"IBUS     : {s['current_A']:.5f} A")
                print(f"Power    : {s['power_W']:.5f} W")
                print(f"D+       : {s['dp_V']:.3f} V")
                print(f"D-       : {s['dn_V']:.3f} V")
                print(f"Temp     : {s['temp_C']:.1f} °C")
                print(f"Protocol : {s['protocol']}")
            else:
                print("No data received.", file=sys.stderr)
                sys.exit(1)
        else:
            run_monitor(fd, interval=args.interval, count=args.count)
    finally:
        os.close(fd)


if __name__ == "__main__":
    main()

The program allows for using USB_HID or BLE for communicating with the FNB58.  Since I'll  be using USB, I can simplify the code by deleting the BLE sections when I create the benchmark logger.  For the initial test we can use the program unchanged just by passing the usb argument at the command line.  The program uses polling for the USB_HID protocol.  The default is 1 second interval.

Here are the results of the first few seconds: (fnb58_debug.py just adds debug printouts to the original program)

ralph@CM5-Frigate:~/OpenFNB58 $ python3 -u fnb58_debug.py --usb --interval 1

      Time    Voltage     Current     Power      D+      D-       Temp   Protocol

         (s)         (V)            (A)          (W)        (V)      (V)       (°C)

------------------------------------------------------------------------------------------

      0.04    5.12432    0.63418   3.24974   0.034   0.059     27.2  USB SDP
      0.08    5.12432    0.66473   3.40629   0.034   0.059     27.2  USB SDP
      0.12    5.13784    0.55195   2.83583   0.034   0.059     27.2  USB SDP
      0.16    5.12436    0.55394   2.83859   0.033   0.058     27.2  USB SDP
      0.20    5.12436    0.56313   2.88568   0.032   0.058     27.2  USB SDP
      0.24    5.12444    0.57743   2.95901   0.033   0.058     27.2  USB SDP
      0.28    5.12444    0.55518   2.84499   0.033   0.058     27.2  USB SDP
      0.32    5.12448    0.56015   2.87048   0.033   0.058     27.2  USB SDP
      0.36    5.12448    0.55593   2.84885   0.033   0.057     27.2  USB SDP
      0.40    5.12448    0.57754   2.95959   0.033   0.058     27.2  USB SDP
      0.44    5.12440    0.55220   2.82969   0.033   0.058     27.2  USB SDP
      0.48    5.12440    0.57729   2.95826   0.033   0.058     27.2  USB SDP
      0.52    5.12436    0.56760   2.90859   0.032   0.058     27.2  USB SDP
      0.56    5.12436    0.57763   2.95998   0.033   0.058     27.2  USB SDP
      0.60    5.12436    0.55096   2.82332   0.033   0.058     27.2  USB SDP
      0.64    5.12440    0.56288   2.88442   0.033   0.058     27.2  USB SDP
      0.68    5.12440    0.54723   2.80423   0.033   0.058     27.2  USB SDP
      0.72    5.12448    0.57766   2.96021   0.033   0.058     27.2  USB SDP
      0.76    5.12448    0.54922   2.81447   0.033   0.058     27.2  USB SDP
      0.80    5.12448    0.55717   2.85521   0.033   0.058     27.2  USB SDP
      0.84    5.12455    0.55146   2.82598   0.033   0.058     27.2  USB SDP
      0.88    5.12455    0.57760   2.95994   0.033   0.058     27.2  USB SDP
      0.92    5.12471    0.54897   2.81331   0.033   0.058     27.2  USB SDP
      0.96    5.11152    0.60958   3.11588   0.033   0.058     27.2  USB SDP
      1.00    5.12471    0.57752   2.95962   0.033   0.058     27.2  USB SDP

DEBUG: sending POLL
DEBUG: POLL write result = True

      1.04    5.12487    0.58425   2.99421   0.033   0.058     27.2  USB SDP
      1.08    5.12487    0.56697   2.90565   0.033   0.058     27.2  USB SDP
      1.12    5.12491    0.55873   2.86344   0.033   0.058     27.2  USB SDP
      1.16    5.12491    0.55873   2.86344   0.032   0.057     27.2  USB SDP
      1.20    5.12491    0.57034   2.92294   0.033   0.058     27.2  USB SDP
      1.24    5.12471    0.55608   2.84975   0.033   0.058     27.2  USB SDP
      1.28    5.12471    0.55608   2.84975   0.033   0.058     27.2  USB SDP
      1.32    5.12487    0.57282   2.93563   0.032   0.057     27.2  USB SDP
      1.36    5.12487    0.56711   2.90637   0.032   0.058     27.2  USB SDP
      1.40    5.12487    0.55596   2.84922   0.033   0.058     27.2  USB SDP
      1.44    5.12495    0.55617   2.85034   0.033   0.058     27.2  USB SDP
      1.48    5.12495    0.56686   2.90513   0.033   0.058     27.2  USB SDP
      1.52    5.12506    0.58971   3.02230   0.033   0.058     27.2  USB SDP
      1.56    5.12506    0.55630   2.85107   0.033   0.058     27.2  USB SDP
      1.60    5.12506    0.55630   2.85107   0.033   0.058     27.2  USB SDP
      1.64    5.12518    0.57282   2.93581   0.033   0.058     27.2  USB SDP
      1.68    5.12518    0.58176   2.98162   0.033   0.058     27.2  USB SDP
      1.72    5.12518    0.55636   2.85145   0.033   0.058     27.2  USB SDP
      1.76    5.12518    0.55636   2.85145   0.033   0.058     27.2  USB SDP
      1.80    5.12518    0.56760   2.90905   0.033   0.058     27.2  USB SDP
      1.84    5.12510    0.57307   2.93704   0.033   0.058     27.2  USB SDP
      1.88    5.12510    0.55666   2.85294   0.033   0.058     27.2  USB SDP
      1.92    5.12502    0.55666   2.85289   0.033   0.058     27.2  USB SDP
      1.96    5.12502    0.60362   3.09356   0.033   0.058     27.2  USB SDP
      2.00    5.12502    0.60139   3.08214   0.032   0.057     27.2  USB SDP

DEBUG: sending POLL
DEBUG: POLL write result = True

      2.04    5.12498    0.55684   2.85379   0.033   0.058     27.2  USB SDP
      2.08    5.12498    0.55684   2.85379   0.033   0.057     27.2  USB SDP
      2.12    5.12502    0.55685   2.85387   0.033   0.058     27.2  USB SDP
      2.16    5.12502    0.58623   3.00444   0.032   0.058     27.2  USB SDP
      2.20    5.12502    0.55685   2.85387   0.033   0.058     27.2  USB SDP
      2.24    5.12502    0.55696   2.85443   0.033   0.058     27.2  USB SDP
      2.28    5.12502    0.55696   2.85443   0.033   0.058     27.2  USB SDP
      2.32    5.12502    0.60834   3.11775   0.033   0.058     27.2  USB SDP
      2.36    5.12502    0.55713   2.85530   0.033   0.058     27.2  USB SDP
      2.40    5.12502    0.55713   2.85530   0.033   0.058     27.2  USB SDP
      2.44    5.12498    0.55713   2.85528   0.033   0.058     27.2  USB SDP
      2.48    5.12498    0.60288   3.08975   0.032   0.058     27.2  USB SDP
      2.52    5.12491    0.55741   2.85668   0.032   0.057     27.2  USB SDP
      2.56    5.12491    0.55741   2.85668   0.033   0.058     27.2  USB SDP
      2.60    5.12491    0.55741   2.85668   0.033   0.058     27.2  USB SDP
      2.64    5.12491    0.59170   3.03241   0.033   0.058     27.2  USB SDP
      2.68    5.12491    0.55692   2.85416   0.033   0.058     27.2  USB SDP
      2.72    5.12491    0.55665   2.85278   0.033   0.058     27.2  USB SDP
      2.76    5.12491    0.55665   2.85278   0.033   0.058     27.2  USB SDP
      2.80    5.12491    0.59642   3.05660   0.033   0.058     27.2  USB SDP
      2.84    5.12491    0.55650   2.85201   0.033   0.058     27.2  USB SDP
      2.88    5.12491    0.55650   2.85201   0.033   0.058     27.2  USB SDP
      2.92    5.12483    0.55670   2.85299   0.033   0.058     27.2  USB SDP
      2.96    5.12483    0.59418   3.04507   0.033   0.058     27.2  USB SDP
      3.00    5.12483    0.56909   2.91649   0.033   0.058     27.2  USB SDP

DEBUG: sending POLL
DEBUG: POLL write result = True

      3.04    5.12471    0.55696   2.85426   0.033   0.058     27.2  USB SDP
      3.08    5.12471    0.55696   2.85426   0.033   0.058     27.2  USB SDP
      3.12    5.12463    0.57977   2.97111   0.033   0.058     27.2  USB SDP
      3.16    5.12463    0.55610   2.84981   0.033   0.058     27.2  USB SDP
      3.20    5.12463    0.55610   2.84981   0.033   0.058     27.2  USB SDP
      3.24    5.12459    0.55599   2.84922   0.033   0.058     27.2  USB SDP
      3.28    5.12459    0.58946   3.02074   0.032   0.058     27.2  USB SDP
      3.32    5.12448    0.55615   2.84998   0.033   0.058     27.2  USB SDP
      3.36    5.12448    0.55615   2.84998   0.033   0.058     27.2  USB SDP
      3.40    5.12448    0.55615   2.84998   0.032   0.058     27.2  USB SDP
      3.44    5.12444    0.57903   2.96720   0.033   0.058     27.2  USB SDP
      3.48    5.12444    0.56686   2.90484   0.033   0.058     27.2  USB SDP
      3.52    5.12444    0.57282   2.93538   0.033   0.058     27.2  USB SDP
      3.56    5.12444    0.57034   2.92267   0.033   0.058     27.2  USB SDP
      3.60    5.12444    0.58002   2.97228   0.033   0.058     27.2  USB SDP
      3.64    5.12444    0.55578   2.84806   0.032   0.058     27.2  USB SDP
      3.68    5.12444    0.55578   2.84806   0.032   0.057     27.2  USB SDP
      3.72    5.12428    0.55568   2.84746   0.033   0.058     27.2  USB SDP
      3.76    5.12428    0.57853   2.96455   0.032   0.057     27.2  USB SDP
      3.80    5.12428    0.55568   2.84746   0.033   0.058     27.2  USB SDP
      3.84    5.12420    0.55578   2.84793   0.033   0.058     27.2  USB SDP
      3.88    5.12420    0.57158   2.92889   0.033   0.058     27.2  USB SDP
      3.92    5.12424    0.58325   2.98871   0.032   0.057     27.2  USB SDP
      3.96    5.12424    0.57257   2.93399   0.033   0.058     27.2  USB SDP
      4.00    5.12424    0.57804   2.96202   0.033   0.058     27.2  USB SDP

DEBUG: sending POLL
DEBUG: POLL write result = True

The FNB58 is sampling at 100Hz, so when we poll at 1Hz we get 25 packets with 4 samples per packet.  The printout is only showing the first sample in each packet.

So, the stock program is working well.  When moving on to creating a standalone logging program I ran into problems trying to reduce the amount of data being ingested.  When I increased the polling interval to 2 or higher, the polling interface would hang.  After chasing this for a while I decided to move on and leave the polling interval at 1 second.

So, after a few iterations deciding what data and format that I wanted - here is the first "release" of the logging program cm5_power_temp_logger.py.

#!/usr/bin/env python3

from datetime import datetime
from pathlib import Path
import subprocess
import time
import sys
import os
import csv
import select
import struct
import argparse

# Import FNB58 protocol definitions (requires fnb58.py in the same directory)
from fnb58 import (
    CMD_INIT1,
    CMD_INIT2,
    CMD_POLL,
    open_hid,
    hid_write,
    hid_drain,
    infer_protocol,
)

# --- FNB58 Helper Functions ---

def decode_packet(data):
    """Decode a 64-byte FNB58 data packet."""
    if len(data) < 62:
        return None
    if data[0] != 0xAA or data[1] != 0x04:
        return None

    samples = []
    for i in range(4):
        off = 2 + 15 * i
        voltage = struct.unpack_from("<I", data, off)[0] / 100000.0
        current = struct.unpack_from("<I", data, off + 4)[0] / 100000.0
        dp = struct.unpack_from("<H", data, off + 8)[0] / 1000.0
        dn = struct.unpack_from("<H", data, off + 10)[0] / 1000.0
        temp = struct.unpack_from("<H", data, off + 13)[0] / 10.0

        samples.append({
            "voltage_V": voltage,
            "current_A": current,
            "power_W": voltage * current,
            "dp_V": dp,
            "dn_V": dn,
            "temp_C": temp,
            "protocol": infer_protocol(dp, dn, voltage),
        })
    return samples

def hid_read(fd, timeout=2.0):
    """Read one HID report with a timeout."""
    r, _, _ = select.select([fd], [], [], timeout)
    if not r:
        return None
    try:
        return os.read(fd, 64)
    except OSError:
        return None

def initialize_fnb58(fd):
    """Initialize the FNB58 using the known-working sequence."""
    hid_drain(fd)
    if not hid_write(fd, CMD_INIT1):
        return False
    time.sleep(0.05)
    if not hid_write(fd, CMD_INIT2):
        return False
    time.sleep(0.05)
    if not hid_write(fd, CMD_INIT2):
        return False
    return True

def get_fnb58_packet(fd, deadline):
    """Wait for a valid FNB58 data packet until deadline."""
    while time.time() < deadline:
        remaining = deadline - time.time()
        data = hid_read(fd, timeout=min(1.0, remaining))
        if data is None:
            continue
        samples = decode_packet(data)
        if samples:
            return samples
    return None


# --- CM5 System / Thermal Helper Functions ---

def read_file(path):
    """Safely read a file's contents, returning an empty string if it fails."""
    try:
        return Path(path).read_text().strip()
    except Exception:
        return ""

def get_throttled():
    """Run vcgencmd to get the throttled status."""
    try:
        res = subprocess.run(
            ["vcgencmd", "get_throttled"],
            capture_output=True,
            text=True,
            check=True,
        )
        return res.stdout.strip().split("=")[1]
    except Exception:
        return ""

def get_cm5_metrics():
    """Gather all CM5 system metrics."""
    # 1. Temperature (C)
    temp_raw = read_file("/sys/class/thermal/thermal_zone0/temp")
    try:
        temp = f"{float(temp_raw) / 1000:.2f}" if temp_raw else ""
    except ValueError:
        temp = ""

    # 2. CPU Frequency (MHz)
    freq_raw = read_file("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
    try:
        freq = f"{float(freq_raw) / 1000:.0f}" if freq_raw else ""
    except ValueError:
        freq = ""

    # 3. Fan PWM and RPM
    pwm = read_file("/sys/devices/platform/cooling_fan/hwmon/hwmon2/pwm1")
    rpm = read_file("/sys/devices/platform/cooling_fan/hwmon/hwmon2/fan1_input")

    # 4. Throttling status
    throttled = get_throttled()

    # 5. 1-minute CPU load average
    load_raw = read_file("/proc/loadavg")
    load = load_raw.split()[0] if load_raw else ""

    return temp, freq, pwm, rpm, throttled, load


# --- Main Execution ---

def main():
    parser = argparse.ArgumentParser(description="Combined CM5 System & FNB58 Power Logger")
    parser.add_argument("--usb", action="store_true", default=True, help="Use USB HID interface (default)")
    parser.add_argument("--interval", type=float, default=1.0, help="Seconds between polls (default: 1)")
    parser.add_argument("--count", type=int, default=None, help="Number of measurements, or run until Ctrl-C")
    parser.add_argument("--output", type=str, default=None, help="Custom CSV output filename")
    args = parser.parse_args()

    if not args.usb:
        print("Error: --usb flag is required to talk to the FNB58 meter.", file=sys.stderr)
        sys.exit(1)

    # Setup directories and default output filename if not provided
    log_dir = Path.home() / "cm5_benchmark"
    log_dir.mkdir(parents=True, exist_ok=True)
    
    if args.output:
        log_file = Path(args.output)
    else:
        timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        log_file = log_dir / f"combined_benchmark_{timestamp_str}.csv"

    # Open FNB58 HID device
    fd = open_hid("/dev/hidraw0")
    if fd < 0:
        print("Unable to open FNB58 HID device at /dev/hidraw0.", file=sys.stderr)
        sys.exit(1)

    if not initialize_fnb58(fd):
        print("Failed to initialize FNB58.", file=sys.stderr)
        os.close(fd)
        sys.exit(1)

    start_time = time.time()
    next_poll = start_time + args.interval
    count = 0

    # Write combined CSV header
    header = [
        "timestamp",
        "elapsed_s",
        "voltage_V",
        "current_A",
        "power_W",
        "cm5_temp_C",
        "cpu_freq_MHz",
        "fan_rpm",
        "throttled",
        "cpu_load_1min"
    ]

    with open(log_file, "w", newline="") as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow(header)
        csvfile.flush()

        print("Combined CM5 & FNB58 Logger Started")
        print(f"Logging to: {log_file}")
        print(f"Interval:   {args.interval} seconds")
        print("Press Ctrl-C to stop.\n")

        try:
            while args.count is None or count < args.count:
                now = time.time()
                if now < next_poll:
                    time.sleep(next_poll - now)

                poll_time = time.time()
                elapsed = poll_time - start_time

                # 1. Gather CM5 Metrics
                cm5_temp, freq, pwm, rpm, throttled, load = get_cm5_metrics()

                # 2. Gather FNB58 Power Metrics
                if not hid_write(fd, CMD_POLL):
                    print("WARNING: FNB58 POLL write failed", file=sys.stderr)
                    next_poll = poll_time + args.interval
                    continue

                samples = get_fnb58_packet(fd, poll_time + 1.5)
                
                timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(poll_time))

                if samples is None:
                    print(f"{elapsed:8.1f}s  WARNING: FNB58 no response (CM5 logged)", file=sys.stderr)
                    # Log row with empty power values if meter drops out
                    writer.writerow([
                        timestamp, f"{elapsed:.2f}", "", "", "", cm5_temp, freq, rpm,
                        throttled, load
                    ])
                else:
                    s = samples[0]
                    # Write full combined row
                    writer.writerow([
                        timestamp,
                        f"{elapsed:.2f}",
                        f"{s['voltage_V']:.5f}",
                        f"{s['current_A']:.5f}",
                        f"{s['power_W']:.5f}",
                        cm5_temp,
                        freq,
                        rpm,
                        throttled,
                        load
                    ])

                    # Print combined quick view to terminal
                    print(
                        f"{elapsed:6.1f}s | Power: {s['voltage_V']:.2f}V {s['current_A']:.2f}A {s['power_W']:.2f}W | "
                        f"CM5: {cm5_temp:>5}C {freq:>4}MHz {rpm:>4}RPM"
                    )

                csvfile.flush()
                count += 1
                next_poll = poll_time + args.interval

        except KeyboardInterrupt:
            print("\nLogging stopped by user.")
        finally:
            os.close(fd)

if __name__ == "__main__":
    main()

It is about a third of the length of fnb58.py.  I'll need to circle back and add comments and options as required.

Here is a test run for about 30 seconds with the system at idle:

image

The printed output is missing a few parameters on purpose.

Here is the CSV file that is created:

image

As a final check I ran another stress test, this time including memory stress and running for 30 minutes (logger runs for 45).

.image

The data plots from the CSV file.

image

image

image

At this point I'm ready to start trying different performance benchmarks.  

  • Sign in to reply
element14 Community

element14 is the first online community specifically for engineers. Connect with your peers and get expert answers to your questions.

  • Members
  • Learn
  • Technologies
  • Challenges & Projects
  • Products
  • Store
  • About Us
  • Feedback & Support
  • FAQs
  • Terms of Use
  • Privacy Policy
  • Legal and Copyright Notices
  • Sitemap
  • Cookies

An Avnet Company © 2026 Premier Farnell Limited. All Rights Reserved.

Premier Farnell Ltd, registered in England and Wales (no 00876412), registered office: Farnell House, Forge Lane, Leeds LS12 2NE.

Follow element14

  • X
  • Facebook
  • linkedin
  • YouTube