test_device_config.py 32 KB

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