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 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
+ )