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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ Backends: `spi_flash.c` (fmc100), `spi_flash_hisfc350.c` (V1-era parts),
flash path. Keep installer-transient environment here; persistent board policy
belongs to firmware/device profiles.
- **Power** (`src/defib/power/`) — `routeros` (MikroTik PoE, default), `vectis`,
`rack`, chosen by `DEFIB_POWER_TYPE`.
`rack`, `tasmota` (smart plug `/cm?cmnd=` API), `http` (relay driven by two GET
URLs), chosen by `DEFIB_POWER_TYPE`.
- **TUI** (`src/defib/tui/`) — Textual UI, including the Flash Doctor screen.
- **Web** (`web/`) — WebSerial browser UI: standalone HTML/JS, no build step,
deployed to GitHub Pages. `src/defib/web/` is an empty placeholder package —
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,8 @@ Handles both `tftpboot` and `tftp` U-Boot commands transparently.
## Automated Power Cycling

Defib can automatically power-cycle devices, eliminating manual intervention
for recovery loops and research workflows. Two backends are supported,
selected via `DEFIB_POWER_TYPE`:
for recovery loops and research workflows. The backend is selected via
`DEFIB_POWER_TYPE`:

### MikroTik RouterOS PoE switch (default)

Expand Down Expand Up @@ -267,6 +267,47 @@ socat -,raw,echo=0 TCP:172.17.32.17:35240
> window can close before the round-trip completes; running Vectis
> on the same host as defib (or close to it on a LAN) is recommended.

### Tasmota smart plug

Any plug running [Tasmota](https://tasmota.github.io/) (or a firmware that
speaks the same `/cm?cmnd=` HTTP API) in front of the camera's power supply.
Each switch is confirmed from the plug's reply, so a cycle that did not
happen is an error rather than a silent no-op:

```bash
export DEFIB_POWER_TYPE=tasmota
export DEFIB_TASMOTA_HOST=192.168.1.50
export DEFIB_TASMOTA_RELAY=1 # optional, multi-relay devices only
export DEFIB_TASMOTA_USER=admin # optional, if a web password is set
export DEFIB_TASMOTA_PASSWORD=secret

defib burn -c gk7205v500 -f u-boot-gk7205v500-nand.bin -p /dev/ttyUSB0 --power-cycle -t
```

On a mains plug, leave the default 3 s off time alone: a camera brick can
hold enough charge to ride through a shorter gap.

### Generic HTTP relay

Any relay that turns on and off with a plain GET request — for example a
small local bridge in front of a cloud-only smart switch:

```bash
export DEFIB_POWER_TYPE=http
export DEFIB_HTTP_POWER_ON_URL=http://127.0.0.1:8090/relay/on
export DEFIB_HTTP_POWER_OFF_URL=http://127.0.0.1:8090/relay/off
export DEFIB_HTTP_POWER_TIMEOUT=10 # optional, seconds per request

defib agent upload -c hi3516ev300 -p /dev/ttyUSB0 --power-cycle
```

The cycle is timed on the host, so it inherits the relay's latency.
`agent upload --power-cycle` copes with that by starting the handshake while
the camera is still off and re-cycling once if the bootrom does not answer.

Both are single-outlet controllers: `--poe-port` does not apply, and like
Vectis they do not work with `defib restore`.

The `-t` flag auto-detects the post-boot mode:
- **Normal U-Boot shell** (e.g. hi3516ev300): a two-way serial terminal — your
keystrokes go to the board and its output comes back. Ctrl-C exits the
Expand Down
175 changes: 167 additions & 8 deletions src/defib/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

from __future__ import annotations

from typing import Any
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any

import typer

from defib.install import layout as _install_layout

if TYPE_CHECKING:
from defib.power.base import PowerController
from defib.recovery.events import HandshakeResult
from defib.transport.base import Transport

# Compatibility aliases for existing private imports. Install implementation
# lives in defib.install.layout; CLI code does not own these helpers.
_NAND_LAYOUT = _install_layout.NAND_LAYOUT
Expand All @@ -29,7 +35,7 @@ def burn(
port: str = typer.Option("/dev/ttyUSB0", "-p", "--port", help="Serial device (/dev/ttyUSB0), tcp://host:port, rfc2217://host:port, or socket:///path"),
send_break: bool = typer.Option(False, "-b", "--break", help="Send Ctrl-C after upload"),
terminal: bool = typer.Option(False, "-t", "--terminal", help="Open serial terminal after upload"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via PoE (needs DEFIB_POE_* env vars)"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via the controller selected by DEFIB_POWER_TYPE (default routeros, needs DEFIB_POE_* env vars)"),
poe_port_override: str = typer.Option("", "--poe-port", help="Explicit MikroTik ether port (e.g. ether3) — overrides comment-based auto-discovery. Requires --power-cycle."),
output: str = typer.Option("human", "--output", help="Output mode: human, json, quiet"),
debug: bool = typer.Option(False, "-d", "--debug", help="Enable debug logging"),
Expand Down Expand Up @@ -1076,16 +1082,18 @@ def agent_upload(
output: str = typer.Option("human", "--output", help="Output mode: human, json"),
file: str | None = typer.Option(None, "-f", "--file", help="CV6xx composite boot file (GSL+DDR+U-Boot); required for CV6xx, ignored for other protocols"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via configured controller (DEFIB_POWER_TYPE)"),
poe_port_override: str = typer.Option("", "--poe-port", help="Explicit MikroTik ether port (e.g. ether3) — overrides comment-based auto-discovery. Requires --power-cycle."),
) -> None:
"""Upload flash agent to device via boot protocol (requires power-cycle)."""
import asyncio
asyncio.run(_agent_upload_async(chip, port, output, file, power_cycle))
asyncio.run(_agent_upload_async(chip, port, output, file, power_cycle, poe_port_override))


async def _agent_upload_async(
chip: str, port: str, output: str,
composite_path: str | None = None,
power_cycle: bool = False,
poe_port_override: str = "",
) -> None:
import json as json_mod

Expand All @@ -1095,11 +1103,12 @@ async def _agent_upload_async(
FlashAgentClient, agent_binary_help, get_agent_binary,
)
from defib.firmware import get_cached_path
from defib.power.base import PowerControllerError
from defib.profiles.loader import load_profile
from defib.protocol.hisilicon_cv6xx import HiSiliconCV6xx
from defib.protocol.hisilicon_standard import HiSiliconStandard
from defib.protocol.registry import find_protocol
from defib.recovery.events import ProgressEvent
from defib.recovery.events import ProgressEvent, Stage
from defib.transport.serial_platform import (
create_transport, normalize_port_name,
)
Expand Down Expand Up @@ -1164,12 +1173,31 @@ async def _agent_upload_async(
spl_data = cached_fw.read_bytes()
spl_source = f"full U-Boot ({len(spl_data)} bytes — boundary auto-detected)"

power = None
power_port = ""
if power_cycle:
from defib.power.factory import power_controller_from_env
try:
power = power_controller_from_env()
power_port = await _resolve_power_port(power, port, poe_port_override)
except Exception as e:
if power is not None:
await power.close()
if output == "json":
print(json_mod.dumps({"event": "error", "message": str(e)}))
else:
console.print(f"[red]Power controller error:[/red] {e}")
raise typer.Exit(1)

if output == "human":
console.print(f"Agent: [cyan]{agent_path.name}[/cyan] ({len(agent_data)} bytes)")
console.print(f"SPL: {spl_source}")
console.print("\n[yellow]Power-cycle the camera now![/yellow]\n")
if power is None:
console.print("\n[yellow]Power-cycle the camera now![/yellow]\n")

transport = await create_transport(normalize_port_name(port))
if power is not None:
_attach_power_transport(power, transport)
protocol = HiSiliconStandard()
protocol.set_profile(profile)

Expand All @@ -1180,7 +1208,32 @@ def on_progress(e: ProgressEvent) -> None:
elif output == "json":
print(json_mod.dumps({"event": "progress", "message": e.message}), flush=True)

hs = await protocol.handshake(transport, on_progress)
if power is not None:
# The bootrom's catch window is short, so flood 0xAA from the start.
protocol.set_continuous_ack(True)

def on_power_log(message: str) -> None:
on_progress(ProgressEvent(
stage=Stage.POWER_CYCLE, bytes_sent=0, bytes_total=1, message=message,
))

try:
hs = await _power_cycle_into_handshake(
power, power_port, transport,
lambda: protocol.handshake(transport, on_progress),
on_power_log,
)
except PowerControllerError as e:
if output == "json":
print(json_mod.dumps({"event": "error", "message": f"Power cycle failed: {e}"}))
else:
console.print(f"[red]Power cycle failed:[/red] {e}")
await transport.close()
raise typer.Exit(1)
finally:
await power.close()
else:
hs = await protocol.handshake(transport, on_progress)
if not hs.success:
if output == "json":
print(json_mod.dumps({"event": "error", "message": "Handshake failed"}))
Expand Down Expand Up @@ -1235,6 +1288,112 @@ def on_progress(e: ProgressEvent) -> None:
await transport.close()


async def _resolve_power_port(
power: PowerController, serial_port: str, poe_port_override: str,
) -> str:
"""The controller port that powers the camera on ``serial_port``.

Only RouterOS addresses ports by name: ``--poe-port`` wins, otherwise
the interface whose comment names the device (``/dev/uart-<NAME>`` ->
``<NAME>``). Single-outlet controllers ignore the port, so ``""``.
"""
from pathlib import Path

from defib.power.routeros import RouterOSController

if not isinstance(power, RouterOSController):
return ""
if poe_port_override:
return poe_port_override
label = Path(serial_port).name.removeprefix("uart-")
return await power.find_port_by_comment(label)


def _attach_power_transport(power: PowerController, transport: Transport) -> None:
"""Let Vectis pulse reset over the UART connection defib already holds."""
from defib.power.vectis import VectisController
from defib.transport.rfc2217 import Rfc2217Transport

if isinstance(power, VectisController) and isinstance(transport, Rfc2217Transport):
power.attach_transport(transport)


async def _power_cycle_into_handshake(
power: PowerController,
power_port: str,
transport: Transport,
start_handshake: Callable[[], Coroutine[Any, Any, HandshakeResult]],
log: Callable[[str], None],
off_duration: float = 3.0,
handshake_timeout: float = 15.0,
attempts: int = 2,
proactive: bool = False,
) -> HandshakeResult:
"""Power the device off, start the handshake, then power it back on.

The handshake starts while the device is still off: the running OS
is dead by then, so nothing on the line can be mistaken for bootrom
markers, and the blaster is already on the wire when the bootrom's
catch window opens. This holds however long the controller takes
to answer ``power_on`` — cloud-backed relays can lag by seconds.

Pulse-only controllers (``supports_independent_power`` False) cannot
hold the device off, so the order follows RecoverySession: a
``proactive`` handshake (V500, CV6xx: the magic must already be on the
wire when the bootrom wakes) starts before the pulse; a reactive one
(HiSilicon standard, which counts 0x20 markers that a running OS could
also print) starts after it.

A handshake that does not complete within ``handshake_timeout`` is
retried with a fresh power cycle, up to ``attempts`` times in all.
Power-controller failures propagate as ``PowerControllerError``.
"""
import asyncio

from defib.recovery.events import HandshakeResult

hs = HandshakeResult(success=False, message="no attempt made")
for attempt in range(1, attempts + 1):
suffix = f" (attempt {attempt}/{attempts})" if attempt > 1 else ""
if power.supports_independent_power:
log(f"Powering off via {power.name()}{suffix}...")
await power.power_off(power_port)
await asyncio.sleep(off_duration)
pulse_first = not power.supports_independent_power and not proactive
if pulse_first:
log(f"Power-cycling via {power.name()}{suffix}...")
await power.power_cycle(power_port)
await transport.flush_input()
task = asyncio.create_task(start_handshake())
# Yield so the first frames reach the wire before power returns.
await asyncio.sleep(0.05)
try:
if power.supports_independent_power:
log("Powering on...")
await power.power_on(power_port)
elif not pulse_first:
log(f"Power-cycling via {power.name()}{suffix}...")
await power.power_cycle(power_port)
except BaseException:
task.cancel()
try:
await task
except BaseException:
pass
raise
try:
hs = await asyncio.wait_for(task, timeout=handshake_timeout)
except asyncio.TimeoutError:
hs = HandshakeResult(
success=False,
message=f"no bootrom response within {handshake_timeout:.0f}s of power-on",
)
if hs.success:
return hs
log(f"Handshake failed: {hs.message}")
return hs


async def _agent_upload_cv6xx(
*,
chip: str,
Expand Down Expand Up @@ -2159,7 +2318,7 @@ def install(
help="Explicit U-Boot artifact override",
),
port: str = typer.Option("/dev/ttyUSB0", "-p", "--port", help="Serial device (/dev/ttyUSB0), tcp://host:port, rfc2217://host:port, or socket:///path"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via PoE"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via the controller selected by DEFIB_POWER_TYPE"),
poe_port_override: str = typer.Option("", "--poe-port", help="Explicit MikroTik ether port (e.g. ether3) — overrides comment-based auto-discovery. Requires --power-cycle."),
nic: str = typer.Option("", "--nic", help="Network interface for TFTP (auto-detect if empty)"),
host_ip: str = typer.Option("192.168.1.10", "--host-ip", help="IP to assign to host NIC for TFTP"),
Expand Down Expand Up @@ -2266,7 +2425,7 @@ def restore(
host_ip: str = typer.Option("", "--host-ip", help="Host IP for TFTP (auto-detect if empty)"),
device_ip: str = typer.Option("", "--device-ip", help="Device IP in U-Boot"),
nic: str = typer.Option("", "--nic", help="Network interface for TFTP"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via PoE"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via the controller selected by DEFIB_POWER_TYPE"),
poe_port_override: str = typer.Option("", "--poe-port", help="Explicit MikroTik ether port (e.g. ether3) — overrides comment-based auto-discovery. Requires --power-cycle."),
tftp_via: str = typer.Option(
"auto", "--tftp-via",
Expand Down
5 changes: 5 additions & 0 deletions src/defib/power/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class PowerController(ABC):
smart PDU outlet, relay board).
"""

#: Whether ``power_off`` and ``power_on`` work on their own. Controllers
#: that can only pulse (Vectis) set this False and support only
#: ``power_cycle``.
supports_independent_power: bool = True

@classmethod
@abstractmethod
def name(cls) -> str:
Expand Down
12 changes: 11 additions & 1 deletion src/defib/power/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def power_controller_from_env() -> PowerController:
configured via ``DEFIB_VECTIS_*``.
- ``DEFIB_POWER_TYPE=rack``: rack pod HTTP API
(``~/git/rack`` ESP32-S3 spinoff), configured via ``DEFIB_RACK_*``.
- ``DEFIB_POWER_TYPE=tasmota``: Tasmota smart plug ``/cm?cmnd=`` API,
configured via ``DEFIB_TASMOTA_*``.
- ``DEFIB_POWER_TYPE=http``: generic relay driven by two GET URLs,
configured via ``DEFIB_HTTP_POWER_*``.

Raises:
PowerControllerError: if the type is unknown or required env
Expand All @@ -31,7 +35,13 @@ def power_controller_from_env() -> PowerController:
if kind == "rack":
from defib.power.rack import RackController
return RackController.from_env()
if kind == "tasmota":
from defib.power.tasmota import TasmotaController
return TasmotaController.from_env()
if kind == "http":
from defib.power.http import HttpRelayController
return HttpRelayController.from_env()
raise PowerControllerError(
f"Unknown DEFIB_POWER_TYPE: {kind!r} "
"(expected 'routeros', 'vectis', or 'rack')"
"(expected 'routeros', 'vectis', 'rack', 'tasmota', or 'http')"
)
Loading
Loading