4
0

test_device_config.py 29 KB

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