4
0

test_device_config.py 30 KB

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