Skip to content

Commit 079ac7c

Browse files
committed
Add support for option lock message
1 parent 62f33e2 commit 079ac7c

File tree

5 files changed

+128
-6
lines changed

5 files changed

+128
-6
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ Tips:
121121
- `await device.refresh()` → hydrate cached state
122122
- `await device.get_location()` → parsed last location
123123
- `await device.fetch_pictures(n)` + `await device.download_photo(item)`
124+
- Commands: `await device.play_sound()`, `await device.take_front_photo()`,
125+
`await device.take_rear_photo()`, `await device.lock(message=None)`,
126+
`await device.wipe(confirm=True)`
127+
128+
### Example: Lock device with a message
129+
130+
```python
131+
import asyncio
132+
from fmd_api import FmdClient, Device
133+
134+
async def main():
135+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
136+
device = Device(client, "alice")
137+
# Optional message is sanitized (quotes/newlines removed, whitespace collapsed)
138+
await device.lock(message="Lost phone. Please call +1-555-1234")
139+
await client.close()
140+
141+
asyncio.run(main())
142+
```
124143

125144
## Testing
126145

docs/MIGRATE_FROM_V1.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
7575
|----|----------------|-------------|-------|
7676
| `await api.take_picture('back')` | `await client.take_picture('back')` | `await device.take_rear_photo()` | Device method preferred |
7777
| `await api.take_picture('front')` | `await client.take_picture('front')` | `await device.take_front_photo()` | Device method preferred |
78+
> Note: `Device.lock(message=None)` now supports passing an optional message string. The server may ignore the
79+
> message if UI or server versions don't yet consume it, but the base lock command will still be executed.
7880
7981
### Bluetooth & Audio Settings
8082

fmd_api/device.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,29 @@ async def download_photo(self, picture_blob_b64: str) -> PhotoResult:
109109
raise OperationError(f"Failed to decode picture blob: {e}") from e
110110

111111
async def lock(self, message: Optional[str] = None, passcode: Optional[str] = None) -> bool:
112-
# The original API supports "lock" command; it does not carry message/passcode in the current client
113-
# Implementation preserves original behavior (sends "lock" command).
114-
# Extensions can append data if server supports it.
115-
return await self.client.send_command("lock")
112+
"""Lock the device, optionally passing a message (and future passcode).
113+
114+
Notes:
115+
- The public web UI may not expose message/passcode yet, but protocol-level
116+
support is expected. We optimistically send a formatted command if a message
117+
is provided: "lock <escaped>".
118+
- Sanitization: collapse whitespace, limit length, and strip unsafe characters.
119+
- If server ignores the payload, the base "lock" still executes.
120+
- Passcode argument reserved for potential future support; currently unused.
121+
"""
122+
base = "lock"
123+
if message:
124+
# Basic sanitization: trim, collapse internal whitespace, remove newlines
125+
sanitized = " ".join(message.strip().split())
126+
# Remove characters that could break command parsing (quotes/backticks/semicolons)
127+
for ch in ['"', "'", "`", ";"]:
128+
sanitized = sanitized.replace(ch, "")
129+
# Cap length to 120 chars to avoid overly long command payloads
130+
if len(sanitized) > 120:
131+
sanitized = sanitized[:120]
132+
if sanitized:
133+
base = f"lock {sanitized}"
134+
return await self.client.send_command(base)
116135

117136
async def wipe(self, confirm: bool = False) -> bool:
118137
if not confirm:

tests/functional/test_commands.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
Commands:
77
ring - Make device ring
8-
lock - Lock device screen
8+
lock [message...] - Lock device screen (optional message)
99
camera <front|back> - Take picture (default: back)
1010
bluetooth <on|off> - Set Bluetooth on/off
1111
dnd <on|off> - Set Do Not Disturb on/off
@@ -53,7 +53,18 @@ async def main():
5353
print(f"Ring command sent: {result}")
5454

5555
elif command == "lock":
56-
result = await client.send_command("lock")
56+
# Optional message; sanitize similar to Device.lock
57+
msg = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else None
58+
cmd = "lock"
59+
if msg:
60+
sanitized = " ".join(msg.strip().split())
61+
for ch in ['"', "'", "`", ";"]:
62+
sanitized = sanitized.replace(ch, "")
63+
if len(sanitized) > 120:
64+
sanitized = sanitized[:120]
65+
if sanitized:
66+
cmd = f"lock {sanitized}"
67+
result = await client.send_command(cmd)
5768
print(f"Lock command sent: {result}")
5869

5970
elif command == "camera":

tests/unit/test_lock_message.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pytest
2+
from aioresponses import aioresponses, CallbackResult
3+
4+
from fmd_api.client import FmdClient
5+
from fmd_api.device import Device
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_device_lock_without_message_sends_plain_lock():
10+
client = FmdClient("https://fmd.example.com")
11+
client.access_token = "token"
12+
13+
class DummySigner:
14+
def sign(self, message_bytes, pad, algo):
15+
return b"\xab" * 64
16+
17+
client.private_key = DummySigner()
18+
19+
await client._ensure_session()
20+
device = Device(client, "test-device")
21+
22+
with aioresponses() as m:
23+
# capture payload via callback
24+
captured = {}
25+
26+
def cb(url, **kwargs):
27+
captured["json"] = kwargs.get("json")
28+
return CallbackResult(status=200, body="OK")
29+
30+
m.post("https://fmd.example.com/api/v1/command", callback=cb)
31+
try:
32+
ok = await device.lock()
33+
assert ok is True
34+
assert captured["json"]["Data"] == "lock"
35+
finally:
36+
await client.close()
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_device_lock_with_message_sanitizes_and_sends():
41+
client = FmdClient("https://fmd.example.com")
42+
client.access_token = "token"
43+
44+
class DummySigner:
45+
def sign(self, message_bytes, pad, algo):
46+
return b"\xab" * 64
47+
48+
client.private_key = DummySigner()
49+
50+
await client._ensure_session()
51+
device = Device(client, "test-device")
52+
53+
with aioresponses() as m:
54+
captured = {}
55+
56+
def cb(url, **kwargs):
57+
captured["json"] = kwargs.get("json")
58+
return CallbackResult(status=200, body="OK")
59+
60+
m.post("https://fmd.example.com/api/v1/command", callback=cb)
61+
try:
62+
ok = await device.lock(" Hello world; \n stay 'safe' \"pls\" ")
63+
assert ok is True
64+
sent = captured["json"]["Data"]
65+
assert sent.startswith("lock ")
66+
# Ensure removed quotes/semicolons/newlines and collapsed spaces
67+
assert '"' not in sent and "'" not in sent and ";" not in sent and "\n" not in sent
68+
assert " " not in sent
69+
assert sent.endswith("Hello world stay safe pls")
70+
finally:
71+
await client.close()

0 commit comments

Comments
 (0)