Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
b5f56e35ad docs(LAB-003): Battery disconnect switch installation guide (#528)
Some checks failed
Agent PR Gate / gate (pull_request) Failing after 22s
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 21s
Smoke Test / smoke (pull_request) Failing after 33s
Agent PR Gate / report (pull_request) Successful in 28s
- Add docs/LAB-003-battery-disconnect-install.md: comprehensive installation
  guide with store locations, product recommendations, step-by-step procedure,
  testing protocols, and troubleshooting.
- Add docs/LAB-003-verification-template.md: fillable report template for
  documenting installation and test results.
- Add scripts/lab_003_battery_disconnect.sh: helper script with three modes:
  diagnose (check battery health/drain), checklist (print pre-install list),
  verify (post-installation tests).

This prepares the physical work for LAB-003. Next step: execute the
installation using these docs.

References: timmy-home#528
2026-04-22 03:36:12 -04:00
7 changed files with 605 additions and 670 deletions

View File

@@ -1,18 +0,0 @@
{
"owner": "Timmy_Foundation",
"repos": [
"timmy-home",
"timmy-config",
"fleet-ops",
"the-beacon",
"the-door",
"the-nexus"
],
"lookback_days": 14,
"alert": {
"recent_days": 7,
"baseline_days": 7,
"minimum_baseline_closed": 4,
"drop_ratio": 0.6
}
}

View File

@@ -1,70 +0,0 @@
# Burn-down Velocity Tracking
Refs #519.
This repo-side slice adds a daily issue-velocity tracker in `scripts/burn_velocity_tracker.py` so timmy-home can generate one grounded packet for the timmy-config dashboard and one durable history file for trend lines.
## What it emits
Daily run outputs:
- `~/.timmy/burn-velocity/latest.json` — machine-readable payload for the timmy-config dashboard
- `~/.timmy/burn-velocity/latest.md` — operator-facing markdown summary
- `~/.timmy/burn-velocity/history.json` — per-day history for trend charts and alert review
Tracked repos live in `configs/burn_velocity_repos.json`.
## Cron command
```bash
cd ~/timmy-home && \
python3 scripts/burn_velocity_tracker.py \
--config configs/burn_velocity_repos.json \
--output-json ~/.timmy/burn-velocity/latest.json \
--output-md ~/.timmy/burn-velocity/latest.md \
--history-file ~/.timmy/burn-velocity/history.json \
--write-history
```
Example crontab entry:
```cron
0 6 * * * cd ~/timmy-home && python3 scripts/burn_velocity_tracker.py --config configs/burn_velocity_repos.json --output-json ~/.timmy/burn-velocity/latest.json --output-md ~/.timmy/burn-velocity/latest.md --history-file ~/.timmy/burn-velocity/history.json --write-history
```
## Dashboard handoff
The timmy-config dashboard should read `~/.timmy/burn-velocity/latest.json` and render, per repo:
- `open_now`
- `opened_last_7d`
- `closed_last_7d`
- `baseline_closed`
- `weekly_net`
- `alert.status`
- `alert.kind`
- `alert.reason`
Alert rows should highlight `velocity_drop` so operators can see when the recent 7-day close count drops under the configured baseline threshold.
## Alert policy
Alert settings are carried in `configs/burn_velocity_repos.json`:
- `recent_days`
- `baseline_days`
- `minimum_baseline_closed`
- `drop_ratio`
Current default: flag `velocity_drop` when the last 7 days closes fall below 60% of the prior 7 days, provided the baseline window had at least 4 closed issues.
## Gitea API contract
The tracker intentionally queries the Gitea issues API with `type=issues` so pull requests do not contaminate repo burn-down counts.
Live collection shape:
- open backlog uses `/repos/{owner}/{repo}/issues?state=open&type=issues`
- recent event scan uses `/repos/{owner}/{repo}/issues?state=all&type=issues&since=...`
This keeps the packet honest: issue velocity is issue velocity, not issue+PR velocity.
## Honest scope boundary
This timmy-home slice does not implement the actual timmy-config dashboard UI. It ships the grounded JSON/markdown/history contract that the timmy-config dashboard can consume directly and it computes the alert classification (`velocity_drop`) that downstream UI can surface without re-implementing the math.

View File

@@ -0,0 +1,281 @@
# LAB-003: Truck Battery Disconnect Switch Installation
**Issue:** [timmy-home#528](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home/issues/528)
**Objective:** Eliminate parasitic battery drain via proper disconnect switch installation
**Status:** Planning Complete — Ready for Execution
---
## Problem Statement
Parasitic battery drain is killing the truck battery when parked. This is critical for operational mobility in a rural location where the truck is essential for:
- Supply runs to Newport/Claremont
- Emergency egress
- Equipment transport
The battery has likely been damaged from repeated deep discharges and may need replacement.
---
## Pre-Installation Checklist
### Diagnostic Steps (Do These First)
1. **Verify parasitic drain with multimeter:**
- Set multimeter to DC Amps (10A scale)
- Disconnect negative battery terminal
- Connect multimeter in series between battery negative and cable
- Normal drain: <50mA (0.05A)
- Problem drain: >100mA (0.1A)
- Record reading: __________ mA
2. **Identify the culprit (if drain is high):**
- While monitoring current, pull fuses one at a time
- When current drops, you've found the circuit
- Common culprits: aftermarket radio, alarm system, interior lights, OBD-II tracker
3. **Test battery health:**
- With engine off, battery voltage should be ~12.6V
- With engine running, alternator should show ~13.7-14.7V
- If voltage <12.4V when "fully charged," battery is degraded
---
## Shopping List
### Required Items
| Item | Purpose | Est. Cost | Stores |
|------|---------|-----------|--------|
| Battery disconnect switch (side-post or top-post) | Isolate battery when parked | $8-15 | AutoZone, Advance, O'Reilly, NAPA |
| Terminal shim/post riser (if needed) | Ensure proper terminal clearance | $3-8 | Same as above |
| Dielectric grease | Prevent corrosion on terminals | $3-5 | Same as above |
| Battery terminal cleaner brush | Clean posts before install | $2-4 | Same as above |
| **Total Estimated** | | **$15-30** | |
### Product Recommendations
#### Option 1: Top Terminal Post Mount (Most Common)
- **Recommended:** Battery Doctor Knife Switch #20138 (Advance Auto)
- $12-15
- 250A continuous, 1000A surge
- Easy quarter-turn operation
- No tools needed to operate
- **Alternative:** EverStart Battery Disconnect Switch (Walmart/AutoZone)
- $8-12
- 125A continuous
- Twist-knob style
#### Option 2: Side Terminal Mount (GM Vehicles)
- **Recommended:** Battery Doctor Side Terminal Switch #20140
- $12-18
- Designed for GM-style side terminals
- Requires terminal shim for proper fit
#### Option 3: Quick-Disconnect (Side Post with Cable)
- **Recommended:** Quick Cable Battery Disconnect #5091
- $10-15
- Works with existing cable ends
- Marine-grade (good for NH weather)
### Store Locations (Newport/Claremont Area)
**AutoZone — Newport**
- 65 Main St, Newport, NH 03773
- (603) 863-5040
- Hours: M-Sat 7:30AM-9PM, Sun 9AM-8PM
**Advance Auto Parts — Newport**
- 71 Main St, Newport, NH 03773
- (603) 863-2860
- Hours: M-Sat 7:30AM-9PM, Sun 9AM-7PM
**O'Reilly Auto Parts — Claremont**
- 385 Washington St, Claremont, NH 03743
- (603) 542-4635
- Hours: M-Sat 7:30AM-9PM, Sun 9AM-8PM
**NAPA Auto Parts — Newport**
- 29 John Stark Hwy, Newport, NH 03773
- (603) 863-5500
- Hours: M-F 7:30AM-6PM, Sat 7:30AM-4PM, Sun Closed
---
## Installation Procedure
### Tools Required
- 10mm wrench (for most battery terminals)
- 13mm wrench (if GM side terminals)
- Wire brush or terminal cleaner
- Shop rags
- Optional: zip ties for cable management
### Step-by-Step Installation
1. **Safety First**
- Park on level ground
- Engage parking brake
- Remove keys from ignition
- Wear safety glasses
2. **Disconnect Battery**
- **CRITICAL:** Disconnect NEGATIVE (-) terminal FIRST
- This prevents short circuits if wrench touches frame
- Loosen 10mm nut, wiggle terminal off post
- Tuck cable away so it can't touch battery post
3. **Clean Terminals**
- Use terminal brush to clean inside of cable clamp
- Clean battery post until shiny
- Apply thin layer of dielectric grease to post
4. **Install Disconnect Switch**
**For Top Post Batteries:**
- Remove battery cable end from switch (if pre-attached)
- Slide switch onto battery negative post
- Re-attach cable to other side of switch
- Tighten securely (don't overtighten — battery posts strip easily)
**For Side Terminal (GM) Batteries:**
- May need terminal shim/post riser for clearance
- Install shim on negative side terminal
- Mount switch to shim
- Connect cable to switch
**For Cable-End Style:**
- Cut existing negative cable near battery (leave enough slack)
- Strip 1/2" of insulation from both ends
- Install in quick-disconnect connector
- Crimp or bolt securely per manufacturer instructions
5. **Test Installation**
- Switch should rotate/turn smoothly
- No binding or interference with battery hold-down
- Cable has enough slack for switch operation
- Switch in "ON" position: truck electronics work
- Switch in "OFF" position: no power to truck
6. **Reconnect and Verify**
- Switch to ON position
- Attempt to start truck — should start normally
- Check all electronics function
- Switch to OFF position
- Verify no interior lights, radio, etc.
---
## Testing Protocol
### Immediate Test (Same Day)
- [ ] Start truck with switch ON — engine starts normally
- [ ] Turn switch OFF while running — engine dies (expected)
- [ ] Switch OFF, wait 30 seconds, attempt start — no response (expected)
- [ ] Switch ON, attempt start — starts normally
### Overnight Test (Critical)
- [ ] Park truck with switch in OFF position
- [ ] Note battery voltage: __________ V
- [ ] Wait 24 hours
- [ ] Next day, switch ON, attempt start
- [ ] Record result: □ Started normally □ Slow crank □ No start
- [ ] If started, check voltage: __________ V
### 48-Hour Test (If Battery Healthy)
- [ ] Repeat overnight test with 48-hour duration
- [ ] If truck starts normally, installation is successful
- [ ] If truck fails to start, battery replacement needed
---
## If Battery Needs Replacement
### Symptoms of Bad Battery
- Voltage <12.4V after "charging" overnight
- Slow cranking even with switch disconnected
- Battery case bulging or terminals corroded
- Battery >4 years old
### Replacement Battery Shopping
**Common Truck Batteries (Group Size):**
- Measure existing battery or check current battery label
- Common truck sizes: Group 24F, 27F, 31, 65, 78
**Recommended:**
- **DieHard Platinum AGM** (Advance Auto) — $200-250
- Best cold cranking amps (CCA) for NH winters
- AGM handles deep discharges better
- 3-year full replacement warranty
- **EverStart Maxx** (Walmart) — $100-150
- Budget option
- Check CCA rating matches or exceeds old battery
- **Optima YellowTop** (Pep Boys/Amazon) — $300+
- Deep cycle + starting
- Best for vehicles with parasitic drain issues
- Handles repeated discharge cycles
---
## Documentation Requirements
Per issue #528 acceptance criteria, upload to Gitea:
- [ ] Photo of installed disconnect switch (close-up)
- [ ] Photo of receipt from parts store
- [ ] Photo of truck odometer (optional, for record)
- [ ] Note of test results (overnight start success/failure)
- [ ] Note of battery voltage readings (before/after)
Upload via:
1. Open issue #528 in browser
2. Comment with photos attached
3. Check off acceptance criteria
---
## Troubleshooting
| Problem | Cause | Solution |
|---------|-------|----------|
| Switch won't tighten on post | Wrong terminal type | Get side-terminal adapter or different switch style |
| Switch hits battery hold-down | Clearance issue | Add terminal shim to raise switch, or relocate hold-down |
| Cable too short | Switch adds height | Get battery cable extension or longer replacement cable |
| Still drains with switch OFF | Switch installed on wrong terminal | Move to NEGATIVE terminal only |
| Switch gets hot | Loose connection | Tighten terminal nuts; check for corrosion |
| Truck won't start even with switch ON | Battery too dead | Jump start, then evaluate if battery needs replacement |
---
## Cold Weather Considerations (NH)
- Batteries lose ~50% capacity at 0°F
- Disconnect switch prevents drain but doesn't prevent cold damage
- If storing truck long-term:
- Switch to OFF
- Consider battery maintainer (trickle charger)
- Or remove battery and store in heated space
---
## Summary
This installation is straightforward and should take 30-60 minutes including store run. The key steps:
1. **Diagnose first** — verify parasitic drain, check battery health
2. **Buy the right switch** — match your battery terminal type (top vs side)
3. **Install on NEGATIVE terminal only** — this is critical for safety
4. **Test thoroughly** — overnight test proves the fix worked
5. **Document** — photos and receipts to close the issue
**Estimated total time:** 2-3 hours (including store run)
**Estimated cost:** $15-30 (switch only) or $100-300 (if battery replacement needed)
---
*Prepared for: timmy-home#528*
*Last updated: 2026-04-22*

View File

@@ -0,0 +1,109 @@
# LAB-003 Verification Report Template
**Issue:** [timmy-home#528](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home/issues/528)
**Date:** __________
**Technician:** __________
---
## Pre-Installation Diagnostics
| Test | Reading | Normal Range | Status |
|------|---------|--------------|--------|
| Battery Voltage (engine off) | _____ V | 12.4-12.7V | □ Pass □ Fail |
| Parasitic Current | _____ mA | <50mA | □ Pass □ Fail |
| Battery Voltage (engine running) | _____ V | 13.7-14.7V | □ Pass □ Fail |
**Battery Health Assessment:** □ Good □ Fair □ Replace
---
## Parts Purchased
| Item | Store | Cost |
|------|-------|------|
| Battery Disconnect Switch | _________ | $_____ |
| Dielectric Grease | _________ | $_____ |
| Terminal Cleaner | _________ | $_____ |
| Other: _________ | _________ | $_____ |
| **Total** | | **$_____** |
---
## Installation Checklist
- [ ] Negative terminal disconnected first
- [ ] Terminals cleaned
- [ ] Dielectric grease applied
- [ ] Switch installed on NEGATIVE terminal
- [ ] All connections tight
- [ ] Switch operates smoothly (no tools needed)
- [ ] No interference with hood/battery hold-down
---
## Post-Installation Tests
### Immediate Tests
- [ ] Truck starts with switch ON
- [ ] No power with switch OFF
- [ ] All electronics function normally (switch ON)
### 24-Hour Test
- [ ] Parked with switch OFF for 24+ hours
- [ ] Truck started normally next day
- [ ] Battery voltage before test: _____ V
- [ ] Battery voltage after test: _____ V
### 48-Hour Test (if applicable)
- [ ] Parked with switch OFF for 48+ hours
- [ ] Truck started normally
---
## Photos Required
Upload these to issue #528:
- [ ] Photo of installed disconnect switch (close-up)
- [ ] Photo of receipt from parts store
- [ ] Photo showing switch in OFF position
- [ ] Photo of truck dashboard (optional, for records)
---
## Results Summary
| Acceptance Criterion | Status |
|---------------------|--------|
| Disconnect switch installed and physically secure | □ Pass □ Fail |
| Truck starts reliably after 24+ hours with switch disconnected | □ Pass □ Fail |
| No special tools required to operate the disconnect | □ Pass □ Fail |
| Receipt uploaded to issue | □ Pass □ Fail |
**Overall Status:** □ Complete - All criteria met
□ Partial - See notes
□ Failed - Requires follow-up
---
## Notes / Issues Encountered
_________________________________________________________________
_________________________________________________________________
_________________________________________________________________
---
## Follow-up Actions (if needed)
- [ ] Replace battery (if tests failed)
- [ ] Exchange switch for different style (if fitment issue)
- [ ] Troubleshoot remaining parasitic drain
- [ ] Other: _____________________________________________
---
*Fill out this template during installation and upload to issue #528*

View File

@@ -1,406 +0,0 @@
#!/usr/bin/env python3
"""Burn-down velocity tracker for Timmy Foundation issue throughput.
Refs: timmy-home #519
"""
from __future__ import annotations
import argparse
import json
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib import parse, request
from base64 import b64encode
DEFAULT_BASE_URL = "https://forge.alexanderwhitestone.com/api/v1"
DEFAULT_OWNER = "Timmy_Foundation"
DEFAULT_TOKEN_FILE = Path.home() / ".config" / "gitea" / "token"
DEFAULT_CONFIG_FILE = Path(__file__).resolve().parent.parent / "configs" / "burn_velocity_repos.json"
DEFAULT_OUTPUT_DIR = Path.home() / ".timmy" / "burn-velocity"
DEFAULT_OUTPUT_JSON = DEFAULT_OUTPUT_DIR / "latest.json"
DEFAULT_OUTPUT_MD = DEFAULT_OUTPUT_DIR / "latest.md"
DEFAULT_HISTORY_FILE = DEFAULT_OUTPUT_DIR / "history.json"
DEFAULT_CONFIG = {
"owner": DEFAULT_OWNER,
"repos": ["timmy-home", "timmy-config", "fleet-ops", "the-beacon", "the-door", "the-nexus"],
"lookback_days": 14,
"alert": {
"recent_days": 7,
"baseline_days": 7,
"minimum_baseline_closed": 4,
"drop_ratio": 0.6,
},
}
def parse_iso8601(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def normalize_today(value: str | date | None = None) -> date:
if value is None:
return datetime.now(timezone.utc).date()
if isinstance(value, date):
return value
return date.fromisoformat(value)
def build_day_window(today: date, lookback_days: int) -> list[date]:
start = today - timedelta(days=lookback_days - 1)
return [start + timedelta(days=offset) for offset in range(lookback_days)]
def filter_issue_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in items if not item.get("pull_request")]
def build_daily_series(items: list[dict[str, Any]], today: date, lookback_days: int) -> list[dict[str, int | str]]:
days = build_day_window(today, lookback_days)
counts = {day.isoformat(): {"opened": 0, "closed": 0} for day in days}
start_day = days[0]
for item in filter_issue_items(items):
created_at = parse_iso8601(item.get("created_at"))
if created_at is not None:
created_day = created_at.date()
if start_day <= created_day <= today:
counts[created_day.isoformat()]["opened"] += 1
closed_at = parse_iso8601(item.get("closed_at"))
if closed_at is not None:
closed_day = closed_at.date()
if start_day <= closed_day <= today:
counts[closed_day.isoformat()]["closed"] += 1
return [
{
"date": day.isoformat(),
"opened": counts[day.isoformat()]["opened"],
"closed": counts[day.isoformat()]["closed"],
}
for day in days
]
def summarize_velocity_alert(
*, recent_closed: int, baseline_closed: int, open_now: int, config: dict[str, Any]
) -> dict[str, Any]:
minimum_baseline = int(config.get("minimum_baseline_closed", 4))
drop_ratio = float(config.get("drop_ratio", 0.6))
if baseline_closed >= minimum_baseline and recent_closed < baseline_closed * drop_ratio:
return {
"status": "drop",
"kind": "velocity_drop",
"recent_closed": recent_closed,
"baseline_closed": baseline_closed,
"reason": (
f"velocity_drop: closed {recent_closed} in the last {config.get('recent_days', 7)}d "
f"vs {baseline_closed} in the prior {config.get('baseline_days', 7)}d"
),
}
if open_now > 0 and baseline_closed >= minimum_baseline and recent_closed == 0:
return {
"status": "drop",
"kind": "velocity_drop",
"recent_closed": recent_closed,
"baseline_closed": baseline_closed,
"reason": "velocity_drop: no issues closed in the recent window while backlog is still open",
}
return {
"status": "ok",
"kind": "none",
"recent_closed": recent_closed,
"baseline_closed": baseline_closed,
"reason": "velocity stable",
}
def _sum_window(daily: list[dict[str, int | str]], field: str, days: int) -> int:
if days <= 0:
return 0
return sum(int(item[field]) for item in daily[-days:])
def _sum_baseline_window(daily: list[dict[str, int | str]], recent_days: int, baseline_days: int) -> int:
if baseline_days <= 0:
return 0
if recent_days <= 0:
return sum(int(item["closed"]) for item in daily[-baseline_days:])
baseline_slice = daily[-(recent_days + baseline_days) : -recent_days]
return sum(int(item["closed"]) for item in baseline_slice)
def build_velocity_report(config: dict[str, Any], snapshot: dict[str, Any], today: str | date | None = None) -> dict[str, Any]:
report_day = normalize_today(today)
generated_at = snapshot.get("generated_at") or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
owner = config.get("owner", DEFAULT_OWNER)
repos = list(config.get("repos") or sorted((snapshot.get("repos") or {}).keys()))
lookback_days = int(config.get("lookback_days", 14))
alert_config = dict(DEFAULT_CONFIG["alert"])
alert_config.update(config.get("alert") or {})
recent_days = int(alert_config.get("recent_days", 7))
baseline_days = int(alert_config.get("baseline_days", 7))
repo_reports: list[dict[str, Any]] = []
total_open_now = 0
total_closed_last_7d = 0
repos_with_alerts: list[str] = []
for repo_name in repos:
repo_snapshot = (snapshot.get("repos") or {}).get(repo_name, {})
open_issues = filter_issue_items(list(repo_snapshot.get("open_issues") or []))
recent_issues = filter_issue_items(list(repo_snapshot.get("recent_issues") or []))
daily = build_daily_series(recent_issues, report_day, lookback_days)
open_now = len(open_issues)
opened_last_7d = _sum_window(daily, "opened", recent_days)
closed_last_7d = _sum_window(daily, "closed", recent_days)
baseline_closed = _sum_baseline_window(daily, recent_days, baseline_days)
weekly_net = opened_last_7d - closed_last_7d
alert = summarize_velocity_alert(
recent_closed=closed_last_7d,
baseline_closed=baseline_closed,
open_now=open_now,
config=alert_config,
)
repo_report = {
"repo": repo_name,
"open_now": open_now,
"opened_last_7d": opened_last_7d,
"closed_last_7d": closed_last_7d,
"baseline_closed": baseline_closed,
"weekly_net": weekly_net,
"daily": daily,
"alert": alert,
}
repo_reports.append(repo_report)
total_open_now += open_now
total_closed_last_7d += closed_last_7d
if alert["status"] != "ok":
repos_with_alerts.append(repo_name)
return {
"owner": owner,
"generated_at": generated_at,
"generated_day": report_day.isoformat(),
"lookback_days": lookback_days,
"dashboard_contract_version": 1,
"repos": repo_reports,
"summary": {
"total_open_now": total_open_now,
"total_closed_last_7d": total_closed_last_7d,
"repos_with_alerts": repos_with_alerts,
},
}
def render_markdown(report: dict[str, Any]) -> str:
lines = [
"# Burn-down Velocity Tracking",
"",
f"Generated: {report['generated_at']}",
f"Owner: {report['owner']}",
f"Lookback days: {report['lookback_days']}",
"",
"## Per-repo velocity",
"",
"| Repo | Open now | Opened 7d | Closed 7d | Previous 7d | Alert |",
"| --- | ---: | ---: | ---: | ---: | --- |",
]
for repo in report["repos"]:
alert_label = repo["alert"]["kind"] if repo["alert"]["status"] != "ok" else "ok"
lines.append(
f"| {repo['repo']} | {repo['open_now']} | {repo['opened_last_7d']} | {repo['closed_last_7d']} | {repo['baseline_closed']} | {alert_label} |"
)
lines.extend(
[
"",
"## Dashboard handoff for timmy-config",
"",
"The timmy-config dashboard should consume `~/.timmy/burn-velocity/latest.json` and render, for each repo:",
"- `open_now`",
"- `opened_last_7d`",
"- `closed_last_7d`",
"- `baseline_closed`",
"- `alert.status` / `alert.kind` / `alert.reason`",
"",
"Cron should also persist `~/.timmy/burn-velocity/history.json` so timmy-config can plot the daily trend line instead of only the latest snapshot.",
"",
"## Alerts",
"",
]
)
alerts = [repo for repo in report["repos"] if repo["alert"]["status"] != "ok"]
if not alerts:
lines.append("- none")
else:
for repo in alerts:
lines.append(f"- {repo['repo']}: {repo['alert']['reason']}")
return "\n".join(lines) + "\n"
def update_history(history_path: Path, report: dict[str, Any]) -> dict[str, Any]:
if history_path.exists():
history = json.loads(history_path.read_text(encoding="utf-8"))
else:
history = {"days": []}
entry = {
"date": report["generated_day"],
"generated_at": report["generated_at"],
"summary": report["summary"],
"repos": report["repos"],
}
retained = [item for item in history.get("days", []) if item.get("date") != report["generated_day"]]
retained.append(entry)
retained.sort(key=lambda item: item["date"])
history["days"] = retained
history_path.parent.mkdir(parents=True, exist_ok=True)
history_path.write_text(json.dumps(history, indent=2), encoding="utf-8")
return history
class GiteaClient:
def __init__(self, token: str, owner: str = DEFAULT_OWNER, base_url: str = DEFAULT_BASE_URL):
self.token = token
self.owner = owner
self.base_url = base_url.rstrip("/")
def _headers(self) -> list[dict[str, str]]:
return [
{"Authorization": f"token {self.token}", "Accept": "application/json"},
{
"Authorization": "Basic " + b64encode(f"{self.token}:".encode()).decode(),
"Accept": "application/json",
},
]
def _request_json(self, url: str) -> list[dict[str, Any]]:
last_error: Exception | None = None
for headers in self._headers():
try:
req = request.Request(url, headers=headers)
with request.urlopen(req, timeout=30) as response:
return json.loads(response.read().decode())
except Exception as exc: # pragma: no cover - exercised only on live API failure
last_error = exc
if last_error is None: # pragma: no cover - defensive
raise RuntimeError("request failed without an exception")
raise last_error
def list_issues(self, repo: str, *, state: str, since: str | None = None) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
page = 1
while True:
query = {"state": state, "type": "issues", "limit": 100, "page": page}
if since:
query["since"] = since
url = f"{self.base_url}/repos/{self.owner}/{repo}/issues?{parse.urlencode(query)}"
batch = self._request_json(url)
if not batch:
break
issues.extend(filter_issue_items(batch))
page += 1
return issues
def load_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def load_config(path: Path) -> dict[str, Any]:
config = dict(DEFAULT_CONFIG)
alert = dict(DEFAULT_CONFIG["alert"])
raw = load_json(path, {})
config.update(raw)
alert.update(raw.get("alert") or {})
config["alert"] = alert
return config
def collect_live_snapshot(
config: dict[str, Any], *, today: str | date | None = None, token_file: Path = DEFAULT_TOKEN_FILE, base_url: str = DEFAULT_BASE_URL
) -> dict[str, Any]:
token = token_file.read_text(encoding="utf-8").strip()
report_day = normalize_today(today)
since_day = report_day - timedelta(days=int(config.get("lookback_days", 14)) - 1)
since_timestamp = datetime.combine(since_day, time.min, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
client = GiteaClient(token=token, owner=config.get("owner", DEFAULT_OWNER), base_url=base_url)
repos = list(config.get("repos") or [])
repo_payload = {}
for repo in repos:
repo_payload[repo] = {
"open_issues": client.list_issues(repo, state="open"),
"recent_issues": client.list_issues(repo, state="all", since=since_timestamp),
}
return {
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"repos": repo_payload,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Track per-repo issue burn-down velocity and emit timmy-config dashboard payloads.")
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="Repo tracking config JSON")
parser.add_argument("--snapshot-file", type=Path, help="Use a pre-fetched snapshot JSON instead of calling Gitea")
parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_FILE, help="Gitea token file for live collection")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gitea API base URL")
parser.add_argument("--today", help="Override report date (YYYY-MM-DD)")
parser.add_argument("--output-json", type=Path, default=DEFAULT_OUTPUT_JSON, help="Path for latest JSON payload")
parser.add_argument("--output-md", type=Path, default=DEFAULT_OUTPUT_MD, help="Path for latest markdown summary")
parser.add_argument("--history-file", type=Path, default=DEFAULT_HISTORY_FILE, help="Path for persisted daily history JSON")
parser.add_argument("--write-history", action="store_true", help="Update the daily history file after generating the report")
parser.add_argument("--json", action="store_true", help="Print JSON instead of markdown to stdout")
return parser.parse_args()
def main() -> None:
args = parse_args()
config = load_config(args.config)
if args.snapshot_file:
snapshot = load_json(args.snapshot_file, {"repos": {}})
else:
snapshot = collect_live_snapshot(config, today=args.today, token_file=args.token_file, base_url=args.base_url)
report = build_velocity_report(config, snapshot, today=args.today)
args.output_json.parent.mkdir(parents=True, exist_ok=True)
args.output_md.parent.mkdir(parents=True, exist_ok=True)
args.output_json.write_text(json.dumps(report, indent=2), encoding="utf-8")
args.output_md.write_text(render_markdown(report), encoding="utf-8")
if args.write_history:
update_history(args.history_file, report)
if args.json:
print(json.dumps(report, indent=2))
else:
print(render_markdown(report))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,215 @@
#!/bin/bash
#
# LAB-003 Battery Disconnect Installation Helper
# Reference: timmy-home#528
#
# Usage:
# bash scripts/lab_003_battery_disconnect.sh diagnose # Test battery before install
# bash scripts/lab_003_battery_disconnect.sh checklist # Print installation checklist
# bash scripts/lab_003_battery_disconnect.sh verify # Post-install verification
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$SCRIPT_DIR/../logs/lab_003_$(date +%Y%m%d_%H%M%S).log"
ISSUE_URL="https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-home/issues/528"
echo "=== LAB-003: Battery Disconnect Switch Installation ==="
echo "Issue: $ISSUE_URL"
echo ""
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
diagnose() {
log "=== Battery Diagnosis ==="
echo ""
echo "This will help determine if you need a new battery or just the disconnect switch."
echo ""
echo "Step 1: Check battery voltage with multimeter"
echo " - Set multimeter to DC Volts (20V scale)"
echo " - Red probe to battery positive (+)"
echo " - Black probe to battery negative (-)"
echo ""
read -p "Enter voltage reading (e.g., 12.6): " voltage
log "Battery voltage: ${voltage}V"
if (( $(echo "$voltage >= 12.6" | bc -l) )); then
echo "✓ Battery voltage is GOOD (≥12.6V)"
log "Battery voltage GOOD"
elif (( $(echo "$voltage >= 12.4" | bc -l) )); then
echo "⚠ Battery voltage is FAIR (12.4-12.5V) - may need replacement soon"
log "Battery voltage FAIR"
else
echo "✗ Battery voltage is LOW (<12.4V) - likely needs replacement"
log "Battery voltage LOW - replacement recommended"
fi
echo ""
echo "Step 2: Check for parasitic drain"
echo " - Set multimeter to DC Amps (10A scale)"
echo " - Disconnect negative battery cable"
echo " - Connect multimeter between battery negative post and cable"
echo " - Wait 2 minutes for modules to sleep"
echo ""
read -p "Enter current reading in milliamps (e.g., 50): " current
log "Parasitic current: ${current}mA"
if (( $(echo "$current <= 50" | bc -l) )); then
echo "✓ Parasitic drain is NORMAL (≤50mA)"
log "Parasitic drain NORMAL"
echo ""
echo "NOTE: Normal drain means the disconnect switch may not be necessary"
echo " unless you're storing the truck for weeks at a time."
elif (( $(echo "$current <= 100" | bc -l) )); then
echo "⚠ Parasitic drain is ELEVATED (50-100mA)"
log "Parasitic drain ELEVATED"
echo "Disconnect switch will help prevent dead battery."
else
echo "✗ Parasitic drain is HIGH (>100mA)"
log "Parasitic drain HIGH - disconnect switch highly recommended"
echo ""
echo "You definitely need the disconnect switch!"
fi
echo ""
log "Diagnosis complete. Log saved to: $LOG_FILE"
}
checklist() {
cat << 'EOF'
=== LAB-003 Installation Checklist ===
BEFORE YOU GO:
□ Determine battery terminal type (top post vs side terminal)
□ Measure battery group size (look for label like "Group 24F")
□ Check if you have 10mm and 13mm wrenches
□ Verify multimeter has DC Volts and DC Amps capability
AT THE STORE:
□ Purchase battery disconnect switch (match your terminal type)
□ Purchase dielectric grease
□ Purchase terminal cleaner brush (if you don't have one)
□ Get receipt for documentation
INSTALLATION:
□ Park on level ground, engage parking brake
□ Disconnect NEGATIVE (-) terminal first
□ Clean terminals with wire brush
□ Apply dielectric grease
□ Install switch on NEGATIVE terminal
□ Reconnect and test operation
TESTING:
□ Switch ON: truck starts normally
□ Switch OFF: no power to truck
□ Overnight test: switch OFF, verify start next day
□ Document with photos
□ Upload photos to issue #528
TROUBLESHOOTING:
□ If switch doesn't fit: wrong terminal type - exchange at store
□ If still drains overnight: battery needs replacement
□ If slow crank with new switch: battery degraded - replace
EOF
}
verify() {
log "=== Post-Installation Verification ==="
echo ""
echo "Post-installation tests. Run these AFTER installing the disconnect switch."
echo ""
read -p "Test 1 - Can you start the truck with the switch ON? (y/n): " t1
if [[ "$t1" == "y" ]]; then
log "Test 1 PASSED: Truck starts with switch ON"
echo "✓ Test 1 PASSED"
else
log "Test 1 FAILED: Truck won't start with switch ON"
echo "✗ Test 1 FAILED - Check installation and battery"
fi
echo ""
read -p "Test 2 - With truck OFF and switch OFF, do interior lights/radio work? (y/n): " t2
if [[ "$t2" == "n" ]]; then
log "Test 2 PASSED: No power with switch OFF"
echo "✓ Test 2 PASSED"
else
log "Test 2 FAILED: Power still on with switch OFF"
echo "✗ Test 2 FAILED - Switch may be on wrong terminal or defective"
fi
echo ""
read -p "Test 3 - Is the switch easy to operate by hand (no tools needed)? (y/n): " t3
if [[ "$t3" == "y" ]]; then
log "Test 3 PASSED: Switch operable without tools"
echo "✓ Test 3 PASSED"
else
log "Test 3 WARNING: Switch may require tools"
echo "⚠ Test 3 WARNING - Consider a different switch style"
fi
echo ""
echo "=== 24-Hour Test ==="
echo "Park truck with switch OFF. Tomorrow, try to start it."
echo "Record result in issue #528: $ISSUE_URL"
echo ""
read -p "Did the 24-hour test pass (truck started normally)? (y/n/skip): " t24
case "$t24" in
y)
log "24-hour test PASSED"
echo "✓ Installation SUCCESSFUL!"
echo ""
echo "Close issue #528 with:"
echo " - Photo of installed switch"
echo " - Photo of receipt"
echo " - Note: '24-hour test passed, truck started normally'"
;;
n)
log "24-hour test FAILED"
echo "✗ Test FAILED - Battery likely needs replacement"
echo ""
echo "Next steps:"
echo " 1. Jump start truck"
echo " 2. Drive to store for battery replacement"
echo " 3. Reference LAB-003-battery-disconnect-install.md for battery shopping guide"
;;
*)
log "24-hour test pending"
echo "Run this script again after 24 hours with: bash $0 verify"
;;
esac
echo ""
log "Verification complete. Log saved to: $LOG_FILE"
}
case "${1:-help}" in
diagnose)
diagnose
;;
checklist)
checklist
;;
verify)
verify
;;
*)
echo "Usage: $0 {diagnose|checklist|verify}"
echo ""
echo " diagnose - Check battery voltage and parasitic drain"
echo " checklist - Print installation checklist"
echo " verify - Post-installation verification tests"
echo ""
echo "Full guide: docs/LAB-003-battery-disconnect-install.md"
echo "Issue: $ISSUE_URL"
exit 1
;;
esac

View File

@@ -1,176 +0,0 @@
from __future__ import annotations
import json
import subprocess
import sys
from datetime import date
from pathlib import Path
from scripts.burn_velocity_tracker import build_velocity_report, render_markdown, update_history
ROOT = Path(__file__).resolve().parent.parent
DOC_PATH = ROOT / "docs" / "BURN_VELOCITY_TRACKING.md"
SNAPSHOT = {
"generated_at": "2026-04-22T12:00:00Z",
"repos": {
"timmy-home": {
"open_issues": [
{"number": 501, "state": "open", "created_at": "2026-04-20T09:00:00Z"},
{"number": 502, "state": "open", "created_at": "2026-04-22T07:00:00Z"},
],
"recent_issues": [
{"number": 401, "state": "closed", "created_at": "2026-04-21T09:00:00Z", "closed_at": "2026-04-22T05:30:00Z"},
{"number": 402, "state": "closed", "created_at": "2026-04-20T09:00:00Z", "closed_at": "2026-04-21T05:30:00Z"},
{"number": 403, "state": "closed", "created_at": "2026-04-19T09:00:00Z", "closed_at": "2026-04-20T05:30:00Z"},
{"number": 404, "state": "closed", "created_at": "2026-04-14T09:00:00Z", "closed_at": "2026-04-15T05:30:00Z"},
{"number": 405, "state": "closed", "created_at": "2026-04-13T09:00:00Z", "closed_at": "2026-04-14T05:30:00Z"},
{"number": 406, "state": "closed", "created_at": "2026-04-12T09:00:00Z", "closed_at": "2026-04-13T05:30:00Z"},
{"number": 407, "state": "closed", "created_at": "2026-04-11T09:00:00Z", "closed_at": "2026-04-12T05:30:00Z"},
{"number": 408, "state": "closed", "created_at": "2026-04-10T09:00:00Z", "closed_at": "2026-04-11T05:30:00Z"},
{"number": 409, "state": "closed", "created_at": "2026-04-09T09:00:00Z", "closed_at": "2026-04-10T05:30:00Z"},
{"number": 410, "state": "closed", "created_at": "2026-04-08T09:00:00Z", "closed_at": "2026-04-09T05:30:00Z"},
{"number": 411, "state": "closed", "created_at": "2026-04-07T09:00:00Z", "closed_at": "2026-04-08T05:30:00Z"},
{"number": 412, "state": "closed", "created_at": "2026-04-06T09:00:00Z", "closed_at": "2026-04-07T05:30:00Z"},
{"number": 413, "state": "closed", "created_at": "2026-04-05T09:00:00Z", "closed_at": "2026-04-06T05:30:00Z"},
{"number": 414, "state": "open", "created_at": "2026-04-22T08:45:00Z", "closed_at": None},
{"number": 415, "state": "open", "created_at": "2026-04-17T08:45:00Z", "closed_at": None},
],
},
"timmy-config": {
"open_issues": [
{"number": 601, "state": "open", "created_at": "2026-04-18T09:00:00Z"},
],
"recent_issues": [
{"number": 602, "state": "closed", "created_at": "2026-04-20T09:00:00Z", "closed_at": "2026-04-21T06:00:00Z"},
{"number": 603, "state": "open", "created_at": "2026-04-22T06:00:00Z", "closed_at": None},
],
},
},
}
CONFIG = {
"owner": "Timmy_Foundation",
"repos": ["timmy-home", "timmy-config"],
"lookback_days": 14,
"alert": {
"recent_days": 7,
"baseline_days": 7,
"minimum_baseline_closed": 4,
"drop_ratio": 0.6,
},
}
def test_build_velocity_report_counts_opened_closed_and_flags_drop_alert() -> None:
report = build_velocity_report(CONFIG, SNAPSHOT, today=date(2026, 4, 22))
assert report["generated_day"] == "2026-04-22"
assert report["summary"]["repos_with_alerts"] == ["timmy-home"]
assert report["summary"]["total_open_now"] == 3
home = report["repos"][0]
assert home["repo"] == "timmy-home"
assert home["open_now"] == 2
assert home["opened_last_7d"] == 5
assert home["closed_last_7d"] == 3
assert home["baseline_closed"] == 7
assert home["weekly_net"] == 2
assert home["alert"]["status"] == "drop"
assert home["alert"]["recent_closed"] == 3
assert home["daily"][-1] == {"date": "2026-04-22", "opened": 1, "closed": 1}
timmy_config = report["repos"][1]
assert timmy_config["repo"] == "timmy-config"
assert timmy_config["open_now"] == 1
assert timmy_config["closed_last_7d"] == 1
assert timmy_config["alert"]["status"] == "ok"
def test_render_markdown_includes_dashboard_handoff_and_alerts() -> None:
report = build_velocity_report(CONFIG, SNAPSHOT, today=date(2026, 4, 22))
rendered = render_markdown(report)
for snippet in (
"# Burn-down Velocity Tracking",
"## Per-repo velocity",
"timmy-home",
"timmy-config",
"## Dashboard handoff for timmy-config",
"velocity_drop",
"## Alerts",
):
assert snippet in rendered
def test_update_history_replaces_same_day_snapshot(tmp_path: Path) -> None:
history_path = tmp_path / "burn-velocity-history.json"
report = build_velocity_report(CONFIG, SNAPSHOT, today=date(2026, 4, 22))
update_history(history_path, report)
updated = json.loads(json.dumps(report))
updated["repos"][0]["open_now"] = 9
updated["summary"]["total_open_now"] = 10
update_history(history_path, updated)
history = json.loads(history_path.read_text(encoding="utf-8"))
assert [item["date"] for item in history["days"]] == ["2026-04-22"]
assert history["days"][0]["summary"]["total_open_now"] == 10
assert history["days"][0]["repos"][0]["open_now"] == 9
def test_cli_writes_json_markdown_and_history_from_snapshot(tmp_path: Path) -> None:
snapshot_path = tmp_path / "snapshot.json"
output_json = tmp_path / "latest.json"
output_md = tmp_path / "latest.md"
history_path = tmp_path / "history.json"
snapshot_path.write_text(json.dumps(SNAPSHOT), encoding="utf-8")
result = subprocess.run(
[
sys.executable,
"-m",
"scripts.burn_velocity_tracker",
"--snapshot-file",
str(snapshot_path),
"--today",
"2026-04-22",
"--output-json",
str(output_json),
"--output-md",
str(output_md),
"--history-file",
str(history_path),
"--write-history",
"--json",
],
check=True,
cwd=ROOT,
capture_output=True,
text=True,
)
payload = json.loads(result.stdout)
assert payload["summary"]["repos_with_alerts"] == ["timmy-home"]
assert output_json.exists()
assert output_md.exists()
assert history_path.exists()
assert "timmy-config" in output_md.read_text(encoding="utf-8")
def test_repo_contains_burn_velocity_tracking_doc() -> None:
text = DOC_PATH.read_text(encoding="utf-8")
required = [
"# Burn-down Velocity Tracking",
"python3 scripts/burn_velocity_tracker.py",
"configs/burn_velocity_repos.json",
"~/.timmy/burn-velocity/latest.json",
"timmy-config dashboard",
"type=issues",
"velocity_drop",
]
for snippet in required:
assert snippet in text