test_device_config.py 26 KB

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