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
117 changes: 117 additions & 0 deletions docs/API_EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,120 @@ The response is organized into different categories based on device state:
- `stage`: "Serving"
- Contains beer information
- Temperature maintained at serving temperature

---

## MQTT-over-WebSocket

`pymbrewclient` supports real-time device telemetry over MQTT-over-WebSocket using
`create_mqtt_client()`. The underlying broker is `broker.minibrew.io:15675/ws` (TLS).

> **Security warning:** The MiniBrew REST API token is used as the MQTT
> password. Do **not** log the `MqttClient` object, its `repr()`, or any
> callback data in contexts where credentials could be exposed.

### Connection example

```python
from pymbrewclient import BreweryClient

client = BreweryClient(username="you@example.com", *****)

with client.create_mqtt_client() as mqtt:
mqtt.on_connected(lambda: print("Connected"))
mqtt.on_disconnected(lambda: print("Disconnected"))
mqtt.on_reconnecting(lambda: print("Reconnecting..."))
mqtt.on_error(lambda err: print(f"Error: {err}"))

mqtt.subscribe_device_logs("7391Q4827-5NZC8R2M")

import time
time.sleep(30)
```

### Raw-message example

```python
from pymbrewclient.mqtt.models import MqttMessage

with client.create_mqtt_client() as mqtt:
def handle(msg: MqttMessage) -> None:
print(msg.topic, msg.received_at, msg.payload.hex())

mqtt.on_message(handle)
mqtt.subscribe_device_logs("7391Q4827-5NZC8R2M")
import time; time.sleep(30)
```

### Decoded telemetry example

```python
from pymbrewclient.mqtt.models import DeviceLogMessage

with client.create_mqtt_client() as mqtt:
def handle_log(msg: DeviceLogMessage) -> None:
if msg.decode_error:
print(f"Decode error: {msg.decode_error}")
return
print(f"Session {msg.session_id}")
print(f"Process state: {msg.process_state}")
print(f"Current temp: {msg.current_temperature}°C")
print(f"Target temp: {msg.target_temperature}°C")
print(f"Wi-Fi RSSI: {msg.wifi_rssi_dbm} dBm")
if msg.next_action_at:
print(f"Next action: {msg.next_action_at.isoformat()}")
for measurement_id, value in msg.measurements.items():
print(f"Measurement {measurement_id}: {value}")

mqtt.on_device_log(handle_log)
mqtt.subscribe_device_logs("7391Q4827-5NZC8R2M")
import time; time.sleep(30)
```

### Limitations: protobuf schema

MiniBrew's official protobuf schema (`minibrew/minibrew-protobuf`) is a private
repository that is not publicly accessible. The fields exposed directly on
`DeviceLogMessage` were reconstructed from captured device traffic and compared
with the MiniBrew REST API response structure.

Unknown nested state values are exposed through `DeviceLogMessage.state_fields`.
Confirmed measurement ID 24 is also exposed as `DeviceLogMessage.wifi_rssi_dbm`;
all readings, including measurements whose semantics remain unconfirmed, stay
available by numeric ID in `DeviceLogMessage.measurements`. The raw `payload`
bytes are always preserved in `MqttMessage.payload` and
`DeviceLogMessage.payload`. To inspect a live message:

```bash
protoc --decode_raw < captured_payload.bin
```

Live MQTT messages contain an envelope around the telemetry message.
`DeviceLogMessage.raw_fields` exposes that envelope, while
`DeviceLogMessage.telemetry_fields` exposes the nested telemetry fields.
Telemetry field 26 is the countdown in seconds to the next required action;
`DeviceLogMessage.next_action_at` adds it to the device timestamp and returns a
timezone-aware UTC datetime. Unconfirmed fields, including telemetry fields 8
and 30, remain available without speculative semantic names.

The CLI prints curated decoded telemetry by default, excluding the binary
payload, numeric measurement map, and protobuf field maps. Confirmed named
measurements such as `wifi_rssi_dbm` remain visible:

```bash
pymbrewclient watch-device-logs \
--username you@example.com \
--password 'your-password' \
--serial 7391Q4827-5NZC8R2M
```

Add `--debug` to include the complete numeric `measurements` map, raw payload,
`raw_fields`, `telemetry_fields`, and `state_fields`:

```bash
pymbrewclient watch-device-logs \
--username you@example.com \
--password 'your-password' \
--serial 7391Q4827-5NZC8R2M \
--debug
```
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ classifiers = [
dependencies = [
"pydantic>=1.10.17,<3",
"requests>=2.32.3,<3",
"typer>=0.12.5,<1",
"typer>=0.16.0,<1",
"rich>=13.9.4,<16",
"paho-mqtt>=2.0.0,<3",
]
dynamic = ["version"]

Expand Down
8 changes: 7 additions & 1 deletion src/pymbrewclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,23 @@
#
# Disclaimer: This software is an independent project and is not affiliated with, endorsed by, or associated with MiniBrew. MiniBrew's trademarks, logos, API, and other intellectual property are owned by MiniBrew and are not included in this software. Users are responsible for complying with MiniBrew's terms of service when using this software.
from .client import BreweryClient, BreweryClientError, DeviceLookupError
from .rest.models import Device, TokenResponse, ApiResponse, BreweryOverview, Session, DeviceDetails, Beer
from .mqtt.client import MqttClient
from .mqtt.models import DeviceLogMessage, MqttMessage
from .rest.models import Device, TokenResponse, ApiResponse, BreweryOverview, Session, DeviceDetails, Beer, UserProfile

__all__ = [
"BreweryClient",
"BreweryClientError",
"DeviceLookupError",
"MqttClient",
"MqttMessage",
"DeviceLogMessage",
"Device",
"TokenResponse",
"ApiResponse",
"BreweryOverview",
"Session",
"DeviceDetails",
"Beer",
"UserProfile",
]
93 changes: 93 additions & 0 deletions src/pymbrewclient/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from datetime import datetime
import json
import logging
import threading
from typing import Annotated

import typer
Expand All @@ -46,6 +47,7 @@
from rich.pretty import Pretty

from pymbrewclient.client import BreweryClient, BreweryClientError
from pymbrewclient.mqtt.models import DeviceLogMessage
from pymbrewclient.rest.client import RestApiClient
from pymbrewclient.rest.models import Device, datetime_to_api_string, format_duration

Expand Down Expand Up @@ -74,6 +76,8 @@ def serialize_output(data: object) -> object:
return serialize_output(data.dict())
if isinstance(data, datetime):
return datetime_to_api_string(data)
if isinstance(data, bytes):
return data.hex()
if isinstance(data, Device):
return data.to_dict()
if is_dataclass(data):
Expand All @@ -99,6 +103,29 @@ def print_output(data: BaseModel | dict | list, format: str) -> None:
rich_print(Pretty(serialized_data))


def curate_device_log_output(msg: DeviceLogMessage) -> dict[str, object]:
"""Return useful decoded telemetry without verbose protobuf internals."""
curated = {
"topic": msg.topic,
"received_at": msg.received_at,
"device_uuid": msg.device_uuid,
"sequence_number": msg.sequence_number,
"session_id": msg.session_id,
"device_timestamp": msg.device_timestamp,
"current_state": msg.current_state,
"process_type": msg.process_type,
"process_state": msg.process_state,
"user_action": msg.user_action,
"current_temperature": msg.current_temperature,
"target_temperature": msg.target_temperature,
"wifi_rssi_dbm": msg.wifi_rssi_dbm,
"seconds_until_next_action": msg.seconds_until_next_action,
"next_action_at": msg.next_action_at,
"decode_error": msg.decode_error,
}
return {key: value for key, value in curated.items() if value is not None}


def setup_logging(level: str) -> None:
"""Configure standard logging with the specified log level."""
logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO), format="%(message)s", force=True)
Expand Down Expand Up @@ -328,5 +355,71 @@ def process_estimate(
raise typer.Exit(code=1)


@app.command()
def watch_device_logs(
username: str = typer.Option(..., "--username", help="The username for authentication."),
password: str = typer.Option(..., "--password", help="The password for authentication."),
base_url: str = typer.Option(base_url, "--base-url", help="The base URL for the API."),
serials: Annotated[
list[str],
typer.Option("--serial", help="Device serial number to watch (can be specified multiple times)."),
] = [],
output_format: str = typer.Option("pretty", "--format", help="Output format: pretty or json."),
duration: int | None = typer.Option(
None, "--duration", help="Stop automatically after this many seconds (default: run until Ctrl+C)."
),
debug: bool = typer.Option(
False,
"--debug",
help="Include the raw payload and all decoded protobuf fields.",
),
) -> None:
"""Stream real-time MQTT device telemetry logs for one or more devices."""
if not serials:
typer.echo("Error: at least one --serial is required.")
raise typer.Exit(code=1)

try:
client = initialize_brewery_client(base_url, username, password)
stop_event = threading.Event()

with client.create_mqtt_client() as mqtt:

def on_connected() -> None:
typer.echo(f"Connected. Watching: {', '.join(serials)}")

def on_disconnected() -> None:
typer.echo("Disconnected.")

def on_error(exc: Exception) -> None:
typer.echo(f"MQTT error: {exc}")

def on_device_log(msg: DeviceLogMessage) -> None:
output = msg if debug else curate_device_log_output(msg)
print_output(output, output_format.lower())

mqtt.on_connected(on_connected)
mqtt.on_disconnected(on_disconnected)
mqtt.on_error(on_error)
mqtt.on_device_log(on_device_log)

for serial in serials:
mqtt.subscribe_device_logs(serial)

try:
stop_event.wait(timeout=duration)
except KeyboardInterrupt:
typer.echo("\nStopping...")

except BreweryClientError as e:
logger.error(f"Error: {e}")
typer.echo(f"Error: {e}")
raise typer.Exit(code=1)
except Exception as e:
logger.error(f"Error: {e}")
typer.echo(f"Error: {e}")
raise typer.Exit(code=1)


if __name__ == "__main__":
app()
42 changes: 41 additions & 1 deletion src/pymbrewclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from datetime import datetime

from pymbrewclient.rest.client import RestApiClient
from pymbrewclient.rest.models import BreweryOverview, Device, Session, TokenResponse
from pymbrewclient.rest.models import BreweryOverview, Device, Session, TokenResponse, UserProfile


class BreweryClientError(ValueError):
Expand Down Expand Up @@ -127,3 +127,43 @@ def get_process_estimate_remaining_seconds(
"""Return a locally calculated remaining duration for a selected device."""
device = self.get_device(device_uuid=device_uuid, session_id=session_id)
return device.process_estimate_remaining_seconds

def get_user_profile(self) -> UserProfile:
"""
Fetch and return the authenticated user's profile.

The user UUID is required to construct MQTT credentials.

:return: A UserProfile object containing the user UUID and profile data.
"""
return self.client.get_user_profile()

def create_mqtt_client(self) -> "MqttClient":
"""
Create and return a configured MQTT-over-WebSocket client.

The current REST API token is reused as the MQTT password. A fresh
token is obtained if the current one has expired. A new random
``client_uuid`` is generated for each call, so multiple independent
MQTT clients can be created from the same :class:`BreweryClient`.

.. warning::

The API token is used as the MQTT password. Do not log the
returned :class:`~pymbrewclient.mqtt.MqttClient` instance in
contexts that would reveal sensitive credentials.

:return: A ready-to-connect :class:`~pymbrewclient.mqtt.MqttClient`.
"""
from pymbrewclient.mqtt.client import MqttClient

self.client._ensure_token()
profile = self.get_user_profile()
return MqttClient(api_token=self.client.token, user_uuid=profile.uuid)


# Local alias for the forward reference in create_mqtt_client's type hint
try:
from pymbrewclient.mqtt.client import MqttClient # noqa: E402, F401
except ImportError:
pass
44 changes: 44 additions & 0 deletions src/pymbrewclient/mqtt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# "Commons Clause" License Condition v1.0
#
# The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
#
# Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
#
# For purposes of the foregoing, "Sell" means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
#
# Software: pymbrewclient
# License: MIT License
# Licensor: Stuart Pearson
#
#
# MIT License
#
# Copyright (c) 2024 Stuart Pearson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Disclaimer: This software is an independent project and is not affiliated with, endorsed by, or associated with MiniBrew. MiniBrew's trademarks, logos, API, and other intellectual property are owned by MiniBrew and are not included in this software. Users are responsible for complying with MiniBrew's terms of service when using this software.
from .client import MqttClient
from .models import DeviceLogMessage, MqttMessage

__all__ = [
"MqttClient",
"MqttMessage",
"DeviceLogMessage",
]
Loading