pyezvizapi 1.0.0.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.

Potentially problematic release.


This version of pyezvizapi might be problematic. Click here for more details.

pyezvizapi/camera.py ADDED
@@ -0,0 +1,258 @@
1
+ """pyezvizapi camera api."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from .constants import BatteryCameraWorkMode, DeviceSwitchType, SoundMode
8
+ from .exceptions import PyEzvizError
9
+ from .utils import fetch_nested_value, string_to_list
10
+
11
+ if TYPE_CHECKING:
12
+ from .client import EzvizClient
13
+
14
+
15
+ class EzvizCamera:
16
+ """Initialize Ezviz camera object."""
17
+
18
+ def __init__(
19
+ self, client: EzvizClient, serial: str, device_obj: dict | None = None
20
+ ) -> None:
21
+ """Initialize the camera object."""
22
+ self._client = client
23
+ self._serial = serial
24
+ self._alarmmotiontrigger: dict[str, Any] = {
25
+ "alarm_trigger_active": False,
26
+ "timepassed": None,
27
+ }
28
+ self._device = (
29
+ device_obj if device_obj else self._client.get_device_infos(self._serial)
30
+ )
31
+ self._last_alarm: dict[str, Any] = {}
32
+ self._switch: dict[int, bool] = {
33
+ switch["type"]: switch["enable"] for switch in self._device["SWITCH"]
34
+ }
35
+
36
+ def fetch_key(self, keys: list, default_value: Any = None) -> Any:
37
+ """Fetch dictionary key."""
38
+ return fetch_nested_value(self._device, keys, default_value)
39
+
40
+ def _alarm_list(self) -> None:
41
+ """Get last alarm info for this camera's self._serial."""
42
+ _alarmlist = self._client.get_alarminfo(self._serial)
43
+
44
+ if fetch_nested_value(_alarmlist, ["page", "totalResults"], 0) > 0:
45
+ self._last_alarm = _alarmlist["alarms"][0]
46
+ return self._motion_trigger()
47
+
48
+ def _local_ip(self) -> Any:
49
+ """Fix empty ip value for certain cameras."""
50
+ if (
51
+ self.fetch_key(["WIFI", "address"])
52
+ and self._device["WIFI"]["address"] != "0.0.0.0"
53
+ ):
54
+ return self._device["WIFI"]["address"]
55
+
56
+ # Seems to return none or 0.0.0.0 on some.
57
+ if (
58
+ self.fetch_key(["CONNECTION", "localIp"])
59
+ and self._device["CONNECTION"]["localIp"] != "0.0.0.0"
60
+ ):
61
+ return self._device["CONNECTION"]["localIp"]
62
+
63
+ return "0.0.0.0"
64
+
65
+ def _motion_trigger(self) -> None:
66
+ """Create motion sensor based on last alarm time."""
67
+ if not self._last_alarm.get("alarmStartTimeStr"):
68
+ return
69
+
70
+ _today_date = datetime.date.today()
71
+ _now = datetime.datetime.now().replace(microsecond=0)
72
+
73
+ _last_alarm_time = datetime.datetime.strptime(
74
+ self._last_alarm["alarmStartTimeStr"].replace("Today", str(_today_date)),
75
+ "%Y-%m-%d %H:%M:%S",
76
+ )
77
+
78
+ # returns a timedelta object
79
+ timepassed = _now - _last_alarm_time
80
+
81
+ self._alarmmotiontrigger = {
82
+ "alarm_trigger_active": bool(timepassed < datetime.timedelta(seconds=60)),
83
+ "timepassed": timepassed.total_seconds(),
84
+ }
85
+
86
+ def _is_alarm_schedules_enabled(self) -> bool:
87
+ """Check if alarm schedules enabled."""
88
+ _alarm_schedules = [
89
+ item for item in self._device["TIME_PLAN"] if item.get("type") == 2
90
+ ]
91
+
92
+ if _alarm_schedules:
93
+ return bool(_alarm_schedules[0].get("enable"))
94
+
95
+ return False
96
+
97
+ def status(self) -> dict[Any, Any]:
98
+ """Return the status of the camera."""
99
+ self._alarm_list()
100
+
101
+ return {
102
+ "serial": self._serial,
103
+ "name": self.fetch_key(["deviceInfos", "name"]),
104
+ "version": self.fetch_key(["deviceInfos", "version"]),
105
+ "upgrade_available": bool(
106
+ self.fetch_key(["UPGRADE", "isNeedUpgrade"]) == 3
107
+ ),
108
+ "status": self.fetch_key(["deviceInfos", "status"]),
109
+ "device_category": self.fetch_key(["deviceInfos", "deviceCategory"]),
110
+ "device_sub_category": self.fetch_key(["deviceInfos", "deviceSubCategory"]),
111
+ "upgrade_percent": self.fetch_key(["STATUS", "upgradeProcess"]),
112
+ "upgrade_in_progress": bool(
113
+ self.fetch_key(["STATUS", "upgradeStatus"]) == 0
114
+ ),
115
+ "latest_firmware_info": self.fetch_key(["UPGRADE", "upgradePackageInfo"]),
116
+ "alarm_notify": bool(self.fetch_key(["STATUS", "globalStatus"])),
117
+ "alarm_schedules_enabled": self._is_alarm_schedules_enabled(),
118
+ "alarm_sound_mod": SoundMode(
119
+ self.fetch_key(["STATUS", "alarmSoundMode"], -1)
120
+ ).name,
121
+ "encrypted": bool(self.fetch_key(["STATUS", "isEncrypt"])),
122
+ "encrypted_pwd_hash": self.fetch_key(["STATUS", "encryptPwd"]),
123
+ "local_ip": self._local_ip(),
124
+ "wan_ip": self.fetch_key(["CONNECTION", "netIp"]),
125
+ "mac_address": self.fetch_key(["deviceInfos", "mac"]),
126
+ "local_rtsp_port": self.fetch_key(["CONNECTION", "localRtspPort"], "554")
127
+ if self.fetch_key(["CONNECTION", "localRtspPort"], "554") != 0
128
+ else "554",
129
+ "supported_channels": self.fetch_key(["deviceInfos", "channelNumber"]),
130
+ "battery_level": self.fetch_key(["STATUS", "optionals", "powerRemaining"]),
131
+ "PIR_Status": self.fetch_key(["STATUS", "pirStatus"]),
132
+ "Motion_Trigger": self._alarmmotiontrigger["alarm_trigger_active"],
133
+ "Seconds_Last_Trigger": self._alarmmotiontrigger["timepassed"],
134
+ "last_alarm_time": self._last_alarm.get("alarmStartTimeStr"),
135
+ "last_alarm_pic": self._last_alarm.get(
136
+ "picUrl",
137
+ "https://eustatics.ezvizlife.com/ovs_mall/web/img/index/EZVIZ_logo.png?ver=3007907502",
138
+ ),
139
+ "last_alarm_type_code": self._last_alarm.get("alarmType", "0000"),
140
+ "last_alarm_type_name": self._last_alarm.get("sampleName", "NoAlarm"),
141
+ "cam_timezone": self.fetch_key(["STATUS", "optionals", "timeZone"]),
142
+ "push_notify_alarm": not bool(self.fetch_key(["NODISTURB", "alarmEnable"])),
143
+ "push_notify_call": not bool(
144
+ self.fetch_key(["NODISTURB", "callingEnable"])
145
+ ),
146
+ "alarm_light_luminance": self.fetch_key(
147
+ ["STATUS", "optionals", "Alarm_Light", "luminance"]
148
+ ),
149
+ "Alarm_DetectHumanCar": self.fetch_key(
150
+ ["STATUS", "optionals", "Alarm_DetectHumanCar", "type"]
151
+ ),
152
+ "diskCapacity": string_to_list(
153
+ self.fetch_key(["STATUS", "optionals", "diskCapacity"])
154
+ ),
155
+ "NightVision_Model": self.fetch_key(
156
+ ["STATUS", "optionals", "NightVision_Model"]
157
+ ),
158
+ "battery_camera_work_mode": BatteryCameraWorkMode(
159
+ self.fetch_key(["STATUS", "optionals", "batteryCameraWorkMode"], -1)
160
+ ).name,
161
+ "Alarm_AdvancedDetect": self.fetch_key(
162
+ ["STATUS", "optionals", "Alarm_AdvancedDetect", "type"]
163
+ ),
164
+ "wifiInfos": self._device["WIFI"],
165
+ "switches": self._switch,
166
+ "optionals": self.fetch_key(["STATUS", "optionals"]),
167
+ "supportExt": self._device["deviceInfos"]["supportExt"],
168
+ "ezDeviceCapability": self.fetch_key(["deviceInfos", "ezDeviceCapability"]),
169
+ }
170
+
171
+ def move(self, direction: str, speed: int = 5) -> bool:
172
+ """Move camera."""
173
+ if direction not in ["right", "left", "down", "up"]:
174
+ raise PyEzvizError(f"Invalid direction: {direction} ")
175
+
176
+ # launch the start command
177
+ self._client.ptz_control(str(direction).upper(), self._serial, "START", speed)
178
+ # launch the stop command
179
+ self._client.ptz_control(str(direction).upper(), self._serial, "STOP", speed)
180
+
181
+ return True
182
+
183
+ def move_coordinates(self, x_axis: float, y_axis: float) -> bool:
184
+ """Move camera to specified coordinates."""
185
+ return self._client.ptz_control_coordinates(self._serial, x_axis, y_axis)
186
+
187
+ def alarm_notify(self, enable: int) -> bool:
188
+ """Enable/Disable camera notification when movement is detected."""
189
+ return self._client.set_camera_defence(self._serial, enable)
190
+
191
+ def alarm_sound(self, sound_type: int) -> bool:
192
+ """Enable/Disable camera sound when movement is detected."""
193
+ # we force enable = 1 , to make sound...
194
+ return self._client.alarm_sound(self._serial, sound_type, 1)
195
+
196
+ def do_not_disturb(self, enable: int) -> bool:
197
+ """Enable/Disable do not disturb.
198
+
199
+ if motion triggers are normally sent to your device as a
200
+ notification, then enabling this feature stops these notification being sent.
201
+ The alarm event is still recorded in the EzViz app as normal.
202
+ """
203
+ return self._client.do_not_disturb(self._serial, enable)
204
+
205
+ def alarm_detection_sensibility(
206
+ self, sensibility: int, type_value: int = 0
207
+ ) -> bool | str:
208
+ """Enable/Disable camera sound when movement is detected."""
209
+ # we force enable = 1 , to make sound...
210
+ return self._client.detection_sensibility(self._serial, sensibility, type_value)
211
+
212
+ def switch_device_audio(self, enable: int = 0) -> bool:
213
+ """Switch audio status on a device."""
214
+ return self._client.switch_status(
215
+ self._serial, DeviceSwitchType.SOUND.value, enable
216
+ )
217
+
218
+ def switch_device_state_led(self, enable: int = 0) -> bool:
219
+ """Switch led status on a device."""
220
+ return self._client.switch_status(
221
+ self._serial, DeviceSwitchType.LIGHT.value, enable
222
+ )
223
+
224
+ def switch_device_ir_led(self, enable: int = 0) -> bool:
225
+ """Switch ir status on a device."""
226
+ return self._client.switch_status(
227
+ self._serial, DeviceSwitchType.INFRARED_LIGHT.value, enable
228
+ )
229
+
230
+ def switch_privacy_mode(self, enable: int = 0) -> bool:
231
+ """Switch privacy mode on a device."""
232
+ return self._client.switch_status(
233
+ self._serial, DeviceSwitchType.PRIVACY.value, enable
234
+ )
235
+
236
+ def switch_sleep_mode(self, enable: int = 0) -> bool:
237
+ """Switch sleep mode on a device."""
238
+ return self._client.switch_status(
239
+ self._serial, DeviceSwitchType.SLEEP.value, enable
240
+ )
241
+
242
+ def switch_follow_move(self, enable: int = 0) -> bool:
243
+ """Switch follow move."""
244
+ return self._client.switch_status(
245
+ self._serial, DeviceSwitchType.MOBILE_TRACKING.value, enable
246
+ )
247
+
248
+ def switch_sound_alarm(self, enable: int = 0) -> bool:
249
+ """Sound alarm on a device."""
250
+ return self._client.sound_alarm(self._serial, enable)
251
+
252
+ def change_defence_schedule(self, schedule: str, enable: int = 0) -> bool:
253
+ """Change defence schedule. Requires json formatted schedules."""
254
+ return self._client.api_set_defence_schedule(self._serial, schedule, enable)
255
+
256
+ def set_battery_camera_work_mode(self, work_mode: BatteryCameraWorkMode) -> bool:
257
+ """Change work mode for battery powered camera device."""
258
+ return self._client.set_battery_camera_work_mode(self._serial, work_mode.value)
pyezvizapi/cas.py ADDED
@@ -0,0 +1,169 @@
1
+ """pyezvizapi CAS API Functions."""
2
+
3
+ from io import BytesIO
4
+ from itertools import cycle
5
+ import random
6
+ import socket
7
+ import ssl
8
+
9
+ from Crypto.Cipher import AES
10
+ import xmltodict
11
+
12
+ from .constants import FEATURE_CODE, XOR_KEY
13
+ from .exceptions import InvalidHost
14
+
15
+
16
+ def xor_enc_dec(msg, xor_key=XOR_KEY):
17
+ """Xor encodes camera serial."""
18
+ with BytesIO(msg) as stream:
19
+ xor_msg = bytes(a ^ b for a, b in zip(stream.read(), cycle(xor_key)))
20
+ return xor_msg
21
+
22
+
23
+ class EzvizCAS:
24
+ """Ezviz CAS server client."""
25
+
26
+ def __init__(self, token) -> None:
27
+ """Initialize the client object."""
28
+ self._session = None
29
+ self._token = token or {
30
+ "session_id": None,
31
+ "rf_session_id": None,
32
+ "username": None,
33
+ "api_url": "apiieu.ezvizlife.com",
34
+ }
35
+ self._service_urls = token["service_urls"]
36
+
37
+ def cas_get_encryption(self, devserial):
38
+ """Fetch encryption code from ezviz cas server."""
39
+
40
+ # Random hex 64 characters long.
41
+ rand_hex = random.randrange(10**80)
42
+ rand_hex = "%064x" % rand_hex
43
+ rand_hex = rand_hex[:64]
44
+
45
+ payload = (
46
+ f"\x9e\xba\xac\xe9\x01\x00\x00\x00\x00\x00"
47
+ f"\x00\x02" # Check or order?
48
+ f"\x00\x00\x00\x00\x00\x00 "
49
+ f"\x01" # Check or order?
50
+ f"\x00\x00\x00\x00\x00\x00\x02\t\x00\x00\x00\x00"
51
+ f'<?xml version="1.0" encoding="utf-8"?>\n<Request>\n\t'
52
+ f'<ClientID>{self._token["session_id"]}</ClientID>'
53
+ f"\n\t<Sign>{FEATURE_CODE}</Sign>\n\t"
54
+ f"<DevSerial>{devserial}</DevSerial>"
55
+ f"\n\t<ClientType>0</ClientType>\n</Request>\n"
56
+ ).encode("latin1")
57
+
58
+ payload_end_padding = rand_hex.encode("latin1")
59
+
60
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS)
61
+
62
+ context.set_ciphers(
63
+ "DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK"
64
+ )
65
+
66
+ # Create a TCP/IP socket
67
+ my_socket = socket.create_connection(
68
+ (self._service_urls["sysConf"][15], self._service_urls["sysConf"][16])
69
+ )
70
+ my_socket = context.wrap_socket(
71
+ my_socket, server_hostname=self._service_urls["sysConf"][15]
72
+ )
73
+
74
+ # Get CAS Encryption Key
75
+ try:
76
+ my_socket.send(payload + payload_end_padding)
77
+ response = my_socket.recv(1024)
78
+ print(f"Get Encryption Key: {response}")
79
+
80
+ except (socket.gaierror, ConnectionRefusedError) as err:
81
+ raise InvalidHost("Invalid IP or Hostname") from err
82
+
83
+ finally:
84
+ my_socket.close()
85
+
86
+ # Trim header, tail and convert xml to dict.
87
+ response = response[32::]
88
+ response = response[:-32:]
89
+ response = xmltodict.parse(response)
90
+
91
+ return response
92
+
93
+ def set_camera_defence_state(self, serial, enable=1):
94
+ """Enable alarm notifications."""
95
+
96
+ # Random hex 64 characters long.
97
+ rand_hex = random.randrange(10**80)
98
+ rand_hex = "%064x" % rand_hex
99
+ rand_hex = rand_hex[:64]
100
+
101
+ payload = (
102
+ f"\x9e\xba\xac\xe9\x01\x00\x00\x00\x00\x00"
103
+ f"\x00\x14" # Check or order?
104
+ f"\x00\x00\x00\x00\x00\x00 "
105
+ f"\x05"
106
+ f"\x00\x00\x00\x00\x00\x00\x02\xd0\x00\x00\x01\xe0"
107
+ f'<?xml version="1.0" encoding="utf-8"?>\n<Request>\n\t'
108
+ f'<Verify ClientSession="{self._token["session_id"]}" '
109
+ f'ToDevice="{serial}" ClientType="0" />\n\t'
110
+ f'<Message Length="240" />\n</Request>\n'
111
+ f"\x9e\xba\xac\xe9\x01\x00\x00\x00\x00\x00"
112
+ f"\x00\x13"
113
+ f"\x00\x00\x00\x00\x00\x000\x0f\xff\xff\xff\xff"
114
+ f"\x00\x00\x00\xb0\x00\x00\x00\x00"
115
+ ).encode("latin1")
116
+
117
+ payload_end_padding = rand_hex.encode("latin1")
118
+
119
+ # xor camera serial
120
+ xor_cam_serial = xor_enc_dec(serial.encode("latin1"))
121
+
122
+ defence_msg_string = (
123
+ f'{xor_cam_serial.decode()}2+,*xdv.0" '
124
+ f'encoding="utf-8"?>\n'
125
+ f"<Request>\n"
126
+ f"\t<OperationCode>ABCDEFG</OperationCode>\n"
127
+ f'\t<Defence Type="Global" Status="{enable}" Actor="V" Channel="0" />\n'
128
+ f"</Request>\n"
129
+ f"\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10"
130
+ ).encode("latin1")
131
+
132
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS)
133
+
134
+ context.set_ciphers(
135
+ "DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK"
136
+ )
137
+
138
+ # Create a TCP/IP socket
139
+ my_socket = socket.create_connection(
140
+ (self._service_urls["sysConf"][15], self._service_urls["sysConf"][16])
141
+ )
142
+ my_socket = context.wrap_socket(
143
+ my_socket, server_hostname=self._service_urls["sysConf"][15]
144
+ )
145
+
146
+ cas_client = self.cas_get_encryption(serial)
147
+
148
+ aes_key = cas_client["Response"]["Session"]["@Key"].encode("latin1")
149
+ iv_value = (
150
+ f"{serial}{cas_client['Response']['Session']['@OperationCode']}".encode(
151
+ "latin1"
152
+ )
153
+ )
154
+
155
+ # Message encryption
156
+ cipher = AES.new(aes_key, AES.MODE_CBC, iv_value)
157
+
158
+ try:
159
+ defence_msg_string = cipher.encrypt(defence_msg_string)
160
+ my_socket.send(payload + defence_msg_string + payload_end_padding)
161
+ print(f"Set camera response: {my_socket.recv()}")
162
+
163
+ except (socket.gaierror, ConnectionRefusedError) as err:
164
+ raise InvalidHost("Invalid IP or Hostname") from err
165
+
166
+ finally:
167
+ my_socket.close()
168
+
169
+ return True