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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/
- Version badge in README bumped to 0.1.1.

### Fixed
- **Histórico e cooldown dos alertas saem da árvore do pacote.** `mm log`
/ `mm history` / `mm alerts` gravam em
`~/Library/Application Support/mac-manager/` (no Linux do CI,
`~/.local/share/mac-manager`). Upgrade de pipx/Homebrew não apaga mais
o CSV. Dados antigos em `<repo>/logs/` são copiados na primeira
leitura. `MACMANAGER_LOGS_DIR` sobrescreve o caminho.
- **`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
Expand Down
14 changes: 7 additions & 7 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ mm doctor
## Battery — history and alerts

### `mm log`
**What it does:** writes **one snapshot** of the battery to `logs/battery.csv` (append-only CSV format).
**What it does:** writes **one snapshot** of the battery to `~/Library/Application Support/mac-manager/battery.csv` (append-only CSV format).

**Columns:** `timestamp, percent, is_charging, power_source, cycle_count, max_capacity_mah, design_capacity_mah, health_percent, temperature_c`

Expand Down Expand Up @@ -215,7 +215,7 @@ mm history -n 200 # last 200
mm alerts
```

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

---
Expand Down Expand Up @@ -364,14 +364,14 @@ mm history -n 30

| File | What it has |
|---|---|
| `logs/battery.csv` | Append-only history of battery measurements |
| `logs/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) |
| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` |
| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` |
| `~/Library/Application Support/mac-manager/battery.csv` | Append-only history of battery measurements |
| `~/Library/Application Support/mac-manager/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) |
| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` (source install) |
| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` (source install) |

## Reinstall / uninstall

```bash
./install.sh # creates venv, symlink ~/.local/bin/mm and loads launchd agents
./uninstall.sh # removes everything (preserves CSVs in logs/)
./uninstall.sh # removes everything (preserves CSVs in Application Support)
```
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ This will:
- install `rich` and `psutil`
- create the symlink `~/.local/bin/mm`
- register two `launchd` agents:
- **daily log** at 09:00 → writes a snapshot to `logs/battery.csv`
- **daily log** at 09:00 → writes a snapshot to `~/Library/Application Support/mac-manager/battery.csv`
- **battery alerts** every 15 min → native macOS notification

Make sure `~/.local/bin` is in your `PATH`:
Expand Down Expand Up @@ -113,7 +113,7 @@ To preserve the lithium-ion battery (every cycle counts!), the agent checks ever

## History

Every day at 9 AM, a snapshot is written to `logs/battery.csv`:
Every day at 9 AM, a snapshot is written to `~/Library/Application Support/mac-manager/battery.csv`:

```csv
timestamp,percent,is_charging,power_source,cycle_count,max_capacity_mah,design_capacity_mah,health_percent,temperature_c
Expand Down Expand Up @@ -156,7 +156,8 @@ mac-manager/
./uninstall.sh
```

Removes launchd agents, symlink and venv. Preserves the CSVs in `logs/`.
Removes launchd agents, symlink and venv. Preserves battery history in
`~/Library/Application Support/mac-manager/`.

## Status

Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ echo " mm log # battery snapshot → CSV"
echo " mm history -n 20 # last measurements"
echo ""
echo "Active automation:"
echo " • Daily log at 09:00 → logs/battery.csv"
echo " • Daily log at 09:00 → ~/Library/Application Support/mac-manager/battery.csv"
echo " • Battery alerts every 15 min (native notifications)"
echo ""
echo "To uninstall everything: ./uninstall.sh"
8 changes: 5 additions & 3 deletions macmanager/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
- Not charging and <= 10%: critical alert
- Health < 80%: once per week

To avoid spam, we keep the state of the last notification in logs/.alert_state.
O estado da última notificação fica em .alert_state no diretório de dados
do usuário (Application Support), para o cooldown sobreviver a upgrades.
"""

from __future__ import annotations
Expand All @@ -15,7 +16,7 @@
import time

from macmanager.battery import get_battery
from macmanager.logger import ALERT_STATE, LOGS_DIR
from macmanager.logger import ALERT_STATE, ensure_logs
from macmanager.notify import notify

HIGH = 80
Expand All @@ -32,6 +33,7 @@


def _load_state() -> dict:
ensure_logs()
if not ALERT_STATE.exists():
return {}
try:
Expand All @@ -41,7 +43,7 @@ def _load_state() -> dict:


def _save_state(state: dict) -> None:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
ensure_logs()
ALERT_STATE.write_text(json.dumps(state, indent=2))


Expand Down
60 changes: 57 additions & 3 deletions macmanager/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,63 @@
from __future__ import annotations

import csv
import os
import platform
import shutil
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path

from macmanager.battery import get_battery
from macmanager.ui import console

LOGS_DIR = Path(__file__).resolve().parent.parent / "logs"
_DATA_FILES = ("battery.csv", ".alert_state")
_LEGACY_LOGS_DIR = Path(__file__).resolve().parent.parent / "logs"


def default_logs_dir(*, home: Path | None = None, system: str | None = None) -> Path:
"""Diretório estável do usuário — sobrevive a upgrade de pipx/Homebrew.

No Mac: ~/Library/Application Support/mac-manager
Em outros SOs (CI Linux): ~/.local/share/mac-manager
"""
home = home or Path.home()
system = platform.system() if system is None else system
if system == "Darwin":
return home / "Library" / "Application Support" / "mac-manager"
return home / ".local" / "share" / "mac-manager"


def resolve_logs_dir(
*,
env: Mapping[str, str] | None = None,
home: Path | None = None,
system: str | None = None,
) -> Path:
"""MACMANAGER_LOGS_DIR ganha; senão o diretório padrão da plataforma."""
environ = os.environ if env is None else env
override = (environ.get("MACMANAGER_LOGS_DIR") or "").strip()
if override:
return Path(override).expanduser().resolve()
return default_logs_dir(home=home, system=system)


def migrate_legacy(legacy_dir: Path, dest_dir: Path) -> list[str]:
"""Copia CSV/estado antigos se o destino ainda não tiver o arquivo."""
copied: list[str] = []
if legacy_dir.resolve() == dest_dir.resolve():
return copied
for name in _DATA_FILES:
src = legacy_dir / name
dest = dest_dir / name
if src.is_file() and not dest.exists():
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
copied.append(name)
return copied


LOGS_DIR = resolve_logs_dir()
BATTERY_CSV = LOGS_DIR / "battery.csv"
ALERT_STATE = LOGS_DIR / ".alert_state"

Expand All @@ -26,13 +76,16 @@
]


def _ensure_dir() -> None:
def ensure_logs() -> Path:
"""Garante o diretório e migra dados do `logs/` antigo do pacote."""
migrate_legacy(_LEGACY_LOGS_DIR, LOGS_DIR)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
return LOGS_DIR


def log_battery() -> dict:
"""Appends a battery snapshot to the CSV."""
_ensure_dir()
ensure_logs()
info = get_battery()
row = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
Expand Down Expand Up @@ -68,6 +121,7 @@ def cmd_history(args=None) -> None:
"""Shows the last N entries from the CSV."""
from rich.table import Table

ensure_logs()
if not BATTERY_CSV.exists():
console.print("[yellow]No history yet. Run `mm log` or wait for launchd.[/]")
return
Expand Down
70 changes: 69 additions & 1 deletion tests/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@

from pathlib import Path

from macmanager.logger import ALERT_STATE, BATTERY_CSV, BATTERY_FIELDS, LOGS_DIR
from macmanager.logger import (
ALERT_STATE,
BATTERY_CSV,
BATTERY_FIELDS,
LOGS_DIR,
default_logs_dir,
migrate_legacy,
resolve_logs_dir,
)


class TestBatteryFields:
Expand Down Expand Up @@ -56,3 +64,63 @@ def test_alert_state_lives_under_logs_dir(self) -> None:
assert ALERT_STATE.parent == LOGS_DIR
# Dotfile so it doesn't clutter `ls`.
assert ALERT_STATE.name.startswith(".")

def test_logs_dir_is_not_inside_the_package(self) -> None:
# pipx/Homebrew upgrade apagava site-packages/logs/. O diretório
# de dados tem que viver fora da árvore do pacote.
package_root = Path(__file__).resolve().parents[1]
# tests/ está na raiz do repo; o logger antigo usava <repo>/logs.
assert package_root / "logs" != LOGS_DIR


class TestResolveLogsDir:
def test_env_override_wins(self, tmp_path: Path) -> None:
resolved = resolve_logs_dir(env={"MACMANAGER_LOGS_DIR": str(tmp_path)})
assert resolved == tmp_path.resolve()

def test_empty_override_falls_back_to_default(self, tmp_path: Path) -> None:
resolved = resolve_logs_dir(
env={"MACMANAGER_LOGS_DIR": " "},
home=tmp_path,
system="Darwin",
)
assert resolved == tmp_path / "Library" / "Application Support" / "mac-manager"

def test_darwin_uses_application_support(self, tmp_path: Path) -> None:
assert default_logs_dir(home=tmp_path, system="Darwin") == (
tmp_path / "Library" / "Application Support" / "mac-manager"
)

def test_linux_uses_xdg_data_home(self, tmp_path: Path) -> None:
assert default_logs_dir(home=tmp_path, system="Linux") == (
tmp_path / ".local" / "share" / "mac-manager"
)


class TestMigrateLegacy:
def test_copies_missing_files(self, tmp_path: Path) -> None:
legacy = tmp_path / "legacy"
dest = tmp_path / "dest"
legacy.mkdir()
(legacy / "battery.csv").write_text("ts,percent\n", encoding="utf-8")
(legacy / ".alert_state").write_text("{}", encoding="utf-8")

copied = migrate_legacy(legacy, dest)
assert set(copied) == {"battery.csv", ".alert_state"}
assert (dest / "battery.csv").read_text(encoding="utf-8") == "ts,percent\n"
assert (dest / ".alert_state").read_text(encoding="utf-8") == "{}"

def test_does_not_overwrite_existing_dest(self, tmp_path: Path) -> None:
legacy = tmp_path / "legacy"
dest = tmp_path / "dest"
legacy.mkdir()
dest.mkdir()
(legacy / "battery.csv").write_text("old\n", encoding="utf-8")
(dest / "battery.csv").write_text("new\n", encoding="utf-8")

assert migrate_legacy(legacy, dest) == []
assert (dest / "battery.csv").read_text(encoding="utf-8") == "new\n"

def test_same_dir_is_a_noop(self, tmp_path: Path) -> None:
(tmp_path / "battery.csv").write_text("x\n", encoding="utf-8")
assert migrate_legacy(tmp_path, tmp_path) == []
2 changes: 1 addition & 1 deletion uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ if [ -d "$SCRIPT_DIR/.venv" ]; then
fi

echo ""
echo "Uninstalled. The logs in logs/ were preserved."
echo "Uninstalled. Battery history in ~/Library/Application Support/mac-manager/ was preserved."
Loading