tinytoolslib 0.2.5__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.
tinytoolslib/models.py ADDED
@@ -0,0 +1,1674 @@
1
+ from dataclasses import dataclass, field, asdict
2
+ import re
3
+ from typing import Tuple, Union, Dict, List, ClassVar, Any
4
+ import warnings
5
+
6
+ from tinytoolslib.parsers import (
7
+ int_inverted,
8
+ parse_version,
9
+ up_to_int,
10
+ float_div10,
11
+ float_div100,
12
+ float_div1000,
13
+ strint_to_int_list,
14
+ name_list,
15
+ list_map,
16
+ )
17
+ from tinytoolslib.requests import get, async_get
18
+ from aiohttp import ClientSession
19
+ from tinytoolslib.constants import (
20
+ FAMILY_DCDC,
21
+ FAMILY_LK,
22
+ FAMILY_PS,
23
+ FAMILY_TCPDU,
24
+ FW_URL_TEMPLATE,
25
+ )
26
+ from tinytoolslib.exceptions import (
27
+ TinyToolsRequestConnectionError,
28
+ TinyToolsRequestError,
29
+ TinyToolsRequestInternalServerError,
30
+ TinyToolsRequestNotFound,
31
+ TinyToolsRequestSSLError,
32
+ TinyToolsRequestTimeout,
33
+ TinyToolsUnsupported,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class DeviceInfo:
39
+ model: str
40
+ family: str
41
+ fw_tag: Union[str, None] = None
42
+ fw_url: Union[str, None] = field(init=False, default=None)
43
+ fw_changelog: Union[str, None] = None
44
+ extras: Dict[str, Any] = field(default_factory=dict)
45
+
46
+ def __post_init__(self):
47
+ if self.fw_tag:
48
+ self.fw_url = FW_URL_TEMPLATE.format(self.fw_tag)
49
+
50
+
51
+ @dataclass
52
+ class DeviceModel:
53
+ """Base class for tinycontrol devices with common methods."""
54
+
55
+ info: ClassVar[Union[DeviceInfo, None]] = None
56
+ mapping: ClassVar[Dict[str, Dict]] = {}
57
+ parsers: ClassVar[List[str]] = []
58
+
59
+ host: str
60
+ schema: str = "http"
61
+ port: int = 80
62
+ username: str = ""
63
+ password: str = ""
64
+ hardware_version: str = ""
65
+ software_version: str = ""
66
+ session: Union[ClientSession, None] = None
67
+
68
+ _context: Dict[str, Dict] = field(init=False, default_factory=dict)
69
+ _close_session: bool = False
70
+
71
+ def __post_init__(self):
72
+ if self.schema == "https" and self.port != 443:
73
+ warnings.warn(
74
+ "Devices (LK3.X, LK4.X, tcPDU) always use port 443 for https. "
75
+ f"You are about to use {self.port}."
76
+ )
77
+
78
+ @classmethod
79
+ def check_version(
80
+ cls, hardware_version: Union[str, None], software_version: str
81
+ ) -> bool:
82
+ """Verifies if versions matches this Device model."""
83
+ raise NotImplementedError
84
+
85
+ def _get(
86
+ self,
87
+ data: Dict[str, Any],
88
+ path: str,
89
+ skip_keys: Union[List[str], None] = None,
90
+ remove_mapped_keys: bool = False,
91
+ ) -> Dict[str, Any]:
92
+ """Process get data (dict) with mapping."""
93
+ if data is not None:
94
+ updates = {}
95
+ for key, value in data.items():
96
+ if skip_keys and key in skip_keys:
97
+ continue
98
+ mapper = self.mapping.get(key)
99
+ if mapper is not None:
100
+ mapped = mapper["format"](value)
101
+ if isinstance(mapper["name"], list):
102
+ for name, val in zip(mapper["name"], mapped):
103
+ updates[name] = val
104
+ else:
105
+ updates[mapper["name"]] = mapped
106
+ data.update(updates)
107
+ if remove_mapped_keys:
108
+ # Remove keys that were parsed/mapped
109
+ for key, val in self.mapping.items():
110
+ if isinstance(val["name"], str) and key != val["name"]:
111
+ data.pop(key, None)
112
+ # Run extra parsers, that need to work on whole response.
113
+ for parser in self.parsers:
114
+ parser_func = getattr(self, parser)
115
+ parser_func(data, path)
116
+ return data
117
+
118
+ def get(
119
+ self,
120
+ path: str,
121
+ skip_keys: Union[List[str], None] = None,
122
+ remove_mapped_keys: bool = False,
123
+ **kwargs,
124
+ ) -> Dict[str, Any]:
125
+ """Get data, process parsed part with mapping."""
126
+ response = get(
127
+ self.host,
128
+ path,
129
+ schema=self.schema,
130
+ port=self.port,
131
+ username=self.username,
132
+ password=self.password,
133
+ **kwargs,
134
+ )
135
+ return self._get(response.get("parsed"), path, skip_keys, remove_mapped_keys)
136
+
137
+ def set_out(self, index, value=None):
138
+ """Set output state to value or toggle if value is None."""
139
+ if hasattr(self, "_set_out") and callable(self._set_out):
140
+ return self.get(self._set_out(index, value))
141
+ raise TinyToolsUnsupported(
142
+ "{} does not support controlling OUTs".format(self.__class__.__name__)
143
+ )
144
+
145
+ def set_pwm(self, index, value):
146
+ """Set pwm state to value or toggle if value is None."""
147
+ if hasattr(self, "_set_pwm") and callable(self._set_pwm):
148
+ return self.get(self._set_pwm(index, value))
149
+ raise TinyToolsUnsupported(
150
+ "{} does not support controlling PWMs".format(self.__class__.__name__)
151
+ )
152
+
153
+ def set_pwm_duty(self, index, value):
154
+ """Set pwm duty to value."""
155
+ if hasattr(self, "_set_pwm_duty") and callable(self._set_pwm_duty):
156
+ return self.get(self._set_pwm_duty(index, value))
157
+ raise TinyToolsUnsupported(
158
+ "{} does not support controlling PWM duty".format(self.__class__.__name__)
159
+ )
160
+
161
+ def set_pwm_freq(self, index, value):
162
+ """Set pwm freq to value."""
163
+ if hasattr(self, "_set_pwm_freq") and callable(self._set_pwm_freq):
164
+ return self.get(self._set_pwm_freq(index, value))
165
+ raise TinyToolsUnsupported(
166
+ "{} does not support controlling PWM freq".format(self.__class__.__name__)
167
+ )
168
+
169
+ def set_var(self, index, value):
170
+ """Set VAR/EVENT variable to value."""
171
+ if hasattr(self, "_set_var") and callable(self._set_var):
172
+ return self.get(self._set_var(index, value))
173
+ raise TinyToolsUnsupported(
174
+ "{} does not support controlling VARs".format(self.__class__.__name__)
175
+ )
176
+
177
+ def set_ds(self, index: int, value: str):
178
+ """Set ID of DS on position to value."""
179
+ if hasattr(self, "_set_ds") and callable(self._set_ds):
180
+ return self.get(self._set_ds(index, value))
181
+ raise TinyToolsUnsupported(
182
+ "{} does not support setting DSs".format(self.__class__.__name__)
183
+ )
184
+
185
+ def get_all(self) -> Dict[str, Any]:
186
+ """Get set of all sensor/readings."""
187
+ if hasattr(self, "_get_all") and callable(self._get_all):
188
+ data = {}
189
+ for url in self._get_all():
190
+ data.update(self.get(url))
191
+ return data
192
+ raise TinyToolsUnsupported(
193
+ "{} does not support get_all command".format(self.__class__.__name__)
194
+ )
195
+
196
+ def reset_to_defaults(self):
197
+ """Reset settings to defaults."""
198
+ if hasattr(self, "_reset_to_defaults") and callable(self._reset_to_defaults):
199
+ return self.get(self._reset_to_defaults())
200
+ raise TinyToolsUnsupported(
201
+ "{} does not support reset to defaults".format(self.__class__.__name__)
202
+ )
203
+
204
+ def restart(self):
205
+ """Restart device."""
206
+ if hasattr(self, "_restart") and callable(self._restart):
207
+ return self.get(self._restart())
208
+ raise TinyToolsUnsupported(
209
+ "{} does not support restart command".format(self.__class__.__name__)
210
+ )
211
+
212
+ # region Async variants
213
+ async def async_get(
214
+ self,
215
+ path: str,
216
+ skip_keys: Union[List[str], None] = None,
217
+ remove_mapped_keys: bool = False,
218
+ **kwargs,
219
+ ):
220
+ """Async version of get."""
221
+ if self.session is None:
222
+ self.session = ClientSession()
223
+ self._close_session = True
224
+ response = await async_get(
225
+ self.host,
226
+ path,
227
+ schema=self.schema,
228
+ port=self.port,
229
+ username=self.username,
230
+ password=self.password,
231
+ session=self.session,
232
+ **kwargs,
233
+ )
234
+ return self._get(response.get("parsed"), path, skip_keys, remove_mapped_keys)
235
+
236
+ async def async_set_out(self, index, value=None):
237
+ if hasattr(self, "_set_out") and callable(self._set_out):
238
+ return await self.async_get(self._set_out(index, value))
239
+ raise TinyToolsUnsupported(
240
+ "{} does not support controlling OUTs".format(self.__class__.__name__)
241
+ )
242
+
243
+ async def async_set_pwm(self, index, value):
244
+ if hasattr(self, "_set_pwm") and callable(self._set_pwm):
245
+ return await self.async_get(self._set_pwm(index, value))
246
+ raise TinyToolsUnsupported(
247
+ "{} does not support controlling PWMs".format(self.__class__.__name__)
248
+ )
249
+
250
+ async def async_set_var(self, index, value):
251
+ if hasattr(self, "_set_var") and callable(self._set_var):
252
+ return await self.async_get(self._set_var(index, value))
253
+ raise TinyToolsUnsupported(
254
+ "{} does not support controlling VARs".format(self.__class__.__name__)
255
+ )
256
+
257
+ async def async_get_all(self) -> Dict[str, Any]:
258
+ if hasattr(self, "_get_all") and callable(self._get_all):
259
+ data = {}
260
+ for url in self._get_all():
261
+ data.update(await self.async_get(url))
262
+ return data
263
+ raise TinyToolsUnsupported(
264
+ "{} does not support get_all command".format(self.__class__.__name__)
265
+ )
266
+
267
+ # region Session handling for asyncio
268
+ async def close(self) -> None:
269
+ """Close open client session."""
270
+ await self.session.close()
271
+
272
+ async def __aenter__(self) -> "DeviceModel":
273
+ """Async enter.
274
+
275
+ Returns
276
+ -------
277
+ The Device object.
278
+ """
279
+ return self
280
+
281
+ async def __aexit__(self, *_exc_info: object) -> None:
282
+ """Async exit.
283
+
284
+ Args:
285
+ ----
286
+ _exc_info: Exec type.
287
+ """
288
+ await self.close()
289
+
290
+ # endregion
291
+ # endregion
292
+
293
+
294
+ @dataclass
295
+ class LK_HW_20_PS(DeviceModel):
296
+ """Methods for working with Power Socket on LK2.0.
297
+
298
+ Note: for outputs it uses unified values 0 - off, 1 - on.
299
+ """
300
+
301
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
302
+ "IP Power Socket v1 (LK2.0)", # 5G10A/6G10A
303
+ FAMILY_PS,
304
+ )
305
+ mapping: ClassVar[Dict[str, Dict]] = {
306
+ # --- st0.xml
307
+ "out0": {"name": "out0", "format": int_inverted},
308
+ "out1": {"name": "out1", "format": int_inverted},
309
+ "out2": {"name": "out2", "format": int_inverted},
310
+ "out3": {"name": "out3", "format": int_inverted},
311
+ "out4": {"name": "out4", "format": int_inverted},
312
+ "out5": {"name": "out5", "format": int_inverted},
313
+ "out6": {"name": "out_negation", "format": int_inverted},
314
+ "di0": {"name": "iDValue1", "format": up_to_int},
315
+ "di1": {"name": "iDValue2", "format": up_to_int},
316
+ "ia0": {"name": "boardTemp", "format": float_div10},
317
+ "ia1": {"name": "ds1", "format": float_div10},
318
+ "ia2": {"name": "ds2", "format": float_div10},
319
+ "ia3": {"name": "ds3", "format": float_div10},
320
+ "ia4": {"name": "ds4", "format": float_div10},
321
+ "ia5": {"name": "iAValue1", "format": float_div100}, # Voltage input
322
+ "ia6": {"name": "boardVoltage", "format": float_div10},
323
+ # Doubled keys for time
324
+ "sec0": {"name": "uptimeSeconds", "format": int},
325
+ "sec1": {"name": "uptimeMinutes", "format": int},
326
+ "sec2": {"name": "uptimeHours", "format": int},
327
+ "sec3": {"name": "uptimeDays", "format": int},
328
+ "sec4": {"name": "time", "format": int},
329
+ "t": {"name": "time", "format": int},
330
+ # --- st2.xml
331
+ "ver": {"name": "software_version", "format": str},
332
+ "hw": {"name": "hardware_version", "format": lambda x: "2." + x},
333
+ "na": {"name": "hostname", "format": str},
334
+ "r0": {"name": "out0_reset_time", "format": int},
335
+ "r1": {"name": "out1_reset_time", "format": int},
336
+ "r2": {"name": "out2_reset_time", "format": int},
337
+ "r3": {"name": "out3_reset_time", "format": int},
338
+ "r4": {"name": "out4_reset_time", "format": int},
339
+ "r5": {"name": "out5_reset_time", "format": int},
340
+ "r6": {"name": "out0_name", "format": str},
341
+ "r7": {"name": "out1_name", "format": str},
342
+ "r8": {"name": "out2_name", "format": str},
343
+ "r9": {"name": "out3_name", "format": str},
344
+ "r10": {"name": "out4_name", "format": str},
345
+ "r11": {"name": "out5_name", "format": str},
346
+ # Autoswitch times 6*on + 6*off (X*X*X*...)
347
+ "a": {"name": "autoswitch_times", "format": str},
348
+ # Autoswitch enabled (int with bin values 000000)
349
+ "as": {"name": "autoswitch_active", "format": str},
350
+ # Names divided with *
351
+ "d": {"name": "dsName1-4_iAName1_iDName1-2", "format": str},
352
+ # --- board.xml (configuration like network, remote access, email, etc.)
353
+ # a0, a1, a2 - auto send trap settings
354
+ # Email
355
+ "b0": {"name": "email_host", "format": str},
356
+ "b1": {"name": "email_port", "format": int},
357
+ "b2": {"name": "email_username", "format": str},
358
+ "b3": {"name": "email_password", "format": str},
359
+ "b4": {"name": "email_to", "format": str},
360
+ "b5": {"name": "email_sender", "format": str},
361
+ "b26": {"name": "email_subject", "format": str},
362
+ # Network
363
+ "b6": {"name": "mac", "format": str},
364
+ "b7": {"name": "hostname", "format": str},
365
+ "b27": {"name": "dhcp", "format": bool}, # 'true' or ''
366
+ "b8": {"name": "ip_address", "format": str},
367
+ "b9": {"name": "gateway", "format": str},
368
+ "b10": {"name": "netmask", "format": str},
369
+ "b11": {"name": "dns_primary", "format": str},
370
+ "b12": {"name": "dns_secondary", "format": str},
371
+ "b13": {"name": "http_port", "format": int},
372
+ # Access
373
+ "b14": {"name": "admin_username", "format": str},
374
+ "b15": {"name": "admin_password", "format": str},
375
+ "b29": {"name": "basic_auth", "format": bool}, # 'true' or ''
376
+ "b30": {"name": "user_username", "format": str},
377
+ "b31": {"name": "user_password", "format": str},
378
+ # NTP
379
+ "b16": {"name": "ntp_host", "format": str},
380
+ "b17": {"name": "ntp_port", "format": int},
381
+ "b18": {"name": "ntp_interval", "format": int},
382
+ "b19": {"name": "ntp_timezone", "format": int},
383
+ # SNMP
384
+ "b20": {"name": "snmp_public_community", "format": str},
385
+ "b21": {"name": "snmp_public_community2", "format": str},
386
+ "b22": {"name": "snmp_private_community", "format": str},
387
+ "b23": {"name": "snmp_private_community2", "format": str},
388
+ "b24": {"name": "snmp_trap_ip", "format": str},
389
+ "b25": {"name": "snmp_trap_community", "format": str},
390
+ "b28": {"name": "snmp_trap_active", "format": bool}, # 'true' or ''
391
+ # r0, r1, r2 - remote control
392
+ }
393
+
394
+ @classmethod
395
+ def check_version(
396
+ cls, hardware_version: Union[str, None], software_version: str
397
+ ) -> bool:
398
+ return hardware_version == "2.0" and software_version in [
399
+ "6.00",
400
+ "6.09",
401
+ "6.10",
402
+ "6.12a",
403
+ "6.12",
404
+ ]
405
+
406
+ def _get(
407
+ self,
408
+ data: Dict[str, Any],
409
+ path: str,
410
+ skip_keys: Union[List[str], None] = None,
411
+ remove_mapped_keys: bool = False,
412
+ ) -> Dict[str, Any]:
413
+ if path == "/board.xml":
414
+ if skip_keys is None:
415
+ skip_keys = set()
416
+ # Ignore few variables for /board.xml as they overlap in /st2.xml and /board.xml,
417
+ # but with different meaning (out reset time instead of remote control).
418
+ skip_keys.update({"r0", "r1", "r2", "r3", "r4"})
419
+ return super()._get(data, path, skip_keys, remove_mapped_keys)
420
+
421
+ def _set_out(
422
+ self, index: Union[int, List[int]], value: Union[int, List[int], None]
423
+ ) -> str:
424
+ """Prepare command for setting outputs OUT.
425
+
426
+ Arguments:
427
+ index: 0-5 (single or list)
428
+ value: 0-1 (single or list)
429
+ """
430
+ cmd = "/outs.cgi?"
431
+ if isinstance(index, list):
432
+ if value is None:
433
+ cmd += "out=" + "".join(map(str, index))
434
+ elif isinstance(value, list):
435
+ cmd += "&".join(
436
+ [
437
+ "out{}={}".format(ix, int_inverted(val))
438
+ for ix, val in zip(index, value)
439
+ ]
440
+ )
441
+ else:
442
+ cmd += "&".join(
443
+ ["out{}={}".format(ix, int_inverted(value)) for ix in index]
444
+ )
445
+ else:
446
+ if value is None:
447
+ cmd += "out={}".format(index)
448
+ else:
449
+ cmd += "out{}={}".format(index, int_inverted(value))
450
+ # Fix for HW2.0 - HW2.0 uses NON inverted for out5=Y,
451
+ # so Y=1 to turn on and Y=0 to turn off.
452
+ if self.hardware_version == "2.0":
453
+ if "out5=0" in cmd:
454
+ cmd = cmd.replace("out5=0", "out5=1")
455
+ elif "out5=1" in cmd:
456
+ cmd = cmd.replace("out5=1", "out5=0")
457
+ return cmd
458
+
459
+ def _get_all(self) -> List[str]:
460
+ """Prepare list of URLs to fetch data from."""
461
+ return ["/st0.xml", "/board.xml", "/st2.xml"]
462
+
463
+
464
+ @dataclass
465
+ class LK_HW_20(LK_HW_20_PS):
466
+ """Methods for working with LK2.0.
467
+
468
+ Note: for outputs it uses unified values 0 - off, 1 - on.
469
+ """
470
+
471
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
472
+ "LK HW 2.0",
473
+ FAMILY_LK,
474
+ "lc20",
475
+ "https://tinycontrol.pl/en/archives/lan-controller-20/#firmware",
476
+ )
477
+ mapping: ClassVar[Dict[str, Dict]] = {
478
+ # Overwrite PS mapping (there will be extra b30, b31)
479
+ **LK_HW_20_PS.mapping,
480
+ # --- st0.xml
481
+ "di2": {"name": "iDValue3", "format": up_to_int},
482
+ "di3": {"name": "iDValue4", "format": up_to_int},
483
+ "ia0": {"name": "boardTemp", "format": float_div10},
484
+ "ia1": {"name": "boardVoltage", "format": float_div10},
485
+ "ia2": {"name": "iAValue1", "format": float_div100},
486
+ "ia3": {"name": "iAValue2", "format": float_div100},
487
+ "ia4": {"name": "iAValue3", "format": float_div10},
488
+ "ia5": {"name": "iAValue4", "format": float_div100},
489
+ "ia6": {"name": "iAValue5", "format": float_div10},
490
+ "ia7": {"name": "ds1", "format": float_div10},
491
+ "ia8": {"name": "ds2", "format": float_div10},
492
+ "ia9": {"name": "ds3", "format": float_div10},
493
+ "ia10": {"name": "ds4", "format": float_div10},
494
+ "ia11": {"name": "ds5", "format": float_div10},
495
+ "ia12": {"name": "ds6", "format": float_div10},
496
+ "ia13": {"name": "dth22 temp", "format": float_div10},
497
+ "ia14": {"name": "dth22 hum", "format": float_div10},
498
+ "ia15": {"name": "power1", "format": float_div1000}, # power1 is iA4*iA5
499
+ "ia16": {"name": "energy1", "format": float_div1000},
500
+ "ia17": {
501
+ "name": "power2",
502
+ "format": float_div1000,
503
+ }, # power2 is iD4 impulse counter
504
+ "ia18": {"name": "inp4d_ia18", "format": float_div1000}, # No idea what is it
505
+ "ia19": {"name": "diff1", "format": float_div10},
506
+ "freq": {"name": "pwmFrequency0", "format": int},
507
+ "duty": {"name": "pwmDuty0", "format": float_div10},
508
+ "pwm": {"name": "pwm0", "format": int},
509
+ # --- st2.xml
510
+ # Calibrations
511
+ "k0": {"name": "cal_board_temp", "format": float_div10},
512
+ "k1": {"name": "cal_board_voltage", "format": float_div10},
513
+ "k2": {"name": "cal_iA1", "format": float_div100},
514
+ "k3": {"name": "cal_iA2", "format": float_div100},
515
+ "k4": {"name": "cal_iA3", "format": float_div10},
516
+ "k5": {"name": "cal_iA4", "format": float_div100},
517
+ "k6": {"name": "cal_iA5", "format": float_div10},
518
+ "k7": {"name": "cal_iA1_sensor", "format": float_div10},
519
+ "k8": {"name": "cal_iA4_sensor", "format": float_div10},
520
+ "k9": {"name": "cal_iA5_sensor", "format": float_div10},
521
+ "k10": {"name": "diff1_part1", "format": int},
522
+ "k11": {"name": "diff1_part2", "format": int},
523
+ # Names divided with *
524
+ "d": {"name": "dsName1-6_iDName1-4", "format": str},
525
+ "dz": {"name": "power2_iD4_divisor", "format": int},
526
+ "mm": {"name": "power2_iD4_unit", "format": str},
527
+ "mh": {"name": "power2_iD4_divisor2", "format": int},
528
+ # Negation of iD (int with bin 0000)
529
+ "db": {"name": "iD_negation", "format": str},
530
+ # --- board.xml
531
+ "ds": {"name": "ds_read_id", "format": str},
532
+ }
533
+
534
+ @classmethod
535
+ def check_version(
536
+ cls, hardware_version: Union[str, None], software_version: str
537
+ ) -> bool:
538
+ return hardware_version == "2.0" and software_version not in [
539
+ "6.00",
540
+ "6.09",
541
+ "6.10",
542
+ "6.12a",
543
+ "6.12",
544
+ ]
545
+
546
+ def _set_pwm(
547
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
548
+ ) -> str:
549
+ """Prepare command for setting PWM.
550
+
551
+ Arguments:
552
+ index: 0-3 (single or list)
553
+ value: 0-1 (single or list)
554
+ NOTE: 1-3 can be only set, not read
555
+ """
556
+ cmd = "/ind.cgi?"
557
+ if isinstance(index, list):
558
+ if isinstance(value, list):
559
+ cmd += "&".join(
560
+ ["pwm{}={}".format(ix, val) for ix, val in zip(index, value)]
561
+ )
562
+ else:
563
+ cmd += "&".join(["pwm{}={}".format(ix, value) for ix in index])
564
+ else:
565
+ cmd += "pwm{}={}".format(index, value)
566
+ cmd = cmd.replace("pwm0", "pwm")
567
+ return cmd
568
+
569
+ def _set_pwm_duty(
570
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
571
+ ) -> str:
572
+ """Prepare command for setting PWM duty.
573
+
574
+ Arguments:
575
+ index: 0-3 (single or list)
576
+ value: 0-100 (single or list)
577
+ NOTE: 1-3 can be only set, not read
578
+ """
579
+ cmd = "/ind.cgi?"
580
+ if isinstance(index, list):
581
+ if isinstance(value, list):
582
+ cmd += "&".join(
583
+ [
584
+ "pwmd{}={}".format(ix, int(val * 10))
585
+ for ix, val in zip(index, value)
586
+ ]
587
+ )
588
+ else:
589
+ cmd += "&".join(
590
+ ["pwmd{}={}".format(ix, int(value * 10)) for ix in index]
591
+ )
592
+ else:
593
+ cmd += "pwmd{}={}".format(index, int(value * 10))
594
+ cmd = cmd.replace("pwmd0", "pwmd")
595
+ return cmd
596
+
597
+ def _set_pwm_freq(self, index: Any, value: int) -> str:
598
+ """Prepare command for setting PWM freq.
599
+
600
+ Arguments:
601
+ index: not used (only one frequency)
602
+ value: 2_600-4_000_000
603
+ """
604
+ return "/ind.cgi?pwmf={}".format(value)
605
+
606
+ def _set_ds(self, index: int, value: Any = None) -> str:
607
+ """Set ID of DS on position to value.
608
+
609
+ Arguments:
610
+ index - 1-6
611
+ value - not used for LK2.X
612
+ """
613
+ cmd = "/ind.cgi?ds={}".format(index)
614
+ return cmd
615
+
616
+ def get_ds_id(self) -> str:
617
+ """Get ID of detected DS."""
618
+ self.get("/ind.cgi?ds=0")
619
+ return self.get("/board.xml").get("ds_read_id")
620
+
621
+
622
+ @dataclass
623
+ class LK_HW_25(LK_HW_20):
624
+ """Methods for working with LK2.5.
625
+
626
+ Note: for outputs it uses unified values 0 - off, 1 - on.
627
+ """
628
+
629
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
630
+ "LK HW 2.5",
631
+ FAMILY_LK,
632
+ "lc25",
633
+ "https://tinycontrol.pl/en/lan-controller-25/firmware-docs/#firmware",
634
+ )
635
+
636
+ @classmethod
637
+ def check_version(
638
+ cls, hardware_version: Union[str, None], software_version: str
639
+ ) -> bool:
640
+ return hardware_version == "2.5" and software_version != "6.15"
641
+
642
+
643
+ @dataclass
644
+ class LK_HW_25_PS(LK_HW_20_PS):
645
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
646
+ "IP Power Socket v2 (LK2.5)",
647
+ FAMILY_PS,
648
+ )
649
+
650
+ @classmethod
651
+ def check_version(
652
+ cls, hardware_version: Union[str, None], software_version: str
653
+ ) -> bool:
654
+ return hardware_version == "2.5" and software_version == "6.15"
655
+
656
+
657
+ @dataclass
658
+ class LK_HW_30(DeviceModel):
659
+ """Methods for working with LK3.0.
660
+
661
+ Note that INPD/digital for HW 3.5+ SW 1.49+ includes negation
662
+ right in response from LK. Previous SW and HW 3.0 do NOT.
663
+ """
664
+
665
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
666
+ "LK HW 3.0",
667
+ FAMILY_LK,
668
+ "lc30",
669
+ "https://tinycontrol.pl/en/archives/lan-controller-30/#firmware",
670
+ {"number_of_outputs": 6},
671
+ )
672
+ mapping: ClassVar[Dict[str, Dict]] = {
673
+ # OUTs - further parsed with _parse_outs
674
+ "out0": {"name": "out0", "format": int},
675
+ "out1": {"name": "out1", "format": int},
676
+ "out2": {"name": "out2", "format": int},
677
+ "out3": {"name": "out3", "format": int},
678
+ "out4": {"name": "out4", "format": int},
679
+ "out5": {"name": "out5", "format": int},
680
+ "out": {"name": name_list("out", 6, 0), "format": strint_to_int_list(6)},
681
+ # PWM
682
+ "pwm": {"name": name_list("pwm", 4, 0), "format": strint_to_int_list(4)},
683
+ "pwmd0": {"name": "pwmDuty0", "format": int},
684
+ "pwmd1": {"name": "pwmDuty1", "format": int},
685
+ "pwmd2": {"name": "pwmDuty2", "format": int},
686
+ "pwmd3": {"name": "pwmDuty3", "format": int},
687
+ "pwmf0": {"name": "pwmFrequency0", "format": int},
688
+ "pwmf1": {"name": "pwmFrequency13", "format": int},
689
+ # EVENT
690
+ "eventVariables": {
691
+ "name": name_list("event", 8),
692
+ "format": strint_to_int_list(8),
693
+ },
694
+ # analog
695
+ "inpp1": {"name": "iAValue1", "format": float_div100},
696
+ "inpp2": {"name": "iAValue2", "format": float_div100},
697
+ "inpp3": {"name": "iAValue3", "format": float_div100},
698
+ "inpp4": {"name": "iAValue4", "format": float_div100},
699
+ "inpp5": {"name": "iAValue5", "format": float_div100},
700
+ "inpp6": {"name": "iAValue6", "format": float_div100},
701
+ # ds
702
+ "ds1": {"name": "ds1", "format": float_div10},
703
+ "ds2": {"name": "ds2", "format": float_div10},
704
+ "ds3": {"name": "ds3", "format": float_div10},
705
+ "ds4": {"name": "ds4", "format": float_div10},
706
+ "ds5": {"name": "ds5", "format": float_div10},
707
+ "ds6": {"name": "ds6", "format": float_div10},
708
+ "ds7": {"name": "ds7", "format": float_div10},
709
+ "ds8": {"name": "ds8", "format": float_div10},
710
+ # i2c
711
+ "dthTemp": {"name": "i2cTemp", "format": float_div10},
712
+ "dthHum": {"name": "i2cHum", "format": float_div10},
713
+ "bm280p": {"name": "i2cPressure", "format": float_div100},
714
+ "dewPoint": {"name": "dewPoint", "format": float_div10},
715
+ # pm1-10
716
+ "pm1": {"name": "pm1.0", "format": float_div10},
717
+ "pm2": {"name": "pm2.5", "format": float_div10},
718
+ "pm4": {"name": "pm4.0", "format": float_div10},
719
+ "pm10": {"name": "pm10.0", "format": float_div10},
720
+ # co2
721
+ "co2": {"name": "co2", "format": int},
722
+ # m1-30 - parsed in _parse_custom_readings
723
+ # diffs w/ diffConfig - they have special parsing _parse_diffs
724
+ # digital
725
+ "ind": {"name": name_list("iDValue", 4), "format": strint_to_int_list(4)},
726
+ "inpdnn": {"name": name_list("iDNegation", 4), "format": strint_to_int_list(4)},
727
+ # power and energy
728
+ "power1": {"name": "power1", "format": float_div1000},
729
+ "power2": {"name": "power2", "format": float_div1000},
730
+ "power3": {"name": "power3", "format": float_div1000},
731
+ "power4": {"name": "power4", "format": float_div1000},
732
+ "power5": {"name": "power5", "format": float_div1000},
733
+ "power6": {"name": "power6", "format": float_div1000},
734
+ "energy1": {"name": "energy1", "format": float_div1000},
735
+ "energy2": {"name": "energy2", "format": float_div1000},
736
+ "energy3": {"name": "energy3", "format": float_div1000},
737
+ "energy4": {"name": "energy4", "format": float_div1000},
738
+ "energy5": {"name": "energy5", "format": float_div1000},
739
+ "energy6": {"name": "energy6", "format": float_div1000},
740
+ # serial port sensors?
741
+ "distanceSensor": {"name": "distanceSensor", "format": float},
742
+ "ozon": {"name": "ozon", "format": int}, # the same value as co2
743
+ # "rhewa": {"name": 'rhewa', "format": str},
744
+ # "barcode": {"name": 'barcode', "format": str},
745
+ # general stuff
746
+ "hw": {"name": "hardware_version", "format": str},
747
+ "sw": {"name": "software_version", "format": str},
748
+ "ip4": {"name": "mac", "format": str},
749
+ "vin": {"name": "boardVoltage", "format": float_div100},
750
+ "tem": {"name": "boardTemp", "format": float_div100},
751
+ "time": {"name": "time", "format": int},
752
+ "sname": {"name": "hostname", "format": str},
753
+ # others
754
+ "inpd1tim": {"name": "digital1_impulse_timer", "format": int},
755
+ "inpd3tim": {"name": "digital3_impulse_timer", "format": int},
756
+ # DS reading
757
+ "dsid": {"name": "ds_read_id", "format": str},
758
+ "d0": {"name": "duralux0", "format": float_div10},
759
+ "d1": {"name": "duralux1", "format": float_div10},
760
+ "d2": {"name": "duralux2", "format": float_div10},
761
+ "d3": {"name": "duralux3", "format": float_div10},
762
+ "d4": {"name": "duralux4", "format": float_div100},
763
+ "d5": {"name": "duralux5", "format": int},
764
+ "d6": {"name": "duralux6", "format": int},
765
+ "d7": {"name": "duralux7", "format": float_div10},
766
+ "d8": {"name": "duralux8", "format": int},
767
+ }
768
+ parsers: ClassVar[List[str]] = [
769
+ "_parse_outs",
770
+ "_parse_diffs",
771
+ "_parse_custom_readings",
772
+ ]
773
+
774
+ @classmethod
775
+ def check_version(
776
+ cls, hardware_version: Union[str, None], software_version: str
777
+ ) -> bool:
778
+ return hardware_version == "3.0"
779
+
780
+ # region Parser methods that modifies data in _get()
781
+ def _parse_diff(self, value, source, diffsel, depth=3):
782
+ """Parse value of diff according to source sensor.
783
+
784
+ Valid for HW3.0 and HW 3.5+ up to SW 1.47a
785
+ """
786
+ if depth == 0:
787
+ return 0
788
+ if source < 6 or source == 24:
789
+ return value / 100
790
+ elif source < 14 or source == 22 or source == 23:
791
+ return value / 10
792
+ elif source >= 25 and source <= 27:
793
+ return self._parse_diff(value, diffsel[source - 25], diffsel, depth - 1)
794
+ else:
795
+ return value / 1000
796
+
797
+ def _parse_diffs(self, data: Dict[str, Any], path: str) -> None:
798
+ """Parse diffs according to sw/hw versions if set."""
799
+ if "diff1" not in data:
800
+ return
801
+ if self.hardware_version != "3.0" and self.software_version >= "1.49":
802
+ # HW3.5+ SW 1.49+ - Version with 6 diffs
803
+ for index in range(1, 7):
804
+ key = "diff{}".format(index)
805
+ data[key] = float_div1000(data[key])
806
+ elif "diffsel" in data:
807
+ # HW 3.0 or HW3.5+ SW <1.49 - Version with 3 diffs.
808
+ # Requires diffsel to parse, so currently it will only work for all.json.
809
+ diffsel = list(map(int, data.get("diffsel").split("*")))
810
+ if diffsel[0] != 25 and diffsel[1] != 26 and diffsel[2] != 27:
811
+ # No pointing itself 25-27 is diff1-3
812
+ for index in range(1, 4):
813
+ key = "diff{}".format(index)
814
+ data[key] = self._parse_diff(
815
+ float(data[key]), diffsel[index - 1], diffsel
816
+ )
817
+
818
+ def _parse_custom_readings(self, data: Dict[str, Any], path: str) -> None:
819
+ """Parse readings mappings (Modbus/1 Wire) and HW 3.0 SDMs."""
820
+ if not (self.hardware_version and self.software_version):
821
+ # No data
822
+ return
823
+ if self.hardware_version == "3.0" or (
824
+ self.hardware_version != "3.0" and self.software_version <= "1.31"
825
+ ):
826
+ # LK HW 3.0 or HW 3.5+ SW <=1.31 - Dict method for sdm1-sdm14, rest is unknown
827
+ if "sdm1" not in data:
828
+ return
829
+ for index in range(1, 15):
830
+ data["mValue{}".format(index)] = float_div100(data["sdm{}".format(index)])
831
+ for index in range(15, 31):
832
+ data["mValue{}".format(index)] = 0
833
+ elif self.hardware_version != "3.0" and self.software_version < "1.33":
834
+ # HW 3.5+ SW 1.32+ - List method for sdm1-sdm29
835
+ if "modbusSensor" not in data or "sdm1" not in data:
836
+ return
837
+ modbus_sensor = int(data.get("modbusSensor", 0))
838
+ # Prepare list of 30 values
839
+ tmp = list_map(int)(data.get("sdm")) + [0]
840
+ if modbus_sensor == 5:
841
+ tmp[6] = tmp[6] / 10
842
+ tmp[7] = tmp[7] / 100
843
+ tmp[8] = tmp[8] / 10
844
+ tmp[9] = tmp[9] / 100
845
+ tmp[10] = tmp[10] / 100
846
+ tmp[11] = tmp[11] / 100
847
+ tmp[12] = tmp[12] / 100
848
+ tmp[13] = tmp[13] / 100
849
+ tmp[14] = tmp[14] / 100
850
+ tmp[15] = tmp[15] / 10
851
+ tmp[16] = tmp[16] / 100
852
+ tmp[17] = tmp[17] / 10
853
+ tmp[18] = tmp[18] / 100
854
+ tmp[19] = tmp[19] / 10
855
+ tmp[20] = tmp[20] / 100
856
+ tmp[25] = tmp[25] / 100
857
+ elif modbus_sensor == 4:
858
+ for index in range(0, 9): # 0-8
859
+ tmp[index] = tmp[index] / 100
860
+ for index in range(13, 29): # 13-28
861
+ tmp[index] = tmp[index] / 100
862
+ else:
863
+ for index in range(0, 29): # 0-28
864
+ tmp[index] = tmp[index] / 100
865
+ # Update data with changes above
866
+ for index in range(0, 30):
867
+ data["mValue{}".format(index + 1)] = tmp[index]
868
+ elif self.hardware_version != "3.0" and self.software_version < "1.50":
869
+ # HW 3.5+ SW 1.33+ (3 modbus slots)
870
+ # modbusMapping points to positions in modbusReadings
871
+ if "modbusMapping" not in data or "modbusReadings" not in data:
872
+ return
873
+ modbus_mapping = [
874
+ (int(item[0]), int(item[1:]))
875
+ for item in data.get("modbusMapping").split("*")
876
+ ]
877
+ modbus_values = data.get("modbusReadings")
878
+ tmp = []
879
+ for index, item in enumerate(modbus_mapping):
880
+ if item[0] == 0:
881
+ tmp.append(0)
882
+ else:
883
+ # Slots are 1-3, so `-1`
884
+ tmp.append(float(modbus_values[item[0] - 1][item[1]]))
885
+ # Update data with changes above
886
+ for index in range(0, 30):
887
+ data["mValue{}".format(index + 1)] = tmp[index]
888
+ elif self.hardware_version != "3.0" and self.software_version >= "1.50":
889
+ # HW 3.5+ SW 1.50+ - direct access to m1-30
890
+ if "customReadings" not in data:
891
+ return
892
+ for index, item in enumerate(data.get("customReadings")):
893
+ data["mValue{}".format(index + 1)] = float(item)
894
+
895
+ def _parse_outs(self, data: Dict[str, Any], path: str) -> None:
896
+ """Parse outputs OUT including negation."""
897
+ if "outnn" in data:
898
+ self._context["out_negation"] = int(data.get("outnn"))
899
+ if "out0" in data:
900
+ out_negation = self._context.get("out_negation")
901
+ for name in name_list("out", self.info.extras["number_of_outputs"], 0):
902
+ data[name] = (
903
+ int_inverted(data[name]) if out_negation else int(data[name])
904
+ )
905
+
906
+ # endregion
907
+
908
+ def _set_out(
909
+ self, index: Union[int, List[int]], value: Union[int, List[int], None]
910
+ ) -> str:
911
+ """Prepare command for setting outputs OUT.
912
+
913
+ Arguments:
914
+ index: 0-5 (single or list)
915
+ value: 0-1 (single or list)
916
+ NOTE: When out is negated it will negate passed value,
917
+ so value=1 will actually set 0 and value=0 set 1.
918
+ """
919
+ cmd = "/outs.cgi?"
920
+ if value is not None and self._context.get("out_negation"):
921
+ if isinstance(value, list):
922
+ value = [int_inverted(val) for val in value]
923
+ else:
924
+ value = int_inverted(value)
925
+ if isinstance(index, list):
926
+ if value is None:
927
+ cmd += "&".join(["out=out{}".format(ix) for ix in index])
928
+ elif isinstance(value, list):
929
+ cmd += "&".join(
930
+ ["out{}={}".format(ix, val) for ix, val in zip(index, value)]
931
+ )
932
+ else:
933
+ cmd += "&".join(["out{}={}".format(ix, value) for ix in index])
934
+ else:
935
+ if value is None:
936
+ cmd += "out=out{}".format(index)
937
+ else:
938
+ cmd += "out{}={}".format(index, value)
939
+ return cmd
940
+
941
+ def _set_pwm(
942
+ self, index: Union[int, List[int]], value: Union[int, List[int], None]
943
+ ) -> str:
944
+ """Prepare command for setting PWM.
945
+
946
+ Arguments:
947
+ index: 0-3 (single or list)
948
+ value: 0-1 (single or list)
949
+ """
950
+ cmd = "/outs.cgi?"
951
+ if isinstance(index, list):
952
+ if value is None:
953
+ cmd += "&".join(["pwm=pwm{}".format(ix) for ix in index])
954
+ elif isinstance(value, list):
955
+ cmd += "&".join(
956
+ ["pwm{}={}".format(ix, val) for ix, val in zip(index, value)]
957
+ )
958
+ else:
959
+ cmd += "&".join(["pwm{}={}".format(ix, value) for ix in index])
960
+ else:
961
+ if value is None:
962
+ cmd += "pwm=pwm{}".format(index)
963
+ else:
964
+ cmd += "pwm{}={}".format(index, value)
965
+ return cmd
966
+
967
+ def _set_pwm_duty(
968
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
969
+ ) -> str:
970
+ """Set pwm duty to value.
971
+
972
+ Arguments:
973
+ index: 0-3 (single or list)
974
+ value: 0-100 (single or list)
975
+ """
976
+ cmd = "/stm.cgi?"
977
+ if isinstance(index, list):
978
+ if isinstance(value, list):
979
+ cmd += "&".join(
980
+ ["pwmd={}{}".format(ix, int(val)) for ix, val in zip(index, value)]
981
+ )
982
+ else:
983
+ cmd += "&".join(["pwmd={}{}".format(ix, int(value)) for ix in index])
984
+ else:
985
+ cmd += "pwmd={}{}".format(index, int(value))
986
+ return cmd
987
+
988
+ def _set_pwm_freq(
989
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
990
+ ) -> str:
991
+ """Set pwm freq to value.
992
+
993
+ Arguments:
994
+ index: 0-1 (single or list; 0 - pwm0, 1 - pwm1-3 shared)
995
+ value: 1-1_000_000
996
+ """
997
+ cmd = "/stm.cgi?"
998
+ if isinstance(index, list):
999
+ if isinstance(value, list):
1000
+ cmd += "&".join(
1001
+ ["pwmf={}{}".format(ix, int(val)) for ix, val in zip(index, value)]
1002
+ )
1003
+ else:
1004
+ cmd += "&".join(["pwmf={}{}".format(ix, int(value)) for ix in index])
1005
+ else:
1006
+ cmd += "pwmf={}{}".format(index, int(value))
1007
+ return cmd
1008
+
1009
+ def _set_ds(
1010
+ self, index: Union[int, List[int]], value: Union[str, List[str]]
1011
+ ) -> str:
1012
+ """Set ID of DS on position to value.
1013
+
1014
+ Arguments:
1015
+ index: 1-8
1016
+ value: DS ID
1017
+ """
1018
+ cmd = "/stm.cgi?"
1019
+ if isinstance(index, list):
1020
+ if isinstance(value, list):
1021
+ cmd += "&".join(
1022
+ ["dswrite={}:{}".format(ix, val) for ix, val in zip(index, value)]
1023
+ )
1024
+ else:
1025
+ cmd += "&".join(["dswrite={}:{}".format(ix, value) for ix in index])
1026
+ else:
1027
+ cmd += "dswrite={}:{}".format(index, value)
1028
+ return cmd
1029
+
1030
+ def get_ds_id(self) -> str:
1031
+ """Get ID of detected DS."""
1032
+ self.get("/stm.cgi?dswrite=0")
1033
+ return self.get("/json/dsi2c.json").get("ds_read_id")
1034
+
1035
+ def _get_all(self) -> List[str]:
1036
+ """Prepare list of URLs to fetch data from."""
1037
+ urls = ["/json/all.json", "/json/pwmpid.json"]
1038
+ if self.hardware_version >= "3.5" and "1.50" > self.software_version >= "1.22b":
1039
+ urls.append("/json/events_per.json")
1040
+ return urls
1041
+
1042
+ def _reset_to_defaults(self):
1043
+ """Reset settings to defaults."""
1044
+ return "/stm.cgi?eeprom_reset=1"
1045
+
1046
+ def _restart(self):
1047
+ """Restart device."""
1048
+ return "/stm.cgi?upgrade=lkstart3"
1049
+
1050
+ def set_analog_input(
1051
+ self,
1052
+ index: Union[int, List[int]],
1053
+ sensor: Union[int, List[int]],
1054
+ calibration: Union[int, List[int], None] = None,
1055
+ multiplier: Union[float, List[float], None] = None,
1056
+ ) -> Dict[str, Any]:
1057
+ """Set sensor, calibration and multiplier for analog input.
1058
+
1059
+ Arguments:
1060
+ index: 1-6 (analog input index)
1061
+ sensor: 0-20, sensor to set (range varies depending on index)
1062
+ calibration: -32768 - 32767 (calibration offset)
1063
+ multiplier: 0.01 - 327.67 (before sending it will be int(X*100)
1064
+ """
1065
+ cmd = "/inpa.cgi?"
1066
+ if isinstance(index, list):
1067
+ if isinstance(sensor, list):
1068
+ cmd += "&".join(
1069
+ [
1070
+ "sensor={}{}".format(ix - 1, val)
1071
+ for ix, val in zip(index, sensor)
1072
+ ]
1073
+ )
1074
+ else:
1075
+ cmd += "&".join(["sensor={}{}".format(ix - 1, sensor) for ix in index])
1076
+ if isinstance(calibration, list):
1077
+ cmd += "&".join(
1078
+ [
1079
+ "calibration={}{}".format(ix - 1, val)
1080
+ for ix, val in zip(index, calibration)
1081
+ ]
1082
+ )
1083
+ elif calibration is not None:
1084
+ cmd += "&".join(
1085
+ ["calibration={}{}".format(ix - 1, calibration) for ix in index]
1086
+ )
1087
+ if isinstance(multiplier, list):
1088
+ cmd += "&".join(
1089
+ [
1090
+ "multiplier={}{}".format(ix - 1, int(val * 100))
1091
+ for ix, val in zip(index, multiplier)
1092
+ ]
1093
+ )
1094
+ elif multiplier is not None:
1095
+ cmd += "&".join(
1096
+ [
1097
+ "multiplier={}{}".format(ix - 1, int(multiplier * 100))
1098
+ for ix in index
1099
+ ]
1100
+ )
1101
+ else:
1102
+ cmd += "sensor={}{}".format(index - 1, sensor)
1103
+ if calibration is not None:
1104
+ cmd += "&calibration={}{}".format(index - 1, calibration)
1105
+ if multiplier is not None:
1106
+ cmd += "&multiplier={}{}".format(index - 1, int(multiplier * 100))
1107
+ return self.get(cmd)
1108
+
1109
+
1110
+ @dataclass
1111
+ class LK_HW_35(LK_HW_30):
1112
+ """Methods for working with LK3.5."""
1113
+
1114
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1115
+ "LK HW 3.5", # Covers HW 3.5, 3.6, 3.7, 3.8
1116
+ FAMILY_LK,
1117
+ "lc35",
1118
+ "https://tinycontrol.pl/en/lan-controller-35/firmware/#firmware",
1119
+ {"number_of_outputs": 6},
1120
+ )
1121
+
1122
+ @classmethod
1123
+ def check_version(
1124
+ cls, hardware_version: Union[str, None], software_version: str
1125
+ ) -> bool:
1126
+ return (
1127
+ "3.5" <= hardware_version < "4.0"
1128
+ and not software_version.endswith("ps")
1129
+ and not software_version.endswith("dcdc")
1130
+ )
1131
+
1132
+ def _set_var(
1133
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
1134
+ ) -> str:
1135
+ """Prepare command for setting VAR/EVENT variables.
1136
+
1137
+ Arguments:
1138
+ index: 1-8 (single or list)
1139
+ value: 0-1 (single or list)
1140
+ """
1141
+ cmd = "/outs.cgi?"
1142
+ if isinstance(index, list):
1143
+ if isinstance(value, list):
1144
+ cmd += "&".join(
1145
+ ["vout{}={}".format(ix - 1, val) for ix, val in zip(index, value)]
1146
+ )
1147
+ else:
1148
+ cmd += "&".join(["vout{}={}".format(ix - 1, value) for ix in index])
1149
+ else:
1150
+ cmd += "vout{}={}".format(index - 1, value)
1151
+ return cmd
1152
+
1153
+
1154
+ @dataclass
1155
+ class LK_HW_35_PS(LK_HW_35):
1156
+ """Methods for working with IP Power Socket v2 (LK3.5)."""
1157
+
1158
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1159
+ "IP Power Socket v2 (LK3.5)", # 5G10A/6G10A
1160
+ FAMILY_PS,
1161
+ )
1162
+
1163
+ @classmethod
1164
+ def check_version(
1165
+ cls, hardware_version: Union[str, None], software_version: str
1166
+ ) -> bool:
1167
+ return "3.5" <= hardware_version < "4.0" and software_version.endswith("ps")
1168
+
1169
+
1170
+ @dataclass
1171
+ class LK_HW_35_DCDC(LK_HW_35):
1172
+ """Methods for working with LK3.5."""
1173
+
1174
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1175
+ "Converter DC/DC",
1176
+ FAMILY_DCDC,
1177
+ )
1178
+
1179
+ @classmethod
1180
+ def check_version(
1181
+ cls, hardware_version: Union[str, None], software_version: str
1182
+ ) -> bool:
1183
+ return "3.5" <= hardware_version < "4.0" and software_version.endswith("dcdc")
1184
+
1185
+
1186
+ @dataclass
1187
+ class LK_HW_40(DeviceModel):
1188
+ """Device model for LK4.
1189
+
1190
+ For information about possible requests see:
1191
+ https://docs.tinycontrol.pl/en/lk4/api/commands/
1192
+ """
1193
+
1194
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1195
+ "LK HW 4.0",
1196
+ FAMILY_LK,
1197
+ "lc40",
1198
+ "https://tinycontrol.pl/en/lk4/downloads/#firmware",
1199
+ {"number_of_outputs": 6},
1200
+ )
1201
+ mapping: ClassVar[Dict[str, Dict]] = {
1202
+ "netMac": {"name": "mac", "format": str},
1203
+ "softwareVersion": {"name": "software_version", "format": str},
1204
+ "hardwareVersion": {"name": "hardware_version", "format": str},
1205
+ }
1206
+ parsers: ClassVar[List[str]] = ["_parse_outs"]
1207
+
1208
+ @classmethod
1209
+ def check_version(
1210
+ cls, hardware_version: Union[str, None], software_version: str
1211
+ ) -> bool:
1212
+ return hardware_version == "4.0" and not software_version.endswith("mini")
1213
+
1214
+ def _parse_outs(self, data: Dict[str, Any], path: str) -> None:
1215
+ """Parse outputs OUT including negation."""
1216
+ if "outNegation" in data:
1217
+ self._context["out_negation"] = data.get("outNegation")
1218
+ if "out1" in data:
1219
+ out_negation = self._context.get("out_negation")
1220
+ for name in name_list("out", self.info.extras["number_of_outputs"]):
1221
+ data[name] = (
1222
+ int_inverted(data[name]) if out_negation else int(data[name])
1223
+ )
1224
+
1225
+ def _set_out(
1226
+ self, index: Union[int, List[int]], value: Union[int, List[int], None]
1227
+ ) -> str:
1228
+ """Prepare command for setting outputs OUT.
1229
+
1230
+ Arguments:
1231
+ index: 1-n (n - self.info.extras['number_of_outputs']; single or list)
1232
+ value: 0-1 (single or list)
1233
+ NOTE: When out is negated it will negate passed value,
1234
+ so value=1 will actually set 0 and value=0 set 1.
1235
+ """
1236
+ cmd = "/api/v1/save/?"
1237
+ if value is not None and self._context.get("out_negation"):
1238
+ if isinstance(value, list):
1239
+ value = [int_inverted(val) for val in value]
1240
+ else:
1241
+ value = int_inverted(value)
1242
+ if isinstance(index, list):
1243
+ if value is None:
1244
+ cmd += "&".join(["out=out{}".format(ix) for ix in index])
1245
+ elif isinstance(value, list):
1246
+ cmd += "&".join(
1247
+ ["out{}={}".format(ix, val) for ix, val in zip(index, value)]
1248
+ )
1249
+ else:
1250
+ cmd += "&".join(["out{}={}".format(ix, value) for ix in index])
1251
+ else:
1252
+ if value is None:
1253
+ cmd += "out=out{}".format(index)
1254
+ else:
1255
+ cmd += "out{}={}".format(index, value)
1256
+ return cmd
1257
+
1258
+ def _set_pwm(
1259
+ self, index: Union[int, List[int]], value: Union[int, List[int], None]
1260
+ ) -> str:
1261
+ """Prepare command for setting PWM.
1262
+
1263
+ Arguments:
1264
+ index: 1-3 (single or list)
1265
+ value: 0-1 (single or list)
1266
+ """
1267
+ cmd = "/api/v1/save/?"
1268
+ if isinstance(index, list):
1269
+ if value is None:
1270
+ cmd += "&".join(["pwm=pwm{}".format(ix) for ix in index])
1271
+ elif isinstance(value, list):
1272
+ cmd += "&".join(
1273
+ ["pwm{}={}".format(ix, val) for ix, val in zip(index, value)]
1274
+ )
1275
+ else:
1276
+ cmd += "&".join(["pwm{}={}".format(ix, value) for ix in index])
1277
+ else:
1278
+ if value is None:
1279
+ cmd += "pwm=pwm{}".format(index)
1280
+ else:
1281
+ cmd += "pwm{}={}".format(index, value)
1282
+ return cmd
1283
+
1284
+ def _set_pwm_duty(
1285
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
1286
+ ) -> str:
1287
+ """Prepare command for setting PWM duty.
1288
+
1289
+ Arguments:
1290
+ index: 1-3 (single or list)
1291
+ value: 0-100 (single or list)
1292
+ """
1293
+ cmd = "/api/v1/save/?"
1294
+ if isinstance(index, list):
1295
+ if isinstance(value, list):
1296
+ cmd += "&".join(
1297
+ [
1298
+ "pwmDuty{}={}".format(ix, int(val))
1299
+ for ix, val in zip(index, value)
1300
+ ]
1301
+ )
1302
+ else:
1303
+ cmd += "&".join(["pwmDuty{}={}".format(ix, int(value)) for ix in index])
1304
+ else:
1305
+ cmd += "pwmDuty{}={}".format(index, int(value))
1306
+ return cmd
1307
+
1308
+ def _set_pwm_freq(self, index: Any, value: int) -> str:
1309
+ """Prepare command for setting PWM freq.
1310
+
1311
+ Arguments:
1312
+ index: not used (only one frequency)
1313
+ value: 1-1_000_000
1314
+ """
1315
+ cmd = "/api/v1/save/?pwmFrequency={}".format(value)
1316
+ return cmd
1317
+
1318
+ def _set_var(
1319
+ self, index: Union[int, List[int]], value: Union[int, List[int]]
1320
+ ) -> str:
1321
+ """Prepare command for setting VAR/EVENT variables.
1322
+
1323
+ Arguments:
1324
+ index: 1-8 (single or list)
1325
+ value: 0-1 (single or list)
1326
+ """
1327
+ cmd = "/api/v1/save/?"
1328
+ if isinstance(index, list):
1329
+ if isinstance(value, list):
1330
+ cmd += "&".join(
1331
+ ["var{}={}".format(ix, val) for ix, val in zip(index, value)]
1332
+ )
1333
+ else:
1334
+ cmd += "&".join(["var{}={}".format(ix, value) for ix in index])
1335
+ else:
1336
+ cmd += "var{}={}".format(index, value)
1337
+ return cmd
1338
+
1339
+ def _set_ds(
1340
+ self, index: Union[int, List[int]], value: Union[str, List[str]]
1341
+ ) -> str:
1342
+ """Set ID of DS on position to value.
1343
+
1344
+ Arguments:
1345
+ index: 1-8
1346
+ value: DS ID
1347
+ """
1348
+ cmd = "/api/v1/save/?"
1349
+ if isinstance(index, list):
1350
+ if isinstance(value, list):
1351
+ cmd += "&".join(
1352
+ ["dsID{}={}".format(ix, val) for ix, val in zip(index, value)]
1353
+ )
1354
+ else:
1355
+ cmd += "&".join(["dsID{}={}".format(ix, value) for ix in index])
1356
+ else:
1357
+ cmd += "dsID{}={}".format(index, value)
1358
+ return cmd
1359
+
1360
+ def get_ds_id(self) -> str:
1361
+ """Get ID of detected DS."""
1362
+ self.get("/api/v1/save/?dsReadID=0")
1363
+ return self.get("/api/v1/read/status/?dsValues").get("dsReadID")
1364
+
1365
+ def _get_all(self) -> List[str]:
1366
+ """Prepare list of URLs to fetch data from."""
1367
+ return [
1368
+ "/api/v1/read/set/?generalConfig&outConfig&powerConfig&networkConfig",
1369
+ "/api/v1/read/status/?boardValues&statusValues&timeValues&outValues&pwmValues&iAValues&dsValues&i2cValues&otherSensorsValues&diffValues&iDValues&powerValues&mrValues&varValues",
1370
+ ]
1371
+
1372
+ def _reset_to_defaults(self):
1373
+ """Reset settings to defaults."""
1374
+ return "/api/v1/save/?eeprom_reset=1"
1375
+
1376
+ def _restart(self):
1377
+ """Restart device."""
1378
+ return "/api/v1/save/?restart=1"
1379
+
1380
+
1381
+ @dataclass
1382
+ class TCPDU(LK_HW_40):
1383
+ """Device model for tcPDU.
1384
+
1385
+ For information about possible requests see:
1386
+ https://docs.tinycontrol.pl/en/tcpdu/api/commands/
1387
+ """
1388
+
1389
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1390
+ "tcPDU",
1391
+ FAMILY_TCPDU,
1392
+ "tcpdu",
1393
+ "https://tinycontrol.pl/en/tcpdu/downloads/#firmware",
1394
+ {"number_of_outputs": 7},
1395
+ )
1396
+
1397
+ @classmethod
1398
+ def check_version(
1399
+ cls, hardware_version: Union[str, None], software_version: str
1400
+ ) -> bool:
1401
+ return hardware_version in ["1.0", "1.1"] and software_version.endswith("tcPDU")
1402
+
1403
+ # Unset few commands - there are no PWM in tcPDU
1404
+ _set_pwm = None
1405
+ _set_pwm_duty = None
1406
+ _set_pwm_freq = None
1407
+
1408
+ def _get_all(self) -> List[str]:
1409
+ """Prepare list of URLs to fetch data from.
1410
+
1411
+ Compared to LK4 does not include pwm, analog, pm, co2, mapped (modbus readings).
1412
+ """
1413
+ return [
1414
+ "/api/v1/read/set/?generalConfig&outConfig&powerConfig&networkConfig",
1415
+ "/api/v1/read/status/?boardValues&statusValues&timeValues&outValues&dsValues&i2cValues&diffValues&iDValues&powerValues&varValues",
1416
+ ]
1417
+
1418
+
1419
+ @dataclass
1420
+ class LK_HW_40_mini(LK_HW_40):
1421
+ """Device model for LK4mini.
1422
+
1423
+ It's basically the same as LK4 minus outputs, PWM outputs, analog
1424
+ inputs, digital inputs, power and energy, serial port.
1425
+ """
1426
+
1427
+ info: ClassVar[Union[DeviceInfo, None]] = DeviceInfo(
1428
+ "LK HW 4.0 mini",
1429
+ FAMILY_LK,
1430
+ "lc40mini",
1431
+ "https://tinycontrol.pl/en/lk4mini/downloads/#firmware",
1432
+ {"number_of_outputs": 0},
1433
+ )
1434
+ parsers: ClassVar[List[str]] = []
1435
+
1436
+ @classmethod
1437
+ def check_version(
1438
+ cls, hardware_version: Union[str, None], software_version: str
1439
+ ) -> bool:
1440
+ return hardware_version == "4.0" and software_version.endswith("mini")
1441
+
1442
+ # Unset few commands - there are no OUT, PWM in LK4 mini
1443
+ _set_out = None
1444
+ _set_pwm = None
1445
+ _set_pwm_duty = None
1446
+ _set_pwm_freq = None
1447
+
1448
+ def _get_all(self) -> List[str]:
1449
+ return [
1450
+ "/api/v1/read/set/?generalConfig&networkConfig",
1451
+ "/api/v1/read/status/?boardValues&statusValues&timeValues&dsValues&i2cValues&otherSensorsValues&diffValues&mrValues&varValues"
1452
+ ]
1453
+
1454
+
1455
+ DEVICE_MODELS = [
1456
+ LK_HW_40_mini,
1457
+ TCPDU,
1458
+ LK_HW_40,
1459
+ LK_HW_35,
1460
+ LK_HW_35_PS,
1461
+ LK_HW_35_DCDC,
1462
+ LK_HW_30,
1463
+ LK_HW_25,
1464
+ LK_HW_25_PS,
1465
+ LK_HW_20,
1466
+ LK_HW_20_PS,
1467
+ ]
1468
+
1469
+
1470
+ def detect_version(version_text: str) -> Union[str, None]:
1471
+ """Detect hw version based on sw version (from discovery).
1472
+
1473
+ Currently it is only useful for LK20/25.
1474
+ """
1475
+ pattern = re.compile(r"^(\d\.\d+)(.*)$")
1476
+ match = pattern.search(version_text)
1477
+ hardware_version = ""
1478
+ if match is not None:
1479
+ parts = match.groups()
1480
+ software_version = float(parts[0])
1481
+ # First check 'frozen' versions like 2.0 or 3.0
1482
+ if software_version in [
1483
+ 2.0,
1484
+ 2.03,
1485
+ 2.07,
1486
+ 2.09,
1487
+ 3.03,
1488
+ 3.06,
1489
+ 3.10,
1490
+ 3.13,
1491
+ 3.15,
1492
+ 3.18,
1493
+ ]:
1494
+ hardware_version = "2.0"
1495
+ elif software_version in [6.0, 6.09, 6.10, 6.12]: # power socket 2.0
1496
+ hardware_version = "2.0"
1497
+ elif software_version == 6.15: # power socket 2.5
1498
+ hardware_version = "2.5"
1499
+ elif software_version in [2.01, 3.01, 3.02]:
1500
+ hardware_version = "2.5"
1501
+ return hardware_version
1502
+
1503
+
1504
+ def get_device_info(
1505
+ hardware_version: str, software_version: str, asdict_: bool = False
1506
+ ) -> Union[DeviceInfo, Dict[str, Any]]:
1507
+ """Return device info based on given HW and SW."""
1508
+ device_info = None
1509
+ for device_model in DEVICE_MODELS:
1510
+ if device_model.check_version(hardware_version, software_version):
1511
+ device_info = device_model.info
1512
+ break
1513
+ if not device_info:
1514
+ device_info = DeviceInfo("?", "?")
1515
+ if asdict_:
1516
+ device_info = asdict(device_info)
1517
+ return device_info
1518
+
1519
+
1520
+ def get_device(
1521
+ hardware_version: str,
1522
+ software_version: str,
1523
+ host: str,
1524
+ schema: str = "http",
1525
+ port: int = 80,
1526
+ username: str = "",
1527
+ password: str = "",
1528
+ session: Union[ClientSession, None] = None,
1529
+ ) -> Union[DeviceModel, None]:
1530
+ """Get Device instance."""
1531
+ for device_model in DEVICE_MODELS:
1532
+ if device_model.check_version(hardware_version, software_version):
1533
+ return device_model(
1534
+ host,
1535
+ schema,
1536
+ port,
1537
+ username,
1538
+ password,
1539
+ hardware_version,
1540
+ software_version,
1541
+ session=session,
1542
+ )
1543
+ return None
1544
+
1545
+
1546
+ def _get_version(
1547
+ response: Union[Any, None],
1548
+ host: str,
1549
+ port: str,
1550
+ username: str,
1551
+ password: str,
1552
+ with_info: bool,
1553
+ with_device: bool,
1554
+ session: Union[ClientSession, None],
1555
+ ) -> Union[Dict[str, Any], None]:
1556
+ """Analyze responses to get version info."""
1557
+ if response is None:
1558
+ return response
1559
+ version_info = parse_version(response["parsed"])
1560
+ version_info["network_info"] = {
1561
+ "host": host,
1562
+ "schema": str(response["_response"].url).split(":", 1)[0],
1563
+ "port": port,
1564
+ "username": username,
1565
+ "password": password,
1566
+ }
1567
+ if with_info:
1568
+ version_info.update(
1569
+ get_device_info(
1570
+ version_info["hardware_version"],
1571
+ version_info["software_version"],
1572
+ asdict_=True,
1573
+ )
1574
+ )
1575
+ if with_device:
1576
+ version_info["device_model"] = get_device(
1577
+ version_info["hardware_version"],
1578
+ version_info["software_version"],
1579
+ **version_info["network_info"],
1580
+ session=session,
1581
+ )
1582
+ return version_info
1583
+
1584
+
1585
+ def _get_version_initial(
1586
+ exc: Exception, schema: str, port: int, silent: bool
1587
+ ) -> Tuple[str, str, int]:
1588
+ """Handle first response in get_version - prepare data for second request."""
1589
+ schema, path = "http", ""
1590
+ try:
1591
+ raise exc
1592
+ except TinyToolsRequestNotFound:
1593
+ # Likely LK3.X
1594
+ path = "/xml/stat.xml"
1595
+ except TinyToolsRequestInternalServerError:
1596
+ # Likely LK4/LK4mini/tcPDU
1597
+ path = "/api/v1/read/set/?generalConfig"
1598
+ except TinyToolsRequestSSLError:
1599
+ # When LK3 has https enabled it will redirect http and with ssl verification we land here.
1600
+ port = 443
1601
+ path = "/xml/stat.xml"
1602
+ schema = "https"
1603
+ except (TinyToolsRequestConnectionError, TinyToolsRequestTimeout):
1604
+ # Seems that newer SW LK4/tcPDU with https get here, but still include timeout
1605
+ port = 443
1606
+ path = "/api/v1/read/set/?generalConfig"
1607
+ schema = "https"
1608
+ except TinyToolsRequestError:
1609
+ if not silent:
1610
+ raise
1611
+ return path, schema, port
1612
+
1613
+
1614
+ def get_version(
1615
+ host: str,
1616
+ port: int = 80,
1617
+ username: str = "",
1618
+ password: str = "",
1619
+ with_info: bool = True,
1620
+ with_device: bool = False,
1621
+ silent: bool = True,
1622
+ ) -> Union[Dict[str, Any], None]:
1623
+ """Query device for version and optionally device model.
1624
+
1625
+ NOTE: Might not work properly with https and port different than 443,
1626
+ eg. proxy, as LK always uses 443 for https.
1627
+ TODO: Might add schema parameter so it would work to handle proxy.
1628
+ """
1629
+ response = None
1630
+ try:
1631
+ response = get(host, "/st2.xml", "http", port, username, password)
1632
+ except TinyToolsRequestError as exc:
1633
+ path, schema, port = _get_version_initial(exc, "http", port, silent)
1634
+ if path:
1635
+ response = get(host, path, schema, port, username, password, silent=silent)
1636
+ return _get_version(
1637
+ response, host, port, username, password, with_info, with_device, None
1638
+ )
1639
+
1640
+
1641
+ async def async_get_version(
1642
+ host: str,
1643
+ port: int = 80,
1644
+ username: str = "",
1645
+ password: str = "",
1646
+ with_info: bool = True,
1647
+ with_device: bool = False,
1648
+ silent: bool = True,
1649
+ session: Union[ClientSession, None] = None,
1650
+ ) -> Union[Dict[str, Any], None]:
1651
+ response = None
1652
+ try:
1653
+ response = await async_get(
1654
+ host, "/st2.xml", "http", port, username, password, session=session
1655
+ )
1656
+ except TinyToolsRequestError as exc:
1657
+ path, schema, port = _get_version_initial(exc, "http", port, silent)
1658
+ if path:
1659
+ response = await async_get(
1660
+ host,
1661
+ path,
1662
+ schema,
1663
+ port,
1664
+ username,
1665
+ password,
1666
+ silent=silent,
1667
+ session=session,
1668
+ )
1669
+ return _get_version(
1670
+ response, host, port, username, password, with_info, with_device, session
1671
+ )
1672
+
1673
+
1674
+ async_get_version.__doc__ = get_version.__doc__