pyezvizapi 1.0.2.1__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
@@ -99,6 +102,7 @@ class EzvizCamera:
99
102
  self._alarmmotiontrigger: dict[str, Any] = {
100
103
  "alarm_trigger_active": False,
101
104
  "timepassed": None,
105
+ "last_alarm_time_str": None,
102
106
  }
103
107
  self._record: EzvizDeviceRecord | None = None
104
108
 
@@ -171,100 +175,21 @@ class EzvizCamera:
171
175
 
172
176
  Prefer numeric epoch fields if available to avoid parsing localized strings.
173
177
  """
174
- # Use timezone-aware datetimes based on camera or local timezone.
175
178
  tzinfo = self._get_tzinfo()
176
- now = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
177
-
178
- # Prefer epoch fields if available
179
- epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
180
- "alarmTime"
179
+ active, seconds_out, last_alarm_str = compute_motion_from_alarm(
180
+ self._last_alarm, tzinfo
181
181
  )
182
- last_alarm_dt: datetime.datetime | None = None
183
- if epoch is not None:
184
- try:
185
- # Accept int/float/str; auto-detect ms vs s
186
- if isinstance(epoch, str):
187
- epoch = float(epoch)
188
- ts = float(epoch)
189
- if ts > 1e11: # very likely milliseconds
190
- ts = ts / 1000.0
191
- last_alarm_dt = datetime.datetime.fromtimestamp(ts, tz=tzinfo)
192
- except (
193
- TypeError,
194
- ValueError,
195
- OSError,
196
- ): # fall back to string parsing below
197
- last_alarm_dt = None
198
-
199
- if last_alarm_dt is None:
200
- # Fall back to string parsing
201
- raw = str(
202
- self._last_alarm.get("alarmStartTimeStr")
203
- or self._last_alarm.get("alarmTimeStr")
204
- or ""
205
- )
206
- if not raw:
207
- return
208
- if "Today" in raw:
209
- raw = raw.replace("Today", str(now.date()))
210
- try:
211
- last_alarm_dt = datetime.datetime.strptime(
212
- raw, "%Y-%m-%d %H:%M:%S"
213
- ).replace(tzinfo=tzinfo)
214
- except ValueError: # Unrecognized format; give up gracefully
215
- _LOGGER.debug(
216
- "Unrecognized alarm time format for %s: %s", self._serial, raw
217
- )
218
- self._alarmmotiontrigger = {
219
- "alarm_trigger_active": False,
220
- "timepassed": None,
221
- }
222
- return
223
-
224
- timepassed = now - last_alarm_dt
225
- seconds = max(0.0, timepassed.total_seconds()) if timepassed else None
226
182
 
227
183
  self._alarmmotiontrigger = {
228
- "alarm_trigger_active": bool(timepassed < datetime.timedelta(seconds=60)),
229
- "timepassed": seconds,
184
+ "alarm_trigger_active": active,
185
+ "timepassed": seconds_out,
186
+ "last_alarm_time_str": last_alarm_str,
230
187
  }
231
188
 
232
189
  def _get_tzinfo(self) -> datetime.tzinfo:
233
- """Return tzinfo from camera setting if recognizable, else local tzinfo.
234
-
235
- Attempts to parse common formats like 'UTC+02:00', 'GMT+8', '+0530', or IANA names.
236
- Falls back to local timezone.
237
- """
190
+ """Return tzinfo from camera setting if recognizable, else local tzinfo."""
238
191
  tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
239
- # IANA zone name
240
- if isinstance(tz_val, str) and "/" in tz_val:
241
- try:
242
- return ZoneInfo(tz_val)
243
- except ZoneInfoNotFoundError:
244
- pass
245
- # Offset formats
246
- offset_minutes: int | None = None
247
- if isinstance(tz_val, int):
248
- # Heuristic: treat small absolute values as hours, large as minutes/seconds
249
- if -14 <= tz_val <= 14:
250
- offset_minutes = tz_val * 60
251
- elif -24 * 60 <= tz_val <= 24 * 60:
252
- offset_minutes = tz_val
253
- elif -24 * 3600 <= tz_val <= 24 * 3600:
254
- offset_minutes = int(tz_val / 60)
255
- elif isinstance(tz_val, str):
256
- s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
257
- # Normalize formats like '+02:00', '+0200', '+2'
258
- m = re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
259
- if m:
260
- sign = -1 if m.group(1) == "-" else 1
261
- hours = int(m.group(2))
262
- minutes = int(m.group(3)) if m.group(3) else 0
263
- offset_minutes = sign * (hours * 60 + minutes)
264
- if offset_minutes is not None:
265
- return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
266
- # Fallback to local timezone
267
- return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
192
+ return parse_timezone_value(tz_val)
268
193
 
269
194
  def _is_alarm_schedules_enabled(self) -> bool:
270
195
  """Check if alarm schedules enabled."""
@@ -370,7 +295,10 @@ class EzvizCamera:
370
295
  "PIR_Status": self.fetch_key(["STATUS", "pirStatus"]),
371
296
  "Motion_Trigger": self._alarmmotiontrigger["alarm_trigger_active"],
372
297
  "Seconds_Last_Trigger": self._alarmmotiontrigger["timepassed"],
373
- "last_alarm_time": self._last_alarm.get("alarmStartTimeStr"),
298
+ # Keep last_alarm_time in sync with the time actually used to
299
+ # compute Motion_Trigger/Seconds_Last_Trigger.
300
+ "last_alarm_time": self._alarmmotiontrigger.get("last_alarm_time_str")
301
+ or self._last_alarm.get("alarmStartTimeStr"),
374
302
  "last_alarm_pic": self._last_alarm.get(
375
303
  "picUrl",
376
304
  "https://eustatics.ezvizlife.com/ovs_mall/web/img/index/EZVIZ_logo.png?ver=3007907502",
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.1
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=Vpuh7RkUBfSmNCFAXaALzfvvL0RD3SzJJyWqwZzWuHk,25191
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.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- pyezvizapi-1.0.2.1.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
- pyezvizapi-1.0.2.1.dist-info/METADATA,sha256=7bfpp78cETKjTT4kfylOsfEqxiLFRBJ2w8X8tcalz5Y,695
18
- pyezvizapi-1.0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- pyezvizapi-1.0.2.1.dist-info/entry_points.txt,sha256=_BSJ3eNb2H_AZkRdsv1s4mojqWn3N7m503ujvg1SudA,56
20
- pyezvizapi-1.0.2.1.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
21
- pyezvizapi-1.0.2.1.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,,