diff --git a/src/defib/power/routeros.py b/src/defib/power/routeros.py index c029dc4..a6a813f 100644 --- a/src/defib/power/routeros.py +++ b/src/defib/power/routeros.py @@ -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)", @@ -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() diff --git a/src/defib/recovery/session.py b/src/defib/recovery/session.py index ba8c43f..7ad157f 100644 --- a/src/defib/recovery/session.py +++ b/src/defib/recovery/session.py @@ -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", @@ -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, )