Help us shape the future of Go! Take the 2026 Go Developer Survey

cellwatch

package
v0.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 19, 2026 License: GPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package cellwatch reads battery state from the host operating system and records it, so that charge, drain rate and per-application activity can be reported over time.

The package is deliberately honest about what it cannot know. No mainstream operating system exposes true per-application battery drain, and the ones that come closest either require administrator rights or an undocumented file format. Rather than fill those gaps with invented numbers, every reading is returned alongside a Capability bitmask describing what the platform actually reported. A field the platform could not supply is left un-known; it is never reported as zero.

The package imports nothing outside the standard library and its own dependencies, so it can be split out of a host project without dragging that project along.

Package-level SQLite store for recorded battery history.

The host project already links github.com/ncruces/go-sqlite3: the SQLite engine ships inside that module and is already present in the built binary, so using it here adds no module and no bytes. What follows is a small purpose-built schema rather than a reuse of the project's file index, because battery history has a different lifecycle: a cleared file index is rebuilt in seconds, and a cleared battery history is gone.

Two measurements taken against this driver shaped the code below.

Binding time.Time into an INTEGER column fails with "datatype mismatch", so every instant is passed as an explicit int64 of Unix microseconds. The driver's default text encoding would have failed on the first insert.

Pragmas are set in the DSN rather than with a follow-up Exec. A bare "PRAGMA journal_mode=WAL" runs once on whatever connection happens to be current, which is correct only by accident of the connection count. In the DSN it is applied to every connection the pool ever creates.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrUnsupported reports that the current platform has no reader. It is a
	// normal answer on a platform outside the supported set, not a bug.
	ErrUnsupported = errors.New("cellwatch: battery monitoring is not supported on this platform")

	// ErrNoBattery reports that the platform answered correctly but found no
	// battery, which is the ordinary state of a desktop.
	ErrNoBattery = errors.New("cellwatch: no battery present")

	// ErrNeedsElevation reports that the data exists but reading it requires
	// privileges this process does not have.
	ErrNeedsElevation = errors.New("cellwatch: reading this data requires elevation")
)

Functions

This section is empty.

Types

type ACState

type ACState int

ACState is the mains-line state of the machine.

const (
	// ACUnknown means the platform could not determine the mains state.
	ACUnknown ACState = iota
	// ACOffline means the machine is running on battery.
	ACOffline
	// ACOnline means the machine is running on mains.
	ACOnline
)

func (ACState) String

func (a ACState) String() string

type AppActivity

type AppActivity struct {
	Name string

	// Procs is how many processes were folded into this name.
	Procs int

	// CPUSeconds is measured CPU time consumed in the window.
	CPUSeconds float64

	// ReadBytes and WriteBytes are measured I/O in the window.
	ReadBytes  uint64
	WriteBytes uint64

	// Share is this application's fraction of attributed system drain, 0 to 1.
	Share float64

	// PctPerHour is the drain rate apportioned to it. ESTIMATE.
	PctPerHour float64

	// Provenance is always ProvEstimated for Share and PctPerHour. It travels
	// with the data so a stored value cannot later be read as a measurement.
	Provenance Provenance `json:"provenance"`
}

AppActivity is one application's measured activity over a window, together with the share of observed drain apportioned to it.

Everything from Share onward is an estimate. No operating system reports per-application battery use to an unprivileged process, so the share is derived by weighting CPU time and I/O and dividing by the total. The field names say so, because a caller that renames them to "Watts" has removed the only warning the data carries.

type AppTotal

type AppTotal struct {
	Name       string  `json:"name"`
	PctPerHour float64 `json:"pct_per_hour_estimate"`
	CPUSeconds float64 `json:"cpu_seconds"`
	ReadBytes  uint64  `json:"read_bytes"`
	WriteBytes uint64  `json:"write_bytes"`
}

AppTotal is one application's total over a query range.

type Attribution

type Attribution struct {
	Window time.Duration `json:"window"`

	// Processes is how many processes contributed.
	Processes int `json:"processes"`
	// ProcessesLost is how many were dropped for a counter that moved backwards
	// or an identity that could not be confirmed.
	ProcessesLost int `json:"processes_lost"`

	// CPUCapacityPct is the share of total machine CPU the attributed
	// processes used, and is reported even when the result is Idle, because it
	// is the measurement that explains why the estimate was refused.
	CPUCapacityPct float64 `json:"cpu_capacity_pct"`

	// Idle reports that activity was too low to apportion.
	Idle bool `json:"idle"`

	// Apps is the per-application table, largest share first.
	Apps []AppActivity `json:"apps,omitempty"`

	// Unattributed is the share of drain that no process was credited with.
	Unattributed float64 `json:"unattributed_estimate"`

	// Notes carry the reasons a figure is limited. They are for display.
	Notes []string `json:"notes,omitempty"`

	// Reason explains an empty result.
	Reason string `json:"reason,omitempty"`
}

Attribution is the result of apportioning drain across applications.

type Attributor

type Attributor struct {
	// contains filtered or unexported fields
}

Attributor accumulates process snapshots and apportions observed drain across applications. The zero value is not usable; construct with NewAttributor.

The only state is the pair of snapshots. Nothing else is carried between Sample and Attribute, so Attribute is a pure function of the window it is given: calling it twice over the same window returns the same table, and a caller that renders the attribution and reports its coverage does not get figures that drift on every render.

func NewAttributor

func NewAttributor() *Attributor

NewAttributor returns an unattributed attributor. Call Sample once per tick and Attribute to obtain the current estimate.

func (*Attributor) Attribute

func (a *Attributor) Attribute(systemPctPerHour float64) Attribution

Attribute computes per-application activity over the window since the first sample, and the share of observed drain each is apportioned.

func (*Attributor) Sample

func (a *Attributor) Sample() error

Sample records one instant. The first call only establishes a baseline, because a single snapshot contains no interval and therefore no rate.

type Capability

type Capability uint32

Capability is a bitmask describing what the running platform was able to report. It exists so that a caller can ask rather than guess: a report that shows an empty health field is visibly a limitation, not a machine with a perfect battery.

A platform reader must set only the bits it actually read. Padding the mask to look complete would defeat the purpose of having it.

const (
	// CapChargePct reports charge remaining as a percentage.
	CapChargePct Capability = 1 << iota
	// CapACState reports whether the machine is on mains.
	CapACState
	// CapStatusWord reports the platform's own status string verbatim.
	CapStatusWord
	// CapWatts reports instantaneous power draw.
	CapWatts
	// CapFullCapacity reports present full-charge capacity in watt-hours.
	CapFullCapacity
	// CapDesignCapacity reports factory design capacity in watt-hours.
	CapDesignCapacity
	// CapHealth reports full capacity as a percentage of design.
	CapHealth
	// CapCycleCount reports completed charge cycles.
	CapCycleCount
	// CapTimeRemaining reports the platform's own time-remaining estimate.
	CapTimeRemaining
	// CapTemperature reports pack temperature.
	CapTemperature
	// CapCount reports how many batteries the platform could enumerate.
	CapCount
)

func PlatformCapabilities

func PlatformCapabilities() Capability

PlatformCapabilities reports what this build of cellwatch can ever read, before any particular machine is consulted. It is the ceiling that PlatformSupport then narrows: a build for linux knows it can read sysfs, but whether the running kernel exposes power_now is a question about the machine.

CapAppEnergy is deliberately absent. No supported platform reports true per-application battery energy through any interface cellwatch can read without administrator rights. Per-application figures in this package are apportioned from CPU and I/O activity and are estimates; naming the absence here is what keeps that from being forgotten.

func (Capability) Has

func (c Capability) Has(want Capability) bool

Has reports whether every bit in want is set.

func (Capability) String

func (c Capability) String() string

String renders the set capabilities in a stable order, for diagnostics and for the JSON payload so that a consumer can see what the platform lacked.

func (Capability) With

func (c Capability) With(bits Capability) Capability

With returns c with the given bits set, used by readers as they accumulate.

type Coverage

type Coverage struct {
	Samples  int       `json:"samples"`
	From     time.Time `json:"from"`
	To       time.Time `json:"to"`
	Expected int       `json:"expected"`
	Pct      float64   `json:"pct"`
}

Coverage records how much of a range the store actually holds. A report built from a machine that never ran the sampler is not a machine that used no power, and the two must be distinguishable in the output.

func (Coverage) String

func (c Coverage) String() string

func (Coverage) Usable

func (c Coverage) Usable() bool

Usable reports whether coverage is high enough to describe a range.

Pct is a percentage rather than a fraction because that is what a reader wants to see printed, and comparing it against a fraction is the kind of unit slip that makes every report claim to be complete.

type Period

type Period uint8

Period is a reporting window.

const (
	// PeriodDay is the current UTC day.
	PeriodDay Period = iota
	// PeriodWeek is the current ISO week, which starts on Monday.
	PeriodWeek
	// PeriodMonth is the current calendar month.
	PeriodMonth
	// PeriodYear is the current calendar year.
	PeriodYear
	// PeriodAll is everything recorded.
	PeriodAll
)

func (Period) Previous

func (p Period) Previous(now time.Time) Range

Previous returns the equivalent range immediately before r, so two periods can be compared. It is the range for the same period one period earlier, not simply r shifted back by its own length: a month is not always as long as the month before it.

func (Period) String

func (p Period) String() string

type Point

type Point struct {
	At         time.Time `json:"at"`
	CPUSeconds float64   `json:"cpu_seconds"`
	ReadBytes  uint64    `json:"read_bytes"`
	WriteBytes uint64    `json:"write_bytes"`
	Share      float64   `json:"share_estimate"`
}

Point is one sample in a per-application series.

type Provenance

type Provenance uint8

Provenance records whether a figure was measured by the platform or derived by this package. It is carried in output so a consumer cannot present an apportionment as a measurement.

const (
	// ProvUnknown is the zero value, deliberately. An unset provenance must not
	// default to ProvMeasured, because that would let a value which was never
	// marked read as a measurement purely by being left alone.
	ProvUnknown Provenance = iota
	// ProvMeasured came from the operating system.
	ProvMeasured
	// ProvEstimated was apportioned by this package.
	ProvEstimated
)

func (Provenance) Estimated

func (p Provenance) Estimated() bool

Estimated reports whether the value is derived rather than measured.

func (Provenance) String

func (p Provenance) String() string

type Range

type Range struct {
	From time.Time `json:"from"`
	To   time.Time `json:"to"`
}

Range is a half-open interval [From, To).

func ThisPeriod

func ThisPeriod(now time.Time, p Period) Range

ThisPeriod returns the range for the period containing now.

Periods are cut in UTC rather than in local time. A local day is twenty-three or twenty-five hours twice a year, and a daily total that silently changes length at a daylight-saving boundary is a total nobody can compare against yesterday's. UTC costs the reader the comfort of their own midnight and buys totals that mean the same thing every day of the year.

type Rate

type Rate struct {
	// PctPerHour is the smoothed estimate used for display.
	PctPerHour float64

	// RawPctPerHour is the un-smoothed figure computed across the window
	// endpoints. It is reported alongside the smoothed one so a consumer can
	// see how much smoothing was applied, rather than being handed a number
	// with no indication that it is a moving average.
	RawPctPerHour float64

	// Stable reports whether enough history accumulated to trust the figure.
	Stable bool

	// Span is the interval the estimate was computed over.
	Span time.Duration

	// Samples is how many readings contributed.
	Samples int

	// Reason explains a non-Stable result in a form fit for display, so a UI
	// can say why it is waiting rather than showing a wrong number.
	Reason string
}

Rate is an estimated rate of change of charge, in percentage points per hour. Positive means the pack is losing charge.

type RateEstimator

type RateEstimator struct {
	// contains filtered or unexported fields
}

RateEstimator accumulates readings and reports the rate of change of charge. It is the exported form of the window logic above, for callers that sample over time rather than analysing a slice they already hold.

The zero value is not usable; construct one with NewRateEstimator so the window parameters are explicit rather than silently defaulted.

func NewRateEstimator

func NewRateEstimator(interval time.Duration) *RateEstimator

NewRateEstimator returns an estimator sampling at the given interval. The window is sized from that interval, and is deliberately long: a window shorter than the platform's charge quantisation does not average the noise away, it manufactures it.

func (*RateEstimator) Add

func (e *RateEstimator) Add(s Sample)

Add records one reading, discarding the window if the gap since the previous reading indicates a sleep or a suspend. Averaging across one of those produces a rate for a machine that never existed.

func (*RateEstimator) Rate

func (e *RateEstimator) Rate() Rate

Rate returns the current estimate and why it is or is not trustworthy.

func (*RateEstimator) Reset

func (e *RateEstimator) Reset()

Reset drops accumulated history, which a caller does after a power source change or when it wants a fresh measurement.

type RateParams

type RateParams struct {
	// MinSpan is the shortest interval over which a rate is considered
	// measured. Below this the result is a quantiser reading.
	MinSpan time.Duration

	// MinSamples is the smallest number of readings that can produce a rate.
	MinSamples int

	// Alpha is the weight given to a new observation in the exponential
	// moving average applied to the displayed figure. Lower is smoother.
	Alpha float64
}

RateParams configures the estimator. The defaults are set for the measured platform behaviour rather than for a round number.

func DefaultRateParams

func DefaultRateParams(interval time.Duration) RateParams

DefaultRateParams spans roughly ten minutes at the default sample interval, which is comfortably longer than the seventy-second quantum measured on Windows hardware. An estimator tuned to react quickly is an estimator that reports fiction.

type Reader

type Reader interface {
	// Read returns the current state. A machine with no battery returns a
	// Status with Present false and a nil error: that is an answer, not a
	// failure. A read that genuinely could not be performed returns an error.
	Read() (Status, error)
}

Reader reads battery state from the host. Implementations are supplied per platform by the build-tagged files in this package.

func NewReader

func NewReader() (Reader, error)

NewReader returns a Reader for the host platform. It reports ErrUnsupported on a platform this package has no reader for, which is a normal answer rather than a failure: a caller can fall back to whatever it was going to do on a machine it cannot read.

type Sample

type Sample struct {
	At     time.Time
	Pct    float64
	AC     ACState
	Charge bool
}

Sample is one observation in a rate window.

type Sampler

type Sampler struct {
	// contains filtered or unexported fields
}

Sampler reads battery state on an interval and records it.

The daemon question deserves a straight answer rather than a hedge. A daily, weekly, monthly or yearly total can only be complete if something is observing the machine while nobody is looking at it. A command that records only while it is open produces a history with holes in it, and a hole is indistinguishable from a period in which nothing happened. So the sampler runs until it is stopped, and it is started deliberately rather than installed as a service: an installer that silently begins observing a machine is a different decision from running a program, and only the user gets to make it.

func NewSampler

func NewSampler(cfg SamplerConfig) (*Sampler, error)

NewSampler returns a sampler configured by cfg.

func (*Sampler) Interval added in v0.6.1

func (s *Sampler) Interval() time.Duration

Interval reports the sampling period actually in force, which is the configured interval raised to the 5s floor. A caller that displays a requested interval rather than this one will misreport what it is doing.

func (*Sampler) Run

func (s *Sampler) Run(ctx context.Context) (int, error)

Run samples until the context is cancelled or the process is interrupted, and returns the number of readings recorded.

A failed read is counted and the loop continues. A sampler that stops on the first transient failure is a sampler that has silently become a program that records nothing, and nothing in its output would say so. The failure is surfaced through Stats and in the final report instead.

func (*Sampler) Stats

func (s *Sampler) Stats() SamplerStats

Stats returns a snapshot of progress.

type SamplerConfig

type SamplerConfig struct {
	// Reader supplies battery state. Required.
	Reader Reader
	// Store receives readings. Required.
	Store Store
	// Interval is the sampling period. Values below 5s are raised to it.
	//
	// The floor is not a politeness setting. The charge counter is quantised to
	// whole percentage points, so sampling faster than the quantisation does
	// not resolve anything; it multiplies the work and the storage by the
	// sampling rate while producing identical numbers.
	Interval time.Duration
	// Attrib enables per-application attribution, which enumerates every
	// process on the machine and is therefore markedly more expensive.
	Attrib bool
	// Out, when set, receives a line per pass.
	Out io.Writer
}

SamplerConfig configures a Sampler.

type SamplerStats

type SamplerStats struct {
	Started  time.Duration `json:"started_for"`
	Samples  int           `json:"samples"`
	Recorded int           `json:"recorded"`
	Skipped  int           `json:"skipped"`
	LastErr  string        `json:"last_error,omitempty"`
}

SamplerStats reports what a sampler managed to do.

type Series

type Series struct {
	App    string  `json:"app"`
	Tier   Tier    `json:"tier"`
	Points []Point `json:"points"`
}

Series is a per-application history over a range.

type Status

type Status struct {
	// Present is false when the platform reported no battery, which is the
	// normal answer for a desktop rather than a failure.
	Present bool

	// Count is the number of batteries the platform reported. Windows
	// GetSystemPowerStatus reports only the first pack, so a two-battery
	// machine reads 1.
	Count int

	// Source names the mechanism that produced this reading, for example
	// "GetSystemPowerStatus". It is a diagnostic for humans and logs, not a
	// stable API; callers should not switch on it.
	Source string

	// AC is the mains-line state.
	AC ACState

	// Status is the platform's own status word verbatim: "Charging",
	// "Discharging", "Full", "Not charging" or "Unknown". Empty when the
	// platform has no such word.
	Status string

	// Charging is taken from the platform's own charging flag. It is never
	// inferred from the sign of a current reading, because that sign
	// convention is not consistent across platforms.
	Charging bool

	// Full is true when the platform reports the pack fully charged.
	Full bool

	// ChargePct is charge remaining, 0 to 100.
	ChargePct   float64
	ChargeKnown bool

	// Watts is instantaneous power draw for the whole machine. Consult
	// Wattage to judge how much it is worth.
	Watts      float64
	WattsKnown bool
	Wattage    WattageSource

	// FullWh is present full-charge capacity in watt-hours.
	FullWh      float64
	FullWhKnown bool

	// DesignWh is factory design capacity in watt-hours.
	DesignWh      float64
	DesignWhKnown bool

	// HealthPct is FullWh as a percentage of DesignWh.
	HealthPct   float64
	HealthKnown bool

	// Cycles is the completed charge-cycle count. Absent on most platforms.
	Cycles      int
	CyclesKnown bool

	// TimeToEmptyS is the platform's own time-remaining estimate in seconds.
	// Firmware reports an unsigned sentinel rather than a negative number when
	// the value is meaningless, so readers must reject anything at or above
	// 1<<31 before trusting it.
	TimeToEmptyS     int64
	TimeToEmptyKnown bool

	// TemperatureC is pack temperature where the platform exposes it. It is
	// cosmetic and is not used in any calculation.
	TemperatureC     float64
	TemperatureKnown bool

	// Cap is the set of fields this platform actually reported.
	Cap Capability
}

Status is a single reading of the machine's battery state.

Every measured quantity has a companion Known flag. This is the central honesty invariant of the package: a platform that cannot report watts leaves WattsKnown false and Watts at zero, which is distinguishable from a machine genuinely drawing no power. Collapsing the two would let a caller print a confident "0 W" for a value nobody measured.

func (Status) Draining

func (s Status) Draining() bool

Draining reports whether the machine is consuming stored charge right now.

func (Status) Has

func (s Status) Has(c Capability) bool

Has reports whether the platform supplied the given capability.

func (Status) IsCharging

func (s Status) IsCharging() bool

Charging reports whether the pack is taking charge, treating a full pack on mains as not charging, which is what the platform means by the two states.

type Store

type Store interface {
	// Record writes one reading and folds it into the rollup tiers.
	Record(ctx context.Context, s Status) error

	// Apps records per-application activity for a window. Attribution is an
	// estimate derived from CPU and I/O weight, never a measurement, and every
	// stored value carries that provenance.
	Apps(ctx context.Context, at time.Time, apps []AppActivity) error

	// TopApps returns the highest attributed applications within a range.
	TopApps(ctx context.Context, r Range, n int) ([]AppTotal, error)

	// Usage summarises system activity and coverage over a range.
	Usage(ctx context.Context, r Range) (*UsageReport, error)

	// AppHistory returns one application's attributed activity over a range.
	AppHistory(ctx context.Context, app string, r Range) (Series, error)

	// Close releases the database.
	Close() error
}

Store is the persistence seam. It is deliberately narrow so that swapping the engine is a new implementation rather than a rewrite of everything above it.

func Open

func Open(path string) (Store, error)

Open opens the battery history database at path, creating it if needed. An empty path uses battery.db in the user's home directory.

type SystemUsage

type SystemUsage struct {
	ChargeStartPct     float64 `json:"charge_start_pct"`
	ChargeEndPct       float64 `json:"charge_end_pct"`
	ChargeLostPct      float64 `json:"charge_lost_pct"`
	DrainPctPerHour    float64 `json:"drain_pct_per_hour_estimate"`
	ChargingSamples    int     `json:"charging_samples"`
	DischargingSamples int     `json:"discharging_samples"`
}

SystemUsage is measured system-level activity over a range. The charge delta is measured by the platform; the rate derived from it is an estimate, and Provenance says which applies to the report as a whole.

type Tier

type Tier uint8

Tier is a rollup resolution. Raw samples are kept briefly and aggregated upward, so a query costs one row per bucket at the resolution asked for rather than a scan of the whole history.

const (
	// TierRaw is an individual reading.
	TierRaw Tier = iota
	// TierMinute aggregates one minute.
	TierMinute
	// TierHour aggregates one hour.
	TierHour
	// TierDay aggregates one UTC day.
	TierDay
	// TierWeek aggregates one ISO week, which starts on Monday.
	TierWeek
	// TierMonth aggregates one calendar month.
	TierMonth
	// TierYear aggregates one calendar year.
	TierYear
)

func (Tier) String

func (t Tier) String() string

type UsageReport

type UsageReport struct {
	Range      Range       `json:"range"`
	System     SystemUsage `json:"system"`
	Coverage   Coverage    `json:"coverage"`
	Provenance Provenance  `json:"provenance"`
	Apps       []AppTotal  `json:"apps,omitempty"`
	Notes      []string    `json:"notes,omitempty"`
}

UsageReport is a summary over a range.

type WattageSource

type WattageSource uint8

WattageSource says where a wattage figure came from, because the sources are not equally trustworthy. A caller that wants to weigh a number needs this.

const (
	// WattageNone means no wattage was determined.
	WattageNone WattageSource = iota
	// WattageSysfs is a direct read of power_now on Linux.
	WattageSysfs
	// WattageIOReg is Voltage x Amperage from the macOS IORegistry.
	WattageIOReg
	// WattageChargeDelta is derived by dividing capacity lost over time by
	// elapsed time. It is only as precise as the platform's charge counter,
	// which on Windows is quantised to whole percentage points.
	WattageChargeDelta
	// WattageDevice is a charge rate read directly from the battery
	// controller. On Windows it comes from the battery class driver rather
	// than from the system power status call, which is why it is reported
	// separately: it is the one source that is a measurement rather than a
	// derivation, and a caller ranking sources should be able to prefer it.
	WattageDevice
)

func (WattageSource) String

func (w WattageSource) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL