test_device_config.py 33 KB

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