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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ All entities are created automatically per device.
| Button | Stop | Abort the running job |
| Button | Pause | Pause the running job |
| Button | Resume | Resume a paused job |
| Button | Create debug export | Write the current raw protocol ring buffer to a downloadable JSON file *(diagnostic)* |

---

Expand Down Expand Up @@ -249,6 +250,22 @@ runs without interruption.
| Job card shows no jobs | No jobs saved yet | Save a job after running one in XCS |
| Start button does nothing | Laser not connected via WebSocket | Check connection sensor; restart integration if needed |

### Debug export

For protocol-level troubleshooting, use the device button
**Create debug export**. It writes a JSON snapshot under Home
Assistant's `/local/xtool_s1_debug/` directory and opens a persistent
notification with a direct download link.

Each export contains:

- The current coordinator mode and parsed device state
- The last 250 raw HTTP / WebSocket frames from an in-memory ring buffer
- Binary WebSocket payloads as hex so firmware quirks stay visible

The ring buffer rotates automatically in memory, so normal operation
does not spam the Home Assistant log.

---

## Example automations
Expand Down
68 changes: 67 additions & 1 deletion custom_components/xtool_s1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@
from __future__ import annotations

import asyncio
from collections import deque
from collections.abc import Callable
import contextlib
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
import ipaddress
import json
import logging
Expand All @@ -72,6 +74,7 @@
HTTP_PORT,
MCODE_PAUSE,
MCODE_RESUME,
RAW_PROTOCOL_RING_BUFFER_SIZE,
SCAN_MAX_HOSTS,
UDP_DISCOVERY_BROADCAST_ADDR,
UDP_DISCOVERY_PORT,
Expand Down Expand Up @@ -222,6 +225,23 @@ class XToolS1State:
extras: dict[str, Any] = field(default_factory=dict)


@dataclass(slots=True, frozen=True)
class RawProtocolFrame:
"""Single raw HTTP/WS frame captured for diagnostics export."""

captured_at: str
direction: str
payload_type: str
payload_text: str | None = None
payload_hex: str | None = None
payload_length: int | None = None


def _utcnow_iso() -> str:
"""Return a compact UTC timestamp for diagnostics payloads."""
return datetime.now(UTC).isoformat().replace("+00:00", "Z")


# --- parser helpers ---------------------------------------------------------


Expand Down Expand Up @@ -404,6 +424,9 @@ def __init__(
self._state = XToolS1State()
self._subscribers: list[StateCallback] = []
self._lock = asyncio.Lock()
self._raw_protocol_frames: deque[RawProtocolFrame] = deque(
maxlen=RAW_PROTOCOL_RING_BUFFER_SIZE
)

# -- properties -----------------------------------------------------

Expand All @@ -427,6 +450,21 @@ def state(self) -> XToolS1State:
"""Return the latest immutable state snapshot."""
return self._state

@property
def raw_protocol_frames(self) -> list[dict[str, Any]]:
"""Return a JSON-ready copy of the raw protocol ring buffer."""
return [
{
"captured_at": frame.captured_at,
"direction": frame.direction,
"payload_type": frame.payload_type,
"payload_text": frame.payload_text,
"payload_hex": frame.payload_hex,
"payload_length": frame.payload_length,
}
for frame in self._raw_protocol_frames
]

# -- subscriptions --------------------------------------------------

def on_state(self, callback: StateCallback) -> Callable[[], None]:
Expand All @@ -439,6 +477,28 @@ def _unsubscribe() -> None:

return _unsubscribe

def _record_raw_protocol(self, direction: str, payload: str | bytes) -> None:
"""Append raw HTTP/WS traffic to the in-memory diagnostics ring buffer."""
if isinstance(payload, bytes):
self._raw_protocol_frames.append(
RawProtocolFrame(
captured_at=_utcnow_iso(),
direction=direction,
payload_type="bytes",
payload_hex=payload.hex(),
payload_length=len(payload),
)
)
return
self._raw_protocol_frames.append(
RawProtocolFrame(
captured_at=_utcnow_iso(),
direction=direction,
payload_type="text",
payload_text=payload,
)
)

# -- lifecycle ------------------------------------------------------

async def connect(self) -> None:
Expand Down Expand Up @@ -643,6 +703,7 @@ async def send_command_http(self, gcode: str | list[str]) -> None:
body_str = "".join(
(line if line.endswith("\n") else line + "\n") for line in gcode
)
self._record_raw_protocol("http send /cmd", body_str)
try:
async with self._session.post(
f"{self._http_base}/cmd",
Expand Down Expand Up @@ -729,7 +790,9 @@ async def _fetch_system_action(self, action: str) -> str | None:
) as resp:
if resp.status != 200:
return None
text = (await resp.text()).strip()
text = await resp.text()
self._record_raw_protocol(f"http recv /system?action={action}", text)
text = text.strip()
return text or None
except (TimeoutError, ClientError, OSError) as err:
_LOGGER.debug("S1 %s /system?action=%s failed: %s", self._host, action, err)
Expand Down Expand Up @@ -762,6 +825,7 @@ async def _send(self, text: str) -> None:
if not self.connected:
raise XToolS1ConnectionError(f"S1 {self._host} not connected")
assert self._ws is not None # nosec - guarded by `connected` above
self._record_raw_protocol("ws send", text)
try:
await asyncio.wait_for(self._ws.send_str(text), timeout=_SEND_TIMEOUT)
except (TimeoutError, ClientError, OSError) as err:
Expand Down Expand Up @@ -800,6 +864,7 @@ def _handle_binary_frame(self, payload: bytes) -> None:
body. We pull out the printable section and feed it back into
the regular text-frame handler.
"""
self._record_raw_protocol("ws recv binary", payload)
# latin-1 always succeeds (every byte maps to a codepoint), so
# there's no UnicodeDecodeError path to guard.
decoded = payload.decode("latin-1")
Expand All @@ -811,6 +876,7 @@ def _handle_binary_frame(self, payload: bytes) -> None:

def _handle_frame(self, text: str) -> None:
"""Parse a single text frame and emit a state update if anything changed."""
self._record_raw_protocol("ws recv text", text)
text = text.strip()
if not text:
return
Expand Down
64 changes: 59 additions & 5 deletions custom_components/xtool_s1/button.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Button platform for the xTool S1 — job-control actions over HTTP.
"""Button platform for the xTool S1 — job control plus debug export.

All three buttons go through the HTTP ``POST /cmd`` gateway so they
keep working even when the XCS desktop app is hammering the
Expand All @@ -16,22 +16,32 @@
dims the fill light to ``M15 A1 S0``.
* **Resume** — `M22 S2`. Verified: the device resumes immediately
without requiring a physical button press.
* **Create debug export** — writes the current in-memory raw protocol
ring buffer to a JSON file under HA's `/local/` directory and
raises a persistent notification with a download link.
"""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass

from homeassistant.components import persistent_notification
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .api import XToolS1Client, XToolS1ConnectionError
from .const import BUTTON_PAUSE, BUTTON_RESUME, BUTTON_STOP
from .const import (
BUTTON_CREATE_DEBUG_EXPORT,
BUTTON_PAUSE,
BUTTON_RESUME,
BUTTON_STOP,
)
from .coordinator import XToolS1ConfigEntry, XToolS1Coordinator
from .entity import XToolS1HttpEntity
from .entity import XToolS1Entity, XToolS1HttpEntity

PARALLEL_UPDATES = 1

Expand Down Expand Up @@ -69,9 +79,11 @@ async def async_setup_entry(
) -> None:
"""Set up the xTool S1 control buttons from a config entry."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
entities: list[ButtonEntity] = [
XToolS1Button(coordinator, description) for description in BUTTON_DESCRIPTIONS
)
]
entities.append(XToolS1DebugExportButton(coordinator))
async_add_entities(entities)


class XToolS1Button(XToolS1HttpEntity, ButtonEntity):
Expand All @@ -96,3 +108,45 @@ async def async_press(self) -> None:
raise HomeAssistantError(
f"Failed to send {self.entity_description.key} command: {err}"
) from err


class XToolS1DebugExportButton(XToolS1Entity, ButtonEntity):
"""Diagnostic button that writes the raw protocol ring buffer to JSON."""

_attr_translation_key = BUTTON_CREATE_DEBUG_EXPORT
_attr_entity_category = EntityCategory.DIAGNOSTIC

def __init__(self, coordinator: XToolS1Coordinator) -> None:
super().__init__(coordinator)

@property
def extra_state_attributes(self) -> dict[str, str | bool]:
"""Expose the latest export link directly on the entity."""
attributes = dict(super().extra_state_attributes)
if self.coordinator.last_debug_export_at is not None:
attributes["generated_at"] = self.coordinator.last_debug_export_at
if self.coordinator.last_debug_export_url is not None:
attributes["download_url"] = self.coordinator.last_debug_export_url
return attributes

async def async_press(self) -> None:
"""Write a JSON debug export and show a download link."""
try:
download_url = await self.coordinator.async_create_debug_export()
except OSError as err:
raise HomeAssistantError(f"Failed to create debug export: {err}") from err

self.async_write_ha_state()
persistent_notification.async_create(
self.hass,
message=(
"A new xTool S1 debug export is ready.\n\n"
f"[Download JSON export]({download_url})\n\n"
"The file contains the current state snapshot plus the in-memory "
"raw HTTP/WebSocket ring buffer."
),
title="xTool S1 debug export ready",
notification_id=(
f"xtool_s1_debug_export_{self.coordinator.config_entry.entry_id}"
),
)
7 changes: 7 additions & 0 deletions custom_components/xtool_s1/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
# the counters fresh. 1 MB is plenty for the last few boot cycles.
LOGFILE_MAX_SIZE: Final = 1_000_000

# Keep the last N raw HTTP/WS frames in memory for on-demand debug exports.
RAW_PROTOCOL_RING_BUFFER_SIZE: Final = 250

# Limit how many JSON debug exports we keep under HA's /local directory.
DEBUG_EXPORT_KEEP_FILES: Final = 5

# Time to wait for the first M2003 reply during config-flow validation.
CONFIG_FLOW_PROBE_TIMEOUT: Final = 8.0

Expand Down Expand Up @@ -113,6 +119,7 @@
BUTTON_STOP: Final = "stop"
BUTTON_PAUSE: Final = "pause"
BUTTON_RESUME: Final = "resume"
BUTTON_CREATE_DEBUG_EXPORT: Final = "create_debug_export"

# Status enum values reported via the `status` sensor.
# These MUST be lowercase, snake_case, and matched 1:1 in
Expand Down
Loading
Loading