pyibaby 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyibaby/__init__.py +23 -0
- pyibaby/bridge.py +224 -0
- pyibaby/client.py +192 -0
- pyibaby/cloud.py +150 -0
- pyibaby/crypto.py +110 -0
- pyibaby/pppp.py +217 -0
- pyibaby/protocol.py +318 -0
- pyibaby/rtspd.py +377 -0
- pyibaby/sensors.py +121 -0
- pyibaby-0.1.0.dist-info/METADATA +130 -0
- pyibaby-0.1.0.dist-info/RECORD +14 -0
- pyibaby-0.1.0.dist-info/WHEEL +5 -0
- pyibaby-0.1.0.dist-info/licenses/LICENSE +373 -0
- pyibaby-0.1.0.dist-info/top_level.txt +1 -0
pyibaby/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""pyibaby - an open-source client library for iBaby monitor cameras.
|
|
2
|
+
|
|
3
|
+
Reverse-engineered, vendor-cloud-optional access to iBaby (ThroughTek PPCS/PPPP)
|
|
4
|
+
cameras: cloud login, P2P login-token generation, the LAN transport, the control
|
|
5
|
+
channel (PTZ / two-way audio / media), and H.264 + G.711 media.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import crypto, protocol, sensors
|
|
9
|
+
from .client import LANCamera, iter_video_frames
|
|
10
|
+
from .cloud import Camera, IBabyCloud
|
|
11
|
+
from .sensors import SensorReading
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"IBabyCloud",
|
|
15
|
+
"Camera",
|
|
16
|
+
"LANCamera",
|
|
17
|
+
"SensorReading",
|
|
18
|
+
"iter_video_frames",
|
|
19
|
+
"crypto",
|
|
20
|
+
"protocol",
|
|
21
|
+
"sensors",
|
|
22
|
+
]
|
|
23
|
+
__version__ = "0.1.0"
|
pyibaby/bridge.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Live media producer for stock go2rtc.
|
|
2
|
+
|
|
3
|
+
Two output modes:
|
|
4
|
+
|
|
5
|
+
* **default (video-only)** - writes a raw Annex-B H.264 elementary stream to
|
|
6
|
+
**stdout**, which go2rtc ingests directly as an ``exec:`` source (the same way
|
|
7
|
+
it ingests ``libcamera-vid``). No ffmpeg needed.
|
|
8
|
+
* ``--audio`` **(video + audio)** - muxes H.264 (copy) + G.711->AAC into an
|
|
9
|
+
MPEG-TS stream on stdout using a bundled static ffmpeg. go2rtc reads it the
|
|
10
|
+
same way as the raw mode; use this when you want sound.
|
|
11
|
+
|
|
12
|
+
Diagnostics go to stderr so they never corrupt stdout.
|
|
13
|
+
|
|
14
|
+
Camera selection (credentials from the environment, never on the command line):
|
|
15
|
+
|
|
16
|
+
* **direct** - ``IBABY_UID``, ``IBABY_CAMID``, ``IBABY_DEV_PASSWORD``
|
|
17
|
+
* **cloud** - ``IBABY_EMAIL`` + ``IBABY_PASSWORD`` (+ ``--camid`` to choose one)
|
|
18
|
+
|
|
19
|
+
go2rtc examples (see ``examples/go2rtc.yaml``)::
|
|
20
|
+
|
|
21
|
+
nursery: exec:python3 -m pyibaby.bridge#killsignal=15#restart
|
|
22
|
+
nursery-av: exec:python3 -m pyibaby.bridge --audio#killsignal=15#restart
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import os
|
|
29
|
+
import queue
|
|
30
|
+
import signal
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from .client import LANCamera
|
|
37
|
+
from .cloud import Camera, IBabyCloud
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _log(*a) -> None:
|
|
41
|
+
print("[bridge]", *a, file=sys.stderr, flush=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_camera(camid_arg: str | None) -> Camera:
|
|
45
|
+
uid = os.environ.get("IBABY_UID")
|
|
46
|
+
camid = os.environ.get("IBABY_CAMID")
|
|
47
|
+
dev_pw = os.environ.get("IBABY_DEV_PASSWORD")
|
|
48
|
+
if uid and camid and dev_pw:
|
|
49
|
+
return Camera(camid=camid, camname=camid, camtype="", p2p_uid=uid, p2p_provider="1", p2p_password=dev_pw)
|
|
50
|
+
email, password = os.environ.get("IBABY_EMAIL"), os.environ.get("IBABY_PASSWORD")
|
|
51
|
+
if not (email and password):
|
|
52
|
+
raise SystemExit("set IBABY_UID+IBABY_CAMID+IBABY_DEV_PASSWORD (direct) or IBABY_EMAIL+IBABY_PASSWORD (cloud)")
|
|
53
|
+
cameras = IBabyCloud().login(email, password)
|
|
54
|
+
if camid_arg:
|
|
55
|
+
return next(c for c in cameras if c.camid == camid_arg)
|
|
56
|
+
pppp = [c for c in cameras if c.is_pppp]
|
|
57
|
+
if not pppp:
|
|
58
|
+
raise SystemExit("no PPPP (provider != 0) camera in this account")
|
|
59
|
+
return pppp[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_stdout(cam: Camera, stop: dict) -> int:
|
|
63
|
+
frames = 0
|
|
64
|
+
out = sys.stdout.buffer
|
|
65
|
+
|
|
66
|
+
def on_video(_h, payload: bytes) -> None:
|
|
67
|
+
nonlocal frames
|
|
68
|
+
out.write(payload)
|
|
69
|
+
out.flush()
|
|
70
|
+
frames += 1
|
|
71
|
+
|
|
72
|
+
lan = LANCamera(cam)
|
|
73
|
+
try:
|
|
74
|
+
lan.connect()
|
|
75
|
+
_log("connected; streaming Annex-B H.264 to stdout")
|
|
76
|
+
lan.stream_video(on_video, should_stop=lambda: stop["flag"])
|
|
77
|
+
finally:
|
|
78
|
+
_close(lan)
|
|
79
|
+
return frames
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _writer(q: queue.Queue, fd: int) -> None:
|
|
83
|
+
f = os.fdopen(fd, "wb")
|
|
84
|
+
try:
|
|
85
|
+
while True:
|
|
86
|
+
item = q.get()
|
|
87
|
+
if item is None:
|
|
88
|
+
break
|
|
89
|
+
f.write(item)
|
|
90
|
+
f.flush()
|
|
91
|
+
except (BrokenPipeError, OSError):
|
|
92
|
+
pass
|
|
93
|
+
finally:
|
|
94
|
+
try:
|
|
95
|
+
f.close()
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_audio(cam: Camera, stop: dict) -> int:
|
|
101
|
+
"""Mux H.264 (copy) + G.711->AAC into MPEG-TS on stdout for a go2rtc exec source."""
|
|
102
|
+
import imageio_ffmpeg
|
|
103
|
+
|
|
104
|
+
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
|
105
|
+
vr, vw = os.pipe()
|
|
106
|
+
ar, aw = os.pipe()
|
|
107
|
+
os.set_inheritable(vr, True)
|
|
108
|
+
os.set_inheritable(ar, True)
|
|
109
|
+
cmd = [
|
|
110
|
+
ffmpeg,
|
|
111
|
+
"-hide_banner",
|
|
112
|
+
"-loglevel",
|
|
113
|
+
"error",
|
|
114
|
+
"-fflags",
|
|
115
|
+
"+genpts",
|
|
116
|
+
"-f",
|
|
117
|
+
"h264",
|
|
118
|
+
"-r",
|
|
119
|
+
"15",
|
|
120
|
+
"-i",
|
|
121
|
+
f"pipe:{vr}",
|
|
122
|
+
"-f",
|
|
123
|
+
"mulaw",
|
|
124
|
+
"-ar",
|
|
125
|
+
"8000",
|
|
126
|
+
"-ac",
|
|
127
|
+
"1",
|
|
128
|
+
"-i",
|
|
129
|
+
f"pipe:{ar}",
|
|
130
|
+
"-map",
|
|
131
|
+
"0:v:0",
|
|
132
|
+
"-map",
|
|
133
|
+
"1:a:0",
|
|
134
|
+
"-c:v",
|
|
135
|
+
"copy",
|
|
136
|
+
"-c:a",
|
|
137
|
+
"aac",
|
|
138
|
+
"-b:a",
|
|
139
|
+
"64k",
|
|
140
|
+
"-f",
|
|
141
|
+
"mpegts",
|
|
142
|
+
"pipe:1",
|
|
143
|
+
]
|
|
144
|
+
# ffmpeg writes MPEG-TS to fd 1 (the bridge's stdout, which go2rtc reads).
|
|
145
|
+
proc = subprocess.Popen(cmd, pass_fds=(vr, ar), stdin=subprocess.DEVNULL, stdout=sys.stdout.buffer.fileno())
|
|
146
|
+
os.close(vr)
|
|
147
|
+
os.close(ar)
|
|
148
|
+
|
|
149
|
+
vq: queue.Queue = queue.Queue(maxsize=300)
|
|
150
|
+
aq: queue.Queue = queue.Queue(maxsize=600)
|
|
151
|
+
threading.Thread(target=_writer, args=(vq, vw), daemon=True).start()
|
|
152
|
+
threading.Thread(target=_writer, args=(aq, aw), daemon=True).start()
|
|
153
|
+
n = {"v": 0, "a": 0}
|
|
154
|
+
|
|
155
|
+
def on_video(_h, payload: bytes) -> None:
|
|
156
|
+
n["v"] += 1
|
|
157
|
+
try:
|
|
158
|
+
vq.put_nowait(payload)
|
|
159
|
+
except queue.Full:
|
|
160
|
+
pass # drop under backpressure rather than stall the network loop
|
|
161
|
+
|
|
162
|
+
def on_audio(_h, payload: bytes) -> None:
|
|
163
|
+
n["a"] += 1
|
|
164
|
+
try:
|
|
165
|
+
aq.put_nowait(payload)
|
|
166
|
+
except queue.Full:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
lan = LANCamera(cam)
|
|
170
|
+
try:
|
|
171
|
+
lan.connect()
|
|
172
|
+
_log("connected; muxing H.264 + AAC (MPEG-TS) to stdout")
|
|
173
|
+
lan.stream_media(on_video, on_audio, should_stop=lambda: stop["flag"] or proc.poll() is not None)
|
|
174
|
+
finally:
|
|
175
|
+
vq.put(None)
|
|
176
|
+
aq.put(None)
|
|
177
|
+
_close(lan)
|
|
178
|
+
try:
|
|
179
|
+
proc.terminate()
|
|
180
|
+
proc.wait(timeout=3)
|
|
181
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
182
|
+
proc.kill()
|
|
183
|
+
return n["v"] + n["a"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _close(lan: LANCamera) -> None:
|
|
187
|
+
try:
|
|
188
|
+
lan.close()
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> int:
|
|
194
|
+
ap = argparse.ArgumentParser(description="iBaby -> go2rtc media bridge")
|
|
195
|
+
ap.add_argument("--audio", action="store_true", help="include audio (MPEG-TS H.264+AAC on stdout; needs ffmpeg)")
|
|
196
|
+
ap.add_argument("--camid", help="select a specific camera (cloud mode)")
|
|
197
|
+
ap.add_argument("--reconnect", action="store_true", help="auto-reconnect on failure")
|
|
198
|
+
args = ap.parse_args()
|
|
199
|
+
|
|
200
|
+
stop = {"flag": False}
|
|
201
|
+
|
|
202
|
+
def _stop(*_):
|
|
203
|
+
stop["flag"] = True
|
|
204
|
+
|
|
205
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
206
|
+
signal.signal(sig, _stop)
|
|
207
|
+
|
|
208
|
+
cam = resolve_camera(args.camid)
|
|
209
|
+
_log(f"camera {cam.camid} uid={cam.p2p_uid}")
|
|
210
|
+
while not stop["flag"]:
|
|
211
|
+
try:
|
|
212
|
+
count = _run_audio(cam, stop) if args.audio else _run_stdout(cam, stop)
|
|
213
|
+
_log(f"session ended ({count} frames)")
|
|
214
|
+
except (OSError, TimeoutError, ValueError) as e:
|
|
215
|
+
_log(f"error: {e!r}")
|
|
216
|
+
if not args.reconnect or stop["flag"]:
|
|
217
|
+
break
|
|
218
|
+
time.sleep(2)
|
|
219
|
+
_log("stopped")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
raise SystemExit(main())
|
pyibaby/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""High-level client: tie the cloud, the login token, and the PPPP transport
|
|
2
|
+
together to reach a LAN camera, control it, and pull H.264 video."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from . import crypto
|
|
7
|
+
from . import protocol as P
|
|
8
|
+
from .cloud import Camera
|
|
9
|
+
from .pppp import PPPPConnection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iter_video_frames(conn: PPPPConnection):
|
|
13
|
+
"""Yield (FrameHeader, payload) from the reassembled video channel."""
|
|
14
|
+
data = conn.reassemble(P.CH_VIDEO)
|
|
15
|
+
off = 0
|
|
16
|
+
while off + 16 <= len(data):
|
|
17
|
+
h = P.FrameHeader.parse(data[off : off + 16])
|
|
18
|
+
if h.codec not in P.VIDEO_CODECS or not 0 < h.size <= 800_000:
|
|
19
|
+
break
|
|
20
|
+
yield h, data[off + 16 : off + 16 + h.size]
|
|
21
|
+
off += 16 + h.size
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LANCamera:
|
|
25
|
+
"""A single camera reached over the local network (provider != 0 / PPPP)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, cam: Camera):
|
|
28
|
+
self.cam = cam
|
|
29
|
+
self.conn = PPPPConnection()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def token(self) -> str:
|
|
33
|
+
return crypto.generate_p2p_token(self.cam.p2p_password, self.cam.camid)
|
|
34
|
+
|
|
35
|
+
def connect(self, peer: tuple[str, int] | None = None) -> LANCamera:
|
|
36
|
+
self.conn.connect(self.cam.p2p_uid, peer)
|
|
37
|
+
self.conn.send(P.CH_CMD, P.login_command(self.cam.p2p_uid, self.token))
|
|
38
|
+
self.conn.pump(1.0)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def start_video(self, quality: str | None = None) -> None:
|
|
42
|
+
"""Start the live video stream. If ``quality`` is given (a key of
|
|
43
|
+
``protocol.STREAM_PRESETS``), set that profile first."""
|
|
44
|
+
if quality is not None:
|
|
45
|
+
self.set_quality(quality)
|
|
46
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_VIDEO_START))
|
|
47
|
+
|
|
48
|
+
def set_stream(self, resolution: int, bitrate: int, framerate: int) -> None:
|
|
49
|
+
"""Set the live stream resolution/bitrate/framerate directly (cmd 273).
|
|
50
|
+
|
|
51
|
+
Wire-tested resolutions on M7: 1080->1920x1080, 720->1280x720, 640->640x368,
|
|
52
|
+
320->320x200; a literal 480 is clamped by the firmware to 720. The change
|
|
53
|
+
applies on the next keyframe and affects the camera's encoder globally."""
|
|
54
|
+
self.conn.send(P.CH_CMD, P.stream_config_command(resolution, bitrate, framerate))
|
|
55
|
+
|
|
56
|
+
def set_quality(self, preset: str) -> None:
|
|
57
|
+
"""Switch the live stream to a named preset from ``protocol.STREAM_PRESETS``
|
|
58
|
+
(``1080p``, ``720p``, ``720p_eco``, ``360p``, ``tiny``)."""
|
|
59
|
+
res, bitrate, framerate = P.STREAM_PRESETS[preset]
|
|
60
|
+
self.set_stream(res, bitrate, framerate)
|
|
61
|
+
|
|
62
|
+
def stream_video(self, on_frame, *, should_stop=None) -> None:
|
|
63
|
+
"""Stream live video frames to ``on_frame(FrameHeader, payload)`` until stopped."""
|
|
64
|
+
self.start_video()
|
|
65
|
+
self.conn.stream(on_frame, should_stop=should_stop)
|
|
66
|
+
|
|
67
|
+
def start_audio(self) -> None:
|
|
68
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_AUDIO_START))
|
|
69
|
+
|
|
70
|
+
def stream_media(self, on_video, on_audio, *, should_stop=None) -> None:
|
|
71
|
+
"""Stream live video and audio frames until stopped (starts both channels)."""
|
|
72
|
+
self.start_video()
|
|
73
|
+
self.start_audio()
|
|
74
|
+
self.conn.stream(on_video, on_audio=on_audio, should_stop=should_stop)
|
|
75
|
+
|
|
76
|
+
def stop_video(self) -> None:
|
|
77
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_VIDEO_STOP))
|
|
78
|
+
|
|
79
|
+
def stop_audio(self) -> None:
|
|
80
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_AUDIO_STOP))
|
|
81
|
+
|
|
82
|
+
def ptz(self, direction: int) -> None:
|
|
83
|
+
self.conn.send(P.CH_CMD, P.ptz_command(direction))
|
|
84
|
+
|
|
85
|
+
def read_sensors(self, timeout: float = 8.0):
|
|
86
|
+
"""Return the camera's latest environment reading (temp/humidity/CO2/VOC) or None."""
|
|
87
|
+
from .sensors import SensorReading
|
|
88
|
+
|
|
89
|
+
tags = self.conn.read_userdata(timeout)
|
|
90
|
+
return SensorReading.from_tags(tags) if tags else None
|
|
91
|
+
|
|
92
|
+
def start_talk(self) -> None:
|
|
93
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_TALK_START))
|
|
94
|
+
|
|
95
|
+
def stop_talk(self) -> None:
|
|
96
|
+
self.conn.send(P.CH_CMD, P.media_command(P.MEDIA_TALK_STOP))
|
|
97
|
+
|
|
98
|
+
def talk(self, g711_ulaw: bytes, *, frame_size: int = 160, pace: float = 0.02) -> None:
|
|
99
|
+
"""Play G.711 u-law audio (8 kHz mono) through the camera speaker (talk-back)."""
|
|
100
|
+
import time
|
|
101
|
+
|
|
102
|
+
self.start_talk()
|
|
103
|
+
sock = self.conn.sock
|
|
104
|
+
sock.setblocking(False)
|
|
105
|
+
ts = 0
|
|
106
|
+
next_t = time.time()
|
|
107
|
+
try:
|
|
108
|
+
for i in range(0, len(g711_ulaw), frame_size):
|
|
109
|
+
chunk = g711_ulaw[i : i + frame_size].ljust(frame_size, b"\xff") # 0xFF = u-law silence
|
|
110
|
+
self.conn.send(P.CH_TALK, P.audio_frame(chunk, ts), reliable=False)
|
|
111
|
+
ts += frame_size
|
|
112
|
+
try: # service keepalives so the camera doesn't drop us mid-clip
|
|
113
|
+
while True:
|
|
114
|
+
data, _ = sock.recvfrom(2048)
|
|
115
|
+
t, _ch, _idx, _pl = P.parse(data)
|
|
116
|
+
if t == P.MSG_ALIVE:
|
|
117
|
+
sock.sendto(P.pppp(P.MSG_ALIVE_ACK), self.conn.peer)
|
|
118
|
+
except (BlockingIOError, OSError):
|
|
119
|
+
pass
|
|
120
|
+
next_t += pace
|
|
121
|
+
delay = next_t - time.time()
|
|
122
|
+
if delay > 0:
|
|
123
|
+
time.sleep(delay)
|
|
124
|
+
finally:
|
|
125
|
+
sock.setblocking(True)
|
|
126
|
+
sock.settimeout(0.3)
|
|
127
|
+
self.stop_talk()
|
|
128
|
+
|
|
129
|
+
def moonlight(self, on: bool = True, minutes: int = 15) -> None:
|
|
130
|
+
"""Projector lamp (stars + moon) on/off. The app hardcodes a 15-min auto-off
|
|
131
|
+
timer (openTime=900); ``minutes`` lets you try other values."""
|
|
132
|
+
self.conn.send(P.CH_CMD, P.moonlight_command(1 if on else 0, 0, 0, open_time=minutes * 60))
|
|
133
|
+
|
|
134
|
+
def music_light(self, on: bool = True, minutes: int = 15) -> None:
|
|
135
|
+
"""Projector lamp plus a PTZ sweep (the app's 'music light'); plays no audio itself."""
|
|
136
|
+
self.conn.send(P.CH_CMD, P.moonlight_command(1 if on else 0, 0, 1 if on else 0, open_time=minutes * 60))
|
|
137
|
+
|
|
138
|
+
def privacy_mode(self, on: bool = True) -> None:
|
|
139
|
+
"""Privacy mode: point the camera down (on) or restore it (off)."""
|
|
140
|
+
self.ptz(P.PTZ_PRIVACY_ON if on else P.PTZ_PRIVACY_OFF)
|
|
141
|
+
|
|
142
|
+
def play_music(
|
|
143
|
+
self,
|
|
144
|
+
track: dict | None = None,
|
|
145
|
+
*,
|
|
146
|
+
url: str | None = None,
|
|
147
|
+
track_id: int = 0,
|
|
148
|
+
playlist_id: int = 0,
|
|
149
|
+
album_id: int = 0,
|
|
150
|
+
mode: int = P.VMODE_SINGLE,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Play a built-in sound on the camera's speaker.
|
|
153
|
+
|
|
154
|
+
Pass a ``track`` dict from ``IBabyCloud.music_list()`` (preferred) - it carries
|
|
155
|
+
the camera-fetchable URL, track id, and category ``playlist_id``. Otherwise
|
|
156
|
+
pass ``url`` explicitly; it must be the plain-http S3 form
|
|
157
|
+
(``protocol.to_camera_url()``) - the CloudFront/https URL the cloud returns will
|
|
158
|
+
not play. ``mode``: 1=single, 2=single-loop, 3=sequence, 4=random.
|
|
159
|
+
"""
|
|
160
|
+
if track is not None:
|
|
161
|
+
url = track.get("camera_url") or P.to_camera_url(track.get("url") or "")
|
|
162
|
+
track_id = track.get("id") or track_id
|
|
163
|
+
playlist_id = track.get("playlist_id", playlist_id)
|
|
164
|
+
self.conn.send(
|
|
165
|
+
P.CH_CMD,
|
|
166
|
+
P.play_music_v2_command(
|
|
167
|
+
url=url or "",
|
|
168
|
+
track_id=track_id or 0,
|
|
169
|
+
album_id=album_id,
|
|
170
|
+
control=P.VMUSIC_PLAY,
|
|
171
|
+
mode=mode,
|
|
172
|
+
play_list_id=playlist_id,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def pause_music(self) -> None:
|
|
177
|
+
self.conn.send(P.CH_CMD, P.play_music_v2_command(control=P.VMUSIC_PAUSE))
|
|
178
|
+
|
|
179
|
+
def stop_music(self) -> None:
|
|
180
|
+
self.conn.send(P.CH_CMD, P.play_music_v2_command(control=P.VMUSIC_STOP))
|
|
181
|
+
|
|
182
|
+
def next_music(self) -> None:
|
|
183
|
+
self.conn.send(P.CH_CMD, P.play_music_v2_command(control=P.VMUSIC_NEXT))
|
|
184
|
+
|
|
185
|
+
def pump(self, seconds: float) -> None:
|
|
186
|
+
self.conn.pump(seconds)
|
|
187
|
+
|
|
188
|
+
def video_bitstream(self) -> bytes:
|
|
189
|
+
return b"".join(frame for _, frame in iter_video_frames(self.conn))
|
|
190
|
+
|
|
191
|
+
def close(self) -> None:
|
|
192
|
+
self.conn.close()
|
pyibaby/cloud.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""iBaby cloud API client (aapiibc.ibabycloud.com), reverse-engineered.
|
|
2
|
+
|
|
3
|
+
Flow, all over HTTPS with ``application/x-www-form-urlencoded`` bodies of the
|
|
4
|
+
form ``uuid=<plaintext>&data=<rncryptor-b64>``:
|
|
5
|
+
|
|
6
|
+
1. ``index/get-secret-key`` (encrypted under the fixed bootstrap key) returns a
|
|
7
|
+
per-session ``secret_key``; the session AES password is ``secret_key[2:10]``
|
|
8
|
+
and the returned ``uuid`` is echoed on every later request.
|
|
9
|
+
2. ``user/login`` returns ``auth_key`` plus the ``camera_list`` device array.
|
|
10
|
+
|
|
11
|
+
Validated against the live global endpoint. Only read operations are used here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import gzip
|
|
18
|
+
import json
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from . import crypto
|
|
24
|
+
from . import protocol as P
|
|
25
|
+
|
|
26
|
+
GLOBAL_BASE = "https://aapiibc.ibabycloud.com"
|
|
27
|
+
CN_BASE = "https://cn.ibabycloud.com"
|
|
28
|
+
APP_ID = "45615cf6-09cf-11e5-8dc5-12314305"
|
|
29
|
+
|
|
30
|
+
# Built-in music categories (IBabyMediaCore TYPE_*). "Generation" (age playlists)
|
|
31
|
+
# and "Favorites" (user playlists) use separate APIs and are not covered here.
|
|
32
|
+
MUSIC_CATEGORIES = {"lullabies": "1", "stories": "2", "white_noise": "3", "nature": "4", "songs": "10"}
|
|
33
|
+
|
|
34
|
+
_HEADERS = {
|
|
35
|
+
"charset": "utf-8",
|
|
36
|
+
"x-ibl-appversion": "2.14.5",
|
|
37
|
+
"accept-language": "en_CA",
|
|
38
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
39
|
+
"Accept-Encoding": "gzip",
|
|
40
|
+
"User-Agent": "okhttp/4.3.1",
|
|
41
|
+
"http_user_agent": "iBabyCare/2.14.5 (Linux; pyibaby)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Camera:
|
|
47
|
+
camid: str
|
|
48
|
+
camname: str
|
|
49
|
+
camtype: str
|
|
50
|
+
p2p_uid: str
|
|
51
|
+
p2p_provider: str
|
|
52
|
+
p2p_password: str # decrypted, ready for generate_p2p_token / avClientStart
|
|
53
|
+
firmware_version: str = ""
|
|
54
|
+
init_string: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_pppp(self) -> bool:
|
|
58
|
+
"""provider != 0 -> ThroughTek PPCS/PPPP (the M6S/M7 family handled here)."""
|
|
59
|
+
return str(self.p2p_provider) != "0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CloudError(RuntimeError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class IBabyCloud:
|
|
67
|
+
def __init__(self, base: str = GLOBAL_BASE, timeout: int = 30):
|
|
68
|
+
self.base = base
|
|
69
|
+
self.timeout = timeout
|
|
70
|
+
self._session_key: str | None = None
|
|
71
|
+
self._uuid: str = ""
|
|
72
|
+
self.auth_key: str | None = None
|
|
73
|
+
|
|
74
|
+
def _post(self, path: str, payload: dict, key: str) -> dict:
|
|
75
|
+
data = crypto.rncryptor_encrypt(json.dumps(payload).encode(), key, 2)
|
|
76
|
+
body = urllib.parse.urlencode({"uuid": self._uuid, "data": base64.b64encode(data).decode()}).encode()
|
|
77
|
+
req = urllib.request.Request(self.base + path, data=body, headers=_HEADERS, method="POST")
|
|
78
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
79
|
+
raw = resp.read()
|
|
80
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
81
|
+
raw = gzip.decompress(raw)
|
|
82
|
+
env = json.loads(raw.decode("utf-8", "replace"))
|
|
83
|
+
if env.get("status") != 0:
|
|
84
|
+
raise CloudError(f"{path}: status={env.get('status')} msg={env.get('msg')!r}")
|
|
85
|
+
return json.loads(crypto.rncryptor_decrypt(base64.b64decode(env["data"]), key, 2))
|
|
86
|
+
|
|
87
|
+
def get_secret_key(self) -> None:
|
|
88
|
+
out = self._post("/ibabycare/index/get-secret-key", {"appid": APP_ID, "uuid": ""}, crypto.BOOTSTRAP_KEY)
|
|
89
|
+
self._session_key = out["secret_key"][2:10]
|
|
90
|
+
self._uuid = out["uuid"]
|
|
91
|
+
|
|
92
|
+
def login(self, email: str, password: str) -> list[Camera]:
|
|
93
|
+
if self._session_key is None:
|
|
94
|
+
self.get_secret_key()
|
|
95
|
+
out = self._post(
|
|
96
|
+
"/ibabycare/user/login",
|
|
97
|
+
{"email": email, "password": crypto.login_password(password)},
|
|
98
|
+
self._session_key,
|
|
99
|
+
)
|
|
100
|
+
self.auth_key = out.get("auth_key")
|
|
101
|
+
return [self._camera(c) for c in out.get("camera_list", [])]
|
|
102
|
+
|
|
103
|
+
def music_list(self, category: str = "lullabies") -> list[dict]:
|
|
104
|
+
"""Built-in sounds for a category - name or numeric type:
|
|
105
|
+
lullabies=1, stories=2, white_noise=3, nature=4, songs=10.
|
|
106
|
+
|
|
107
|
+
Returns ``[{id, name, url, camera_url, play_time, type, playlist_id}, ...]``.
|
|
108
|
+
``url`` is the CloudFront/https link (for display); ``camera_url`` is the
|
|
109
|
+
plain-http S3 form the camera actually fetches. ``playlist_id`` is the
|
|
110
|
+
category's pseudo-playlist id (negated cloud type) to pass to ``play_music``.
|
|
111
|
+
"""
|
|
112
|
+
if self._session_key is None or self.auth_key is None:
|
|
113
|
+
raise CloudError("must login() before fetching the music list")
|
|
114
|
+
ctype = str(MUSIC_CATEGORIES.get(category, category))
|
|
115
|
+
out = self._post(
|
|
116
|
+
"/ibabycare/music/get-built-in-music-list",
|
|
117
|
+
{"auth_key": self.auth_key, "type": ctype},
|
|
118
|
+
self._session_key,
|
|
119
|
+
)
|
|
120
|
+
type_num = int(ctype) if ctype.lstrip("-").isdigit() else None
|
|
121
|
+
playlist_id = -type_num if type_num is not None else 0
|
|
122
|
+
tracks = []
|
|
123
|
+
for item in out.get("list", []):
|
|
124
|
+
media = item.get("media") or {}
|
|
125
|
+
cloud_url = media.get("url")
|
|
126
|
+
tracks.append(
|
|
127
|
+
{
|
|
128
|
+
"id": item.get("id"),
|
|
129
|
+
"name": item.get("media_name"),
|
|
130
|
+
"url": cloud_url,
|
|
131
|
+
"camera_url": P.to_camera_url(cloud_url) if cloud_url else None,
|
|
132
|
+
"play_time": item.get("play_time"),
|
|
133
|
+
"type": type_num,
|
|
134
|
+
"playlist_id": playlist_id,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
return tracks
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _camera(c: dict) -> Camera:
|
|
141
|
+
return Camera(
|
|
142
|
+
camid=c.get("camid", ""),
|
|
143
|
+
camname=c.get("camname", ""),
|
|
144
|
+
camtype=c.get("camtype", ""),
|
|
145
|
+
p2p_uid=c.get("p2p_uid", ""),
|
|
146
|
+
p2p_provider=str(c.get("p2p_provider", "")),
|
|
147
|
+
p2p_password=crypto.decrypt_p2p_password(c["p2p_new_password"]) if c.get("p2p_new_password") else "",
|
|
148
|
+
firmware_version=c.get("firmware_version", ""),
|
|
149
|
+
init_string=c.get("init_string", ""),
|
|
150
|
+
)
|