Skip to content

v4.12.0

Choose a tag to compare

@kherio kherio released this 11 Sep 06:04
· 15 commits to main since this release

v4.12.0

  • Merged the hero gauge card and the separate "activo ahora" card into one, removing a real repetition found during a dashboard review: the mode name + "why" text in the hero already said, in slightly different words, exactly what the separate card's own title + "why" line said right underneath it - two stacked cards telling the same story twice. Moved that card's genuinely new content (which mechanisms are doing what, since when, the measured drain-rate comparison) directly into the hero card, right after its own "why" line, and dropped the separate card/title/duplicate "why" entirely. When more than one event is active simultaneously, mechanism categories they'd both otherwise show (e.g. both claim "Apps") now appear once, not duplicated - the first active event's value wins, matching the existing "first active event is primary" convention the "why" text itself already used; the "since" time anchors to whichever event started earliest; the drain comparison stays reserved for the primary event only, with a check against a since-changed primary before a slow background fetch can patch in stale text. New persisted frontend test (test_render_active_now_merge.mjs) covering the empty case, the two-simultaneous-events dedup, and the single-event case. 33 frontend tests across 7 files now pass.

v4.11.0

  • New persisted frontend test suite (tests/frontend/, node tests/frontend/run.mjs) - the same fix tests/ already gave the backend, for the frontend: every frontend bug found this session (the boot-restart-noise filter, the dashboard mode name reading real state instead of recomputing it, the inline display:block override, the swipe animation's class handling, render()'s sub-renderer isolation) was verified with a hand-written, throwaway jsdom script that never outlived the conversation turn it was written in. New reusable harness (tests/frontend/lib/harness.mjs) formalizes that exact pattern - stub a module's imports, load the patched source, assert on the result - plus extractFunction/extractConst for the rare case a function's own module has real side effects on import that make loading it whole impractical. 27 tests across 5 files, two of them confirmed to actually catch their bug by reverting the fix and observing the test fail before restoring it.
  • New functional self-test (PowerSentinel-selftest.sh, run_functional_selftest()) - deliberately different from capability detection ("does this path/command exist on this device?"): this asks "does this function actually produce the right answer for the exact input it's built for?" - synthetic, safe inputs only, never touching this device's real state. Five checks: is_valid_bool_reading's true/false validation, the adaptive-tier thresholds' ascending order, the adaptive engine's hysteresis actually holding a tier in place against a small score drop, every summary function producing valid single-line JSON, and the process-monitor's state-file check matching the real on-disk shape - each one tied directly to a real bug this project already found and fixed (v3.55.0, v3.56.0/v4.6.0/v4.9.0, v3.67.0, v4.3.0). Runs once at every daemon boot (a genuine "fail" reaches the same critical-alert channel as safe mode; a "warn" is just logged) and on demand from the Diagnóstico panel, which now sources this same file rather than duplicating the checks. Confirmed each check actually catches its bug by reverting the corresponding fix and observing it fail.

v4.10.0

  • CRITICAL FIX: Inicio's mode name stuck showing "Normal" even when something is genuinely active - reported directly. Root cause: render()'s sequence of sub-renderers (renderBattery, renderChargeHealth, renderSystemHealth, then renderDashboard - the one that actually sets the mode name - and everything after) ran completely unguarded, one after another, with no error isolation at all. An exception thrown by ANY earlier renderer - a malformed or unexpected value in this specific device's own battery/charge-health/capabilities data, something impossible to reproduce or test against every real device's exact output - would abort the whole render() call right there, meaning renderDashboard() never even ran for that poll. If the same condition holds on every subsequent poll (very plausible for a device-specific data quirk), the mode name freezes at whatever it last showed and stays there indefinitely - exactly the reported symptom. Fixed by isolating each renderer in its own try/catch, logged to the console but never allowed to block the others - confirmed directly with a real crash in an earlier renderer (reproduced live) and verified the mode name still updated correctly to reflect the genuinely active tier afterward. Also wrapped the core-grid rendering section (a second, real crash surfaced by the same test) the same way.

v4.9.0

  • CRITICAL FIX: "Ahorro suave" was STILL flickering every 30-100 seconds despite v4.6.0's fix - reported directly, with a screenshot showing the exact pattern. Traced the root cause much further back than the threshold logic itself: compute_pressure_score()'s screen-off term gave its full +15 the instant it read the screen as off - completely ordinary phone use (checking it, locking it again every minute or two) swings the score by 15 points on every single toggle, comfortably clearing v4.6.0's 5-point hysteresis margin every time. No amount of threshold tuning downstream can fix a single input jumping by more than the margin in one step. Fixed at the actual source: the screen now has to have been continuously off for a real stretch (45s) before this term contributes anything at all, so a brief check-and-lock never moves the score in the first place. Added a second, independent layer on top for extra safety margin: a tier can still escalate immediately when conditions worsen, but can't de-escalate again until it's held the current tier for at least 2 minutes, regardless of what the score does in between.
  • Found and fixed a second real bug while building the above: compute_pressure_score() is always called as x="$(compute_pressure_score)" by every caller (it needs to capture the numeric result) - command substitution runs the entire function in a subshell, so the screen-timing state it tried to update internally was silently discarded the moment that subshell exited, never once actually reaching the caller. The fix above would have done nothing at all without also catching this - split the state update into its own function, called as a plain statement (no subshell) immediately before compute_pressure_score() itself.
  • Verified with a direct simulation of the exact reported pattern (screen toggling every 40 seconds): 15 raw tier changes before any fix, down to 1 with the complete fix, and confirmed a genuine sustained screen-off still correctly escalates after 45 real seconds. Two new persisted tests (test_adaptive_screen_debounce.sh) - one specifically calls the state-update function the same "plain statement before command substitution" way the real daemon does, so a regression back to updating state from inside the subshelled function would be caught immediately. 81 tests across 15 files now pass.

v4.8.0

  • Felt swipe/tab-switch motion (feature request): switching tabs - by swipe, bottom-nav tap, or the "Más" sheet - now plays a brief slide+fade-in on the incoming view, from the direction of travel. Deliberately NOT the live-dragging, finger-following carousel this project already tried and abandoned once before (initSwipeNav()'s own long-standing comment: three separate fix attempts, including a percentage-based one, all rendering wider than the screen on-device - a class of bug impossible to verify without a real browser in this dev environment). This is architecturally a different, much simpler thing: a single one-shot CSS @keyframes animation on only the incoming view (the outgoing one still just disappears instantly exactly as before) - no absolute positioning, no two views ever visible at once, no pixel/percentage width math anywhere, so there's nothing for a stale inline style or a width miscalculation to get stuck in. The animation class is removed the instant it finishes so a later, unrelated display toggle of that view can never accidentally replay it. Respects prefers-reduced-motion. Verified the class-toggling logic directly and captured a mid-animation frame showing the expected offset-and-fading intermediate state.

v4.7.0

  • CRITICAL FIX: "Registro técnico" content still stopped partway down the screen - reported directly as the same symptom already fixed once for Actividad in v3.70.0, but persisting here. Real root cause was broader than the original CSS fix: switchLogSubTab() sets style.display = 'block' directly (an inline style) whenever a person switches to a tab - and an inline style always wins over any stylesheet rule regardless of specificity, including the display: flex v3.70.0 gave #l-view-log/#l-view-journal specifically so their content could stretch to fill the screen. Actividad only ever looked fixed because it's the tab visible by default straight from the static HTML (no inline style to begin with) - switchLogSubTab() never touches it unless a person navigates away and back, which reintroduces the exact same bug there too, and affects "Energía" the same way once actually switched to. Fixed by clearing the property (setting it to '') for whichever view should be visible instead of forcing 'block', letting the stylesheet's own rule take over correctly; 'none' for hiding is unaffected, since there's no competing "should be visible" rule to fight there. Confirmed with real navigation sequences this time (switch to Registro técnico directly, and switch away to another tab and back to Actividad) rather than a static snapshot, which is exactly what let the original fix's verification miss this.

v4.6.0

  • CRITICAL FIX: "Ahorro suave" (adaptive_tier1) flickering in and out of Actividad continuously - reported directly, along with wanting to see the current mode on Inicio. Root cause: pressure_tier_for_score() had no hysteresis at all - a straight threshold comparison, recomputed from scratch every daemon cycle, against a score that's genuinely noisy moment to moment (the 1-minute load average alone swings it 10-20 points crossing its own breakpoints; a screen on/off toggle swings it 15). Tier1's default threshold of 20 is the easiest one to sit near, which is exactly the tier that was reported flapping. Fixed with a standard sticky-threshold margin: escalating to a higher tier is still immediate (reacting fast to genuinely worsening conditions is the safe direction to be quick about), but de-escalating now requires the score to drop meaningfully below the current tier's own entry threshold, not merely dip a fraction under it. Confirmed with the exact reported scenario (a score hovering a couple of points under the threshold no longer drops the tier) and that a genuine large drop (e.g. plugging in the charger) still lands directly at the right tier in one step rather than creeping down.
  • Found and fixed a second, related bug while looking into "cómo saber en qué modo está realmente": Inicio's own mode-name display (the word next to the gauge) was recomputing its own tier independently from the raw score, via a plain no-hysteresis comparison completely separate from the daemon's real decision - meaning even after the backend fix above, the displayed name could still have flickered on its own, potentially disagreeing with what Actividad's timeline said actually happened. Now reads the real, authoritative state directly (sys.activeEvents, the exact same data driving the journal) instead of recalculating a possibly-different answer - the gauge's mode name is the "what mode is it in right now" indicator being asked for, now made trustworthy rather than added as a new element.
  • New persisted test (test_adaptive_tier_hysteresis.sh), confirmed to actually catch the regression by reverting the fix and observing the exact reported flapping reappear before restoring it. 76 tests across 13 files now pass.

v4.5.0

  • New, opt-in status notification ("Mostrar modo actual en notificaciones", off by default): shows which mode is active and what it's actually doing, e.g. "PowerSentinel — Noche / CPU en ahorro · apps en 2º plano ralentizadas · CPU al 50% · pantalla a 60Hz". Deliberately does NOT reintroduce the old noisy per-change notification behavior alertbridge.sh was specifically rewritten to stop doing (its own header comment already says so) - posted with a fixed tag so every update REPLACES the previous notification in place rather than stacking a new one, only re-posts when the text actually changed, and is entirely opt-in so anyone who doesn't ask for it sees no difference at all. Built from active_mechanisms_snapshot() - the exact same resolved data the WebUI's own "active now" card already uses - so the notification can never say something different from what the app itself shows.
  • Real, honest limitation surfaced rather than glossed over: cmd notification post (confirmed against its own AOSP source) has no flag for importance, silent, or ongoing/non-dismissible - the first time this notification appears it will likely make the normal notification sound. The setting's own help text tells the person to long-press it once and set it to "Silent" from Android's own settings; every update after that stays quiet. No amount of additional code changes what the shell command itself can't do.
  • Found and fixed a real gap while building this: max_cpu_freq/max_refresh_rate (v4.1.0's CPU speed and refresh rate limits) were never added to active_mechanisms_snapshot() or the WebUI's own mechanismRows() - meaning Inicio's "active now" card has silently never shown either mechanism even when genuinely active, since the day they shipped. Fixed in both places.
  • Also corrected the existing notify setting's help text, which still described the old "a notification for every change" behavior this project deliberately moved away from, not what it actually does today (critical alerts only).
  • New persisted test (test_notify_active_mode.sh) covering the text builder - the empty case, a single event with several mechanisms, and two simultaneous events combining into one notification without duplicating phrases. 68 tests across 12 files now pass.

v4.4.0

  • CRITICAL FIX: "Arranque" (boot) appeared to flicker in and out of Actividad continuously - reported directly, along with asking for a way to actually know which mode is active. Root cause isn't in the boot-event logic itself: "boot" only ever activates once, in init_v2() at daemon startup, and state_reconcile() correctly undoes whatever the previous run left active before that - individually, both are exactly right. But if the daemon itself is dying and getting relaunched repeatedly (a crash, an OOM kill - service.sh's own 60-second watchdog catches this and relaunches it every time it happens), every relaunch produces this exact "boot ended" (state_reconcile undoing the crashed run's stale boot) immediately followed by a fresh "boot started" - daemon-internal bookkeeping noise from an invisible restart, not a meaningful mode change. Filters out any "boot ended" immediately followed by a "boot started" within 15 real seconds from every view built from journal entries (Actividad, Inicio's recent-activity list, and today's intervention count, which was also being inflated by the same noise) - confirmed with the exact restart case, an isolated genuine boot, and a same-pair-but-too-slow-to-be-a-restart case, all handled correctly.
  • New diagnostics check: counts "boot started" journal entries in the last 2 hours - 1 is completely normal, several means the daemon is actually crash-looping and the watchdog is masking it by relaunching every ~60s. Gives a direct, unambiguous answer to "is something actually wrong" instead of inferring it from timeline noise.

v4.3.0

  • handle_proc's background monitor has likely never actually worked, for any user, since it was written - found while re-auditing the whole module for hang/resource-degradation risks. Its while-loop's own continuation check compared the real, on-disk state file (an object keyed by event name, {"night": <timestamp>, ...}) using a filter that iterates values, not keys - so the check was false unconditionally, every single time, for every real state file this daemon has ever produced. The loop exited on its very first condition check, meaning this feature has silently done nothing since it shipped. Fixed to check the key directly.
  • Fixing that exposed a second, real problem it had been masking: reassert_active_events() calls enable_pwr_save() (and therefore action_proc_apply()) for every still-active event whenever ANY event ends. With no de-duplication, once the monitor loop actually persists (as the fix above makes it do), every such reassert would have spawned another redundant background loop for the same event - an unbounded, ever-accumulating resource leak the longer a device stays up with multiple simultaneous handle_proc events, spending real CPU on a mechanism meant to save it. Fixed by tracking the PID of the currently-running monitor per event and refusing to start a second one while it's still alive. Confirmed with real, live background processes: a monitor started once stays alive and does NOT duplicate across repeated calls while the event stays active, correctly exits on its own once the event ends, and a genuine reactivation afterward starts a fresh one.
  • Added a small defense-in-depth protection: a user's denylist (the one path that can name a system package at all - the normal scan is third-party apps only) could previously name something like com.android.systemui with nothing stopping it from being force-stopped. Not a permanent hang (Android restarts SystemUI on its own), but a visible full-UI crash/flicker for something that should never have been a valid target - now explicitly refused, same as dialer/SMS/emergency/doze-whitelisted apps already were.
  • Reviewed every other mechanism that touches the system (apps, GMS, WiFi, Doze, low-RAM, cores, CPU frequency) for anything of comparable severity and found nothing else at this level - all already had reasonable protections in place.
  • Added a 30-second timeout around each test file in the suite runner itself, since one of the two new regression tests genuinely exercises live background processes - a future hang in code under test now fails that one test instead of hanging the whole suite. 63 tests across 11 files now pass, with both new tests confirmed to actually reproduce their bug before the fix.

v4.2.0

  • CRITICAL FIX: disable_cores=auto could take EVERY core offline on a symmetric CPU, fully hanging the device - found while reviewing CPU mechanisms in response to direct feedback about avoiding "prácticamente inutilizable" outcomes. On a big.LITTLE SoC this couldn't happen (hp_cpus/lp_cpus are always disjoint sets), but on a symmetric CPU - every core sharing the same max frequency, nothing to distinguish a "high power" cluster by - auto_map_cores() classifies EVERY core as high-power, so disable_cores=auto would attempt to disable all of them: a fully hung device needing a hard reboot, categorically worse than any slowdown this project has ever had to guard against. Fixed by counting real online cores fresh before each disable and refusing to take the last one offline, for both the automatic and manual core-selection paths - confirmed by reproducing the exact symmetric-CPU scenario (0 cores left online without the fix) and confirming at least 1 stays online with it.
  • Fixed the CPU speed limit's confusing/backwards preset labels and added a hard safety floor - reported directly: "Moderado ~70%" was actually a MILDER limit than "Agresivo ~50%" (a higher allowed-percentage means less restriction, not more), which read backwards. Relabeled with correctly increasing restriction - Suave 80% allowed / Moderado 50% / Agresivo 30% - and added an actual safety net in the backend itself, not just better preset numbers: no config value, however it got there (a UI preset, a hand-edited raw JSON, a future preset added later), can ever cap any core below 30% of its real max speed. Confirmed with the exact boundary cases (29% correctly floors up to 30%, 30% and above pass through unchanged).
  • Two new persisted regression tests (test_cores_never_zero_online.sh, test_cpufreq_safety_floor.sh) - both confirmed to actually catch their bug by reproducing it first, then confirming the fix - 58 tests across 10 files now pass.

v4.1.0

  • Three new features aimed at making battery savings genuinely noticeable, not just applied:
  • CPU speed limit (max_cpu_freq, new cpu group field): a hard ceiling on how fast any core can ever run (Moderate ~70% / Aggressive ~50% of the device's real max, computed from cpuinfo_max_freq and snapped to the closest step in scaling_available_frequencies when the device reports one), deliberately separate from the existing handle_cores powersave-governor mechanism - that changes which algorithm picks a frequency, this is a ceiling nothing can exceed regardless of governor or load, and both can be active together. Full apply/undo with per-core original-value capture and the same "don't undo out from under a sibling event" composition check already used for GMS/WiFi/etc - verified with a simulated frequency table (confirmed correct percentage-to-Hz rounding) and both the plain-undo and multi-event-composition cases.
  • Screen refresh rate limit (max_refresh_rate, new field alongside kill_wifi): forces peak_refresh_rate/min_refresh_rate to a fixed Hz value - untouched by this project before, and a well-documented, often-significant drain on 90/120Hz panels. Found and fixed a real correctness issue before shipping: settings get prints the literal string "null" for a setting that was never explicitly set, not empty output - naively restoring that string on undo would have left a wrong, real value behind instead of returning to "never set"; undo now deletes the key in that case, confirmed with both the "never set" and "had a real prior value" cases restoring correctly.
  • A real, measured drain-rate comparison per active event ("Medido en tu dispositivo: X%/h con esto activo · Y%/h sin él", shown on Inicio's active-now cards) - never an invented percentage, the exact thing this project already ruled out once before. Built entirely from data PowerSentinel-energylog.sh was already collecting for precisely this purpose (its own header has said so since it was first written: "to actually check whether a given event genuinely saves energy... instead of assuming it does"). New energylog_compare_event() cross-references logged battery/active-event samples, excludes any interval touching a charging session (a rising level would corrupt a drain-rate calculation) and any implausibly long gap between samples, and refuses to report anything with under 30 real minutes of history in either bucket - confirmed with a simulated log at two known, distinct drain rates that the computed rates match exactly, and that both the "not enough data" and "no log yet" cases correctly report nothing rather than a misleading number. Exposed as a new on-demand script (PowerSentinel-comparedrain, same pattern as cpurank/suggestnight/diagnose) and fetched on a slow 5-minute cache from the WebUI, patching only the one line of text that changed rather than re-rendering the whole card.
  • Added a persisted regression test for the new energy-log comparison (tests/test_energylog_compare_event.sh) - 51 tests across 8 files now pass.

v4.0.0

  • CRITICAL FIX: tapping "Registro técnico" from "Actividad" did nothing the first time - reported directly. Root cause: a leftover regression from v3.69.0's sub-tab reorder. switchLogSubTab() early-returns when the requested view already matches its own activeSubView state variable - that variable's initial value stayed at 'log' (the old default) even after the HTML was updated to show 'journal' (Actividad) by default, so the very first tap on "Registro técnico" ('log') matched the stale 'log' and silently did nothing; a second tap (after tapping back to Actividad, which updated the variable for real) would have worked - exactly the "sometimes it does nothing" shape this kind of state-drift bug takes. Fixed by correcting the initial value to match what's actually shown. Confirmed with a direct reproduction of the exact reported sequence (fresh load → first tap on Registro técnico).
  • Removed the severity filter ("Todas"/"Solo críticas"/"Solo avisos"/"Solo informativas") from "Actividad" - reported as not making sense there. "Actividad" is meant to mirror Inicio's own "Actividad reciente", which has no filter at all - removed rather than moved into "Registro técnico" as literally requested, since that tab's own existing filter (INFO/VERBOSE/DEBUG log levels) applies to a genuinely different kind of data (raw log lines have levels, not the critical/warning/info severity journal entries carry) and adding a second, mismatched filter there would have been its own new source of confusion.

v3.70.0

  • CRITICAL FIX: Análisis's "Actividad" and "Registro técnico" content stopped partway down the screen, leaving the rest empty - reported directly. Root cause: both wrap their actual scrollable content (.fill-area) inside a plain #l-view-log/#l-view-journal div - making that .fill-area a GRANDCHILD of .view.active rather than a direct child, so the existing .view.active > .fill-area { flex: 1; ... } rule never matched it. The wrapper itself, having no flex sizing of its own, sized to its own content instead of stretching to fill the screen - the exact same root cause already fixed once before for Config's "Modo avanzado" (#c-view-advanced), just never applied to these two. Reproduced directly with a bordered mock of the real DOM structure at a realistic screen height (clearly showed the container stopping well short of the available space) and confirmed fixed with the same before/after rendering, for both tabs. "Energía" was checked too and confirmed NOT affected - it already has .scroll-area applied directly to itself as a genuine direct child, verified separately to already stretch to the full height correctly.
  • Added a real, persisted automated test suite (tests/, bash tests/run.sh) - every bug found and fixed across this project's development up to now was verified with a hand-written, throwaway simulation script that never outlived the session it was written in, so nothing stopped any of those regressions from silently coming back in a later round. 46 tests across 7 files now guard the most significant ones directly: is_device()'s "unknown" reading validation (the "night never activates" bug class), every summary function producing single-line JSON (the "Hoy card never appears" bug), night/thermal threshold refresh, the night-window suggester's per-day gap grouping, temporary-performance-mode's expiry-file cleanup, charge-health's edge-triggered counting, and adaptive-tier threshold ordering. Two tests were confirmed to actually catch their bug by deliberately reintroducing it and watching the suite fail, then reverting - a test that's never been seen to fail isn't verified to catch anything. Found and fixed two real bugs in the test harness itself before trusting it for anything: set -e was aborting a test file at its first failed assertion instead of collecting all of them, and a trap ... RETURN fired on any inner source completing rather than only when the enclosing test function returned (bash traps are process-scoped, not function-scoped) - both documented in the harness's own comments so a future test doesn't repeat either mistake.

v3.69.0

  • The human-friendly activity view in Análisis was already built - it was just mislabeled and hidden behind the technical one. Reported as a request for something "más natural, como en Actividad reciente de Inicio" - but that exact rendering (renderTimelineEntry(), the same rail+dot format Inicio's own "Actividad reciente" uses) already existed in this tab, in the sub-tab labeled "Historial". The one labeled "Actividad" - first in the list, and the one shown by default on entering the screen - was actually the raw technical text log. Renamed the technical one to "Registro técnico" and the friendly one to "Actividad" (matching Inicio's own naming exactly, so the connection between the two is obvious), and swapped their order and default visibility so the friendly view is both first and what a person actually sees on opening Análisis, with the technical log one tap away rather than the default. No new rendering code needed - this was a naming/discoverability fix, not a missing feature.

v3.68.0

  • Three new features from a self-review of what the module could still use, all built to reuse what already exists rather than new infrastructure:
  • Diagnostics panel ("Más" → "Diagnóstico"): a new standalone, on-demand script (PowerSentinel-diagnose, same pattern as cpurank/usagerank/suggestnight) runs 8 checks, each one tied to a real bug this project actually hit and fixed - is the daemon running at all, is the status file being refreshed, is the config valid JSON, does dumpsys deviceidle return a real screen/charging reading (the exact class of bug behind the "night never activates" report), is the control file root-owned, is charge_limit_node (if set) actually inside /sys/class/power_supply/ and writable, does jq itself work. Every result is a plain pass/warn/fail with a short, concrete reason - the whole point is surfacing the kind of "fails completely silently" problem this project has repeatedly had to reverse-engineer from a vague symptom report, in seconds instead of several rounds of back-and-forth.
  • Update check (Ajustes/Acerca): checks GitHub's releases API for a newer version once per visit to that tab, using the WebView's own fetch() directly (no native/shell bridge, no assumption that curl/wget exist in every device's root shell). Compares versions numerically component-by-component rather than as plain strings - a string comparison would wrongly call "v3.9.0" newer than "v3.10.0" - confirmed with that exact case plus several others. Entirely silent on any failure (offline, GitHub unreachable, rate-limited): an update check is a courtesy, never something that should show an error or block the rest of the screen.
  • Initial setup wizard, step 2 (sleep-schedule question, extending the existing Basic/Advanced first-run choice rather than building a separate flow): asks what time the person usually sleeps and applies it to the recommended baseline config. Built one explicit safety check first: this must NEVER run for a returning person whose localStorage happened to get cleared while their real config already has customized events in it - confirmed with a direct test that the step is correctly skipped whenever the config already has any event blocks, and only ever appears for a genuinely empty, fresh-install config. Confirmed separately that applying a custom schedule correctly threads it into the recommended baseline's "night" block without disturbing the other four.

v3.67.0

  • CRITICAL FIX: "Hoy" card never appeared at all - reported directly ("no aparece"), on the current v3.66.0. Root cause: todaystats_summary() was missing the -c (compact) flag jq needs whenever its output gets embedded in a single echo "Label: $(...)" >> status_file line - every other JSON-producing summary function in this project already had it (screenwake_summary, active_mechanisms_snapshot), this one didn't. Without it, jq pretty-prints across many lines by default, so the status file ended up with TodayStats: { on its own line and the actual fields several lines further down - the WebUI parses the status file one line at a time with a regex expecting the whole {...} object on that single line, which a bare TodayStats: { can never match. sys.todayStats stayed undefined forever, and since v3.62.0 the shared "Hoy" card visibility check treats missing todayStats as one of two signals (alongside night-wake data) - so the card could disappear entirely depending on what night-wake data happened to look like at the time. Reproduced the exact multi-line output this produced before the fix, then confirmed a single, fully-matchable line after it, for both the normal case and a fresh-install empty-file case.
  • Found and fixed the same missing flag in the newer chargehealth_summary() (v3.65.0) too, for consistency - confirmed that one wasn't actually causing a live bug (the line that embeds it already wraps the whole thing in its own compacting jq -cn call), but there's no reason to leave a landmine for whenever that function might get used differently later.

v3.66.0

  • Confirmed config/state correctly survive a device reboot, reported as a request to verify: PowerSentinel-state.sh's existing mechanism (persist which events were active, force-undo everything on the next start, then let the normal boot-time condition checks reapply whatever's actually warranted right now) was traced through and confirmed sound via a direct simulation with synthetic pre-reboot state - no code change needed, this already worked correctly.
  • The "active now" dot in Automatización now actually stays live: activeEventNames only ever refreshed once, when the tab was first opened - the dot itself (already existed, a small pulsing mark on the event icon) never updated again while the tab stayed open, even though charging/night/thermal can all change mid-session. Now refreshes every 10s while the tab is visible, but only ever toggles the existing dot/title on cards that are already there - deliberately never rebuilds the whole event list on a timer, which would destroy any expanded accordion state or in-progress edit just to update a dot.
  • Added an X to clear the Apps tab's search field, shown only once there's something to clear.
  • Added a brief explanation of what "Permitir"/"Restringir" actually do to an app in the per-event app picker - confirmed the exact mechanism first (PowerSentinel-actions.sh): Permitir exempts an app from this event's action entirely; Restringir applies the action to it even where it normally wouldn't (e.g. a system app the event wouldn't otherwise touch).
  • Predefined events in Automatización now show a real name, not the raw internal key - this turned out to affect every predefined event, not just the adaptive tiers named in the request ("screen_off", "night", "adaptive_tier1" were all shown completely literally). Renaming the actual keys was ruled out - they're literal strings in every existing user's saved config - so this reuses the already-existing, already-translated eventDisplayName() (Inicio's own dashboard already relied on it) as the primary label, with the raw technical name kept as a small subtitle for anyone who needs it (editing raw config, reading a log line, following documentation).
  • Caught and fixed a real mistake before it reached a commit: an edit meant to add one new import line instead deleted the existing multi-line api.js import entirely, which the very next build caught immediately.

v3.65.0

  • Five new features, all built on data/mechanisms the module already collects rather than new infrastructure:
  • Export/import config profiles as an actual file - "Perfiles" already let you switch between named configs saved on this one device (saveProfile/readProfile), but never let you share one. Reuses the exact same /sdcard/Download/ export pattern already proven by exportLog(). Import deliberately reads the picked file via the browser's own File/FileReader API rather than asking a root shell to cp from whatever path a native picker returns - Android's picker commonly hands back an opaque content:// URI a shell can't read directly - and validates it's real JSON before it ever reaches the existing saveProfile().
  • Flagged-apps alert now visible on Inicio - the background-CPU flagging mechanism (PowerSentinel-appwatch.sh) already existed with dismiss/limit actions built into Automatización's Básico mode, but nothing surfaced it anywhere a person would actually see it without going looking. A small alert now appears on Inicio when at least one app is flagged, with a direct shortcut into Automatización.
  • Temporary performance mode with auto-revert - a one-tap "Máximo rendimiento durante 1h/30min/2h" that starts the existing manual event and reverts itself via a new check_manual_expiry() in the daemon's main loop, so it's enforced even if the WebUI isn't open when the timer runs out. Found and fixed a real edge case before shipping: if manual mode gets stopped by any OTHER means before the timer expires, the expiry file is now cleaned up immediately (gated on whether manual is actually still active, not just its own timestamp) rather than sitting there and potentially auto-stopping a completely different, untimed manual session that happens to start again later.
  • Night-window suggestion from real history - a new screenwake_suggest_night_window() (PowerSentinel-screenwake.sh) analyzes the existing wake-timestamp history for the single longest quiet gap per calendar day, and suggests the median start/end across at least 3 such nights as the night-wake window. Found and fixed a real bug while testing against synthetic multi-gap-per-day data: the first version counted every gap over the threshold with no per-day limit, letting sparse daytime usage contribute spurious "candidate nights" that diluted the real nightly pattern - fixed by grouping gaps by which day their midpoint falls in and keeping only the longest one per day before applying the threshold. Deliberately a standalone, on-demand script (PowerSentinel-suggestnight, same pattern as cpurank/usagerank) rather than computed inside the always-running daemon - this does real sorting/grouping work with no business running on a multi-second hot loop.
  • Battery-health nudge - a new PowerSentinel-chargehealth.sh tracks how often the battery actually reaches 100% while charging (edge-triggered once per charging session, confirmed not to double-count while sitting at 100% for hours or to miss a session that never reaches it), and the battery card suggests considering charge_limit only when that's happened on 20+ of the last 30 days AND the person hasn't already configured it - a measured pattern specific to this device's real usage, never a blanket claim.
  • Caught and fixed three more stray #-instead-of-// typos in comments while writing this round's own explanatory notes, all caught by the build before they ever reached a commit.

v3.64.0

  • Sixth audit pass, general sweep across the codebase (not tied to a specific report): no new functional bugs found this round beyond dead-code cleanup. Systematically checked every HTML id referenced from JS against what actually exists (all resolved to dynamically-created elements, not real gaps), and every CSS class defined against where it's used.
  • Removed dead CSS left over from the v3.45 timeline redesign: .log-line.journal-critical/.log-line.journal-info/.journal-time (the old fallback-entry markup these styled was replaced by the unified rail+body structure back then, but the now-unreachable rules were never deleted) and .timeline-warning .timeline-main (same story - warning entries are styled via .dot-warn now). Also removed an entirely unused .skeleton/@keyframes shimmer pair that was never applied to anything.
  • Removed a dead, unreachable .replace() call in the journal severity filter (renderJournal(), log.js) - it was written for markup renderTimelineEntry() stopped producing after the same redesign; confirmed filtering already worked correctly through the other branch alone, with a direct before/after test of the filter logic.
  • Reviewed PowerSentinel-state.sh, PowerSentinel-alertbridge.sh, PowerSentinel-capabilities.sh and PowerSentinel-energylog.sh in full - all four already handle their respective edge cases soundly (state reconciliation across crashes, the control-file ownership/TOCTOU defense, capability detection with an honestly-documented root-bypasses-permissions caveat, and change-triggered rather than per-cycle energy sampling); confirmed the recent screen-poll cadence change didn't affect energylog_sample()'s own call site or cadence.

v3.63.0

  • Removed the redundant "Nivel de intervención · X/100" line inside the gauge - it repeated the exact same score the gauge's own percentage already shows one line above it, with no new information. The gauge now shows just the mode name and the percentage.

v3.62.0

  • CRITICAL FIX: "Encendidos nocturnos" could disappear entirely - reported right after last round's consolidation into "Hoy" ("ya no aparece en absoluto... no veo ni el número ni el detalle"). Root cause: nesting the night-wake section inside the "Hoy" card meant the shared outer card's visibility was still being decided from today's-stats data alone - if that ever came back without data while night-wake data was genuinely available, the nested section stayed invisible no matter what it set its own display to, since a display:none ancestor hides everything inside it regardless of the child's own style. Fixed by splitting today's own content into its own inner wrapper (e-today-body) and adding a single coordinating check that shows the shared card whenever EITHER data source has something to show - confirmed with all three combinations (only today data, only night-wake data, neither) rendering correctly, especially the exact "night-wake data present, today data missing" case that matches the report.
  • Caught and fixed a real syntax error introduced while writing this fix's own explanatory comment (a stray # instead of // broke the whole module from parsing) - the build failed immediately, so this never reached a commit, but it's a reminder that even a fix for one bug needs its own basic verification before shipping.

v3.61.0

  • Consolidated "Hoy" and "Encendidos nocturnos" into one card, per the original design intent from the dashboard redesign notes ("encaja PERFECTO aquí [en Hoy]") - they'd ended up as two separate top-level cards purely from being built in different rounds, repeating the same night-wake count in both places. The night-wake detail (editable time window, count, comparison, detail list, remediation hint) now lives as a clearly-separated section nested inside "Hoy", with the redundant summary row removed from Hoy's own list.
  • Moved "Actividad reciente" up, right after "what's happening now" and before the "Hoy"/battery summary cards - a more natural reading order (current state → recent history → today's summary → battery detail → technical), and one fewer card sitting between the live status and the history that explains it.
  • Net result: Inicio goes from 9 separate top-level cards to 8, with the two that most obviously repeated the same number down to one.

v3.60.0

  • CRITICAL FIX: "night"/"thermal" could silently ignore a config change until the next full daemon restart - reported as "night didn't activate at 23:00 as configured". Root cause: get_night_times()/get_thermal_threshold() (which read night_start/night_end/thermal_threshold from config) were only ever called ONCE, at daemon boot/reload - is_night_now()/is_thermal_now() were already being called fresh every single cycle as intended, but against whatever values had been snapshotted at the last boot, never refreshed again for as long as the daemon kept running. This mostly self-healed in practice because saving config from the WebUI already triggers a full reload (exec "$0", re-running the entire boot sequence) - but that was an assumption this code silently relied on rather than guaranteed itself, and directly contradicts this project's own documented rationale for config_get_event_raw() (PowerSentinel-config.sh): "comparatively rare (once per event evaluation)" - i.e. once per cycle, not once ever. Fixed by refreshing both directly in the main loop, right before each is used - confirmed with a reproduction where the config file changes without a full reload: the night window is now picked up on the very next cycle instead of being silently stuck on stale values indefinitely.
  • Note for anyone still seeing a missed transition after updating: this fix addresses one confirmed root cause, but a transition can also be missed if the daemon wasn't actually running through that moment (crashed, killed by an OEM battery manager, or manually paused) - worth checking Análisis/Actividad's journal for daemon-restart entries around the time in question if this keeps happening.

v3.59.0

  • Wake-lock fallback now resolves the acquiring UID to a real installed package, when dumpsys power's wake lock line includes one (most do). Previously it only showed the raw wake lock tag - often informative on its own (a tag like *job*/com.whatsapp/.MessageWorker already names the app), but not always (NetworkStats, *alarm*:reminder name a subsystem, not a specific app). UID-to-package resolution is exact and verifiable, unlike guessing from tag text - a real improvement on "closest-to-real name" for what turns the screen on, per the maintainer's follow-up request. System UIDs (<10000) are skipped rather than resolved (no real "app" behind them, and resolving one would just spend a whole extra pm call to confirm there's nothing more specific to say). pm list packages -U is called at most once per wake (result cached and reused for a second matching UID), and bounded with its own timeout 2 - the cumulative worst case (dumpsys already timing out at 3s, then this also timing out) is a bounded ~5s stall, never unbounded. Falls back to the plain tag exactly as before whenever resolution isn't possible for any reason - confirmed with a failing pm and with a tag carrying no UID at all, neither breaks or regresses from the previous behavior.

v3.58.0

  • Fifth audit pass, re-checking last round's own polling-cadence change - two real findings:
  • Duplicated is_device screen call on every 2s tick: screenwake_check() and todaystats_check() each independently called is_device screen - a pre-existing duplication (both were already called once per cycle before v3.57.0), but tightening the cadence to a fixed 2s made the wasted second dumpsys call meaningfully more frequent in absolute terms. _screen_poll_cycle() now reads screen state once per tick and passes it to both; each function still falls back to reading it itself if called without the shared value, so nothing else that might call them breaks.
  • An "unknown" screen-state reading (is_device's own v3.56.0 fix) could get silently persisted into both functions' own tracked state, quietly undercounting for one interval once the reading recovers - less severe than the classic screen_off bug this project already fixed (this self-heals within a cycle or two rather than getting permanently stuck), but the same class of issue, and just as cheap to close. Both functions now skip entirely on an "unknown" reading rather than accepting it as real.
  • Confirmed todaystats_check()'s own elapsed-time accumulation is unaffected by the tighter cadence in either direction - it measures real wall-clock time between calls rather than assuming a fixed interval, so calling it more often only makes it more precise, never wrong.

v3.57.0

  • Screen-wake detection had a real, quantifiable blind spot for brief screen-on periods: screenwake_check()/todaystats_check() only sampled screen state once per $delay-second main-loop cycle - fine for most of what the loop does, but a genuine problem specifically for counting wakes, since a screen-on that starts and ends between two samples is completely invisible to a poll that only checks once per cycle. Simulated with a realistic spread of brief (0.5-4s) night glances: at the default delay=3s, ~30% were missed entirely; raising delay (uncapped, and some users do raise it for more aggressive battery savings) made it dramatically worse - 77-96% missed at 10-60s.
  • Fixed: screen-wake/today's-screen-time polling now runs on its own fixed 2s cadence, independent of whatever $delay the rest of the loop uses (detect_refresh, appwatch, event checks, etc. are completely unaffected, still exactly $delay as before). Re-ran the same simulation with the fix: missed glances drop to a flat ~16% regardless of $delay - a real improvement, and critically no longer one that gets worse the more aggressively someone else has configured their battery savings. The remaining ~16% is an inherent floor of any poll-based approach (anything shorter than the 2s interval itself), not something a fixed cadence can fully close without moving to event-driven detection - a bigger architecture change than this round's fix.
  • Confirmed the new polling helper handles every edge case cleanly: delay smaller than the poll interval, and delay=0 (already a degenerate config before this round) both still run at least one check with no negative-sleep or busy-loop risk.

v3.56.0

  • CRITICAL FIX: screen_off (and charging/low_power) could silently stop detecting transitions for the entire life of the daemon. Root cause: is_device() called dumpsys deviceidle get $1 with no stderr redirection and no validation of the output - the only dumpsys call in the whole codebase without either. If that call ever failed or returned anything other than exactly "true"/"false" (a permission hiccup, a device/ROM where this subcommand behaves differently, a transient system_server issue), the classic detector's was_screen_on got stuck holding that same invalid value forever - and since was_screen_on being non-empty is also what gates whether change-detection runs at all, a single bad read (even just once, at boot) could permanently disable screen_off detection for as long as the daemon kept running, completely silently. Reported as "screen_off never activates" after reviewing the journal. Fixed: is_device() now validates its own output and returns "unknown" instead of raw error text or garbage; the classic detectors (screen_off, charging, low_power) never persist or act on an "unknown" reading - they simply retry on the next cycle - so a transient failure self-heals instead of permanently wedging detection. Confirmed with a reproduction simulating intermittent dumpsys failures followed by recovery: detection resumed correctly on the very next successful read, both at boot and mid-run.
  • Apps tab: removed the "Ahorro suave/moderado/extremo" lines under every app - reported as not doing or telling anything useful. Root cause: those reflected adaptive_tier1/2/3's own handle_apps field, which isn't part of the "recommended settings" preset and is usually left unconfigured - in practice this just repeated "no action" three times under every single app in the list. Classic mode's two situations (screen off / low battery) are unaffected - those already reflect normally-configured events.
  • Apps tab: rewrote the top description text, reported as unclear - it tried to explain all 4 policy levels inline in one dense sentence, fully redundant with the per-level legend already shown right below it (added last round). Now just states the tab's purpose in one plain sentence and points at the legend for the details.

v3.55.0

  • Fourth audit pass, responding to an external review's list of 6 points - verified each against the real code before acting on any of them:
  • "eval reconstructs config-derived state" (reported Critical): NOT confirmed. This is _restore_event_snapshot()'s use of declare -p + eval to restore a previously-snapshotted event's fields (added in an earlier round's ownership-tracking fix). Tested directly with command substitution ($(...)), backticks, semicolons, and array values crafted into a config-derived variable's content - none executed; declare -p's own quoting is specifically designed to round-trip safely through eval, and confirmed it does. No change made - replacing a correct, safe idiom because it superficially looks dangerous would make the code worse, not better.
  • pgrep with configurable, unanchored patterns (reported Medium) - CONFIRMED, fixed: pgrep's default match is an unanchored substring/regex, not exact - reproduced live (pgrep e alone matched nearly every process on the system). Every pgrep call in the actions file now uses -x (exact match): highest risk was proc_file's free-form user-edited lines (handle_proc could act on far more processes than intended), but also fixed for $app (package names) and a hardcoded GMS lookup that was silently assuming exactly one match despite Google Play Services normally running as multiple same-prefixed processes - confirmed the multi-PID case would have broken renice outright.
  • charge_limit_node accepting any writable path (reported Medium/High) - CONFIRMED, fixed: the daemon runs as root and wrote 0/1 to whatever path was configured, with no scope restriction - a typo or bad config could toggle an unrelated sysfs control, not just "do nothing" as the old help text claimed. Restricted to /sys/class/power_supply/* - where every real charging-control node lives on every device this project is aware of - so legitimate configurations keep working exactly as before.
  • Adaptive tier thresholds not order-validated (reported Low/Medium) - CONFIRMED, fixed: each threshold was already validated as a plain non-negative integer, but nothing checked ascending order - a jumbled set (e.g. tier1=90, tier2=20, tier3=50) would activate the MOST aggressive tier at a much lower score than intended, since it's checked first. Falls back to the documented defaults on any ordering inconsistency. Also fixed a related consistency bug this surfaced: the WebUI's own tier display read the raw, unvalidated thresholds directly - now both the real decision and what's reported to the WebUI draw from the same single validated source, so they can never disagree about what's actually active.
  • Configurable paths generally / event composition (reported Medium) - assessed, no change: allowlist/denylist/proc_file/ctl_file's configurability is bounded by the same trust model as everything else here (root, via KernelSU) - editing the config already requires the access level these paths would grant, so this isn't a privilege-escalation path today. Event composition was reviewed against the extensive, already-shipped ownership-tracking work (multiple past critical fixes) - confirmed conflicting simultaneous events resolve to "most recently applied wins," verified non-corrupting and always correctly reversible, not a bug; a "most restrictive wins" policy would be a deliberate behavior change worth discussing on its own, not a silent fix bundled in here.

v3.54.0

  • Third audit pass, two more real findings in the wake-lock fallback added last round:
  • Unbounded dumpsys power call could stall the whole daemon on every screen wake: unlike the kernel-file read it falls back from (an instant local read), dumpsys power is a Binder call into system_server - normally fast, but with nothing bounding it, a slow or stuck call would block event detection for as long as it hung, on a daemon that runs this synchronously in its single main loop. Wrapped in timeout 3 - confirmed with a genuinely stuck fake dumpsys that this now reliably returns in ~3s instead of hanging indefinitely, degrading to "no hint" exactly like every other failure mode this function already had.
  • No length cap on the wake-lock tags, unlike the kernel-file path which already had one - an unusually long tag could bloat the stored JSON. Capped to 200 chars, confirmed with an artificially long tag.
  • Night-wake detail list had no upper bound on how many entries it could return in one status snapshot (a device with an unusually high number of wakes in one night would show - and store, in the status file rewritten every 3s - all of them). Capped the detail list (times/entries) to the most recent 20 - the headline count stays the true total either way, confirmed with 30 simulated wakes in one night.
  • Confirmed the directory update_status()'s temp file is created in (fixed for atomicity in v3.48.0) is guaranteed to exist by then (created earlier in daemon startup, same directory as the PID file) - re-checked as part of this pass, no issue found there.

v3.53.0

  • CRITICAL FIX: night-wake time pickers still didn't fully show the time after last round's enlargement (16px/66px) - reported as still clipped. Sized down to 13px/74px, giving more width relative to the font than before instead of scaling both up together, which apparently wasn't enough headroom for the native picker's own internal chrome.
  • Wake reason now falls back to active wake locks when no kernel-level reason is available - reported as "always says reason not available": neither of the two known kernel paths (/sys/kernel/wakeup_reasons/last_resume_reason, /sys/power/wakeup_reason) exists on every device (that interface has been superseded by /sys/class/wakeup/ on newer kernels, and some OEM trees block it outright regardless of root). Falls back to dumpsys power's currently-held wake lock tags, which very often already contain a real, recognizable name (a package name, a job/alarm tag) - clearly presented as what it is (an active wake lock at the moment the wake was detected, not proof of causation) rather than upgraded into a false certainty.
  • The category label and the raw reason/wake-lock text are now shown together ("Sincronización en segundo plano · JobScheduler.WAKELOCK:com.whatsapp") instead of the raw text disappearing behind a generic category - the specific, more useful part is exactly what was asked for.
  • Two new recognized categories (push notifications, background sync) and the WiFi/mobile-network remediation hint now also credits push-notification-tagged wake locks toward suggesting Google Play Services limiting on the Night event.

v3.52.0

  • "Encendidos nocturnos" now shows a best-effort wake reason per entry, using the most reliable source actually available: the kernel's own hardware wakeup-reason interface (/sys/kernel/wakeup_reasons/last_resume_reason or /sys/power/wakeup_reason, whichever exists on the device). This is deliberately NOT app/process attribution - there is no reliable way to get that on Android, with or without root, and showing an invented app name would repeat the exact mistake already avoided with the savings-percentage decision (v3.35). What's shown is the real hardware source (an alarm, WiFi chip activity, mobile network activity, USB/charging, the physical button), labeled with a handful of well-known keyword patterns and always falling back to the raw string for anything unrecognized - never guessed into a category it might not belong to, never hidden.
  • A "remedy this" hint appears only when it can point at something PowerSentinel already knows how to do: 2+ wakes correlating with WiFi or mobile-network activity suggests enabling the Night event's existing kill_wifi/handle_gms mechanisms, with a direct shortcut into Automatización - never a blind automatic change, and never a suggestion for categories nothing can be done about (an alarm or the power button are the user's own device doing exactly what it's supposed to).
  • Devices whose kernel doesn't expose either known wakeup-reason path simply show "Motivo no disponible en este dispositivo" per entry instead of failing or fabricating anything - confirmed this degrades gracefully with neither file present.

v3.51.0

  • Night-wake time pickers enlarged - reported as "casi no se ve" ("barely visible"): they were sized at 11px/46px wide, noticeably smaller than anything else on the card and a poor tap target. Sized up to 16px/66px, matching the rest of the card's text instead of standing out as the one hard-to-read element.

v3.50.0

  • Second security/robustness audit pass, two more real findings:
  • Stale in-memory config silently overwriting a change made elsewhere: Automatización only ever loaded PowerSentinel.json once per app session (on first visit, or an explicit "Reload" tap) - harmless while it was the only thing that could write the file. Since v3.49.0 it isn't: the night-wake card on Inicio does its own independent write for its two time fields. Sequence that silently lost data: open Automatización -> go to Inicio and change the night-wake window -> come back to Automatización -> hit Guardar without reloading first - the in-memory model was still the pre-edit snapshot, so saving it overwrote the night-wake change with no warning. Fixed: Automatización now silently refreshes from disk on every activation, but only when there are no unsaved local edits.
  • A night-wake window with the same start and end time silently breaks the counter: each time is individually validated as a proper HH:MM (config_valid_time_hhmm), but start==end makes _screenwake_window_bounds() compute a zero-length window - the count then reads 0 forever with nothing explaining why. Fixed on the WebUI side, the only place both values are known at once: rejects the change and reverts the field to its last known-good value.

v3.49.0

  • Night-wake window is now editable directly on its own card, not through a separate settings form: the two times shown on "Encendidos nocturnos" are real native time pickers now - tapping either one opens the OS/WebView's own time picker right there, and the change saves immediately (same read-modify-write + PowerSentinelctl reload the full Automatización form already used). Replaces the global-settings-form fields added last round entirely, per the maintainer's own suggestion - one place to edit this, not two. A poll landing while the picker is open never overwrites the value mid-edit.
  • screenwake_summary() now also returns start/end as separate JSON fields (alongside the existing combined window string, kept for compatibility) - the frontend needs the two values independently for the pickers, without parsing a "HH:MM - HH:MM" display string back apart.

v3.48.0

  • CRITICAL FIX: Inicio kept "flickering" every ~3 seconds with content briefly disappearing - the animation-restart bug fixed in v3.46.0 turned out not to be the whole story. Root cause: update_status() had never written its status file atomically - it truncated the real file in place (echo -n >) and then built it back up over ~20 separate appends. Any read landing in that window (the WebUI's readStatus(), via cat) saw an empty or partially-written file, hiding whichever dashboard cards depend on the missing fields until the next poll. This window always existed, but became wide enough to reliably hit in practice once update_status() started running every single main-loop cycle (v3.40.0) and calling out to jq several more times per call (the night-wake and "Hoy" summaries added since). Fixed with the same temp-file-then-atomic-rename pattern already used everywhere else in this codebase for anything the WebUI reads - confirmed with a concurrent reader/writer reproduction: 200/200 reads saw a fully-written file after the fix, versus reliably empty/partial reads before it.
  • Night-wake window is now configurable from the WebUI: two new fields in Automatización's global settings ("Encendidos nocturnos: inicio/fin de la franja", reusing the exact same native time-picker widget already used for the "Night" event's own schedule) - previously the only way to change the default 23:00-07:00 window was hand-editing the raw JSON. Deliberately its own pair of global fields, not tied to the "Night" event, matching the counter's own design (it never depended on that event being configured).

v3.47.0

  • Input validation gap found during a security/robustness audit: nightwake_start/nightwake_end (v3.42.0's own config) and the pre-existing night_start/night_end/thermal_threshold fields all flowed straight from the config file into arithmetic contexts with zero format validation - unlike every other format-constrained config key, which already goes through config_get()'s own validation switch. Confirmed this does NOT allow command injection despite reaching a $(( )) context (bash only re-evaluates a literal $(...) written directly in an expression, never one already inside an expanded variable's stored value - verified experimentally). The real impact: a malformed value (e.g. hand-edited JSON) broke the night-wake counter, the night profile, or the thermal profile silently, logging a runtime error every single cycle with no obvious cause visible from the WebUI.
  • Fixed with a new config_valid_time_hhmm() validator, applied consistently to all 4 affected fields - an invalid value now behaves exactly like an unconfigured one (the existing, already-safe fallback), instead of spamming the log and leaving the feature silently broken.

v3.46.0

  • CRITICAL FIX: Inicio's "¿Qué está haciendo ahora?" card visibly flickered every ~3 seconds while any event was active. Root cause: renderActiveNow() fully rebuilds that card's HTML on every status poll (the 3s pollTimer), whether or not anything actually changed - and the CSS entrance animation added for it in the previous round (fade-up, meant to play once when Inicio first loads) re-triggers from scratch every time a matching element is a freshly-inserted DOM node. The two together meant the card faded in again, in a loop, all day. Fixed by removing the animation from .active-now-card specifically (today-card/nightwake-card are unaffected - their own DOM nodes persist between polls; only their child elements' text updates).
  • Found and fixed the same underlying pattern, older and latent, on the CPU core tiles in "Detalles técnicos": coreGrid.innerHTML is also rebuilt on every 3s poll, so its own per-tile entrance animation (tile-in, present since long before this round) replayed every cycle too for anyone with that section expanded - just less visible than the always-open active-now card, which is likely why it went unreported. Fixed the same way: the tiles' resting state is now the only state, no re-triggering entrance animation.
  • Reported by the maintainer as "the Inicio screen flickers every 3 seconds" - the exact interval was the clue that led straight to the poll timer.

v3.45.0

  • Activity timeline redesign: entries now sit in a real vertical timeline (a connecting rail between dots, sub-lines marked with "↳"), replacing the previous inline-dot layout - used both in Inicio's "Actividad reciente" preview and the full journal in Análisis, since both already shared the same renderer. A subtle fade-in on each entry is the first of this round's small set of polish animations.
  • Apps tab: removed the per-app repeated explanation ("PowerSentinel will only lower its background priority...") that used to print once per app card - replaced with a single legend at the top of the tab explaining what each of the 4 policy levels means, read once instead of N times.
  • Bottom nav reduced to 4 primary tabs + "Más": Inicio / Análisis / Automatización / Apps stay one tap away; Perfiles, Ajustes, and a "Detalles técnicos" shortcut straight into Automatización now live in a bottom sheet behind "Más" (with a slide-up animation). Swipe navigation is unaffected - it still moves through every view in order, Perfiles/Ajustes included, just without their own fixed button.
  • Small polish pass: fixed a real dead CSS selector (.hero-card, a class name that no longer exists after the dashboard redesign) that had silently disabled Inicio's own fade-in-on-load animation - now applied to all of Inicio's current cards, plus a smooth color transition on the profile checklist chips.
  • Daily battery-vs-average comparison was already covered by the existing rate-vs-baseline bar on the battery card (added in an earlier round) - verified it's still working correctly after the redesign; no changes needed there.
  • This release ships together with everything from the last two development rounds (the "Hoy" card and the profile checklist), all still pending upload to main as of this release.

v3.44.0

  • "Hoy" summary card: screen time since midnight, time since your last charging session ended, tonight's wake count (reusing the same counter from v3.42.0 - never a second source of truth for the same number), and how many PowerSentinel interventions have started today, plus a small 24-bar hourly screen-activity chart. New daemon-side tracking (PowerSentinel-todaystats.sh): screen-on time and its hourly breakdown reset at the calendar-day boundary, but time-since-last-charge deliberately does NOT (it can legitimately span past midnight). Interventions-today is computed straight from the existing Event Journal (counting today's "started" entries) - no new daemon-side counter needed for that one.
  • Still pending: a more visual activity timeline, a daily battery-vs-average summary, visual polish/microanimations, the Apps-tab policy explanation, and the reduced bottom nav.

v3.43.0

  • Profile checklist on the dashboard (classic mode): every profile (Arranque, Cargando, Pantalla apagada, Ahorro del sistema, Noche, Temperatura alta, Manual) now shows at a glance, active ones in green with a check, the rest muted - the same visual language already used by "Hardware detectado" in Ajustes, reused deliberately rather than inventing a new one. Answers "what's active right now" in one look instead of requiring a scroll to the detailed mechanism cards further down. Adaptive-mode installs don't get this list - the gauge/tier name there already is the "what's active" answer, and the 7 classic profiles aren't independently meaningful once the adaptive engine is driving things.
  • Removed battery % and temperature from the hero's quick stats - they were duplicated with the battery card just below (which already shows both, plus a sparkline), and repeating them in the hero didn't add a second useful view of the same number. Only the real drain rate (%/h) remains there when available.
  • Still pending from the wider dashboard redesign (unchanged from the last few releases' notes): the full "Hoy" summary card, a more visual activity timeline, a daily battery-vs-average summary, visual polish/microanimations, the Apps-tab policy explanation, and the reduced bottom nav.

v3.42.0

  • Night-wake counter: a new "Encendidos nocturnos" card on the dashboard shows how many times the screen turned on within a configurable night window (nightwake_start/nightwake_end, default 23:00-07:00), compared against the average of the previous 7 completed nights, with a tap-to-expand list of the exact wake times. Deliberately its own config rather than reusing the "night" event's own schedule fields, so it works out of the box regardless of whether that event is configured (adaptive-mode installs in particular often never touch it). Edge-triggered (only counts genuine screen off->on transitions, never re-counts a cycle where the screen was already on) and bounded (30 days of history, pruned automatically).
  • First piece of a larger "Hoy" summary card discussed with the maintainer (screen time, time since last charge, intervention count, and a small time chart are planned for a future round, along with an "Arranque" section redesign, an Apps-tab explanation of what each policy level does, and a reduced bottom nav) - not part of this release.

v3.41.0

  • Dashboard redesign, phase 1: the main "Inicio" card is now a real hero, not a stack of centered blocks. Two-column layout - the gauge (mode name, score and intervention level, all still inside its center) sits next to a status column with a live "AHORA" indicator, a plain-language subtitle, and a segmented "energy pressure" bar (replacing the previous gradient-bar-with-a-dot, which showed the exact same number in a less legible shape). Quick stats (battery/temperature/rate) are now icon tiles instead of one joined text line, and the "why" sentence gets a contextual icon and its own separated row at the bottom of the card. Falls back to a single stacked column below 360px so the gauge never gets cramped.
  • First round of the wider dashboard redesign discussed with the maintainer (screen-time stats, a more visual activity timeline, a daily battery summary, and further visual polish are planned for upcoming rounds - not part of this release).
  • Deliberately NOT included: a "you saved ~18%" style summary card - it would have directly contradicted the v3.35 decision to never show an invented savings figure, since there's no way to causally attribute a saving to PowerSentinel. Left for a future round with different wording (a measured comparison, not a causal claim).
  • CHANGELOG.md is written in English from this release onward (previous entries stay in Spanish, as written at the time).

v3.40.0

  • CRITICAL FIX: Inicio/Estado data (battery, temperature, CPU frequencies, WiFi, Doze) only refreshed when an event started or ended, never during a steady period (screen on, not charging, no adaptive-tier changes). The stale-data warning itself (v3.35) assumed a refresh roughly every ~3s, but nothing delivered that outside of a transition - so anyone going more than ~90s without an event transition would see the warning permanently and frozen readings, while the daemon was running completely normally. The main loop now refreshes status every cycle (reusing the detection already done that same cycle, no duplicated work) instead of depending solely on transitions.
  • Finding from a code review, verified by reading every call site of update_status before fixing anything.

v3.39.0

  • CRITICAL FIX: Safe Mode used to unsuspend every system app, not just the ones PowerSentinel itself had suspended - directly contradicting the ownership system. Removed the global loop.
  • CRITICAL FIX: pause only worked the first time - a global variable was never reset, so a second pause ended instantly without ever actually waiting. Fixed.
  • CRITICAL FIX: an invalid handle_apps value (typo, corruption) fell through to suspend, the most aggressive action, instead of doing nothing. It now fails safely.
  • CRITICAL FIX: the watchdog race was real, and more relevant now because of the new manual restart button - two simultaneous daemon launches could genuinely happen. Fixed with a real atomic lock (mkdir), tested with 20 simultaneous attempts.
  • CRITICAL FIX: handle_proc didn't have the same composition protection already applied to GMS/WiFi/apps/cores - two events targeting the same process could leave it with the wrong nice value once they ended.
  • Findings from an external code review, verified with real reproductions before fixing anything.

v3.38.0

  • Apps sorted by policy level: Protected first, then Gentle, Balanced, and Restricted last - previously they appeared in whatever order the system happened to return, unrelated to each app's own policy.

v3.37.0

  • "Restart PowerSentinel" button inside the stale-data warning: kills and relaunches the actual daemon process (not a cooperative reload, which wouldn't help if the daemon is stuck in pause or hung for any other reason). Any active event automatically recovers on startup, through the same safety net that already protects against a real crash.
  • PowerSentinelconf set/add-event/rm-event now warn that a reload is needed to apply changes, just like the interactive wizard already did.

v3.36.0

  • CRITICAL FIX: saving the config (or loading a profile) while an event was active could leave changes applied forever. When undoing an event after a reload, the daemon re-read the config from disk - but that config was already the new one, just saved moments earlier. If the active event no longer matched what the new config said, the "undo" found nothing to revert, leaving apps reniced, cores disabled, WiFi or GMS disabled permanently, with the daemon believing everything was clean. Not a rare edge case - editing an active event and saving, or switching profiles, are completely ordinary actions. Fixed by capturing a real snapshot of what was actually applied at the moment each event activates, used when undoing it instead of re-reading the config.
  • Finding from a self-audit, verified with real reproductions before fixing anything - same as the rest of this series' critical fixes.

v3.35.0

  • Stale-data detection: "Actualizado HH:MM:SS" only confirmed the request itself had succeeded, not that the displayed data was actually recent - if the daemon is paused or stuck, it could show battery data from an hour ago with full confidence. The daemon now writes its own real timestamp, and the frontend clearly warns if the data has gone too long without refreshing.
  • New comparison bar under the battery estimate: your current consumption rate versus your real historical average, with the percentage difference - never an invented "savings" figure, since there's no way to causally measure how much would have been spent without PowerSentinel.

v3.34.0

  • CRITICAL FIX: ownership tracking wasn't safe across simultaneous events - two active events requesting different actions on GMS/WiFi/apps/cores could leave the system permanently stuck in the wrong state. Fixed for GMS, WiFi, low_ram, and apps, verified with real reproductions.
  • CRITICAL FIX: manual handle_cores restored an empty governor on high-performance cores that weren't auto-detected; disable_cores re-enabled cores that were already offline for another reason before PowerSentinel ever acted. Both fixed with the same "restore only what changed" pattern.
  • action_proc_undo() fixed: now preserves the real original per-process nice value (it used to force 0 instead), and along the way two unreported bugs were fixed too: handling of processes with multiple PIDs had never worked, and an empty IFS= meant the user-configured nice value was never actually read.
  • Full Inicio redesign: the circular gauge and mode name merge into a single main card; new "why" sentence in human language (never invented); real consumption rate (%/h) alongside battery and temperature; the battery card is now always visible, with a mini chart of the last few hours; recent activity with color-coded dots by type; one-line CPU summary with direct access to technical details.

v3.33.0

  • CRITICAL FIX: "high power" core detection was broken on symmetric SoCs (the most common design). auto_map_cores() used uniq -u, which only detects values that appear exactly once - with 4 cores at one frequency and 4 at another, none was "unique", leaving the high-performance core list completely empty. This meant disable_cores=auto disabled nothing, and handle_cores=auto forced power-save mode onto every core, including performance ones.
  • More robust watchdog: replaced the simple pgrep with a PID file plus a real liveness and process-identity check, so as not to risk a second daemon instance.
  • CRITICAL FIX x2: GMS and WiFi now only restore what PowerSentinel actually changed - the same pattern already fixed earlier for apps and low_ram. If you turn WiFi off yourself, PowerSentinel will no longer re-enable it once an event ends. Also hardened low_ram's idempotency against repeated calls.
  • Findings from an external code review, verified one by one with real reproductions before fixing anything.

v3.32.0

  • Navigation restructured: Inicio / Perfiles / Automatización / Apps / Análisis / Ajustes. Apps becomes its own top-level tab (it used to live inside Config → Advanced). "Log" is renamed to "Análisis", with its technical subtab renamed to "Actividad". "Acerca de" is renamed to "Ajustes".
  • Advanced settings: a new card in Ajustes clearly signposts the 4 technical categories (Engine, CPU, Apps, System) with a direct shortcut into Automatización's Advanced mode - nothing has been removed, it simply no longer appears the moment you open the app.
  • Visual hierarchy in Inicio: restructured from ~9 always-visible cards into a clear reading order (Main status → What's happening → Recent activity → Technical details, the latter collapsed by default).

v3.31.0

  • Apps: from "package list" to "per-app policy" (Config → Advanced → Apps). The 4 levels were renamed to behavior language (Protected / Gentle / Balanced / Restricted) with a clear explanation of each. Every app now shows what would actually happen in your device's real situations: in classic mode, "When the screen is off" / "When the battery is low"; in adaptive mode, the 3 pressure levels (Gentle/Moderate/Extreme saving) - never the same template for both modes, since the adaptive engine doesn't break down into independent conditions the way classic events do.

v3.30.0

  • Activity timeline (Historial): the journal now logs every event start/end with the real mechanisms actually applied at that moment, instead of a generic "Active Events: X Y Z" line. The view was rewritten as a readable timeline: "23:14 🌙 Entered Night mode" + "Deep Doze enabled" + "GMS limited", "07:42 ☀️ Exited Night" + "Previous state restored". New warning severity level (never triggers a real notification) for when an action is skipped due to a device capability limitation - this used to only show in the technical log, now it's clearly marked with ⚠️.
  • Energy health (Log → Energía): recent consumption (last 6h, %/h) compared against your previous historical average, and the time of day when you tend to drain the most battery - calculated entirely from data already collected by the energy log.

v3.29.0

  • Estado becomes an energy control center. New main card: "Protección energética: ACTIVA/inactiva", battery/temperature/screen summary, and the current mode in plain language. In adaptive mode, a visual Normal → Saving → Extreme indicator positioned by the real pressure value, with "Detalles" (collapsed by default) showing the real breakdown: total pressure, and how much each factor contributes (temperature, battery, screen off, night, CPU load).
  • "What's happening now?": one card per active event, with icon, plain-language name, "Active since HH:MM", the mechanisms actually applied (CPU/Doze/Apps/GMS/WiFi), and a sentence explaining why it activated.
  • Goal vs Mechanism in Basic mode: levels are now presented as 🚀 Maximum performance / ⚖️ Balanced / 🔋 Maximum battery life instead of Low/Medium/High, with "How it's achieved" as a collapsible technical list (Apps → limit processes, Doze → deep...) instead of always showing.
  • Background daemon work: real energy-pressure breakdown, start time of each active event, and a snapshot of resolved mechanisms per event - all exposed for the first time outside the daemon process itself.

v3.28.0

  • Per-app policy screen (Config → Avanzado → Apps): browse every installed app and set its 4-level policy directly (never touch / gentle only / follow event / always aggressive), instead of only reachable through PowerSentinelconf on a terminal.
  • Usage-frequency context on the same screen, via a new PowerSentinel-usagerank script - queries Android's own App Standby Buckets (am get-standby-bucket) one app at a time using its small, documented single-package form, rather than the fragile raw dumpsys usagestats text dump. Purely informational, never wired into any automatic decision.
  • Energy log analysis (Log → Energía): a battery-level chart for the last 24h, and a "ritmo de descarga por régimen activo" breakdown comparing how many minutes it takes to drop 1% battery under each combination of active events - answering, with the device's own real data, whether a given aggressiveness level genuinely slows discharge.

v3.27.0

  • CRITICAL FIX: saved config was silently wiped, or crashed the daemon, on every reload. serializeConfig() wrote "version" at the top level of the JSON, but the daemon (both the migration idempotency check and the normal config read) expects it inside global. Every save from the WebUI immediately triggers a daemon reload, which - finding the version key missing - either rebuilt the config from a stale, frozen .conf snapshot (discarding whatever was just saved) or crashed with "FATAL: could not migrate to v2". This affected every save made through the WebUI since the JSON config format was introduced (v3.10.0).
  • Basic mode: selecting an aggressiveness level now shows a detailed, tier-by-tier breakdown of what it actually does, generated directly from the same data used to apply the setting.
  • Config (Advanced): the allow/deny apps picker now appears right under "Gestión de apps" in each event, instead of at the very bottom of the card.
  • Hardware detection: a new "Hardware detectado" section in Acerca de shows the real device manufacturer/model and which mechanisms it actually supports. The existing Samsung/OnePlus risk warnings now only show when they're actually relevant to the detected device, instead of to everyone regardless of hardware.
  • On-demand CPU consumption ranking (Estado tab) - explicitly triggered, sorted by %, with zero ongoing cost when not in use.
  • Acerca de: removed the fork/DethByte64 attribution, added the project's Telegram channel link.
  • Expanded the README with a Philosophy section and a full Daemon architecture explanation, and brought the Features list up to date with everything built since the JSON config rewrite.

v3.26.0

  • 3 critical fixes, all reported by a user and verified with real reproductions before being trusted.
  • Apps: action_apps_undo() had no per-app record of what PowerSentinel actually changed - an app already suspended by something else before PowerSentinel touched it could get force-unsuspended once the event ended, and nice always hard-reset to 0 regardless of a process's real original value. Fixed with per-app ownership tracking (PowerSentinel.appstate) - only what PowerSentinel itself actually changed gets restored, and the real original nice value is preserved.
  • low_ram: ro.config.low_ram was unconditionally forced to false when an event ended, with no record of what it was before - a device that genuinely ships with low_ram=true by default would have that silently overwritten. Fixed by recording and restoring the real original value (PowerSentinel.lowram_orig).
  • Event composition: an ending event's undo could revert a setting (WiFi, cores, doze, GMS, low_ram) that another still-active event also needed - confirmed with a real reproduction (two events both requesting kill_wifi=true, one ending while the other stayed active incorrectly re-enabled WiFi). Fixed with a re-assertion pass after any event ends, re-applying whatever the remaining active events still need. Not a full policy-composition engine - if two active events want genuinely different things for the same category, there's still no defined precedence between them.

v3.25.0

  • Basic mode's Config screen expanded with 4 new blocks: a live battery summary, a reassurance line showing how many apps are always protected, a list of apps flagged for real sustained background CPU use (each with one-tap "Limitar esta app" or "Ignorar"), and Safe Mode - previously only reachable via a terminal command, now a simple button that reflects its current state.
  • appwatch.sh's app detections are now persisted (PowerSentinel.flagged) instead of living only in memory - manageable via the new WebUI blocks or PowerSentinelconf flagged-apps list/dismiss.

v3.24.0

  • The mode switcher now sits in its own row above the action toolbar, separate from Guardar/Recargar/Restaurar recomendados - clearer that it's a state indicator, not a fourth action button.
  • Each aggressiveness level's description now says a bit more about what actually happens (e.g. "Alta" mentions deep Doze and low-RAM mode specifically) instead of a vague "máximo ahorro".

v3.23.1

  • Removed the redundant "Modo avanzado" checkbox in Config - since the "?" button already lets you switch modes via the explanation screen, having a separate toggle was two ways to do the same thing. The single remaining button now shows your current mode as its own label and opens that same screen when tapped.

v3.23.0

  • Per-app policy in 4 levels, replacing the binary allowlist/denylist-only model: 0 (never touch), 1 (gentle only, capped at "nice"), 2 (default, follow the event as configured), 3 (always aggressive, forces suspend). Global rather than per-event, built on top of the real CPU detection added in v3.20.0 - a level can now be an informed decision rather than a guess. Manage it now via PowerSentinelconf app-policy set/get/rm/list; a WebUI section is planned for a later polish pass.

v3.22.0

  • Redesigned Basic mode's Config screen. The three aggressiveness levels are now cards with an icon and a one-line description of what each actually means, instead of plain unexplained buttons. A new live status line shows whether adaptive savings are genuinely doing something right now ("Ahorrando ahora mismo" / "Sin ahorro activo ahora mismo"), not just whether the setting is turned on.

v3.21.0

  • Proper Basic/Advanced mode choice screen. Instead of a bare toggle, a new screen explains both modes clearly (what Basic gives you vs what Advanced requires) and appears automatically the first time you visit Config. A "?" icon next to the toggle reopens the same explanation anytime.

v3.20.1

  • Fixed: Advanced mode's Config tab had no scrolling at all (a layout CSS rule silently stopped applying when Advanced mode's content got wrapped for the show/hide toggle in v3.18.0).
  • Fixed: an event that's active (like boot, which fires at every daemon start and is never explicitly undone) had no way to reach it in Config unless already explicitly added. A hint now points you to "Añadir evento" for any active event with no configured block.

v3.20.0

  • Problematic-app detection (observational only). A new watch, using the stable /proc/[pid]/stat kernel interface rather than any fragile Android dumpsys command, flags apps sustaining real, measurable CPU use while the screen is off - recorded to the Event Journal, no automatic action taken. This is groundwork for a future per-app policy system to target apps that are actually measured as heavy, instead of an arbitrary manually-curated list.

v3.19.0

  • Energy log: real validation, not just correctness. A new PowerSentinel.energylog records battery level, temperature, and what was active, but only when something actually changed - not every cycle. This is raw data collection for after-the-fact analysis (e.g. "did aggressiveness High actually drain slower than Medium last night", "did temperature actually drop after thermal fired") - no built-in conclusions, no new WebUI view yet, honestly a correlation tool for your own device rather than a scientific power model.

v3.18.0

  • Basic mode by default. New installs now open to a simple Config view: one switch for adaptive savings and an Aggressiveness picker (Low/Medium/High) - no events, no per-field settings to understand. A visible "Modo avanzado" toggle reveals the full Form/Text editing that existed before, unchanged, for anyone who wants complete control. Purely a WebUI presentation layer - both modes read and write the exact same configuration.
  • Basic mode's aggressiveness presets never suspend apps (only the safer, reversible "nice") - that level of control is exactly what Advanced mode is for.

v3.17.0

  • Critical app protection. The device's default dialer, SMS, and emergency apps - plus anything already exempted from Android's own battery optimization - are now automatically protected from handle_apps' kill/nice/suspend, regardless of your allowlist/denylist configuration. Losing the ability to make a call or receive a text is a different category of risk than "an app I like lags a bit". Detected via official, documented Android commands (cmd role get-role-holders, dumpsys deviceidle) - not configurable, since this is specifically about safety, not general preference.

v3.16.2

  • Security fix: PowerSentinel.json (and .state/.journal) were world-writable (666) on some devices - readable and writable by any app, not just root. Found by a user while helping diagnose an unrelated issue. Every write path now sets 600 permissions, and existing installs get corrected automatically on their next daemon start.

v3.16.1

  • CRITICAL FIX: is_event_locked() was accidentally deleted in v3.13.0. This has meant that no event has ever actually applied its settings on any release from v3.13.0 through v3.16.0 - screen_off, adaptive tiers, low power, all of it. Every single "enable" attempt silently failed and returned early. Found during a full-codebase audit. If you're on any version from v3.13.0 to v3.16.0, this update is essential - please update immediately.
  • CRITICAL FIX: PowerSentinelconf (the terminal CLI configurator, documented in the README as a full WebUI alternative) has silently done nothing since v3.10.0 - it read and wrote the old .conf file directly, but the daemon has only read PowerSentinel.json since then. Every set/add-event/rm-event command reported success while having zero real effect. Rewritten to operate on the actual JSON config. PowerSentinelctl had a narrower version of the same issue (only affecting a customized ctl_file path) - also fixed.
  • Fixed a persistent "FATAL: could not migrate config to v2" startup loop some users hit after updating: the migration process couldn't tell a genuinely-completed migration apart from a minimal, incomplete one left behind if jq ever failed partway through - it now retries automatically on the next start instead of getting permanently stuck, and a new early check gives a specific, actionable message if jq itself doesn't work on your device.
  • Removed dead code that never worked (an undefined magic_remount_rw/ro call present since this project's very first commit) and fixed two long-standing busy-loops that pegged a CPU core at 100% while safe mode was active.

v3.16.0

  • v1 compatibility mode removed entirely. Anyone still on a legacy v1 config (or any config missing event blocks, for any reason) now auto-migrates to v2 automatically on the very next daemon start - no manual edit needed anymore, unlike before. Since every install is now guaranteed to reach v2, the ~180 lines of v1-only code (a completely separate, unmaintained code path that received none of the last ~15 versions of improvements) have been removed.
  • Fixed a real, long-standing bug found while doing this: the background process-priority monitor (handle_proc) relied on a variable that was never actually set in v2, so it silently never looped for any v2 user - it's been non-functional independent of this release's changes. Now correctly checks the persisted state file instead.
  • No user-facing configuration changes - if you were somehow still on v1, your settings carry over automatically and unattended.

v3.15.0

  • Front 5: State Manager. A new PowerSentinel-state.sh persists which events are currently active across daemon restarts and reboots. Previously, a crash (relaunched by the watchdog) or an unclean reboot left the daemon with no memory of what it had previously applied - cores could stay offline, apps stay suspended, or WiFi stay blocked indefinitely with no awareness to undo any of it. Now the daemon reconciles back to a clean baseline on every startup before evaluating current conditions.
  • New "Historial" tab in the WebUI (next to Log): shows the full structured Event Journal introduced in v3.14.0 - not just the small fraction of messages that ever reach a real Android notification - with a severity filter and newest-first ordering.
  • No user-facing behavior changes beyond the crash-recovery fix and the new tab.

v3.14.0

  • Notification system redesign. Every event transition and status change used to post a real Android notification - "Config Loaded", "status: Enabled", "Active Events: ...", etc. Only 2 of the 10 messages the daemon ever sent were genuinely critical; the rest were routine status noise interrupting your notification shade for no good reason. Now: a new PowerSentinel-journal.sh records everything (a full, structured history for a future WebUI view), but only genuinely critical situations - Safe Mode being active, or a config safety guard rejecting an unsafe setting - actually reach Android's notification tray, via a new PowerSentinel-alertbridge.sh.
  • PowerSentinel-events.sh (Event Manager): event locking, field resolution, and dispatch extracted out of the daemon into its own file - the first piece of the still-upcoming centralized policy system, pulled forward since it was needed here anyway.
  • Verified: simulated all 10 original notification-triggering messages and confirmed exactly 4 (down from 10) would reach Android's real notification system, while all 10 are still recorded for history. The "Notificaciones" setting still works exactly as before - turning it off suppresses even critical alerts.

v3.13.0

  • Front 2 of the architecture pass complete: detect -> policy -> action separation. PowerSentinel-detect.sh, PowerSentinel-policy.sh, and PowerSentinel-actions.sh now cleanly separate what used to be one large daemon file.
  • Front 3: Capability Manager. A new PowerSentinel-capabilities.sh probes once at startup what your specific device/ROM actually supports (CPU core control, WiFi control method, doze support, whether Google Mobile Services is even installed, pm suspend support), so the daemon skips - with a clear log message - instead of blindly attempting something unsupported every cycle. Notably: GMS handling and doze force-idle used to run unconditionally even on devices/ROMs without Google services or a responsive deviceidle service.
  • Fixed two real bugs found while separating detect/policy/action: manually-specified CPU core selection could restore the wrong governor on disable, or apply powersave to the wrong core on enable. Both only affect manual core selection, not "auto" mode.
  • No user-facing behavior changes beyond the fixes above.

v3.11.1

  • URGENT FIX: event field settings (handle_apps, handle_cores, doze, kill_wifi, etc.) saved through the WebUI since v3.10.0 had no effect on daemon behavior - handle_event() was still reading them from the frozen, no-longer-updated PowerSentinel.conf instead of the live JSON config. Only global settings (delay, adaptive_mode, etc.) were actually working correctly. Root cause: the v3.10.0 commit's message described this fix, but the actual PowerSentineld changes were never included in that commit (a git add oversight) - this release genuinely applies them, re-verified against the real files this time. If you saved event-specific settings via the WebUI on v3.10.0 or v3.11.0, please open Config and re-save after updating to make sure they take effect.
  • Also reapplies two related fixes described but not shipped in v3.10.0: safe mode's persisted flag unified to "true"/"false", and the daemon's startup sequence correctly builds PowerSentinel.json from an existing config on first run after updating.

v3.11.0

  • Front 2 of the architecture pass, part 1/3: PowerSentinel-detect.sh - a new file holding every side-effect-free read of device state (battery, temperature, charging, CPU load, screen). Consolidates four independent dumpsys battery calls that had accumulated across the daemon into one shared read per poll cycle, so every consumer sees a consistent snapshot instead of four separate ones a few lines apart.
  • Removed dead code left over from an earlier, incomplete refactor attempt (PowerSentinel-events.sh, never actually wired into anything) that had started duplicating this same territory and had already begun silently drifting from the real behavior.
  • No user-facing changes - this is internal groundwork. Policy and action separation (parts 2/3 and 3/3) are next.

v3.10.0

  • Configuration is now JSON, not a hand-rolled text format (front 1 of a broader architecture pass - detect/policy/action separation, a capability manager, centralized policy, persistent state, and splitting up monolithic scripts are next). The daemon reads/writes PowerSentinel.json via a bundled, statically-linked jq instead of the old bespoke .conf grammar. Existing installs upgrade automatically and silently the first time this version runs - your current settings are converted once, nothing to do manually.
  • The WebUI's "Texto sin formato" tab is now genuinely a developer mode: it shows and edits the real JSON directly, validated before saving.
  • Eliminated three independent, hand-rolled parsers of the config file that had accumulated over time (inside the daemon's handle_event(), and in the WebUI's log-path resolution) - everything now goes through one shared reader.
  • Fixed several real bugs found while doing this: safe mode's persisted flag was inconsistently "1"/"0" instead of "true"/"false" like every other setting; saving directly from the raw-text tab never validated JSON first (a typo there could have silently broken every setting); and a cores field left in "Personalizado, nothing picked yet" (a legitimate empty value) used to vanish on any save+reload cycle instead of being preserved.
  • Verified extensively before shipping: the migration logic in isolation, a full daemon bootstrap simulation against both a genuine legacy config and a fresh install, and the frontend's parse/serialize round-trip including unknown-key preservation and invalid-JSON handling.

v3.9.2

  • Fixed: the app allow/restrict picker only ever appeared for whichever event already had "Gestión de apps" set to something other than "No gestionar" when its card was first expanded (in practice, usually just screen_off) - changing that dropdown afterward, in any event, never made the picker appear or disappear. Root cause: the picker was mounted once at card-expand time and never re-mounted on subsequent field changes, unlike every other field in the form. Now re-mounts on every field change within the event, so it correctly shows only while "Matar"/"Reducir prioridad"/"Suspender" is selected, in every event, and hides again the moment it's set back to "No gestionar". Verified the full on/off/on sequence (nice → suspend → false → kill) against a non-screen_off event.

v3.9.1

  • Fixed: selecting "Automático" or "Personalizado" for "Núcleos en modo ahorro" or "Núcleos a desactivar" would immediately revert to showing "Desactivado". Root cause: picking "Personalizado" with no cores chosen yet stores an empty string, and the mode-detection logic used a value || 'false' fallback that treats an empty string as falsy - silently reinterpreting it as "Desactivado" on the very next render. Replaced the native <select> for this 3-way choice with tap buttons (matching the existing core-chip style) and fixed the mode detection to handle the empty-string case explicitly instead of relying on JS truthiness. Verified the full click sequence (Desactivado → Automático → Personalizado → pick a core → Desactivado) for both fields against the built bundle.
  • Release asset naming reverted to PowerSentinel-vX.Y.Z.zip (lowercase v).

v3.9.0

  • Adaptive pressure engine (opt-in, adaptive_mode): replaces the classic fixed events (charging/low_power/screen_off/night/thermal) with a single 0-100 "pressure" score recomputed every poll cycle from battery level, temperature, charging state, screen state, night hours, and CPU load - mapped to one of three escalating tiers (adaptive_tier1/2/3, plain config blocks with the same fields as any other event, so the whole existing Config UI works unchanged). Tier boundaries are user-configurable (adaptive_tier1_threshold/2/3, default 20/45/70). Fully backward compatible: disabled by default, and when off the daemon behaves exactly as before.
  • Verified the scoring formula standalone against 6 scenarios: full battery/charging/screen-on (score 0), low battery/screen-off/night (moderate-high), low battery/hot/screen-off (maximum), low battery while charging (relieved sharply), and a same-scenario A/B comparing high vs. low CPU load (confirms the daemon holds back automatically when the device is actively busy, not just when it's idle).
  • Fixed a reactivity gap found while adding the tier-threshold fields: the global settings section didn't re-render on change, so a field's showIf (used to hide the tier thresholds unless adaptive mode is on) would never actually apply - now consistent with how event fields already behave.
  • PowerSentinel-config.sh's validation table extended to cover the new keys (boolean/numeric), matching the existing pattern for every other setting.

v3.8.0

  • KernelSU-only WebUI: removed the Magisk httpd/CGI compatibility path and all runtime backend detection.
  • Removed action.sh, frontend/src/backend-cgi.js, webui/httpd.conf, and all webui/cgi-bin/*.cgi endpoints.
  • The frontend now uses frontend/src/backend-ksu.js directly through the native kernelsu JavaScript API.
  • Removed the WebUI session-token, loopback HTTP server, .serve staging directory, and port 8081 attack surface.
  • Simplified module permissions because no HTTP/CGI files need special handling anymore.
  • Updated security documentation to reflect the single KernelSU transport.
  • Bumped module version to v3.8.0 / versionCode 380.

v3.7.1

  • App picker: allowed/restricted apps are pinned to the top of the list.

v3.7.0

  • Added a Magisk httpd/CGI WebUI compatibility path. This path is intentionally removed in v3.8.0 in favor of a smaller, KernelSU-native attack surface.

v3.6.0

  • Delete-event confirmation, native night-profile time picker, daemon watchdog, thermal profile, optional charge limiter, manual language selector, diagnostics export, running-app indicators, and automatic global-key parsing.

v3.5.1

  • Fixed the manager-visible version string so it no longer exposes the internal -kherio suffix.

v3.5.0-kherio

  • Full English/Spanish WebUI translation with automatic locale detection.

v3.4.x

  • WebUI navigation, battery information, active-event display, persistent chart history, profiles, About screen, pull-to-refresh, and related UI improvements.

v3.3.x

  • Config UI overhaul, security hardening of app/process handling, improved field grouping, and safer allowlist matching.

v3.0.0-kherio

  • Introduced the native KernelSU WebUI-X architecture and Vite frontend.
  • Added the hardened PowerSentinel-writefile helper and the first security audit of the daemon/configuration paths.

Earlier releases

  • Event-driven power management, custom events, Doze/WiFi controls, logging, CPU optimization, safe mode, PowerSentinelctl, and PowerSentinelconf originated in the earlier PowerSentinel/Xtreme-Battery-Saver lineage.