test_device_config.py 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. """Test the config parser"""
  2. import gc
  3. import warnings
  4. import pytest
  5. import voluptuous as vol
  6. from fuzzywuzzy import fuzz
  7. from homeassistant.components.sensor import SensorDeviceClass
  8. from custom_components.tuya_local.helpers.config import get_device_id
  9. from custom_components.tuya_local.helpers.device_config import (
  10. TuyaDeviceConfig,
  11. TuyaDpsConfig,
  12. TuyaEntityConfig,
  13. _bytes_to_fmt,
  14. _typematch,
  15. available_configs,
  16. get_config,
  17. )
  18. from custom_components.tuya_local.sensor import TuyaLocalSensor
  19. from .const import GPPH_HEATER_PAYLOAD, KOGAN_HEATER_PAYLOAD
  20. from .helpers import assert_device_properties_set, mock_device
  21. PRODUCT_SCHEMA = vol.Schema(
  22. {
  23. # Bluetooth and Zigbee devices have 8 character product ids
  24. # WiFi devices have 16 character product ids
  25. vol.Required("id"): vol.All(str, vol.Length(min=8, max=16)),
  26. vol.Optional("name"): str,
  27. vol.Optional("manufacturer"): str,
  28. vol.Optional("model"): str,
  29. vol.Optional("model_id"): str,
  30. }
  31. )
  32. CONDMAP_SCHEMA = vol.Schema(
  33. {
  34. vol.Optional("dps_val"): vol.Maybe(vol.Any(str, int, bool, list)),
  35. vol.Optional("value"): vol.Maybe(vol.Any(str, int, bool, float)),
  36. vol.Optional("value_redirect"): str,
  37. vol.Optional("value_mirror"): str,
  38. vol.Optional("available"): str,
  39. vol.Optional("range"): {
  40. vol.Required("min"): int,
  41. vol.Required("max"): int,
  42. },
  43. vol.Optional("target_range"): {
  44. vol.Required("min"): vol.Any(int, float),
  45. vol.Required("max"): vol.Any(int, float),
  46. },
  47. vol.Optional("scale"): vol.Any(int, float),
  48. vol.Optional("step"): vol.Any(int, float),
  49. vol.Optional("invert"): True,
  50. vol.Optional("unit"): str,
  51. vol.Optional("icon"): vol.Match(r"^mdi:"),
  52. vol.Optional("icon_priority"): int,
  53. vol.Optional("hidden"): True,
  54. vol.Optional("invalid"): True,
  55. vol.Optional("default"): True,
  56. }
  57. )
  58. COND_SCHEMA = CONDMAP_SCHEMA.extend(
  59. {
  60. vol.Required("dps_val"): vol.Maybe(vol.Any(str, int, bool, list)),
  61. vol.Optional("mapping"): [CONDMAP_SCHEMA],
  62. }
  63. )
  64. MAPPING_SCHEMA = CONDMAP_SCHEMA.extend(
  65. {
  66. vol.Optional("constraint"): str,
  67. vol.Optional("conditions"): [COND_SCHEMA],
  68. }
  69. )
  70. FORMAT_SCHEMA = vol.Schema(
  71. {
  72. vol.Required("name"): str,
  73. vol.Required("bytes"): int,
  74. vol.Optional("range"): {
  75. vol.Required("min"): int,
  76. vol.Required("max"): int,
  77. },
  78. }
  79. )
  80. DP_SCHEMA = vol.Schema(
  81. {
  82. vol.Required("id"): int,
  83. vol.Required("type"): vol.In(
  84. [
  85. "string",
  86. "integer",
  87. "boolean",
  88. "hex",
  89. "base64",
  90. "bitfield",
  91. "unixtime",
  92. "json",
  93. "utf16b64",
  94. ]
  95. ),
  96. vol.Required("name"): str,
  97. vol.Optional("range"): {
  98. vol.Required("min"): int,
  99. vol.Required("max"): int,
  100. },
  101. vol.Optional("unit"): str,
  102. vol.Optional("precision"): vol.Any(int, float),
  103. vol.Optional("class"): vol.In(
  104. [
  105. "measurement",
  106. "measurement_angle",
  107. "total",
  108. "total_increasing",
  109. ]
  110. ),
  111. vol.Optional("optional"): True,
  112. vol.Optional("persist"): False,
  113. vol.Optional("hidden"): True,
  114. vol.Optional("readonly"): True,
  115. vol.Optional("sensitive"): True,
  116. vol.Optional("force"): True,
  117. vol.Optional("icon_priority"): int,
  118. vol.Optional("mapping"): [MAPPING_SCHEMA],
  119. vol.Optional("format"): [FORMAT_SCHEMA],
  120. vol.Optional("mask"): str,
  121. vol.Optional("endianness"): vol.In(["little"]),
  122. vol.Optional("mask_signed"): True,
  123. }
  124. )
  125. ENTITY_SCHEMA = vol.Schema(
  126. {
  127. vol.Required("entity"): vol.In(
  128. [
  129. "alarm_control_panel",
  130. "binary_sensor",
  131. "button",
  132. "camera",
  133. "climate",
  134. "cover",
  135. "datetime",
  136. "event",
  137. "fan",
  138. "humidifier",
  139. "infrared",
  140. "lawn_mower",
  141. "light",
  142. "lock",
  143. "media_player",
  144. "number",
  145. "remote",
  146. "select",
  147. "sensor",
  148. "siren",
  149. "switch",
  150. "text",
  151. "time",
  152. "vacuum",
  153. "valve",
  154. "water_heater",
  155. ]
  156. ),
  157. vol.Optional("name"): str,
  158. vol.Optional("class"): str,
  159. vol.Optional(vol.Or("translation_key", "translation_only_key")): str,
  160. vol.Optional("translation_placeholders"): dict[str, str],
  161. vol.Optional("category"): vol.In(["config", "diagnostic"]),
  162. vol.Optional("icon"): vol.Match(r"^mdi:"),
  163. vol.Optional("icon_priority"): int,
  164. vol.Optional("deprecated"): str,
  165. vol.Optional("mode"): vol.In(["box", "slider"]),
  166. vol.Optional("hidden"): vol.In([True, "unavailable"]),
  167. vol.Required("dps"): [DP_SCHEMA],
  168. }
  169. )
  170. YAML_SCHEMA = vol.Schema(
  171. {
  172. vol.Required("name"): str,
  173. vol.Optional("legacy_type"): str,
  174. vol.Optional("products"): [PRODUCT_SCHEMA],
  175. vol.Required("entities"): [ENTITY_SCHEMA],
  176. }
  177. )
  178. KNOWN_DPS = {
  179. "alarm_control_panel": {
  180. "required": ["alarm_state"],
  181. "optional": ["trigger"],
  182. },
  183. "binary_sensor": {"required": ["sensor"], "optional": []},
  184. "button": {"required": ["button"], "optional": []},
  185. "camera": {
  186. "required": [],
  187. "optional": ["switch", "motion_enable", "snapshot", "record"],
  188. },
  189. "climate": {
  190. "required": [],
  191. "optional": [
  192. "current_temperature",
  193. "current_humidity",
  194. "fan_mode",
  195. "humidity",
  196. "hvac_mode",
  197. "hvac_action",
  198. "min_temperature",
  199. "max_temperature",
  200. "preset_mode",
  201. "swing_mode",
  202. {
  203. "xor": [
  204. "temperature",
  205. {"and": ["target_temp_high", "target_temp_low"]},
  206. ]
  207. },
  208. "temperature_unit",
  209. ],
  210. },
  211. "cover": {
  212. "required": [{"or": ["control", "position"]}],
  213. "optional": [
  214. "current_position",
  215. "action",
  216. "open",
  217. "reversed",
  218. ],
  219. },
  220. "datetime": {
  221. "required": [{"or": ["year", "month", "day", "hour", "minute", "second"]}],
  222. "optional": [],
  223. },
  224. "event": {"required": ["event"], "optional": []},
  225. "fan": {
  226. "required": [{"or": ["preset_mode", "speed"]}],
  227. "optional": ["switch", "oscillate", "direction"],
  228. },
  229. "humidifier": {
  230. "required": ["humidity"],
  231. "optional": ["switch", "mode", "current_humidity"],
  232. },
  233. "infrared": {
  234. "required": ["send"],
  235. "optional": ["control", "code_type", "delay"],
  236. },
  237. "lawn_mower": {"required": ["activity", "command"], "optional": []},
  238. "light": {
  239. "required": [{"or": ["switch", "brightness", "effect"]}],
  240. "optional": ["color_mode", "color_temp", {"xor": ["rgbhsv", "named_color"]}],
  241. },
  242. "lock": {
  243. "required": [],
  244. "optional": [
  245. "lock",
  246. "lock_state",
  247. "code_unlock",
  248. {"and": ["request_unlock", "approve_unlock"]},
  249. {"and": ["request_intercom", "approve_intercom"]},
  250. "unlock_fingerprint",
  251. "unlock_password",
  252. "unlock_temp_pwd",
  253. "unlock_dynamic_pwd",
  254. "unlock_offline_pwd",
  255. "unlock_card",
  256. "unlock_app",
  257. "unlock_key",
  258. "unlock_ble",
  259. "jammed",
  260. ],
  261. },
  262. "media_player": {
  263. "required": [],
  264. "optional": [
  265. "switch",
  266. "volume",
  267. "mute",
  268. "source",
  269. "playback_state",
  270. "play",
  271. "pause",
  272. "prev",
  273. "next",
  274. "stop",
  275. "seek_position",
  276. "clear_playlist",
  277. "shuffle",
  278. "repeat",
  279. "sound_mode",
  280. ],
  281. },
  282. "number": {
  283. "required": ["value"],
  284. "optional": ["unit", "minimum", "maximum", "decimal"],
  285. },
  286. "remote": {
  287. "required": ["send"],
  288. "optional": ["receive", "command", "type", "head"],
  289. },
  290. "select": {"required": ["option"], "optional": []},
  291. "sensor": {"required": ["sensor"], "optional": ["unit"]},
  292. "siren": {
  293. "required": [],
  294. "optional": ["tone", "volume", "duration", "switch"],
  295. },
  296. "switch": {"required": ["switch"], "optional": ["current_power_w"]},
  297. "text": {"required": ["value"], "optional": []},
  298. "time": {"required": [{"or": ["hour", "minute", "second", "hms"]}], "optional": []},
  299. "vacuum": {
  300. "required": ["status"],
  301. "optional": [
  302. "command",
  303. "locate",
  304. "power",
  305. "activate",
  306. "battery",
  307. "direction_control",
  308. "error",
  309. "fan_speed",
  310. ],
  311. },
  312. "valve": {
  313. "required": ["valve"],
  314. "optional": ["switch", "current_position"],
  315. },
  316. "water_heater": {
  317. "required": [],
  318. "optional": [
  319. "current_temperature",
  320. "operation_mode",
  321. "temperature",
  322. "temperature_unit",
  323. "min_temperature",
  324. "max_temperature",
  325. "away_mode",
  326. ],
  327. },
  328. }
  329. def test_can_find_config_files():
  330. """Test that the config files can be found by the parser."""
  331. found = False
  332. for _ in available_configs():
  333. found = True
  334. break
  335. assert found
  336. def test_available_configs_closes_scandir_handle():
  337. """Test that the scandir handle is closed when the generator is dropped."""
  338. with warnings.catch_warnings(record=True) as caught:
  339. warnings.simplefilter("always")
  340. configs = available_configs()
  341. next(configs)
  342. del configs
  343. gc.collect()
  344. assert not [w for w in caught if issubclass(w.category, ResourceWarning)]
  345. def dp_match(condition, accounted, unaccounted, known, required=False):
  346. if isinstance(condition, str):
  347. known.add(condition)
  348. if condition in unaccounted:
  349. unaccounted.remove(condition)
  350. accounted.add(condition)
  351. if required:
  352. return condition in accounted
  353. else:
  354. return True
  355. elif "and" in condition:
  356. return and_match(condition["and"], accounted, unaccounted, known, required)
  357. elif "or" in condition:
  358. return or_match(condition["or"], accounted, unaccounted, known)
  359. elif "xor" in condition:
  360. return xor_match(condition["xor"], accounted, unaccounted, known, required)
  361. else:
  362. pytest.fail(f"Unrecognized condition {condition}")
  363. def and_match(conditions, accounted, unaccounted, known, required):
  364. single_match = False
  365. all_match = True
  366. for cond in conditions:
  367. match = dp_match(cond, accounted, unaccounted, known, True)
  368. all_match = all_match and match
  369. single_match = single_match or match
  370. if required:
  371. return all_match
  372. else:
  373. return all_match == single_match
  374. def or_match(conditions, accounted, unaccounted, known):
  375. match = False
  376. # loop through all, to ensure they are transferred to accounted list
  377. for cond in conditions:
  378. match = match or dp_match(cond, accounted, unaccounted, known, True)
  379. return match
  380. def xor_match(conditions, accounted, unaccounted, known, required):
  381. prior_match = False
  382. for cond in conditions:
  383. match = dp_match(cond, accounted, unaccounted, known, True)
  384. if match and prior_match:
  385. return False
  386. prior_match = prior_match or match
  387. # If any matched, all should be considered matched
  388. # this bit only handles nesting "and" within "xor"
  389. if prior_match:
  390. for c in conditions:
  391. if isinstance(c, str):
  392. accounted.add(c)
  393. elif "and" in c:
  394. for c2 in c["and"]:
  395. if isinstance(c2, str):
  396. accounted.add(c2)
  397. return prior_match or not required
  398. def rule_broken_msg(rule):
  399. msg = ""
  400. if isinstance(rule, str):
  401. return f"{msg} {rule}"
  402. elif "and" in rule:
  403. msg = f"{msg} all of ["
  404. for sub in rule["and"]:
  405. msg = f"{msg} {rule_broken_msg(sub)}"
  406. return f"{msg} ]"
  407. elif "or" in rule:
  408. msg = f"{msg} at least one of ["
  409. for sub in rule["or"]:
  410. msg = f"{msg} {rule_broken_msg(sub)}"
  411. return f"{msg} ]"
  412. elif "xor" in rule:
  413. msg = f"{msg} only one of ["
  414. for sub in rule["xor"]:
  415. msg = f"{msg} {rule_broken_msg(sub)}"
  416. return f"{msg} ]"
  417. return "for reason unknown"
  418. def check_entity(entity, cfg, mocker):
  419. """
  420. Check that the entity has a dps list and each dps has an id,
  421. type and name, and any other consistency checks.
  422. """
  423. fname = f"custom_components/tuya_local/devices/{cfg}"
  424. line = entity._config.__line__
  425. assert entity._config.get("entity") is not None, (
  426. f"\n::error file={fname},line={line}::entity type missing in {cfg}"
  427. )
  428. e = entity.config_id
  429. assert entity._config.get("dps") is not None, (
  430. f"\n::error file={fname},line={line}::dps missing from {e} in {cfg}"
  431. )
  432. functions = set()
  433. extra = set()
  434. known = set()
  435. redirects = set()
  436. # Basic checks of dps, and initialising of redirects and extras sets
  437. # for later checking
  438. for dp in entity.dps():
  439. line = dp._config.__line__
  440. dp_type = dp._config.get("type")
  441. assert dp._config.get("id") is not None, (
  442. f"\n::error file={fname},line={line}::dp id missing from {e} in {cfg}"
  443. )
  444. assert dp_type is not None, (
  445. f"\n::error file={fname},line={line}::dp type missing from {e} in {cfg}"
  446. )
  447. assert dp._config.get("name") is not None, (
  448. f"\n::error file={fname},line={line}::dp name missing from {e} in {cfg}"
  449. )
  450. extra.add(dp.name)
  451. mappings = dp._config.get("mapping", [])
  452. assert isinstance(mappings, list), (
  453. f"\n::error file={fname},line={line}::mapping is not a list in {cfg}; entity {e}, dp {dp.name}"
  454. )
  455. for m in mappings:
  456. line = m.__line__
  457. conditions = m.get("conditions", [])
  458. assert isinstance(conditions, list), (
  459. f"\n::error file={fname},line={line}::conditions is not a list in {cfg}; entity {e}, dp {dp.name}"
  460. )
  461. if m.get("invert") and dp_type not in ["integer", "hex", "base64"]:
  462. pytest.fail(
  463. f"\n::error file={fname},line={line}::invert is only valid for numeric values in {cfg}; entity {e}, dp {dp.name}"
  464. )
  465. for c in conditions:
  466. if c.get("value_redirect"):
  467. redirects.add(c.get("value_redirect"))
  468. if c.get("value_mirror"):
  469. redirects.add(c.get("value_mirror"))
  470. if c.get("invert") and dp_type not in ["integer", "hex", "base64"]:
  471. pytest.fail(
  472. f"\n::error file={fname},line={line}::invert is only valid for numeric values in {cfg}; entity {e}, dp {dp.name}"
  473. )
  474. if m.get("value_redirect"):
  475. redirects.add(m.get("value_redirect"))
  476. if m.get("value_mirror"):
  477. redirects.add(m.get("value_mirror"))
  478. line = entity._config.__line__
  479. # Check redirects all exist
  480. for redirect in redirects:
  481. assert redirect in extra, (
  482. f"\n::error file={fname},line={line}::dp {redirect} missing from {e} in {cfg}"
  483. )
  484. # Check dps that are required for this entity type all exist
  485. expected = KNOWN_DPS.get(entity.entity)
  486. for rule in expected["required"]:
  487. assert dp_match(rule, functions, extra, known, True), (
  488. f"\n::error file={fname},line={line}::{cfg} missing required {rule_broken_msg(rule)} in {e}"
  489. )
  490. for rule in expected["optional"]:
  491. assert dp_match(rule, functions, extra, known, False), (
  492. f"\n::error file={fname},line={line}::{cfg} expecting {rule_broken_msg(rule)} in {e}"
  493. )
  494. # Check for potential typos in extra attributes
  495. known_extra = known - functions
  496. for attr in extra:
  497. for dp in known_extra:
  498. assert fuzz.ratio(attr, dp) < 85, (
  499. f"\n::error file={fname},line={line}::Probable typo {attr} is too similar to {dp} in {cfg} {e}"
  500. )
  501. # Check that sensors with mapped values are of class enum and vice versa
  502. if entity.entity == "sensor":
  503. mock_device = mocker.MagicMock()
  504. sensor = TuyaLocalSensor(mock_device, entity)
  505. if sensor.options:
  506. assert entity.device_class == SensorDeviceClass.ENUM, (
  507. f"\n::error file={fname},line={line}::{cfg} {e} has mapped values but does not have a device class of enum"
  508. )
  509. if entity.device_class == SensorDeviceClass.ENUM:
  510. assert sensor.options is not None, (
  511. f"\n::error file={fname},line={line}::{cfg} {e} has a device class of enum, but has no mapped values"
  512. )
  513. def test_config_files_parse(mocker):
  514. """
  515. All configs should be parsable and meet certain criteria
  516. """
  517. for cfg in available_configs():
  518. entities = []
  519. parsed = TuyaDeviceConfig(cfg)
  520. # Check for error messages or unparsed config
  521. if isinstance(parsed, str) or isinstance(parsed._config, str):
  522. pytest.fail(f"unparsable yaml in {cfg}")
  523. fname = f"custom_components/tuya_local/devices/{cfg}"
  524. try:
  525. YAML_SCHEMA(parsed._config)
  526. except vol.MultipleInvalid as e:
  527. messages = []
  528. first_line = None
  529. for err in e.errors:
  530. path = ".".join([str(p) for p in err.path])
  531. messages.append(f"{path}: {err.msg}")
  532. if first_line is None:
  533. # voluptuous doesn't always seem to return line numbers
  534. if err.path and hasattr(err.path[-1], "__line__"):
  535. first_line = err.path[-1].__line__
  536. messages = "; ".join(messages)
  537. if not first_line:
  538. first_line = 1
  539. pytest.fail(
  540. f"\n::error file={fname},line={first_line}::Validation error: {messages}"
  541. )
  542. assert parsed._config.get("name") is not None, (
  543. f"\n::error file={fname},line=1::name missing from {cfg}"
  544. )
  545. count = 0
  546. for entity in parsed.all_entities():
  547. check_entity(entity, cfg, mocker)
  548. # check entities are unique
  549. if entity.config_id in entities:
  550. pytest.fail(
  551. f"\n::error file={fname},line={entity._config.__line__}::"
  552. f"Duplicate entity {entity.config_id} in {cfg}"
  553. )
  554. entities.append(entity.config_id)
  555. count += 1
  556. assert count > 0, f"\n::error file={fname},line=1::No entities found in {cfg}"
  557. def test_configs_can_be_matched():
  558. """Test that the config files can be matched to a device."""
  559. for cfg in available_configs():
  560. optional = set()
  561. required = set()
  562. parsed = TuyaDeviceConfig(cfg)
  563. fname = f"custom_components/tuya_local/devices/{cfg}"
  564. products = parsed._config.get("products")
  565. # Configs with a product list can be matched by product id
  566. if products:
  567. p_match = False
  568. for p in products:
  569. if p.get("id"):
  570. p_match = True
  571. if p_match:
  572. continue
  573. for entity in parsed.all_entities():
  574. for dp in entity.dps():
  575. if dp.optional:
  576. optional.add(dp.id)
  577. else:
  578. required.add(dp.id)
  579. assert len(required) > 0, (
  580. f"\n::error file={fname},line=1::No required dps found in {cfg}"
  581. )
  582. for dp in required:
  583. assert dp not in optional, (
  584. f"\n::error file={fname},line=1::Optional dp {dp} is required in {cfg}"
  585. )
  586. # Most of the device_config functionality is exercised during testing of
  587. # the various supported devices. These tests concentrate only on the gaps.
  588. def test_match_quality():
  589. """Test the match_quality function."""
  590. cfg = get_config("deta_fan")
  591. q = cfg.match_quality({**KOGAN_HEATER_PAYLOAD, "updated_at": 0})
  592. assert q == 0
  593. q = cfg.match_quality({**GPPH_HEATER_PAYLOAD})
  594. assert q == 0
  595. def test_entity_find_unknown_dps_fails():
  596. """Test that finding a dps that doesn't exist fails."""
  597. cfg = get_config("kogan_switch")
  598. for entity in cfg.all_entities():
  599. non_existing = entity.find_dps("missing")
  600. assert non_existing is None
  601. break
  602. @pytest.mark.asyncio
  603. async def test_dps_async_set_readonly_value_fails(mocker):
  604. """Test that setting a readonly dps fails."""
  605. mock_device = mocker.MagicMock()
  606. cfg = get_config("aquatech_x6_water_heater")
  607. for entity in cfg.all_entities():
  608. if entity.entity == "climate":
  609. temp = entity.find_dps("temperature")
  610. with pytest.raises(TypeError):
  611. await temp.async_set_value(mock_device, 20)
  612. break
  613. def test_dps_values_is_empty_with_no_mapping(mocker):
  614. """
  615. Test that a dps with no mapping returns empty list for possible values
  616. """
  617. mock_device = mocker.MagicMock()
  618. cfg = get_config("goldair_gpph_heater")
  619. for entity in cfg.all_entities():
  620. if entity.entity == "climate":
  621. temp = entity.find_dps("current_temperature")
  622. assert temp.values(mock_device) == []
  623. break
  624. def test_config_returned():
  625. """Test that config file is returned by config"""
  626. cfg = get_config("kogan_switch")
  627. assert cfg.config == "smartplugv1.yaml"
  628. def test_float_matches_ints():
  629. """Test that the _typematch function matches int values to float dps"""
  630. assert _typematch(float, 1)
  631. def test_bytes_to_fmt_returns_string_for_unknown():
  632. """
  633. Test that the _bytes_to_fmt function parses unknown number of bytes
  634. as a string format.
  635. """
  636. assert _bytes_to_fmt(5) == "5s"
  637. def test_deprecation(mocker):
  638. """Test that deprecation messages are picked from the config."""
  639. mock_device = mocker.MagicMock()
  640. mock_device.name = "Testing"
  641. mock_config = {"entity": "Test", "deprecated": "Passed"}
  642. cfg = TuyaEntityConfig(mock_device, mock_config)
  643. assert cfg.deprecated
  644. assert (
  645. cfg.deprecation_message
  646. == "The use of Test for Testing is deprecated and should be replaced by Passed."
  647. )
  648. def test_format_with_none_defined(mocker):
  649. """Test that format returns None when there is none configured."""
  650. mock_entity = mocker.MagicMock()
  651. mock_config = {"id": "1", "name": "test", "type": "string"}
  652. cfg = TuyaDpsConfig(mock_entity, mock_config)
  653. assert cfg.format is None
  654. def test_decoding_base64(mocker):
  655. """Test that decoded_value works with base64 encoding."""
  656. mock_entity = mocker.MagicMock()
  657. mock_config = {"id": "1", "name": "test", "type": "base64"}
  658. mock_device = mocker.MagicMock()
  659. mock_device.get_property.return_value = "VGVzdA=="
  660. cfg = TuyaDpsConfig(mock_entity, mock_config)
  661. assert cfg.decoded_value(mock_device) == bytes("Test", "utf-8")
  662. def test_decoding_hex(mocker):
  663. """Test that decoded_value works with hex encoding."""
  664. mock_entity = mocker.MagicMock()
  665. mock_config = {"id": "1", "name": "test", "type": "hex"}
  666. mock_device = mocker.MagicMock()
  667. mock_device.get_property.return_value = "babe"
  668. cfg = TuyaDpsConfig(mock_entity, mock_config)
  669. assert cfg.decoded_value(mock_device) == b"\xba\xbe"
  670. def test_decoding_unencoded(mocker):
  671. """Test that decoded_value returns the raw value when not encoded."""
  672. mock_entity = mocker.MagicMock()
  673. mock_config = {"id": "1", "name": "test", "type": "string"}
  674. mock_device = mocker.MagicMock()
  675. mock_device.get_property.return_value = "VGVzdA=="
  676. cfg = TuyaDpsConfig(mock_entity, mock_config)
  677. assert cfg.decoded_value(mock_device) == "VGVzdA=="
  678. def test_encoding_base64(mocker):
  679. """Test that encode_value works with base64."""
  680. mock_entity = mocker.MagicMock()
  681. mock_config = {"id": "1", "name": "test", "type": "base64"}
  682. cfg = TuyaDpsConfig(mock_entity, mock_config)
  683. assert cfg.encode_value(bytes("Test", "utf-8")) == "VGVzdA=="
  684. def test_encoding_hex(mocker):
  685. """Test that encode_value works with base64."""
  686. mock_entity = mocker.MagicMock()
  687. mock_config = {"id": "1", "name": "test", "type": "hex"}
  688. cfg = TuyaDpsConfig(mock_entity, mock_config)
  689. assert cfg.encode_value(b"\xca\xfe") == "cafe"
  690. def test_encoding_unencoded(mocker):
  691. """Test that encode_value works with base64."""
  692. mock_entity = mocker.MagicMock()
  693. mock_config = {"id": "1", "name": "test", "type": "string"}
  694. cfg = TuyaDpsConfig(mock_entity, mock_config)
  695. assert cfg.encode_value("Test") == "Test"
  696. def test_match_returns_false_on_errors_with_bitfield(mocker):
  697. """Test that TypeError and ValueError cause match to return False."""
  698. mock_entity = mocker.MagicMock()
  699. mock_config = {"id": "1", "name": "test", "type": "bitfield"}
  700. cfg = TuyaDpsConfig(mock_entity, mock_config)
  701. assert not cfg._match(15, "not an integer")
  702. def test_values_with_mirror(mocker):
  703. """Test that value_mirror redirects."""
  704. mock_entity = mocker.MagicMock()
  705. mock_config = {
  706. "id": "1",
  707. "type": "string",
  708. "name": "test",
  709. "mapping": [
  710. {"dps_val": "mirror", "value_mirror": "map_mirror"},
  711. {"dps_val": "plain", "value": "unmirrored"},
  712. ],
  713. }
  714. mock_map_config = {
  715. "id": "2",
  716. "type": "string",
  717. "name": "map_mirror",
  718. "mapping": [
  719. {"dps_val": "1", "value": "map_one"},
  720. {"dps_val": "2", "value": "map_two"},
  721. ],
  722. }
  723. mock_device = mocker.MagicMock()
  724. mock_device.get_property.return_value = "1"
  725. cfg = TuyaDpsConfig(mock_entity, mock_config)
  726. mapping = TuyaDpsConfig(mock_entity, mock_map_config)
  727. mock_entity.find_dps.return_value = mapping
  728. assert set(cfg.values(mock_device)) == {"unmirrored", "map_one", "map_two"}
  729. assert len(cfg.values(mock_device)) == 3
  730. def test_get_device_id():
  731. """Test that child devices are scoped to their gateway."""
  732. assert "my-device-id" == get_device_id({"device_id": "my-device-id"})
  733. assert "sub-id" == get_device_id({"device_cid": "sub-id"})
  734. assert "d/s" == get_device_id({"device_id": "d", "device_cid": "s"})
  735. assert "other/s" == get_device_id({"device_id": "other", "device_cid": "s"})
  736. def test_getting_masked_hex(mocker):
  737. """Test that get_value works with masked hex encoding."""
  738. mock_entity = mocker.MagicMock()
  739. mock_config = {
  740. "id": "1",
  741. "name": "test",
  742. "type": "hex",
  743. "mask": "ff00",
  744. }
  745. mock_device = mocker.MagicMock()
  746. mock_device.get_property.return_value = "babe"
  747. cfg = TuyaDpsConfig(mock_entity, mock_config)
  748. assert cfg.get_value(mock_device) == 0xBA
  749. def test_setting_masked_hex(mocker):
  750. """Test that get_values_to_set works with masked hex encoding."""
  751. mock_entity = mocker.MagicMock()
  752. mock_config = {
  753. "id": "1",
  754. "name": "test",
  755. "type": "hex",
  756. "mask": "ff00",
  757. }
  758. mock_device = mocker.MagicMock()
  759. mock_device.get_property.return_value = "babe"
  760. cfg = TuyaDpsConfig(mock_entity, mock_config)
  761. assert cfg.get_values_to_set(mock_device, 0xCA) == {"1": "cabe"}
  762. def test_getting_masked_b64_with_special_case_mapping(mocker):
  763. """Test that get_value works with masked hex encoding and a mapping that has a special case."""
  764. mock_entity = mocker.MagicMock()
  765. mock_config = {
  766. "id": "1",
  767. "name": "test",
  768. "type": "base64",
  769. "mask": "ffff",
  770. "mapping": [
  771. {"dps_val": 256, "value": "special_case"},
  772. ],
  773. }
  774. mock_device = mocker.MagicMock()
  775. mock_device.get_property.return_value = "AQA="
  776. cfg = TuyaDpsConfig(mock_entity, mock_config)
  777. assert cfg.get_value(mock_device) == "special_case"
  778. def test_setting_masked_b64_with_special_case_mapping(mocker):
  779. """Test that get_values_to_set works with masked hex encoding and a mapping that has a special case."""
  780. mock_entity = mocker.MagicMock()
  781. mock_config = {
  782. "id": "1",
  783. "name": "test",
  784. "type": "base64",
  785. "mask": "ffff",
  786. "mapping": [
  787. {"dps_val": 256, "value": "special_case"},
  788. ],
  789. }
  790. mock_device = mocker.MagicMock()
  791. mock_device.get_property.return_value = "AAA="
  792. cfg = TuyaDpsConfig(mock_entity, mock_config)
  793. assert cfg.get_values_to_set(mock_device, "special_case") == {"1": "AQA="}
  794. def test_default_without_mapping(mocker):
  795. """Test that default returns None when there is no mapping"""
  796. mock_entity = mocker.MagicMock()
  797. mock_config = {"id": "1", "name": "test", "type": "string"}
  798. cfg = TuyaDpsConfig(mock_entity, mock_config)
  799. assert cfg.default is None
  800. def test_matching_with_product_id():
  801. """Test that matching with product id works"""
  802. cfg = get_config("smartplugv1")
  803. assert cfg.matches({}, ["37mnhia3pojleqfh"])
  804. def test_matched_product_id_with_conflict_rejected():
  805. """Test that matching with product id fails when there is a conflict"""
  806. cfg = get_config("smartplugv1")
  807. assert not cfg.matches({"1": "wrong_type"}, ["37mnhia3pojleqfh"])
  808. def test_multi_stage_redirect(mocker):
  809. """Test that multi stage redirects work correctly for read."""
  810. # Redirect used to combine multiple dps into a single value
  811. kc_cfg = get_config("kcvents_vt501_fan")
  812. for entity in kc_cfg.all_entities():
  813. if entity.entity == "fan":
  814. fan = entity
  815. break
  816. assert fan is not None
  817. speed = fan.find_dps("speed")
  818. assert speed is not None
  819. dps = {"1": True, "101": True, "102": False, "103": False}
  820. device = mock_device(dps, mocker)
  821. assert speed.values(device) == [33, 66, 100]
  822. assert speed.get_value(device) == 33
  823. dps["101"] = False
  824. dps["102"] = True
  825. assert speed.get_value(device) == 66
  826. dps["102"] = False
  827. dps["103"] = True
  828. assert speed.get_value(device) == 100
  829. # Redirect used for alternate dps
  830. dewin_cfg = get_config("dewin_kws306wf_energymeter")
  831. for entity in dewin_cfg.all_entities():
  832. if entity.entity == "switch" and entity.name is None:
  833. switch = entity
  834. break
  835. assert switch is not None
  836. main = switch.find_dps("switch")
  837. alt = switch.find_dps("alt")
  838. assert main is not None and alt is not None
  839. dps = {"16": True, "141": None}
  840. device = mock_device(dps, mocker)
  841. assert main.get_value(device) is True
  842. dps["16"] = False
  843. assert main.get_value(device) is False
  844. dps["141"] = True
  845. dps["16"] = None
  846. assert main.get_value(device) is True
  847. dps["141"] = False
  848. assert main.get_value(device) is False
  849. @pytest.mark.asyncio
  850. async def test_setting_multi_stage_redirect(mocker):
  851. """Test that multi stage redirects work correctly for write."""
  852. # Redirect used to combine multiple dps into a single value
  853. kc_cfg = get_config("kcvents_vt501_fan")
  854. for entity in kc_cfg.all_entities():
  855. if entity.entity == "fan":
  856. fan = entity
  857. break
  858. assert fan is not None
  859. speed = fan.find_dps("speed")
  860. assert speed is not None
  861. dps = {"1": True, "101": True, "102": False, "103": False}
  862. device = mock_device(dps, mocker)
  863. async with assert_device_properties_set(device, {"102": True}):
  864. await speed.async_set_value(device, 66)
  865. async with assert_device_properties_set(device, {"103": True}):
  866. await speed.async_set_value(device, 100)
  867. # Redirect used for alternate dps
  868. dewin_cfg = get_config("dewin_kws306wf_energymeter")
  869. for entity in dewin_cfg.all_entities():
  870. if entity.entity == "switch" and entity.name is None:
  871. switch = entity
  872. break
  873. assert switch is not None
  874. main = switch.find_dps("switch")
  875. alt = switch.find_dps("alt")
  876. assert main is not None and alt is not None
  877. dps = {"16": True, "141": None}
  878. device = mock_device(dps, mocker)
  879. async with assert_device_properties_set(device, {"16": False}):
  880. await main.async_set_value(device, False)
  881. dps["16"] = None
  882. dps["141"] = True
  883. async with assert_device_properties_set(device, {"141": False}):
  884. await main.async_set_value(device, False)
  885. def test_reading_target_range(mocker):
  886. """Test reading a number that has a target range."""
  887. mock_config = {
  888. "id": 1,
  889. "name": "test",
  890. "type": "integer",
  891. "range": {"min": 0, "max": 16},
  892. "mapping": [{"target_range": {"min": 0, "max": 100}}],
  893. }
  894. mock_entity = mocker.MagicMock()
  895. mock_device = mocker.MagicMock()
  896. mock_device.get_property.return_value = 8
  897. cfg = TuyaDpsConfig(mock_entity, mock_config)
  898. assert cfg.get_value(mock_device) == 50
  899. def test_writing_target_range(mocker):
  900. """Test writing a number that has a target range."""
  901. mock_config = {
  902. "id": 1,
  903. "name": "test",
  904. "type": "integer",
  905. "range": {"min": 0, "max": 16},
  906. "mapping": [{"target_range": {"min": 0, "max": 100}}],
  907. }
  908. mock_entity = mocker.MagicMock()
  909. mock_device = mocker.MagicMock()
  910. cfg = TuyaDpsConfig(mock_entity, mock_config)
  911. assert cfg.get_values_to_set(mock_device, 100) == {"1": 16}