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
35 changes: 32 additions & 3 deletions src/defib/power/routeros.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,11 @@ async def find_port_by_comment(self, search: str) -> str:
search_lower = search.lower()
for item in items:
comment = item.get("comment", "")
if search_lower in comment.lower():
if not comment:
continue
comment_lower = comment.lower()
# Match if search contains comment or comment contains search
if search_lower in comment_lower or comment_lower in search_lower:
iface_name = item.get("name", "")
logger.info(
"Matched '%s' -> interface %s (comment: %s)",
Expand Down Expand Up @@ -336,18 +340,43 @@ async def _get_poe_out(self, interface_name: str) -> str:
return items[0].get("poe-out", "auto-on")
return "auto-on"

async def _poe_power_cycle(
self, interface_name: str, duration: str = "5s"
) -> None:
"""Use RouterOS native power-cycle command."""
conn = await self._connect()
response = await conn.call(
"/interface/ethernet/poe/power-cycle",
f"=.id={interface_name}",
f"=duration={duration}",
)
for sentence in response:
if sentence[0] == "!trap":
msg = _extract_attr(sentence, "message") or "unknown error"
raise PowerControllerError(
f"power-cycle failed on {interface_name}: {msg}"
)

async def power_off(self, port: str) -> None:
# Save current poe-out mode so power_on can restore it
if port not in self._saved_poe_out:
self._saved_poe_out[port] = await self._get_poe_out(port)
logger.info("PoE OFF: %s on %s", port, self._host)
await self._set_poe(port, "off")

async def power_on(self, port: str) -> None:
restore_mode = self._saved_poe_out.pop(port, "auto-on")
restore_mode = self._saved_poe_out.pop(port, "forced-on")
logger.info("PoE ON: %s on %s (restoring %s)", port, self._host, restore_mode)
await self._set_poe(port, restore_mode)

async def power_cycle(self, port: str, off_duration: float = 5.0) -> None:
"""Power cycle using RouterOS native PoE power-cycle command.

The API call returns immediately; the switch handles the timing.
"""
duration_s = max(int(off_duration), 5)
logger.info("PoE power-cycle: %s on %s (%ds)", port, self._host, duration_s)
await self._poe_power_cycle(port, f"{duration_s}s")

async def close(self) -> None:
if self._conn is not None:
self._conn.close()
Expand Down
24 changes: 5 additions & 19 deletions src/defib/recovery/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,9 @@ async def run(
stage=Stage.POWER_CYCLE, bytes_sent=0, bytes_total=1,
message=f"Power-cycling {self._poe_port}...",
))
try:
await self._power.power_off(self._poe_port)
import asyncio
await asyncio.sleep(3.0)
except Exception as e:
elapsed = (time.monotonic() - start_time) * 1000
if on_log:
on_log(LogEvent(level="error", message=f"Power cycle failed: {e}"))
return RecoveryResult(
success=False,
error=f"Power cycle failed: {e}",
elapsed_ms=elapsed,
)

# Start handshake (flooding 0xAA) BEFORE powering on
# Start handshake (flooding 0xAA) BEFORE power cycle so the
# bootrom sees 0xAA immediately when power returns.
if on_log:
on_log(LogEvent(
level="info",
Expand All @@ -130,19 +118,17 @@ async def run(
handshake_task = asyncio.create_task(
protocol.handshake(transport, on_progress)
)
# Give handshake time to start flooding
await asyncio.sleep(0.3)

try:
await self._power.power_on(self._poe_port)
await self._power.power_cycle(self._poe_port)
except Exception as e:
handshake_task.cancel()
elapsed = (time.monotonic() - start_time) * 1000
if on_log:
on_log(LogEvent(level="error", message=f"Power-on failed: {e}"))
on_log(LogEvent(level="error", message=f"Power cycle failed: {e}"))
return RecoveryResult(
success=False,
error=f"Power-on failed: {e}",
error=f"Power cycle failed: {e}",
elapsed_ms=elapsed,
)

Expand Down
Loading