Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/
- Version badge in README bumped to 0.1.1.

### Fixed
- **`mm alerts` não dispara mais CRITICAL em Mac sem bateria.** Desktop
ou `ioreg` vazio devolve percent=0 / design=0; o launchd a cada 15 min
avisava "plug in the charger". Sem `DesignCapacity` o comando agora
sai quieto.
- **SSID, processo, serial e paths com `[` / `]` não crasham mais a UI.**
Texto que vem do sistema passa por `safe_text` (escape do Rich) antes
de entrar em markup — um Wi-Fi `My[/Home]WiFi` ou um processo
Expand Down
1 change: 1 addition & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ mm alerts
```

> The state of the last notification is kept in `logs/.alert_state` (JSON with timestamps), preventing spam.
> Macs without a battery (desktop, or empty `ioreg`) produce no alerts.

---

Expand Down
10 changes: 8 additions & 2 deletions macmanager/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ def _can_fire(state: dict, key: str, cooldown: int) -> bool:


def check_and_alert() -> list[str]:
"""Evaluates the battery state and fires notifications according to the rules.
Returns the list of fired keys (useful for debug/testing)."""
"""Avalia a bateria e dispara notificações. Devolve as chaves disparadas.

Sem bateria real (desktop / ioreg vazio) não notifica — percent=0
nesses casos dispararia CRITICAL falso a cada 15 min via launchd.
"""
bat = get_battery()
if not bat.is_present():
return []

state = _load_state()
fired: list[str] = []

Expand Down
4 changes: 4 additions & 0 deletions macmanager/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class BatteryInfo:
fully_charged: bool
serial: Optional[str]

def is_present(self) -> bool:
"""False em desktop ou ioreg vazio — DesignCapacity vem 0."""
return self.design_capacity_mah > 0


_IOREG_KEYS = (
"CycleCount",
Expand Down
67 changes: 66 additions & 1 deletion tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@

import pytest

from macmanager.alerts import COOLDOWNS, CRITICAL, HEALTH_WARN, HIGH, LOW, _can_fire
from macmanager.alerts import (
COOLDOWNS,
CRITICAL,
HEALTH_WARN,
HIGH,
LOW,
_can_fire,
check_and_alert,
)
from macmanager.battery import BatteryInfo


class TestThresholdValues:
Expand Down Expand Up @@ -87,3 +96,59 @@ def test_independent_keys_dont_interfere(self, monkeypatch: pytest.MonkeyPatch)
assert _can_fire(state, "high", cooldown=60) is False
assert _can_fire(state, "low", cooldown=60) is True
assert _can_fire(state, "critical", cooldown=60) is True


def _battery(**overrides: object) -> BatteryInfo:
data: dict = {
"percent": 50.0,
"is_charging": False,
"power_source": "Battery Power",
"time_remaining_sec": None,
"cycle_count": 12,
"max_capacity_mah": 4300,
"design_capacity_mah": 4380,
"health_percent": 98.0,
"temperature_c": 30.0,
"fully_charged": False,
"serial": "C123",
}
data.update(overrides)
return BatteryInfo(**data)


class TestNoBatterySkipsAlerts:
"""Desktop / ioreg vazio não pode virar spam de CRITICAL no launchd."""

def test_absent_battery_fires_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"macmanager.alerts.get_battery",
lambda: _battery(
percent=0.0,
design_capacity_mah=0,
max_capacity_mah=0,
health_percent=0.0,
cycle_count=0,
serial=None,
),
)
notified: list[str] = []
monkeypatch.setattr(
"macmanager.alerts.notify",
lambda *args, **kwargs: notified.append(kwargs.get("title", "")),
)
monkeypatch.setattr("macmanager.alerts._load_state", dict)
monkeypatch.setattr("macmanager.alerts._save_state", lambda state: None)

assert check_and_alert() == []
assert notified == []

def test_real_critical_battery_still_fires(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"macmanager.alerts.get_battery",
lambda: _battery(percent=5.0, is_charging=False),
)
monkeypatch.setattr("macmanager.alerts.notify", lambda *args, **kwargs: None)
monkeypatch.setattr("macmanager.alerts._load_state", dict)
monkeypatch.setattr("macmanager.alerts._save_state", lambda state: None)

assert check_and_alert() == ["critical"]
30 changes: 30 additions & 0 deletions tests/test_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,33 @@ def test_has_expected_fields(self) -> None:
# Use subset: extra fields are fine (forward-compatible), missing
# ones are a breaking change.
assert expected.issubset(actual), f"missing fields: {expected - actual}"

def test_is_present_requires_design_capacity(self) -> None:
absent = BatteryInfo(
percent=0.0,
is_charging=False,
power_source="Battery Power",
time_remaining_sec=None,
cycle_count=0,
max_capacity_mah=0,
design_capacity_mah=0,
health_percent=0.0,
temperature_c=None,
fully_charged=False,
serial=None,
)
present = BatteryInfo(
percent=0.0,
is_charging=False,
power_source="Battery Power",
time_remaining_sec=None,
cycle_count=12,
max_capacity_mah=4300,
design_capacity_mah=4380,
health_percent=98.0,
temperature_c=30.0,
fully_charged=False,
serial="C123",
)
assert absent.is_present() is False
assert present.is_present() is True
Loading