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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ uv run socketry set light high --wait
uv run socketry set light sos
uv run socketry get light
uv run socketry set light off

# HTTP: sharing QR code generation
uv run socketry share-qr
uv run socketry share-qr --json
```
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# socketry

[![CI](https://github.com/jlopez/socketry/actions/workflows/ci.yml/badge.svg)](https://github.com/jlopez/socketry/actions/workflows/ci.yml)
![Coverage](https://img.shields.io/badge/coverage-76%25-yellowgreen)
![Coverage](https://img.shields.io/badge/coverage-77%25-yellowgreen)
![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)

Python API and CLI for controlling Jackery portable power stations.
Expand Down Expand Up @@ -155,6 +155,19 @@ Writable settings:
| `sfc` | on / off | Super fast charge |
| `ups` | on / off | UPS mode |

### Sharing devices (`share-qr`)

```bash
# Generate a sharing QR code (expires in 5 minutes)
socketry share-qr

# JSON output
socketry share-qr --json
```

Another Jackery user can scan the QR code in their Jackery app to gain access
to your shared devices.

### Watching live updates (`watch`)

```bash
Expand Down Expand Up @@ -206,6 +219,10 @@ async def main():
await client.set_property("ac", "on")
result = await client.set_property("light", "high", wait=True)

# Generate a sharing QR code
qr = await client.generate_share_qrcode()
print(f"QR Code ID: {qr['qrCodeId']}")

# Subscribe to real-time updates
async def on_update(device_sn: str, properties: dict) -> None:
print(f"{device_sn}: {properties}")
Expand Down
15 changes: 14 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ You need a two-step lookup:
| `/device/bind/remove` | POST | `bindUserId`, `devId` | Remove one device from share |
| `/device/bind/removeAll` | POST | `bindUserId`, `level` | Remove all devices from share |
| `/device/accept_bind` | POST | | Accept a sharing invitation |
| `/device/bind/qrcode` | | | QR code for sharing |
| `/device/bind/qrcode` | GET | `userId` (query) | Generate sharing QR code (valid 5 min) |
| `/device/bind/nickname` | | | Change device nickname |

**Share relationship response** (`GET /device/bind/shared`):
Expand Down Expand Up @@ -157,6 +157,19 @@ You need a two-step lookup:
**Shared device list response** (`POST /device/bind/share/list`):
Returns array of `{devId, devSn, devModel, devName, devNickname, icon}`.

**Sharing QR code response** (`GET /device/bind/qrcode?userId={userId}`):
```json
{
"code": 0,
"data": {
"qrCodeId": "db0c0e9d78f44550992756e643feab95",
"userId": 1947774732782673920
}
}
```
The QR code content is the JSON-serialized `data` object. Another user scans this in the
Jackery app to accept the share. The code expires after 5 minutes.

#### Auth Endpoints

| Endpoint | Method | Purpose |
Expand Down
2 changes: 2 additions & 0 deletions src/socketry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Client,
Device,
MqttError,
ShareQrCode,
SocketryError,
Subscription,
)
Expand All @@ -18,6 +19,7 @@
"MqttError",
"PROPERTIES",
"Setting",
"ShareQrCode",
"SocketryError",
"Subscription",
]
23 changes: 23 additions & 0 deletions src/socketry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,29 @@ def devices() -> None:
typer.echo("\n * = selected. Use `socketry select <index>` to change.")


@app.command("share-qr")
def share_qr(
as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
) -> None:
"""Generate a sharing QR code.

\b
Another Jackery user can scan this QR code (in the Jackery app)
to gain access to your shared devices. The code expires after 5 minutes.
"""
client = _ensure_client()
data = asyncio.run(client.generate_share_qrcode())
if not data:
typer.echo("Failed to generate QR code.", err=True)
raise typer.Exit(1)
if as_json:
_print_json(data)
else:
typer.echo(f"QR Code ID: {data.get('qrCodeId', 'unknown')}")
typer.echo(f"User ID: {data.get('userId', 'unknown')}")
typer.echo("\nExpires in 5 minutes.")


@app.command()
def select(index: int) -> None:
"""Select the active device by index (see ``devices`` for the list)."""
Expand Down
60 changes: 60 additions & 0 deletions src/socketry/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import ssl
import time
from collections.abc import Awaitable, Callable
from typing import TypedDict

import aiohttp
import aiomqtt
Expand All @@ -55,6 +56,13 @@
_TOKEN_EXPIRY_BUFFER = 3600 # seconds before expiry to trigger proactive refresh


class ShareQrCode(TypedDict):
"""Response from the sharing QR code endpoint."""

qrCodeId: str
userId: int


class SocketryError(Exception):
"""Base exception for all socketry errors."""

Expand Down Expand Up @@ -466,6 +474,32 @@ def device(self, index_or_sn: int | str) -> Device:
return Device(self, dev)
raise KeyError(f"No device with SN '{index_or_sn}'.")

# ------------------------------------------------------------------
# Sharing
# ------------------------------------------------------------------

async def generate_share_qrcode(self) -> ShareQrCode:
"""Generate a sharing QR code.

Returns a :class:`ShareQrCode` dict containing ``qrCodeId`` and
``userId``. The QR code is valid for 5 minutes. Another Jackery
user can scan it to gain access to your shared devices.
"""
if not self.user_id:
raise ValueError("No userId. Call login() first.")
await self._ensure_fresh_token()
async with aiohttp.ClientSession() as session:
try:
return await _generate_share_qrcode(self.token, self.user_id, session)
except TokenExpiredError:
self._creds["tokenExp"] = 0
await self._ensure_fresh_token()
return await _generate_share_qrcode(self.token, self.user_id, session)
except _SessionInvalidatedError:
stale = self.token
await self._relogin(stale)
return await _generate_share_qrcode(self.token, self.user_id, session)

# ------------------------------------------------------------------
# Status (HTTP)
# ------------------------------------------------------------------
Expand Down Expand Up @@ -976,6 +1010,32 @@ async def _fetch_device_properties(
return body.get("data") or {}


async def _generate_share_qrcode(
token: str, user_id: str, session: aiohttp.ClientSession
) -> ShareQrCode:
"""Generate a sharing QR code via HTTP API.

Returns a :class:`ShareQrCode` dict containing ``qrCodeId`` and
``userId``. The QR code is valid for 5 minutes.
"""
auth_headers = {**APP_HEADERS, "token": token}
async with session.get(
f"{API_BASE}/device/bind/qrcode",
params={"userId": user_id},
headers=auth_headers,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
resp.raise_for_status()
body = await resp.json()
if body.get("code") == 10402:
raise TokenExpiredError("Token expired (10402)")
if body.get("code") != 0:
msg = body.get("msg", "unknown error")
raise _SessionInvalidatedError(f"QR code generation failed: {msg}")
data: ShareQrCode = body.get("data") or {} # type: ignore[assignment]
return data


# ---------------------------------------------------------------------------
# MQTT
# ---------------------------------------------------------------------------
Expand Down
44 changes: 44 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, patch

Expand Down Expand Up @@ -157,3 +158,46 @@ def test_shows_reconnecting_notice_on_disconnect(self):

assert result.exit_code == 0
assert "Disconnected, reconnecting" in result.output


class TestShareQrCommand:
def test_displays_qr_code_info(self):
mock_data = {"qrCodeId": "abc123def456", "userId": 9876543210}

with (
patch.object(Client, "from_saved", return_value=Client(MOCK_CREDS)),
patch.object(
Client, "generate_share_qrcode", new_callable=AsyncMock, return_value=mock_data
),
):
result = runner.invoke(app, ["share-qr"])

assert result.exit_code == 0
assert "abc123def456" in result.output
assert "9876543210" in result.output
assert "5 minutes" in result.output

def test_json_output(self):
mock_data = {"qrCodeId": "abc123def456", "userId": 9876543210}

with (
patch.object(Client, "from_saved", return_value=Client(MOCK_CREDS)),
patch.object(
Client, "generate_share_qrcode", new_callable=AsyncMock, return_value=mock_data
),
):
result = runner.invoke(app, ["share-qr", "--json"])

assert result.exit_code == 0
parsed = json.loads(result.output)
assert parsed["qrCodeId"] == "abc123def456"

def test_empty_response(self):
with (
patch.object(Client, "from_saved", return_value=Client(MOCK_CREDS)),
patch.object(Client, "generate_share_qrcode", new_callable=AsyncMock, return_value={}),
):
result = runner.invoke(app, ["share-qr"])

assert result.exit_code == 1
assert "Failed to generate QR code" in result.output
Loading
Loading