pyezvizapi 1.0.2.2__py3-none-any.whl → 1.0.2.3__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.
pyezvizapi/camera.py CHANGED
@@ -4,14 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import datetime
6
6
  import logging
7
- import re
8
7
  from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
9
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
8
 
11
9
  from .constants import BatteryCameraWorkMode, DeviceSwitchType, SoundMode
12
10
  from .exceptions import PyEzvizError
13
11
  from .models import EzvizDeviceRecord
14
- from .utils import fetch_nested_value, string_to_list
12
+ from .utils import (
13
+ compute_motion_from_alarm,
14
+ fetch_nested_value,
15
+ parse_timezone_value,
16
+ string_to_list,
17
+ )
15
18
 
16
19
  if TYPE_CHECKING:
17
20
  from .client import EzvizClient
@@ -172,110 +175,10 @@ class EzvizCamera:
172
175
 
173
176
  Prefer numeric epoch fields if available to avoid parsing localized strings.
174
177
  """
175
- # Use timezone-aware datetimes. Compute both camera-local and UTC "now".
176
178
  tzinfo = self._get_tzinfo()
177
- now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
178
- now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
179
-
180
- # Prefer epoch fields if available
181
- epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
182
- "alarmTime"
183
- )
184
- last_alarm_dt: datetime.datetime | None = None
185
- # Capture string time if present so we can cross-check epoch skew
186
- raw_time_str = str(
187
- self._last_alarm.get("alarmStartTimeStr")
188
- or self._last_alarm.get("alarmTimeStr")
189
- or ""
179
+ active, seconds_out, last_alarm_str = compute_motion_from_alarm(
180
+ self._last_alarm, tzinfo
190
181
  )
191
- if epoch is not None:
192
- try:
193
- # Accept int/float/str; auto-detect ms vs s
194
- if isinstance(epoch, str):
195
- epoch = float(epoch)
196
- ts = float(epoch)
197
- if ts > 1e11: # very likely milliseconds
198
- ts = ts / 1000.0
199
- # Convert epoch to UTC for robust delta; derive display time in camera tz
200
- event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
201
- last_alarm_dt = event_utc.astimezone(tzinfo)
202
- # Some devices appear to report epoch as local time rather than UTC.
203
- # If the provided string timestamp exists and differs significantly
204
- # from the epoch-based time, reinterpret the epoch as local time.
205
- if raw_time_str:
206
- raw_norm = raw_time_str
207
- if "Today" in raw_norm:
208
- raw_norm = raw_norm.replace("Today", str(now_local.date()))
209
- try:
210
- dt_str_local = datetime.datetime.strptime(
211
- raw_norm, "%Y-%m-%d %H:%M:%S"
212
- ).replace(tzinfo=tzinfo)
213
- diff = abs(
214
- (
215
- event_utc
216
- - dt_str_local.astimezone(datetime.UTC)
217
- ).total_seconds()
218
- )
219
- if diff > 120:
220
- # Reinterpret the epoch as local clock time in camera tz
221
- naive_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC).replace(tzinfo=None)
222
- event_local_reint = naive_utc.replace(tzinfo=tzinfo)
223
- event_utc = event_local_reint.astimezone(datetime.UTC)
224
- last_alarm_dt = event_local_reint
225
- except ValueError:
226
- pass
227
- except (
228
- TypeError,
229
- ValueError,
230
- OSError,
231
- ): # fall back to string parsing below
232
- last_alarm_dt = None
233
-
234
- last_alarm_str: str | None = None
235
- if last_alarm_dt is None:
236
- # Fall back to string parsing
237
- raw = raw_time_str
238
- if not raw:
239
- return
240
- if "Today" in raw:
241
- raw = raw.replace("Today", str(now_local.date()))
242
- try:
243
- last_alarm_dt = datetime.datetime.strptime(
244
- raw, "%Y-%m-%d %H:%M:%S"
245
- ).replace(tzinfo=tzinfo)
246
- last_alarm_str = last_alarm_dt.strftime("%Y-%m-%d %H:%M:%S")
247
- except ValueError: # Unrecognized format; give up gracefully
248
- _LOGGER.debug(
249
- "Unrecognized alarm time format for %s: %s", self._serial, raw
250
- )
251
- self._alarmmotiontrigger = {
252
- "alarm_trigger_active": False,
253
- "timepassed": None,
254
- "last_alarm_time_str": raw or None,
255
- }
256
- return
257
- else:
258
- # We selected epoch path; format a human-readable local string
259
- last_alarm_str = last_alarm_dt.astimezone(tzinfo).strftime(
260
- "%Y-%m-%d %H:%M:%S"
261
- )
262
-
263
- # Compute elapsed seconds since the last alarm. If the timestamp is
264
- # somehow in the future (timezone mismatch or clock skew), do not
265
- # report a motion trigger; clamp the exposed seconds to 0.0.
266
- # Use UTC delta when epoch was provided; otherwise compute in camera tz.
267
- if epoch is not None and last_alarm_dt is not None:
268
- event_utc_for_delta = last_alarm_dt.astimezone(datetime.UTC)
269
- delta = now_utc - event_utc_for_delta
270
- else:
271
- delta = now_local - last_alarm_dt
272
- seconds = float(delta.total_seconds())
273
- if seconds < 0:
274
- active = False
275
- seconds_out = 0.0
276
- else:
277
- active = seconds < 60.0
278
- seconds_out = seconds
279
182
 
280
183
  self._alarmmotiontrigger = {
281
184
  "alarm_trigger_active": active,
@@ -284,41 +187,9 @@ class EzvizCamera:
284
187
  }
285
188
 
286
189
  def _get_tzinfo(self) -> datetime.tzinfo:
287
- """Return tzinfo from camera setting if recognizable, else local tzinfo.
288
-
289
- Attempts to parse common formats like 'UTC+02:00', 'GMT+8', '+0530', or IANA names.
290
- Falls back to local timezone.
291
- """
190
+ """Return tzinfo from camera setting if recognizable, else local tzinfo."""
292
191
  tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
293
- # IANA zone name
294
- if isinstance(tz_val, str) and "/" in tz_val:
295
- try:
296
- return ZoneInfo(tz_val)
297
- except ZoneInfoNotFoundError:
298
- pass
299
- # Offset formats
300
- offset_minutes: int | None = None
301
- if isinstance(tz_val, int):
302
- # Heuristic: treat small absolute values as hours, large as minutes/seconds
303
- if -14 <= tz_val <= 14:
304
- offset_minutes = tz_val * 60
305
- elif -24 * 60 <= tz_val <= 24 * 60:
306
- offset_minutes = tz_val
307
- elif -24 * 3600 <= tz_val <= 24 * 3600:
308
- offset_minutes = int(tz_val / 60)
309
- elif isinstance(tz_val, str):
310
- s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
311
- # Normalize formats like '+02:00', '+0200', '+2'
312
- m = re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
313
- if m:
314
- sign = -1 if m.group(1) == "-" else 1
315
- hours = int(m.group(2))
316
- minutes = int(m.group(3)) if m.group(3) else 0
317
- offset_minutes = sign * (hours * 60 + minutes)
318
- if offset_minutes is not None:
319
- return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
320
- # Fallback to local timezone
321
- return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
192
+ return parse_timezone_value(tz_val)
322
193
 
323
194
  def _is_alarm_schedules_enabled(self) -> bool:
324
195
  """Check if alarm schedules enabled."""
pyezvizapi/utils.py CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
5
6
  from hashlib import md5
6
7
  import json
7
8
  import logging
9
+ import re as _re
8
10
  from typing import Any
9
11
  import uuid
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
13
 
11
14
  from Crypto.Cipher import AES
12
15
 
@@ -188,3 +191,156 @@ def generate_unique_code() -> str:
188
191
  mac_int = uuid.getnode()
189
192
  mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
190
193
  return md5(mac_str.encode("utf-8")).hexdigest()
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Time helpers for alarm/motion handling
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def normalize_alarm_time(
201
+ last_alarm: dict[str, Any], tzinfo: datetime.tzinfo
202
+ ) -> tuple[datetime.datetime | None, datetime.datetime | None, str | None]:
203
+ """Normalize EZVIZ alarm timestamps.
204
+
205
+ Returns a tuple of:
206
+ - alarm_dt_local: datetime in the camera's timezone (for display)
207
+ - alarm_dt_utc: datetime in UTC (for robust delta calculation)
208
+ - alarm_time_str: formatted 'YYYY-MM-DD HH:MM:SS' string in camera tz
209
+
210
+ Behavior:
211
+ - Prefer epoch fields (alarmStartTime/alarmTime). Interpret as UTC by default.
212
+ - If a string time exists and differs from the epoch by >120 seconds,
213
+ reinterpret the epoch as if reported in camera local time.
214
+ - If no epoch, fall back to parsing the string time in the camera tz.
215
+ """
216
+ # Prefer epoch
217
+ epoch = last_alarm.get("alarmStartTime") or last_alarm.get("alarmTime")
218
+ raw_time_str = str(
219
+ last_alarm.get("alarmStartTimeStr") or last_alarm.get("alarmTimeStr") or ""
220
+ )
221
+
222
+ alarm_dt_local: datetime.datetime | None = None
223
+ alarm_dt_utc: datetime.datetime | None = None
224
+ alarm_str: str | None = None
225
+
226
+ now_local = datetime.datetime.now(tz=tzinfo)
227
+
228
+ if epoch is not None:
229
+ try:
230
+ ts = float(epoch if not isinstance(epoch, str) else float(epoch))
231
+ if ts > 1e11: # milliseconds
232
+ ts /= 1000.0
233
+ event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
234
+ alarm_dt_local = event_utc.astimezone(tzinfo)
235
+ alarm_dt_utc = event_utc
236
+
237
+ if raw_time_str:
238
+ raw_norm = raw_time_str.replace("Today", str(now_local.date()))
239
+ try:
240
+ dt_str_local = datetime.datetime.strptime(
241
+ raw_norm, "%Y-%m-%d %H:%M:%S"
242
+ ).replace(tzinfo=tzinfo)
243
+ diff = abs(
244
+ (event_utc - dt_str_local.astimezone(datetime.UTC)).total_seconds()
245
+ )
246
+ if diff > 120:
247
+ # Reinterpret epoch as local clock time in camera tz
248
+ naive_utc = (
249
+ datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
250
+ .replace(tzinfo=None)
251
+ )
252
+ event_local_reint = naive_utc.replace(tzinfo=tzinfo)
253
+ alarm_dt_local = event_local_reint
254
+ alarm_dt_utc = event_local_reint.astimezone(datetime.UTC)
255
+ except ValueError:
256
+ pass
257
+
258
+ if alarm_dt_local is not None:
259
+ alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
260
+ return alarm_dt_local, alarm_dt_utc, alarm_str
261
+ # If conversion failed unexpectedly, fall through to string parsing
262
+ except (TypeError, ValueError, OSError):
263
+ alarm_dt_local = None
264
+
265
+ # Fallback to string parsing
266
+ if raw_time_str:
267
+ raw = raw_time_str.replace("Today", str(now_local.date()))
268
+ try:
269
+ alarm_dt_local = datetime.datetime.strptime(raw, "%Y-%m-%d %H:%M:%S").replace(
270
+ tzinfo=tzinfo
271
+ )
272
+ alarm_dt_utc = alarm_dt_local.astimezone(datetime.UTC)
273
+ alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
274
+ except ValueError:
275
+ pass
276
+
277
+ return alarm_dt_local, alarm_dt_utc, alarm_str
278
+
279
+
280
+ def compute_motion_from_alarm(
281
+ last_alarm: dict[str, Any], tzinfo: datetime.tzinfo, window_seconds: float = 60.0
282
+ ) -> tuple[bool, float, str | None]:
283
+ """Compute motion state and seconds-since from an alarm payload.
284
+
285
+ Returns (active, seconds_since, last_alarm_time_str).
286
+ - Uses UTC for delta when epoch-derived UTC is available.
287
+ - Falls back to camera local tz deltas when only string times are present.
288
+ - Clamps negative deltas to 0.0 and deactivates motion.
289
+ """
290
+ alarm_dt_local, alarm_dt_utc, alarm_str = normalize_alarm_time(last_alarm, tzinfo)
291
+ if alarm_dt_local is None:
292
+ return False, 0.0, None
293
+
294
+ now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
295
+ now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
296
+
297
+ if alarm_dt_utc is not None:
298
+ delta = now_utc - alarm_dt_utc
299
+ else:
300
+ delta = now_local - alarm_dt_local
301
+
302
+ seconds = float(delta.total_seconds())
303
+ if seconds < 0:
304
+ return False, 0.0, alarm_str
305
+
306
+ return seconds < window_seconds, seconds, alarm_str
307
+
308
+
309
+ def parse_timezone_value(tz_val: Any) -> datetime.tzinfo:
310
+ """Parse EZVIZ timeZone value into a tzinfo.
311
+
312
+ Supports:
313
+ - IANA names like 'Europe/Paris'
314
+ - Offsets like 'UTC+02:00', 'GMT-5', '+0530', or integers (hours/minutes/seconds)
315
+ Falls back to the local system timezone, or UTC if unavailable.
316
+ """
317
+ # IANA zone name
318
+ if isinstance(tz_val, str) and "/" in tz_val:
319
+ try:
320
+ return ZoneInfo(tz_val)
321
+ except ZoneInfoNotFoundError:
322
+ pass
323
+
324
+ # Numeric offsets
325
+ offset_minutes: int | None = None
326
+ if isinstance(tz_val, int):
327
+ if -14 <= tz_val <= 14:
328
+ offset_minutes = tz_val * 60
329
+ elif -24 * 60 <= tz_val <= 24 * 60:
330
+ offset_minutes = tz_val
331
+ elif -24 * 3600 <= tz_val <= 24 * 3600:
332
+ offset_minutes = int(tz_val / 60)
333
+ elif isinstance(tz_val, str):
334
+ s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
335
+ m = _re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
336
+ if m:
337
+ sign = -1 if m.group(1) == "-" else 1
338
+ hours = int(m.group(2))
339
+ minutes = int(m.group(3)) if m.group(3) else 0
340
+ offset_minutes = sign * (hours * 60 + minutes)
341
+
342
+ if offset_minutes is not None:
343
+ return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
344
+
345
+ # Fallbacks
346
+ return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyezvizapi
3
- Version: 1.0.2.2
3
+ Version: 1.0.2.3
4
4
  Summary: Pilot your Ezviz cameras
5
5
  Home-page: https://github.com/RenierM26/pyEzvizApi/
6
6
  Author: Renier Moorcroft
@@ -1,7 +1,7 @@
1
1
  pyezvizapi/__init__.py,sha256=IDnIN_nfIISVwuy0cVBh4wspgAav6MuOJCQGajjyU3g,1881
2
2
  pyezvizapi/__main__.py,sha256=SeV954H-AV-U1thNxRd7rWTGtSlfWyNzdrjF8gikYus,20777
3
3
  pyezvizapi/api_endpoints.py,sha256=rk6VinLVCn-B6DxnhfV79liplNpgUsipNbTEa_MRVwU,2755
4
- pyezvizapi/camera.py,sha256=8F8oyLnvZgl67Id6_R1m3OuhnDPNV92mTKGyLAKIFy8,28250
4
+ pyezvizapi/camera.py,sha256=Pl5oIEdrFcv1Hz5sQI1IyyJIDCMjOjQdtExgKzmLoK8,22102
5
5
  pyezvizapi/cas.py,sha256=ISmb-eTPuacI10L87lULbQ-oDMBuygO7Tf-s9f-tvYE,5995
6
6
  pyezvizapi/client.py,sha256=Bp5eQbn4-pjsZicfpWy6jD5bDjQeYFw-SN1p0uzKJRY,71782
7
7
  pyezvizapi/constants.py,sha256=SqdJRQSRdVYQxMgJa__AgorzdWglgA4MM4H2fq3QLAE,12633
@@ -11,11 +11,11 @@ pyezvizapi/models.py,sha256=NQzwTP0yEe2IWU-Vc6nAn87xulpTuo0MX2Rcf0WxifA,4176
11
11
  pyezvizapi/mqtt.py,sha256=aOL-gexZgYvCCaNQ03M4vZan91d5p2Fl_qsFykn9NW4,22365
12
12
  pyezvizapi/test_cam_rtsp.py,sha256=WGSM5EiOTl_r1mWHoMb7bXHm_BCn1P9X_669YQ38r6k,4903
13
13
  pyezvizapi/test_mqtt.py,sha256=Orn-fwZPJIE4G5KROMX0MRAkLwU6nLb9LUtXyb2ZCQs,4147
14
- pyezvizapi/utils.py,sha256=o342o3LI9eP8qla1vXM2rqlVbdTiLK0dAqrkyUSXpg8,5939
15
- pyezvizapi-1.0.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- pyezvizapi-1.0.2.2.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
- pyezvizapi-1.0.2.2.dist-info/METADATA,sha256=aY8Dh0Ia8j4gTVBxgJW_JnoGFDumEfAfQTDMvNqZI2U,695
18
- pyezvizapi-1.0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- pyezvizapi-1.0.2.2.dist-info/entry_points.txt,sha256=_BSJ3eNb2H_AZkRdsv1s4mojqWn3N7m503ujvg1SudA,56
20
- pyezvizapi-1.0.2.2.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
21
- pyezvizapi-1.0.2.2.dist-info/RECORD,,
14
+ pyezvizapi/utils.py,sha256=bOWLIytbELQfwBpA2LnhP1m8OthmOk9Vhh_uUXTFpoc,12124
15
+ pyezvizapi-1.0.2.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ pyezvizapi-1.0.2.3.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
+ pyezvizapi-1.0.2.3.dist-info/METADATA,sha256=xvvts4etMltvpLLaIr0lcKhm1quwBYCJbFqltigk55U,695
18
+ pyezvizapi-1.0.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ pyezvizapi-1.0.2.3.dist-info/entry_points.txt,sha256=_BSJ3eNb2H_AZkRdsv1s4mojqWn3N7m503ujvg1SudA,56
20
+ pyezvizapi-1.0.2.3.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
21
+ pyezvizapi-1.0.2.3.dist-info/RECORD,,