Skip to content

feat: add PowerStatus plugin for live power and battery monitoring - #484

Merged
ggbond268 merged 20 commits into
mactools-app:mainfrom
AndrewDi:feature/power-status-plugin
Oct 16, 2026
Merged

ggbond268 merged 20 commits into
mactools-app:mainfrom
AndrewDi:feature/power-status-plugin

Conversation

@AndrewDi

Copy link
Copy Markdown
Contributor

Summary

  • Add a new panel-only PowerStatus plugin that displays real-time system power consumption, AC input, battery charging/discharging wattage, battery health, temperature, and cycle count
  • Adapted from PowerTop's IOKit power telemetry reading logic
  • Integrated as a MacTools dashboard widget with no feature rows or shortcuts
  • Supports 12 languages with localized user-facing copy

Plugin Details

  • ID: power-status
  • Category: monitoring
  • Panel: Dashboard widget (4×4 span)
  • Permissions: None required
  • Data source: IOKit AppleSmartBattery service
  • Update interval: 2 seconds with IOPS power source notifications for instant updates on plug/unplug

Changes

  • Plugins/PowerStatus/ — Complete plugin implementation (models, sampler, view model, component view, plugin class, tests, localization)
  • changes/unreleased/power-status-plugin.md — Changelog fragment
  • README.md / README.zh-CN.md — Updated monitoring section description

Testing

  • 7/7 unit tests pass, covering snapshot defaults, flow state descriptions, computed properties, charge/discharge scenarios, supplemental discharge, time formatting, and power source descriptions
  • Plugin package builds successfully with make build-plugin PLUGIN=PowerStatus
  • Run tests with: make test TEST_FILTER=PowerStatusPluginTests

Add a new panel-only PowerStatus plugin that displays real-time system
power consumption, AC input, battery charging/discharging wattage,
battery health, temperature, and cycle count. Adapted from PowerTop's
IOKit power telemetry reading, integrated as a MacTools dashboard widget
with no feature rows or shortcuts.
- Add missing protocol properties (requestPermissionGuidance, shortcutBindingResolver)
- Import IOKit.ps for power source notification types
- Mark non-Sendable properties as nonisolated(unsafe) for Swift 6 concurrency
- Add required settings capability to plugin.json manifest
- Fix test parameter ordering and flow state for time estimation tests
…s panel

The widget declared a fixed span height of 4 (32pt), which clipped the
content. The view now measures its intrinsic height through a preference
key and reports it via context.reportContentHeight, and the declared span
is estimated from the snapshot's visible rows as a starting point.
When macOS stops reporting AvgTimeToEmpty, estimate the remaining time
from stored energy (remaining capacity x pack voltage) divided by an
EMA-smoothed discharge power over a ~60s window, following PowerTop's
approach. Prefer the macOS value when it is valid, label computed
estimates distinctly, and add a tooltip explaining the basis.
Show how power moves between the AC adapter, system, and battery for
each flow state: AC to system when plugged in, AC to system plus AC to
battery while charging, battery to system on battery power, and both
sources feeding the system during supplemental discharge. Flow lines
animate with moving dashes that respect Reduce Motion, and line
thickness scales with wattage.
@ggbond268

Copy link
Copy Markdown
Collaborator

Thanks for adding this plugin! I found a few things to address before merging:

  1. Correct the battery health calculation. In PowerStatusSampler, DesignCapacity is only read from the top level, but it lives inside BatteryData on the Mac I tested, so health is missing. The fallback also treats MaxCapacity as mAh, although it can be a percentage such as 100; a fixture with 90% health currently returns 1%.
    Please read the capacity fields from both supported locations and calculate health using valid mAh values (AppleRawMaxCapacity, then FullChargeCapacity or NominalChargeCapacity). Avoid using a percentage as capacity, and add focused tests for these cases.

  2. Complete localization. The catalog is missing metric.timeEstimate.help, flow.charging, flow.battery, flow.ac, and flow.system. The power-source and remaining-time labels also return Chinese text directly. Please add the missing translations, route those labels through PluginLocalization, and rerun make validate-localization.

  3. Regenerate the website plugin list. site/src/generated/plugins.json is stale. Please run python3 scripts/plugins/generate_website_plugin_data.py, commit the generated update, and verify with make validate-generated-plugin-data.

The 14 existing plugin tests pass locally. Both CI failures above still need to be resolved. Thanks!

Charging now shows one AC node forking into system and battery, and
supplemental discharge merges AC and battery into one system feed,
instead of duplicating the source or destination icon per row.
Canvas closures are not re-evaluated per animation frame, so the
withAnimation dash phase never moved. Drive the phase from
TimelineView(.animation) instead, pausing when reduce motion is on.
Fix two places where flow state was incorrectly classified as .charging
without checking isOnAC:
- Fallback path (line 161): now returns .discharging when not on AC
- resolveBatteryFlow telemetry branch (line 222): same guard

Ground-truth from live IOKit confirmed negative BatteryPower/amperage
indicates discharging; negative = charging was inverted.
Add 'Details' button in header that toggles expand/collapse of component details panel.

When tapped, shows expandable panel with voltage, current, power, battery health,
cycle count, temperature and other IOKit device data.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review! All three points are addressed in 367dc43e.

1. Battery health calculation

All four capacity fields (DesignCapacity, AppleRawMaxCapacity, FullChargeCapacity, NominalChargeCapacity) plus Temperature are now read from both supported locations through a single capacityValue(_:) helper that prefers BatteryData and falls back to the top level.

Health now walks a real-mAh chain — AppleRawMaxCapacity → FullChargeCapacity → NominalChargeCapacity — and MaxCapacity is never read as a capacity, since it is a normalized percentage on modern Macs.

Added four focused tests, including your 90% fixture:

  • testBatteryHealthReportsNinetyPercentFromRawMaxCapacity — MaxCapacity: 100 with AppleRawMaxCapacity: 4500 / DesignCapacity: 5000 → 90% (previously 1%)
  • testBatteryHealthFallsBackToNominalChargeCapacity — only NominalChargeCapacity available → 80%
  • testBatteryCapacityReadsTopLevelWhenBatteryDataAbsent — no BatteryData node → reads the top level → 96%
  • testBatteryHealthUsesDesignCapacityNotPercentage — a percentage must not be read as mAh

2. Localization

powerSourceDescription and estimatedTimeLabel did return hardcoded Chinese. They are replaced by powerSourceLocalizationKey / estimatedTimeLabelKey on the snapshot, resolved through PluginLocalization by the view and the plugin's subtitle. Added 7 keys × 12 locales, taking the catalog from 6195 to 6202 keys.

make validate-localization passes: 76 catalogs, 6202 keys, 6828 resource references.

I also removed PowerFlowState.description. It had no call sites and existed only to back a test asserting fixed copy, so testFlowStateDescription and testPowerSourceDescription are replaced with key-mapping contract tests instead.

One note: metric.timeEstimate.help, flow.charging, flow.battery, flow.ac and flow.system were already present in the catalog and referenced by the flow diagram on this branch, so I did not re-add them.

3. Website plugin list

site/src/generated/plugins.json already contains the power-status entry and is current on this branch — it was regenerated in a6e5b465. Re-running python3 scripts/plugins/generate_website_plugin_data.py produces no diff, and make validate-generated-plugin-data passes. The stale state you saw appears to predate that commit.

Local verification

  • make build-plugin PLUGIN=PowerStatus
  • make validate-localization
  • make validate-generated-plugin-data
  • make script-tests — 229 tests OK
  • PowerStatusPluginTests — 24/24 passing (the suite grew from 14 with the off-power, health, and capacity coverage)

@AndrewDi

Copy link
Copy Markdown
Contributor Author

@ggbond268 PTAL

Read the gas gauge's compensated full-charge capacity (FccComp1) from the
AppleSmartBatteryPack node, which is the figure macOS shows as Maximum
Capacity, and round the percentage up instead of truncating so 88.49%
reads as 89% like the OS does.
Show full-charge over design capacity in one tile and move the time
estimate beside the remaining capacity, which also evens the grid out at
three full rows. Count the merged tile once so the declared widget height
still matches the rendered rows.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

Pushed two follow-ups: 922e3827 (health) and ab9ad6d9 (layout).

Battery health now matches System Settings

The panel showed 84% while System Settings reported 89%. Two separate causes:

Wrong capacity field. System Settings' "Maximum Capacity" is the gas gauge's temperature- and age-compensated reading, FccComp1. On Apple Silicon it is not on AppleSmartBattery at all — it lives on the sibling AppleSmartBatteryPack node's BatteryData, which we were never reading. We were dividing the raw FullChargeCapacity (5154 mAh) instead of the compensated value (5374 mAh).

Integer truncation. measured * 100 / design truncated 84.86% to 84%.

The percentage now rounds up rather than to nearest. FccComp1 drifts with load, so this is not academic:

FccComp1 ÷6075 truncate nearest ceil
5401 88.91% 88 89 89
5377 88.51% 88 89 89
5376 88.49% 88 88 89

The last row is a live reading from my machine: nearest-rounding reports 88 there while System Settings says 89, so only ceiling matches across the range the value actually takes.

Reading order is FccComp1 → AppleRawMaxCapacity → FullChargeCapacity → NominalChargeCapacity, each still checked in both locations, MaxCapacity still never treated as mAh, and the result still capped at 100%.

Verified on live hardware: panel reports 89%, system_profiler SPPowerDataType reports 89%.

Layout

ab9ad6d9 merges full-charge and design capacity into a single tile (5374/6075 mAh) and moves the time estimate beside the remaining capacity, so the two-column grid is now three full rows instead of seven tiles over four. capacityTileCount counts the merged tile once, keeping the declared widget height in step with the rendered rows.

Tests

27/27 pass. New coverage:

  • testBatteryHealthPrefersCompensatedCapacityFromPackNode — FccComp1 on the pack node wins, yields 89%, raw value still exposed
  • testBatteryHealthRoundsUpInsteadOfTruncating — 5376/6075, where both truncation and nearest-rounding give 88
  • testCapacityReadingsShareOneTileForHeight — supplying both capacity readings must not add a row

make build-plugin PLUGIN=PowerStatus and make validate-localization (76 catalogs, 6202 keys, 6828 references) both pass.

@ggbond268

Copy link
Copy Markdown
Collaborator

Thanks for the updates! The earlier health, localization, and generated-data issues are resolved. I found one remaining issue in PowerStatusSampler:

Charging can still be reported as supplemental battery discharge. With AC connected, IsCharging = true, Amperage = 1000, and Voltage = 10000, missing or empty PowerTelemetryData produces .supplementalDischarge(rateW: 10) instead of charging. I reproduced this with fixed inputs. This also affects the flow diagram and time estimate.

Could you handle signed current consistently and respect IsCharging when telemetry is missing or incomplete? The fallback currently treats both current directions as discharge. A focused test for charging and another for supplemental discharge would help cover these paths.

The 27 existing plugin tests pass locally. Thanks!

With AC connected and PowerTelemetryData missing or empty, a charging
current was reported as supplemental discharge. Both the no-telemetry
fallback and the raw-current branch in resolveBatteryFlow treated both
current directions as discharge, and a charging pack with no readable
current could be reported as idle.

Classify from the signed Amperage x Voltage once and reuse it in both
paths, so a positive current is charging, a negative one is discharge,
and the charger flag is honored when no current is readable.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

Thanks — fixed in 4e716f5.

Both the no-telemetry fallback and the raw-current branch of resolveBatteryFlow were treating both current directions as discharge, and a charging pack with no readable current could be reported as idle.

The root cause was that the flow classification only existed twice, with opposite sign conventions:

  • computePowerFromFlow expects PowerFlowState rates to be discharging-positive.
  • IOKit reports Amperage and telemetry BatteryPower charging-positive.

So each branch had to invert the sign by hand, and the fallback had it wrong. Positive current came out as .supplementalDischarge, negative as .charging — exactly inverted.

I now classify once from the signed Amperage x Voltage in a shared flowFromRawCurrent and use it from both paths, so they cannot drift apart:

  • positive current → .charging
  • negative current → .discharging, or .supplementalDischarge on AC
  • no readable current → the IsCharging flag, so a charging pack is not reported as idle

Your repro (AC + IsCharging + Amperage=1000 + Voltage=10000 + no telemetry) now yields .charging(10) instead of .supplementalDischarge(10).

Added testMissingTelemetryReportsChargingInsteadOfDischarge (covering both a nil and an empty telemetry dict), testMissingTelemetryKeepsNegativeAmperageAsSupplementalDischarge for the opposite direction, and testMissingTelemetryHonorsChargerFlagWithoutCurrent.

make test TEST_FILTER=PowerStatusPluginTests passes 30/30 and make build-plugin PLUGIN=PowerStatus is clean.

Resolve conflicts in the README "Monitoring & calendar" rows by keeping
both the upstream container/VM wording and the PowerStatus panel wording
in the English and Chinese READMEs.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

@ggbond268 PTAL again

@ggbond268

ggbond268 commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for the fix! The charging-direction issue is resolved. I reproduced two remaining display issues on 75774acd:

  1. AC power can be shown as idle/battery power. With AC connected, charging off, zero battery current, and both AC input and system load at 20 W, resolveBatteryFlow returns .idle instead of .onAC. This makes the diagram show the battery supplying the system, which can happen when the battery is full or charging is paused. Please preserve .onAC for this case.

  2. Missing telemetry double-counts charging power. With no telemetry and a measured battery charging rate of 10 W, the fallback also assigns 10 W to system load, so the header reports 20 W of AC input. Battery charging power alone cannot establish those values. Please keep it separate and show unavailable system load/AC input as unknown, with consistent handling for missing and empty telemetry.

The 30 existing tests pass; two temporary regression checks reproduce these cases. Please add focused coverage for the resulting state and power values. These findings concern the plugin's display calculations. Thanks!

On AC with no battery current, resolveBatteryFlow fell through to .idle,
so the diagram drew the pack as the source even though the adapter was
carrying the system alone, as when the pack is full or charging paused.
Return .onAC on power instead, keeping .idle off power only.

Without telemetry the fallback also echoed the battery rate into the
system draw, so effectiveACOutputW added it back and reported double the
real AC input for a charging pack. Hold the system draw at zero for
charging and treat an empty telemetry dict like a missing one.

Add focused coverage for the on-AC no-current state and for the charging
power values under missing and empty telemetry.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

Both display issues are fixed in 3e97da6.

1. AC shown as idle. resolveBatteryFlow classified from the two energy-balance guards first, then fell back to raw current. With AC input equal to system load and zero battery current, neither balance guard fired, and the raw-current fallback only returned .onAC when a current reading existed. Zero current read as "nothing to classify", so the sample fell through to .idle, which the diagram draws with the battery as the source.

The fix returns .onAC once the raw current is too small to classify and we are on AC. .idle now means strictly "off power and drawing nothing", which is what the diagram's battery-as-source row needs.

2. Charging double count. With no telemetry the fallback returned systemPowerW = abs(batteryPowerW), so a 10 W charge rate also became a 10 W system draw. effectiveACOutputW then added the rate back (systemPowerW + rate), reporting 20 W of AC input for a 10 W charge. The battery rate is the only measured quantity there, so it now stays out of the system draw for .charging, leaving the header at the real 10 W.

Empty telemetry ([:]) previously took the telemetry path with every field defaulting to 0 and produced a different result from nil. It now routes to the same fallback, so both read identically.

The off-power paths are unchanged: unplugged with real load still resolves to .discharging, and unplugged with no draw still resolves to .idle.

Added testOnACWithoutBatteryCurrentResolvesToOnACNotIdle for the state and testMissingTelemetryChargingDoesNotDoubleCountACInput for the power values, which also asserts empty telemetry matches missing telemetry. make test TEST_FILTER=PowerStatusPluginTests is 32/32, and make build-plugin PLUGIN=PowerStatus is clean.

@ggbond268

Copy link
Copy Markdown
Collaborator

Thanks for the update! The AC/idle issue and double-counting are fixed. One part of the missing-telemetry handling still needs adjustment:

With only a measured battery charging rate of 10 W, the current UI shows system load and AC input as 0 W, while the header and diagram show 10 W of AC power. That 10 W tells us the battery's charging rate; it does not establish total AC input or system load.

Could you distinguish unavailable readings from actual zero values and display them as —, consistently across the metric rows, header, and diagram? The known battery charging rate can still be shown separately. Please also adjust the regression test to cover this distinction instead of assuming AC input equals the charging rate.

All 32 existing tests pass locally. I'd recommend addressing this remaining display issue before merging. Thanks!

Without PowerTelemetryData the pack's current is the only measured
quantity. Total AC input and the system draw are not established by it,
so reporting them as 0 W made the header and diagram claim 10 W of AC
power for a 10 W charge rate.

`systemPowerW` and `acInputW` are now optional, and the metric rows,
header, subtitle, and flow diagram render an unestablished reading as an
em dash. A measured 0 stays 0. The charge/discharge rate is still shown
on its own, and off power the pack's rate still establishes the system
draw because it is the only source.

Also drops the write-only `batteryPowerW` snapshot field and an unused
`extractString` helper.
@AndrewDi

Copy link
Copy Markdown
Contributor Author

Addressed in d6b39e1.

Unavailable is no longer rendered as 0 W. systemPowerW and acInputW are now optional, and an unestablished reading prints as — in the metric rows, the header, the panel subtitle, and the flow-diagram nodes. A measured 0 stays 0.0 W, so the two are no longer interchangeable anywhere they surface.

Without PowerTelemetryData, the pack's current is the only measured quantity, and the fallback now says exactly what it does and does not establish:

  • .charging — the rate is the pack's intake. The adapter also feeds the machine, so total AC input and the system draw are not established: both unavailable, header and AC node read —, and the charge rate is still shown on its own in the battery row and the charging node.
  • .supplementalDischarge — how much of the load the adapter carries is unknown, so AC input and the system draw are unavailable; the battery rate is still shown.
  • .onAC with no measurable current — nothing about the draw is known.
  • .discharging — off power the pack is the only source, so its rate does establish the system draw. That inference is unchanged, and there is no adapter input to report.
  • .idle — off power and drawing nothing measurable, which is a real zero.

effectiveACOutputW and headerPowerW are optional for the same reason, so a charge rate can no longer be echoed back as AC output.

Tests. Replaced the regression that assumed AC input equals the charging rate with testMissingTelemetryChargingLeavesACInputAndSystemLoadUnavailable, which asserts the charge rate is reported while system load, AC input, header, and effective AC output are all unavailable, and that empty telemetry matches missing telemetry. Added testMissingTelemetryDischargingStillDerivesSystemLoadFromPackRate to pin the off-power inference, and testMeasuredZeroStaysDistinctFromUnavailable so a telemetry SystemLoad of 0 stays a measured zero rather than becoming unavailable.

make test TEST_FILTER=PowerStatusPluginTests is 34/34, make build and make build-plugin PLUGIN=PowerStatus are clean.

While here I also dropped the write-only PowerStatusSnapshot.batteryPowerW field and an unused extractString helper; the field carried the opposite sign convention from the rate the UI shows, which is what made the earlier double-count possible.

@ggbond268
ggbond268 merged commit a893d0b into mactools-app:main Oct 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants