SYS.ACTIVE
Tharana-dev
CPU: 12% | MEM: 44%
[ SYSTEMS / TELEMETRY ]

Decoding Battery Telemetry

Talking to the Embedded Controller via /sys and handling desktop fallbacks.

When you look at your laptop's battery percentage, you aren't reading a Python variable. You are reading a number calculated by a tiny, separate microcontroller inside your laptop called the Embedded Controller (EC).

The EC sits on the motherboard and constantly measures the chemical state of the battery cells. It talks to the operating system using a standard called ACPI (Advanced Configuration and Power Interface). The OS translates what the EC says into a format your code can read.

The Linux Way: /sys/class/power_supply/

Just like /proc is an illusion of a filesystem for processes, /sys is an illusion for hardware devices.

If you open your WSL terminal (or a real Linux laptop) and type ls /sys/class/power_supply/, you will usually see folders like BAT1 (Battery 1) or AC1 (the power adapter).

Inside /sys/class/power_supply/, you'll usually see two main folders: BAT1 (the battery) and AC1 (the wall charger).

If you list the contents of these folders using os.listdir(), the kernel exposes an absolute goldmine of hardware data. Here is a peek at what is actually sitting inside them:

# Inside BAT1 (Battery)
['uevent', 'serial_number', 'technology', 'power_now', 'present', 'manufacturer', 
'energy_now', 'capacity', 'cycle_count', 'voltage_now', 'status', 'alarm', 
'model_name', 'voltage_min_design', 'capacity_level', 'energy_full_design', 
'energy_full', 'extensions']

# Inside AC1 (Power Adapter)
['uevent', 'online', 'type', 'device', 'wakeup5', 'subsystem', 'extensions']

You can read almost anything about the physical battery: the chemical composition (technology), the physical hardware serial number (serial_number), the real-time voltage (voltage_now), and even the total number of charge cycles it has been through (cycle_count).

But reading every single file is a waste of CPU. Opening 20 different files every second just to build a dashboard will cause unnecessary I/O overhead. I needed a smarter strategy.

The 5-Metric Strategy

To achieve maximum feature coverage (Status, Speed, and Lifespan) with minimal CPU overhead, I chose to track only 5 specific files:

  • online (from AC1): Returns 1 (Plugged in) or 0 (On Battery). This is our desktop fallback. If the battery folder is completely missing but online is 1, our app safely detects "Desktop Mode" instead of crashing.
  • capacity (from BAT1): Returns a clean integer from 0 to 100. The ultimate baseline "fuel gauge" metric.
  • status (from BAT1): Returns explicit strings: 'Charging', 'Discharging', or 'Full'. Gives directional context to the percentage.
  • power_now (from BAT1): Measures immediate electrical draw/intake in microwatts (uW). Dividing it by 1,000,000 gives us real-time Watt consumption.
  • energy_full & energy_full_design: Tracks current max charge vs. factory max charge. Dividing these gives a highly accurate "Battery Health %" widget without needing bloated diagnostic software.

The psutil Fallback

What if the user is on Windows? Or macOS? Or using a Desktop computer? /sys doesn't exist there.

That's where psutil.sensors_battery() comes in. It returns a named tuple: sbattery(percent, secsleft, power_plugged).

But there are weird edge cases. If I call this on a desktop computer, it returns None. If the laptop is plugged into the wall, secsleft returns -1 (meaning "infinity" or "unknown"), because the battery isn't currently draining.

The Code: Putting it Together

Here is the core logic. It tries the Linux /sys path first for deep metrics. If that fails, it gracefully falls back to psutil.

import psutil
import os
import platform
from dataclasses import dataclass

@dataclass
class PracticeBatterySnapshot:
    online: bool             # True if wall power is connected
    status: str              # 'Charging', 'Discharging', 'Full', 'N/A'
    capacity: int            # 0-100% fuel gauge
    power_watts: float       # Live power draw/intake
    hours_left: float        # Estimated time remaining
    health_percent: float    # Battery hardware degradation

def get_battery_snapshot() -> PracticeBatterySnapshot:
    bat_path = "/sys/class/power_supply/BAT1"
    ac_path = "/sys/class/power_supply/AC1"
    
    # PATHWAY A: DIRECT LINUX SYSFS READS
    if platform.system() == "Linux" and os.path.exists(bat_path):
        try:
            with open(os.path.join(ac_path, "online"), "r") as f:
                online = f.read().strip() == "1"
            with open(os.path.join(bat_path, "status"), "r") as f:
                status = f.read().strip()
            with open(os.path.join(bat_path, "capacity"), "r") as f:
                capacity = int(f.read().strip())
            with open(os.path.join(bat_path, "power_now"), "r") as f:
                power_watts = int(f.read().strip()) / 1_000_000
            with open(os.path.join(bat_path, "energy_now"), "r") as f:
                energy_now = int(f.read().strip()) / 1_000_000
            
            # Calculate time left
            hours_left = round(energy_now / power_watts, 2) if power_watts > 0 and not online else 0.0
            
            with open(os.path.join(bat_path, "energy_full"), "r") as f:
                efull = int(f.read().strip())
            with open(os.path.join(bat_path, "energy_full_design"), "r") as f:
                edesign = int(f.read().strip())
                
            health_percent = round((efull / edesign) * 100, 1) if edesign > 0 else 100.0

            return PracticeBatterySnapshot(online, status, capacity, power_watts, hours_left, health_percent)
                      
        except (FileNotFoundError, PermissionError, ValueError, ZeroDivisionError):
            pass # Fall through to psutil fallback
        
    # PATHWAY B: UNIVERSAL PSUTIL WITH FALLBACKS (Windows, Mac, Desktops)
    battery = psutil.sensors_battery()
    
    # Desktop Case
    if battery is None:
        return PracticeBatterySnapshot(
            online=True,
            status="AC Power Only",
            capacity=100,
            power_watts=0.0,
            hours_left=0.0,
            health_percent=100.0
        )
        
    # Process psutil properties into strict types
    if battery.power_plugged is True or battery.power_plugged is None:
        online = True
        status = "Full" if battery.percent == 100 else "Charging"
    else:
        online = False
        status = "Discharging"
        
    hours_left = 0.0 if battery.secsleft in (-1, -2) or battery.secsleft < 0 else round(battery.secsleft / 3600, 2)
    
    return PracticeBatterySnapshot(online, status, int(battery.percent), 0.0, hours_left, 100.0)

What this looks like in practice

When you loop this function and print it to the console, you get a clean, real-time dashboard of your laptop's battery life:

Status: [Discharging]
Capacity: 85% 
Online (AC): False 
Draw: 12.450W 
Time: 3.25h remaining 
Health: 94.2%

By carefully choosing these 5 metrics, the telemetry module achieves maximum feature coverage (Status, Speed, and Lifespan) with minimal CPU overhead, avoiding kernel-level noise like uevent string parsing or static vendor hardware tags.

Next up in the Telemetry Engine series: Thermals. Stay tuned.

Tharana Nimsara

✅ VERIFIED AUTHOR

SHA256: f9a2c1...b3d7

ESC
tharana@dev: ~

Welcome to TharanaOS v1.0.0

Type 'help' to see available commands.

➜ ~
Hello, user.
(o_o)