check_concentrator: panel order, Power totals line; README + post-commit hook.

- Port table: sort by panel; Power: Total W / mA (per port follows) as sole header;
  dash alignment; fiber_map panel column unchanged.
- Add README.md with git hook setup; githooks/post-commit (opt-in push + remote pull);
  scripts/install-git-hooks.sh sets core.hooksPath.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-04-03 14:54:36 -07:00
parent 31485b0019
commit 3ea65d9271
4 changed files with 173 additions and 26 deletions

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# FiWiManager
Fi-Wi USB power-control hubs, `fiber_map.json`, SSH remotes, and related tooling. Deeper detail lives under [`docs/`](docs/) (for example [`docs/fiwi-cli.md`](docs/fiwi-cli.md) and [`docs/fiwi-design.md`](docs/fiwi-design.md)).
## Git hooks: post-commit push and remote pull
You can use a **post-commit** hook that, after each commit, runs **`git push`** for the current branch to **`origin`**, then **SSH** to another machine (e.g. a lab Pi) and runs **`git pull`** in a clone there.
### Install once per clone
```bash
./scripts/install-git-hooks.sh
```
That sets **`core.hooksPath`** to **`githooks/`** so Git uses the tracked hook in this repository.
### Enable (opt-in)
The hook **does nothing** unless you set:
```bash
export FIWI_POST_COMMIT_SYNC=1
export FIWI_POST_COMMIT_REMOTE='user@host'
export FIWI_POST_COMMIT_REMOTE_PATH='~/Code/FiWiManager' # optional; default shown
```
Put those in your shell profile if you want them every session.
| Variable | Meaning |
|----------|---------|
| `FIWI_POST_COMMIT_SYNC` | Must be `1` for the hook to run. |
| `FIWI_POST_COMMIT_REMOTE` | SSH target for `git pull` (e.g. `rjmcmahon@192.168.1.39`). **Required** when `SYNC=1`; if empty, the hook skips **both** push and pull and prints a message. |
| `FIWI_POST_COMMIT_REMOTE_PATH` | Directory of the FiWiManager clone on that host. Paths starting with `~/` are turned into `$HOME/…` on the remote. |
**Skip once:** `FIWI_POST_COMMIT_SYNC=0 git commit …`
**SSH:** the hook uses `ssh -o BatchMode=yes`, so it expects non-interactive auth (keys).
**Disable** hooks from this repos `githooks/` directory:
```bash
git config --unset core.hooksPath
```
See also comments at the top of [`githooks/post-commit`](githooks/post-commit).

46
githooks/post-commit Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Post-commit hook: push this repo, then optionally `git pull` on another machine over SSH.
#
# Opt-in (all must be set for the hook to do anything):
# export FIWI_POST_COMMIT_SYNC=1
# export FIWI_POST_COMMIT_REMOTE='user@host' # SSH target for the Pi / lab box
# export FIWI_POST_COMMIT_REMOTE_PATH='~/Code/FiWiManager' # repo root on that host
#
# Skip once: FIWI_POST_COMMIT_SYNC=0 git commit ...
# Disable hook: git config --unset core.hooksPath (or point hooksPath elsewhere)
#
# One-time setup in this clone:
# ./scripts/install-git-hooks.sh
set -euo pipefail
if [[ "${FIWI_POST_COMMIT_SYNC:-}" != "1" ]]; then
exit 0
fi
if [[ -z "${FIWI_POST_COMMIT_REMOTE:-}" ]]; then
echo "post-commit: FIWI_POST_COMMIT_SYNC=1 but FIWI_POST_COMMIT_REMOTE is empty; skipping." >&2
exit 0
fi
root=$(git rev-parse --show-toplevel)
cd "$root"
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "HEAD" ]]; then
echo "post-commit: detached HEAD; skipping push." >&2
exit 0
fi
echo "post-commit: git push origin $branch" >&2
git push origin "$branch"
rpath=${FIWI_POST_COMMIT_REMOTE_PATH:-~/Code/FiWiManager}
echo "post-commit: ssh $FIWI_POST_COMMIT_REMOTE git pull in $rpath" >&2
if [[ "$rpath" == ~/* ]]; then
_tail=${rpath#~/}
ssh -o BatchMode=yes "$FIWI_POST_COMMIT_REMOTE" bash -lc "cd \"\$HOME/$_tail\" && git pull"
else
_q=$(printf '%q' "$rpath")
ssh -o BatchMode=yes "$FIWI_POST_COMMIT_REMOTE" bash -lc "cd ${_q} && git pull"
fi

15
scripts/install-git-hooks.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Point this repository at tracked hooks under githooks/ (post-commit push + optional remote pull).
set -euo pipefail
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Run from inside a FiWiManager git clone." >&2
exit 1
}
cd "$root"
git config core.hooksPath githooks
echo "Set core.hooksPath=githooks in this repo."
echo ""
echo "To sync after each commit (when FIWI_POST_COMMIT_SYNC=1), add to your shell profile:"
echo " export FIWI_POST_COMMIT_SYNC=1"
echo " export FIWI_POST_COMMIT_REMOTE='user@host'"
echo " export FIWI_POST_COMMIT_REMOTE_PATH='~/Code/FiWiManager'"

View File

@ -4,8 +4,10 @@ Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus consolidated USB h
metrics tables covering **this machine** and each configured **remote**. The consolidated hub table metrics tables covering **this machine** and each configured **remote**. The consolidated hub table
has **Location** (``local`` or remote IP/hostname, plus **tty** names when known) and **USB** has **Location** (``local`` or remote IP/hostname, plus **tty** names when known) and **USB**
(Bus/Device, VID:PID, product from sysfs). The per-port table starts with **Panel** (``fiber_ports`` (Bus/Device, VID:PID, product from sysfs). The per-port table starts with **Panel** (``fiber_ports``
key / rack position when ``hub``, ``port``, and optional ``ssh`` match this run), then power metrics key / rack position when ``hub``, ``port``, and optional ``ssh`` match this run), rows sorted by panel
and **Location** (no USB column). number; a **Power: Total W / mA (per port follows)** line heads the section, then the column header
and rows.
(no USB column).
Adnacom PCIe catalog last. Adnacom PCIe catalog last.
Requires BrainStem locally for hub enumeration on this host. Remotes use SSH ``show_hostcards`` Requires BrainStem locally for hub enumeration on this host. Remotes use SSH ``show_hostcards``
@ -20,7 +22,7 @@ and ``port-metrics-json``; the remote tree must include that command (same revis
``--powercycle`` runs a destructive self-test on **local** USB hubs and each host in ``--powercycle`` runs a destructive self-test on **local** USB hubs and each host in
``FIWI_CALIBRATE_REMOTES`` / merged hub hosts: all ports OFF (verify), then all ON (verify), ``FIWI_CALIBRATE_REMOTES`` / merged hub hosts: all ports OFF (verify), then all ON (verify),
then prints the **per-port power** table (snapshot after the test). then prints the port power table (snapshot after the test).
With pytest:: With pytest::
@ -263,6 +265,23 @@ def _panel_cell(
return lookup.get((sk, h1, port_0), "") return lookup.get((sk, h1, port_0), "")
def _panel_sort_tuple(pnl: str, hub_table_idx: int, port_0: int) -> tuple:
"""
Sort per-port rows by patch panel label: numeric ``fiber_ports`` keys first (min if comma-list),
then other labels, unmapped () last; tie-break hub # and port.
"""
s = (pnl or "").strip()
if not s or s == "":
return (2, 0, hub_table_idx, port_0)
nums: list[int] = []
for part in re.split(r"[\s,]+", s):
if part.isdigit():
nums.append(int(part))
if nums:
return (0, min(nums), hub_table_idx, port_0)
return (1, s, hub_table_idx, port_0)
def _merged_hub_hosts() -> list[str]: def _merged_hub_hosts() -> list[str]:
from fiwi.ssh import SshNodeConfig from fiwi.ssh import SshNodeConfig
@ -672,17 +691,14 @@ def _print_per_port_power_table(
c: FiWiConcentrator, c: FiWiConcentrator,
hub_rows: list[ConsolidatedHubRow], hub_rows: list[ConsolidatedHubRow],
base_rc: int, base_rc: int,
*,
title: str | None = None,
) -> int: ) -> int:
""" """
Per-port power, current (mA), voltage (V), and Location same hub order as consolidated rows. ``Power: Total `` line, then per-port current (mA), voltage (V), and Location (sorted by panel).
Returns ``base_rc`` ORd with failure if any ``port-metrics-json`` remote call fails. Returns ``base_rc`` ORd with failure if any ``port-metrics-json`` remote call fails.
""" """
rc = base_rc rc = base_rc
section_title = title or "Per-port power (consolidated hub order)"
if not hub_rows: if not hub_rows:
print(" (no hubs — skipping per-port power table.)", flush=True) print(" (no hubs — skipping port power table.)", flush=True)
print(flush=True) print(flush=True)
return rc return rc
@ -707,11 +723,12 @@ def _print_per_port_power_table(
remote_cache[ssh_target] = {} remote_cache[ssh_target] = {}
return remote_cache[ssh_target] return remote_cache[ssh_target]
print(section_title, flush=True) # Collect rows; sum current (mA) and power (W) where metrics allow.
print("-" * len(_PER_PORT_PWR_HDR), flush=True) lines_out: list[tuple[tuple, str]] = []
print(_PER_PORT_PWR_HDR, flush=True) total_ma = 0.0
print("-" * len(_PER_PORT_PWR_HDR), flush=True) n_ma = 0
total_power_w = 0.0
n_power = 0
for row in hub_rows: for row in hub_rows:
sn_key = _norm_hub_serial(row.serial) sn_key = _norm_hub_serial(row.serial)
if row.ssh_target is None: if row.ssh_target is None:
@ -721,6 +738,8 @@ def _print_per_port_power_table(
for port in range(row.n_ports): for port in range(row.n_ports):
m = by_port.get(port) m = by_port.get(port)
cur_f: float | None = None
v_f: float | None = None
if m is None: if m is None:
pwr, ma_s, v_s = "?", "", "" pwr, ma_s, v_s = "?", "", ""
else: else:
@ -730,7 +749,10 @@ def _print_per_port_power_table(
ma_s = "" ma_s = ""
else: else:
try: try:
ma_s = f"{float(cur):.2f}" cur_f = float(cur)
ma_s = f"{cur_f:.2f}"
total_ma += cur_f
n_ma += 1
except (TypeError, ValueError): except (TypeError, ValueError):
ma_s = "" ma_s = ""
vv = m.get("voltage_v") vv = m.get("voltage_v")
@ -738,15 +760,39 @@ def _print_per_port_power_table(
v_s = "" v_s = ""
else: else:
try: try:
v_s = f"{float(vv):.3f}" v_f = float(vv)
v_s = f"{v_f:.3f}"
except (TypeError, ValueError): except (TypeError, ValueError):
v_s = "" v_s = ""
if cur_f is not None and v_f is not None:
total_power_w += v_f * (cur_f / 1000.0)
n_power += 1
pnl = _panel_cell(panel_lookup, hub_rows, row, port) pnl = _panel_cell(panel_lookup, hub_rows, row, port)
print( st = _panel_sort_tuple(pnl, row.idx, port)
line = (
f"{pnl:<8} | {row.idx:<4} | {row.serial:<12} | {port:<3} | {pwr:<5} | " f"{pnl:<8} | {row.idx:<4} | {row.serial:<12} | {port:<3} | {pwr:<5} | "
f"{ma_s:>8} | {v_s:>8} | {_location_cell(row)}", f"{ma_s:>8} | {v_s:>8} | {_location_cell(row)}"
flush=True,
) )
lines_out.append((st, line))
if n_ma and n_power:
summary = (
f"Power: Total {total_power_w:.3f} W / {total_ma:.2f} mA (per port follows)"
)
elif n_ma:
summary = f"Power: Total — W / {total_ma:.2f} mA (per port follows)"
else:
summary = "Power: — (per port follows)"
print(summary, flush=True)
print("-" * len(_PER_PORT_PWR_HDR), flush=True)
print(_PER_PORT_PWR_HDR, flush=True)
print("-" * len(_PER_PORT_PWR_HDR), flush=True)
lines_out.sort(key=lambda x: x[0])
for _sk, line in lines_out:
print(line, flush=True)
print(flush=True) print(flush=True)
@ -766,7 +812,7 @@ def _print_per_port_power_table(
def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int: def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int:
"""Print consolidated hub table and per-port power / current / voltage table. Returns updated rc.""" """Print consolidated hub table and port power / current / voltage table. Returns updated rc."""
rc = base_rc rc = base_rc
print("USB power-control hubs (consolidated)", flush=True) print("USB power-control hubs (consolidated)", flush=True)
print("-" * len(_CONSOLIDATED_HUB_HDR), flush=True) print("-" * len(_CONSOLIDATED_HUB_HDR), flush=True)
@ -838,7 +884,7 @@ def _parse_args() -> argparse.Namespace:
action="store_true", action="store_true",
help=( help=(
"Self-test: all downstream ports OFF then ON on local hubs and each merged " "Self-test: all downstream ports OFF then ON on local hubs and each merged "
"remote hub host; verifies via port metrics; then print per-port power table " "remote hub host; verifies via port metrics; then print port power table "
"(disrupts USB power)." "(disrupts USB power)."
), ),
) )
@ -868,12 +914,7 @@ def main() -> int:
hub_rows, brc = _build_consolidated_hub_rows(c) hub_rows, brc = _build_consolidated_hub_rows(c)
remote_fail = remote_fail or brc remote_fail = remote_fail or brc
hub_rows = _enrich_hub_rows_usb(hub_rows) hub_rows = _enrich_hub_rows_usb(hub_rows)
remote_fail = _print_per_port_power_table( remote_fail = _print_per_port_power_table(c, hub_rows, remote_fail)
c,
hub_rows,
remote_fail,
title="Per-port power (after power-cycle test)",
)
else: else:
remote_fail = _print_consolidated_report(c) remote_fail = _print_consolidated_report(c)
except AssertionError as exc: except AssertionError as exc: