1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
use std::sync::Arc;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use tokio::io::AsyncWrite;
use tokio::sync::RwLock;
use crate::tapo_vendor::api::ApiClient;
use crate::tapo_vendor::error::Error;
use crate::tapo_vendor::requests::{
SmartCamGetGeneralDeviceListParams, SmartCamGetTimezoneParams,
SmartCamSearchDateWithVideoParams, SmartCamSearchVideoWithUtcParams, TapoParams, TapoRequest,
};
use crate::tapo_vendor::responses::{
DeviceInfoCameraHubResult, GeneralDeviceHubResult, GeneralDeviceListHubResultRaw,
RecordingDateHubResult, RecordingDateListHubResultRaw, RecordingDownloadResult,
RecordingHubResult, RecordingListHubResultRaw, TimezoneHubResultRaw, TimezoneResult,
};
use crate::tapo_vendor::utils::unix_timestamp_seconds;
/// Handler for camera hubs, such as the
/// [H200](https://www.tapo.com/en/search/?q=H200) and
/// [H500](https://www.tapo.com/en/search/?q=H500).
///
/// # Example
///
/// ```ignore
/// # use tapo::{ApiClient, HubDevice};
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // Connect to the hub
/// let hub = ApiClient::new("tapo-username@example.com", "tapo-password")
/// .h200("192.168.1.100")
/// .await?;
/// // Get a handler for a child device
/// let device_id = "0000000000000000000000000000000000000000".to_string();
/// let device = hub.t31x(HubDevice::ByDeviceId(device_id)).await?;
/// // Get the device info of the child device
/// let device_info = device.get_device_info().await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct CameraHubHandler {
client: Arc<RwLock<ApiClient>>,
ip: String,
port: u16,
/// Identifies this handler to the hub, new for every handler. It is sent
/// as `player_id` in clip searches (`searchVideoWithUTC`), and on the
/// media stream as `playerId` in the stream URI and `player_id` in the
/// download request.
player_id: String,
}
impl CameraHubHandler {
pub(crate) fn new(client: ApiClient, (ip, port): (String, u16)) -> Self {
Self {
client: Arc::new(RwLock::new(client)),
ip,
port,
player_id: uuid::Uuid::new_v4().to_string(),
}
}
}
tapo_handler!(@methods CameraHubHandler(DeviceInfoCameraHubResult));
tapo_handler!(@handler_ext CameraHubHandler);
/// Hub handler methods.
impl CameraHubHandler {
/// Returns *general device list* as [`GeneralDeviceHubResult`].
/// These are the standalone Wi-Fi cameras paired to the hub.
/// It is not guaranteed to contain all the properties returned from the Tapo API.
/// If the deserialization fails, or if a property that you care about it's not present,
#[cfg_attr(
any(),
doc = "try [`CameraHubHandler::get_general_device_list_json`] (requires the `debug` feature)."
)]
#[cfg_attr(
not(any()),
doc = "try `CameraHubHandler::get_general_device_list_json` (requires the `debug` feature)."
)]
pub async fn get_general_device_list(&self) -> Result<Vec<GeneralDeviceHubResult>, Error> {
let request = TapoRequest::SmartCamGetGeneralDeviceList(TapoParams::new(
SmartCamGetGeneralDeviceListParams::new(),
));
self.client
.read()
.await
.execute_smart_cam_multiple_request::<GeneralDeviceListHubResultRaw>(request)
.await
.map(|result| result.devices())
}
/// Returns *general device list* as [`serde_json::Value`].
/// It contains all the properties returned from the Tapo API for the
/// standalone Wi-Fi cameras paired to the hub.
#[cfg(any())]
pub async fn get_general_device_list_json(&self) -> Result<serde_json::Value, Error> {
let request = TapoRequest::SmartCamGetGeneralDeviceList(TapoParams::new(
SmartCamGetGeneralDeviceListParams::new(),
));
self.client
.read()
.await
.execute_smart_cam_multiple_request::<serde_json::Value>(request)
.await
}
/// Returns the hub's *timezone* as [`TimezoneResult`].
///
/// # Errors
///
/// Returns an error if the hub reports a timezone name that is not in the
/// IANA database.
pub async fn get_timezone(&self) -> Result<TimezoneResult, Error> {
let request =
TapoRequest::SmartCamGetTimezone(TapoParams::new(SmartCamGetTimezoneParams::new()));
self.client
.read()
.await
.execute_smart_cam_multiple_request::<TimezoneHubResultRaw>(request)
.await
.map(Into::into)
}
/// Returns the days that have recordings stored on the hub for the given
/// camera (`searchDateWithVideo`), as [`Vec<RecordingDateHubResult>`].
/// Each day comes with the range of time it covers on the hub, ready to
/// pass to [`CameraHubHandler::get_recordings`].
///
/// The days are searched in the hub's timezone, so they line up with the
/// Tapo app wherever this code runs. Each day that the range touches is
/// searched whole: a range that starts at 00:30 covers all of that day,
/// and a returned day can reach past the range at either end.
///
/// Pass the [`TimezoneResult::timezone`] field from
/// [`CameraHubHandler::get_timezone`] to read it once for several calls.
/// With `None`, each call reads it, which takes one extra request.
///
/// # Arguments
///
/// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
/// * `start_time` - the start of the search range.
/// * `end_time` - the end of the search range.
/// * `timezone` - the hub's timezone, or `None` to read it from the hub.
///
/// # Errors
///
/// Returns an error if `end_time` is before `start_time`, or if the hub
/// reports a timezone name that is not in the IANA database.
pub async fn get_recording_dates(
&self,
child_device_id: impl Into<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
timezone: Option<Tz>,
) -> Result<Vec<RecordingDateHubResult>, Error> {
if end_time < start_time {
return Err(Error::Validation {
field: "end_time".to_string(),
message: "Must not be before start_time".to_string(),
});
}
let timezone = match timezone {
Some(timezone) => timezone,
None => self.get_timezone().await?.timezone,
};
let request = TapoRequest::SmartCamSearchDateWithVideo(TapoParams::new(
SmartCamSearchDateWithVideoParams::new(
start_time.with_timezone(&timezone).date_naive(),
end_time.with_timezone(&timezone).date_naive(),
child_device_id.into(),
),
));
let dates = self
.client
.read()
.await
.execute_smart_cam_multiple_request::<RecordingDateListHubResultRaw>(request)
.await?
.dates(timezone)?;
Ok(dates)
}
/// Returns the recordings stored on the hub for the given camera, within
/// the given time range (`searchVideoWithUTC`), as
/// [`Vec<RecordingHubResult>`]. Like the Tapo app, all pages are fetched,
/// up to 12,300 recordings.
///
/// # Arguments
///
/// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
/// * `start_time` - the start of the search range.
/// * `end_time` - the end of the search range.
///
/// # Errors
///
/// Returns an error if `end_time` is before `start_time`, or if either is
/// before 1970.
pub async fn get_recordings(
&self,
child_device_id: impl Into<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
) -> Result<Vec<RecordingHubResult>, Error> {
// The Tapo app fetches pages of 100 (`start_index: 0`, `end_index: 99`)
// and stops paging past a start index of 12288.
const PAGE_SIZE: u64 = 100;
const MAX_START_INDEX: u64 = 12288;
if end_time < start_time {
return Err(Error::Validation {
field: "end_time".to_string(),
message: "Must not be before start_time".to_string(),
});
}
let start_time = unix_timestamp_seconds("start_time", start_time)?;
let end_time = unix_timestamp_seconds("end_time", end_time)?;
let child_device_id = child_device_id.into();
let client = self.client.read().await;
let mut results = Vec::new();
let mut start_index = 0;
while start_index <= MAX_START_INDEX {
let request = TapoRequest::SmartCamSearchVideoWithUtc(TapoParams::new(
SmartCamSearchVideoWithUtcParams::new(
start_time,
end_time,
start_index,
start_index + PAGE_SIZE - 1,
child_device_id.clone(),
self.player_id.clone(),
),
));
let recordings = client
.execute_smart_cam_multiple_request::<RecordingListHubResultRaw>(request)
.await?
.recordings();
// The H200 sends no `to_be_continued` flag, so like the Tapo app
// in that case, a full page means that another may follow.
let page_is_full = recordings.len() as u64 >= PAGE_SIZE;
results.extend(recordings);
if !page_is_full {
break;
}
start_index += PAGE_SIZE;
}
Ok(results)
}
/// Downloads a recording stored on the hub over the hub's media stream
/// service (TCP port 8800), writing the media to `writer` as it arrives.
///
/// The hub streams the recording as encrypted MPEG-TS; the parts are
/// decrypted with keys derived from the session's key exchange, so
/// writing to a file with a `.ts` extension produces a playable clip. The
/// hub ends the clip itself, with twice the clip's length on top of the
/// client's timeout as a backstop.
///
/// The media is written exactly as the hub sent it, with nothing added or
/// re-muxed, so a player may log a one-off `TS discontinuity` warning for
/// the stream's PAT and PMT when it opens the file. It is harmless: both
/// tables are repeated throughout the stream and the clip plays in full.
///
/// # Arguments
///
/// * `child_device_id` - the `device_id` of a camera returned by [`CameraHubHandler::get_general_device_list`].
/// * `start_time` - the `start_time` of a recording returned by [`CameraHubHandler::get_recordings`].
/// * `end_time` - the `end_time` of that recording.
/// * `writer` - where the media is written, e.g. a `Vec<u8>` or a `tokio::fs::File`.
///
/// # Errors
///
/// Returns an error if `end_time` is not after `start_time`, if either is
/// before 1970, if the hub rejects the request or sends no media, if it
/// sends encrypted media that cannot be decrypted, if writing to `writer`
/// fails, or if the hub closes the connection or the backstop runs out
/// before the hub reports the end of the recording. In that last case `writer` holds only the part of
/// the recording that arrived.
///
/// # Example
///
/// ```ignore
/// # use tapo::ApiClient;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let hub = ApiClient::new("tapo-username@example.com", "tapo-password")
/// .h200("192.168.1.100")
/// .await?;
///
/// let camera = hub
/// .get_general_device_list()
/// .await?
/// .into_iter()
/// .next()
/// .expect("no camera is paired to the hub");
///
/// let end_time = chrono::Utc::now();
/// let start_time = end_time - chrono::Duration::days(1);
/// let recordings = hub
/// .get_recordings(camera.device_id.clone(), start_time, end_time)
/// .await?;
///
/// if let Some(recording) = recordings.first() {
/// let mut media = Vec::new();
/// hub.download_recording(
/// camera.device_id,
/// recording.start_time,
/// recording.end_time,
/// &mut media,
/// )
/// .await?;
/// std::fs::write("recording.ts", media)?;
/// }
/// # Ok(())
/// # }
/// ```
pub async fn download_recording<W: AsyncWrite + Unpin + Send>(
&self,
child_device_id: impl Into<String>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
writer: &mut W,
) -> Result<RecordingDownloadResult, Error> {
self.client
.read()
.await
.download_recording(
&self.ip,
&self.player_id,
child_device_id.into(),
start_time,
end_time,
writer,
)
.await
}
}
hub_child_handlers!(
CameraHubHandler,
ChildDeviceHubResult,
child_device_list_note = "Cameras paired to the hub are not included; use \
[`CameraHubHandler::get_general_device_list`] for those.",
);