diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 3863b87..7800932 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -464,9 +464,22 @@ async def _act( # miss, not a failure, so the fallback agent gets the sentence intact. _LOGGER.debug("intent %s matched nothing: %s", decision.intent_type, err) return await self._fall_back(user_input, "the named target was not found") + except ha_intent.IntentHandleError as err: + # Home Assistant raises this only when no entity succeeded, so nothing + # changed. A media player with no turn_off does this, and the fallback + # agent may know another way to do what was asked. + _LOGGER.debug("intent %s failed: %s", decision.intent_type, err) + return await self._fall_back( + user_input, "the intent failed on every target", "intent_failed" + ) except ha_intent.IntentError as err: _LOGGER.error("intent %s failed: %s", decision.intent_type, err) - return await self._speak(user_input, "intent_failed") + # An error, so a satellite does not hear it as a command that went through. + return await self._speak( + user_input, + "intent_failed", + error=ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + ) # Loading our lines reads translations, so only a reply without a sentence of # its own loads them. diff --git a/custom_components/jev/interpret.py b/custom_components/jev/interpret.py index 9bfec7f..2555c9d 100644 --- a/custom_components/jev/interpret.py +++ b/custom_components/jev/interpret.py @@ -35,8 +35,8 @@ } # The words that turn a number into a percentage, in the languages the integration -# is translated into. "%" carries most of the traffic; these are for a satellite -# that transcribes the word instead of the sign. +# is translated into, and in Hungarian. "%" carries most of the traffic; these are +# for a satellite that transcribes the word instead of the sign. _PERCENT_WORDS = ( "%", r"per ?cento?", # en, and it "per cento" @@ -46,6 +46,7 @@ "por ?ciento", # es "por ?cento", # pt-BR r"процент\w*", # ru + r"százalék\w*", # hu ) # The lookarounds keep a number whole: "1000 percent" and "12.5 percent" are not # brightnesses, and without them the regex found 0 and 5 inside them. @@ -57,13 +58,116 @@ # the number and \b never fires between two characters that are both word # characters. "\u628a\u706f\u8c03\u6697\u523030" has to give 30. _BARE_NUMBER = re.compile(_NUMBER) -# "20% brighter" and "dim it by 20" change the level by an amount. HassLightSet only -# sets a level, so these go to the fallback agent rather than being read as 20%. -_RELATIVE = re.compile( - r"\b(?:brighter|dimmer|darker)\b" - r"|\b(?:by|met|um)\s+\d", +# A number can be the level to set or the amount to change it by. HassLightSet only +# sets a level, so an amount goes to the fallback agent rather than being read as +# the level: "turn it up 20%" on a light at 60% set it to 20. When the words are +# unclear the number is not read, because a fallback costs a sentence and a wrong +# level turns the room dark. +# +# Words in front of the number that make it the level: "to 20%", "auf 20". +_TO = re.compile( + r"(?:\b(?:to|at|auf|zu|op|naar|tot|à|a|au|al|allo|alla|para|na|do|på|till|til|до)" + r"|到|为|成|至)\s*$", re.IGNORECASE, ) +# Words in front of the number that make it an amount: "by 20%", "um 20". Russian +# "на", Portuguese "em" and Spanish "en" mean both, so the change words below decide. +_BY = re.compile( + r"\b(?:by|um|met|de|del|di|un|o|med)\s*$", + re.IGNORECASE, +) +# Hungarian puts "to" and "by" on the number as a suffix: "20%-ra", "20%-kal". +_TO_SUFFIX = re.compile(r"^(?:\s*százalék)?-?(?:ra|re)\b", re.IGNORECASE) +_BY_SUFFIX = re.compile(r"^(?:\s*százalék)?-?(?:kal|kel)\b", re.IGNORECASE) +# Words anywhere in the sentence that ask for a change rather than a level. They +# count only when no "to" stands in front of the number, so "turn it up to 50%" is +# still 50. Stems, matched at a word start. +_CHANGE_STEMS = { + "en": ( + # "dim the lamp 20 percent" can mean either. "dim it to 20" is a level. + r"(?:increase|decrease|raise|lower|reduce|boost|add|brighten)", + r"dim\b", + r"(?:up|down|more|less|plus|minus|brighter|dimmer|darker)\b", + ), + "de": ( + "erhöh", + "verringer", + "reduzier", + "senk", + "heller", + "dunkler", + "mehr\b", + "weniger", + "plus\b", + "minus\b", + ), + "nl": ( + "verhoog", + "verlaag", + "feller", + "lichter", + "donkerder", + "meer\b", + "minder\b", + "min\b", + ), + "fr": ("augment", "baiss", "diminu", "rédui", "redui", "plus\b", "moins\b"), + "it": ("aument", "abbass", "diminu", "riduc", "più\b", "piu\b", "meno\b"), + "es": ("aument", "sube", "baja", "disminu", "reduc", "más\b", "menos\b"), + "pt-BR": ("aument", "diminu", "reduz", "mais\b", "menos\b"), + "pl": ( + "zwiększ", + "zmniejsz", + "podnieś", + "obniż", + "jaśniej", + "ciemniej", + "więcej", + "mniej", + ), + "sv": ("öka", "sänk", "minska", "ljusare", "mörkare", "mer\b", "mindre\b"), + "da": ("øg\b", "sænk", "lysere", "mørkere", "mere\b", "mindre\b"), + "cs": ("zvyš", "zvýš", "sniž", "jasněji", "tmavěji", "víc", "méně"), + "ru": ( + "увелич", + "уменьш", + "прибав", + "убав", + "повыс", + "пониз", + "ярче", + "темнее", + "больше", + "меньше", + ), + "hu": ("növel", "csökkent", "halványabb", "világosabb", "fényesebb", "sötétebb"), +} +_CHANGE = re.compile( + r"\b(?:" + "|".join(s for g in _CHANGE_STEMS.values() for s in g) + ")", + re.IGNORECASE, +) +# No spaces in Chinese, so a word boundary never fires in front of these. +_CHANGE_CJK = ( + "增加", + "减少", + "降低", + "提高", + "调亮", + "调暗", + "调高", + "调低", + "更亮", + "更暗", +) +# A comparative straight after the number is an amount even behind "to": the +# sentence says "20% brighter", not "to 20%". +_COMPARATIVE_AFTER = re.compile( + r"^\s*(?:%|" + "|".join(_PERCENT_WORDS[1:]) + r")?\s*(?:" + r"brighter|dimmer|darker|more|less|heller|dunkler|feller|lichter|donkerder" + r"|plus|ljusare|mörkare|lysere|mørkere|ярче|темнее|更亮|更暗)", + re.IGNORECASE, +) + # A bare number becomes a brightness only when the sentence also says something # about light level. The model already chose set_brightness by this point, so this @@ -82,6 +186,7 @@ "da": ("lys", "dæmp"), "cs": ("jas", "ztlum", "stmív"), "ru": ("ярк", "приглуш", "свет"), + "hu": ("fény", "halvány", "világos"), } _LEVEL = re.compile( r"\b(?:" + "|".join(s for g in _LEVEL_STEMS.values() for s in g) + ")", @@ -97,22 +202,41 @@ def _in_range(raw: str) -> int | None: def find_brightness(text: str) -> int | None: - """A percentage in the text, if there is one. + """The level a sentence sets, if it says one. Prefers an explicit percent sign, because "turn on 2 lamps" holds a number that is not a brightness. Without one, the last number wins, because a device name - comes before its level: "lamp 2 brightness to 40" means 40. + comes before its level: "lamp 2 brightness to 40" means 40. A number that is an + amount to change the level by gives None. """ - if _RELATIVE.search(text): + found = ( + _PERCENT.search(text) + or _PERCENT_PREFIX.search(text) + or (_last_level_number(text)) + ) + if found is None or _is_an_amount(text, found): return None - if m := _PERCENT.search(text): - return _in_range(m.group(1)) - if m := _PERCENT_PREFIX.search(text): - return _in_range(m.group(1)) - if _LEVEL.search(text) or any(word in text for word in _LEVEL_CJK): - if numbers := _BARE_NUMBER.findall(text): - return _in_range(numbers[-1]) - return None + return _in_range(found.group(1)) + + +def _last_level_number(text: str) -> re.Match[str] | None: + if not (_LEVEL.search(text) or any(word in text for word in _LEVEL_CJK)): + return None + *_, last = (None, *_BARE_NUMBER.finditer(text)) + return last + + +def _is_an_amount(text: str, number: re.Match[str]) -> bool: + before, after = text[: number.start()], text[number.end() :] + # "百分之" sits in front of the number, so the words before it come before that. + before = before.removesuffix("百分之").rstrip() + if _COMPARATIVE_AFTER.search(after) or _BY_SUFFIX.search(after): + return True + if _TO.search(before) or _TO_SUFFIX.search(after): + return False + if _BY.search(before): + return True + return bool(_CHANGE.search(text)) or any(word in text for word in _CHANGE_CJK) @dataclass(slots=True) @@ -158,14 +282,16 @@ def build_questions( # No lock wording here on purpose. The agent does not control # locks, and Home Assistant's on/off convention for them runs the # opposite way round from speech. See CONTROLLABLE in snapshot.py. - "turn_on": "Switch something on, open it, start it, " - "or run a script or scene", - "turn_off": "Switch something off, close it, or stop it", + # No playback wording either. "Stop the music" is not a power + # command, and a player without turn_off fails it. + "turn_on": "Switch something on, open it, or run a script or scene", + "turn_off": "Switch something off or close it", "toggle": "Flip whatever state it is in now", "set_brightness": "Change how bright a light is", "get_state": "Answer a question about the current state, " "changing nothing", - NONE: "None of these, or the request is not about the house", + NONE: "None of these, such as playing, pausing, stopping or " + "skipping media, or the request is not about the house", }, ), "compound": Noul( @@ -186,6 +312,25 @@ def build_questions( true="It needs text written, quoted or looked up", false="It is a device command or a question about device state", ), + # Home Assistant's own agent has no timer or condition for an on/off + # command, so "turn off the lamp in 10 minutes" would turn it off now. + # A brightness level is not a part-way position, so each gets its own + # question: one that asked about both scored "set the lamp to 40 + # percent" at 0.64 and refused it. + "later": Noul( + "Does the command say to do it at another time, for a set time, or " + "only if something happens?", + true="It gives a time, a delay, a duration or a condition", + false="It is to be done now", + ), + # turn_on opens a cover all the way, so "open the blinds halfway" read as + # turn_on opens them fully. + "part": Noul( + "Does the command ask to open or close something only part of the way?", + true="It asks for a position between open and closed", + false="It asks for fully open or closed, or it is not about opening " + "or closing", + ), "target_type": Choice( "How is the target named?", { @@ -267,6 +412,10 @@ def out(reason: str) -> Interpretation: return out("several commands in one sentence") if noul("free_text") >= 0.5: return out("needs text written or looked up") + if noul("later") >= 0.5: + return out("for another time or on a condition") + if noul("part") >= 0.5: + return out("a position part of the way") action = choice("action") entity = choice("entity") diff --git a/custom_components/jev/manifest.json b/custom_components/jev/manifest.json index c8e9c3e..b0815bb 100644 --- a/custom_components/jev/manifest.json +++ b/custom_components/jev/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/AboveColin/HA-Jev/issues", "requirements": ["jevclient==1.2.0"], - "version": "1.16.0" + "version": "1.16.1" } diff --git a/site-docs/conversation.md b/site-docs/conversation.md index 4fa246e..0127295 100644 --- a/site-docs/conversation.md +++ b/site-docs/conversation.md @@ -7,11 +7,12 @@ Conversation agent to **Jev**. ## What it does -One sentence becomes one request carrying five to seven questions. Five are always -there: what should happen, is it compound, does it need text written, how is the -target named, which entity. Which room is added when you have rooms holding exposed +One sentence becomes one request carrying seven to nine questions. Seven are always +there: what should happen, is it compound, does it need text written, is it for +another time or on a condition, is it a position part of the way, how is the target +named, which entity. Which room is added when you have rooms holding exposed entities, and which kind of device when the exposed entities span two domains or -more. A one-domain house with no areas is asked five. +more. A one-domain house with no areas is asked seven. All but one or two of those answers are discarded on any given sentence. That is the cheap shape, not waste: three questions measured 712 ms and a hundred measured 714, so @@ -26,6 +27,11 @@ It runs Home Assistant's own intents: `HassTurnOn`, `HassTurnOff`, `HassToggle`, climate entities, vacuums, input booleans, scenes and scripts are turned on and off this way. Climate setpoints, and anything else, go to the fallback agent. +Playing, pausing, stopping and skipping media also go to the fallback agent. A +music player often has no `turn_off`, so "stop the music" read as turning it off +fails. With playback named as none of the above, a test of six playback sentences +all came back none of the above at 0.92 or more. + Locks are not on that list and are never described to the model. Home Assistant reads turn_on on a lock as `lock.lock` and turn_off as `lock.unlock`, the opposite way round from how the command is spoken, and a probability with no reasoning should not @@ -53,12 +59,15 @@ says "Done." | Below the confidence floor | The whole sentence goes to the fallback agent, nothing done first | | Two commands in one sentence | Fallback | | Needs words written or looked up | Fallback | +| For another time, for a set time or on a condition, such as "turn off the lamp in 10 minutes" | Fallback. Home Assistant's intents have no timer, so the command would run now | +| A cover part of the way, such as "open the blinds halfway" | Fallback. `turn_on` opens a cover all the way | | A lock or a garage, gate or door cover | Fallback. The agent never describes one | | An entity you did not expose to Assist | Never described to the model at all | | A hidden device named in full, next to an exposed one with a shorter name | Fallback. "Turn on the desk lamp" does not turn on "Lamp" | | No room and no device named | Refused, unless you allow it. `turn_off` is exempt. Below the confidence floor, fallback | | Two kinds of device, no kind named, whole house | Asks which kind. With one kind exposed, it acts on that kind | | Two devices whose names fit the command equally well | Asks which one, see below | +| The device cannot do the action, such as a player with no `turn_off` | Fallback. Home Assistant reports this only when no device changed | | Too little budget left for the command, a rejected key or no answer | Fallback. With no fallback agent it says which of the three it was, as an error reply | !!! info "It only sees what Assist sees" @@ -102,7 +111,9 @@ up a bill. Measured live: 257 to 455 ms warm, 512 to 753 ms on the first call after a restart, and 1,329 to 1,371 input tokens per command with five entities exposed. Thirty -commands came to $0.0017. +commands came to $0.0017. That was with seven questions. The two added in 1.16.1 +cost 127 more input tokens, 1,696 to 1,823 with twelve entities exposed, and the +same time warm: 261 ms before, 263 ms after. ## Brightness comes from a regex @@ -112,15 +123,21 @@ asking the model. Jev judges and does not calculate, and a regex is exact and fr `40 percent`, `40%` and `40 procent` all work. `turn on 2 lamps` correctly yields no brightness. -The percent word is read in every language the integration is translated into, so -`40 Prozent`, `40 pour cent`, `40 per cento`, `40 por ciento`, `40 procent`, -`40 процентов` and `百分之40` all give 40. A bare number needs a word about light -level next to it, `dimme ... auf 30` or `ztlum ... na 30`, or it stays a count. +The percent word is read in every language the integration is translated into, and +in Hungarian, so `40 Prozent`, `40 pour cent`, `40 per cento`, `40 por ciento`, +`40 procent`, `40 процентов`, `40 százalékra` and `百分之40` all give 40. A bare +number needs a word about light level next to it, `dimme ... auf 30` or `ztlum ... na 30`, or it stays a count. With several numbers, the last one is the level: `dim bedroom 2 to 30` gives 30. -A relative change gives no brightness, so `20% brighter` and `dim it by 20` go to the -fallback agent rather than setting 20. A number over 100 or with a decimal point is -not a percentage. +A relative change gives no brightness, so `20% brighter`, `dim it by 20` and +`20%-kal halványabbra` go to the fallback agent rather than setting 20. A word for +"to" in front of the number makes it a level, so `turn up the lamp to 80%`, +`verhoog de helderheid naar 80%` and `növeld a fényerőt 80%-ra` give 80. A word for +"by", or a word for changing with no "to", makes it an amount: `increase the +brightness by 20%`, `turn the lamp down 20%`, `erhöhe die Helligkeit um 20%` and +`把灯调亮20%` give none. In a test of 52 relative sentences in 14 languages, 45 set +the amount as the level before this rule and none do now. A number over 100 or with +a decimal point is not a percentage. ## A command that is already done diff --git a/site-docs/cost.md b/site-docs/cost.md index 3952e57..c3d50c1 100644 --- a/site-docs/cost.md +++ b/site-docs/cost.md @@ -34,7 +34,8 @@ grouping does for you automatically. ## Where the money actually goes For a small house, the **questions** dominate. A spoken command with 5 entities -exposed is about 1,350 tokens, of which the seven questions are most of it. +exposed was about 1,350 tokens with seven questions, which were most of it. The +agent asks nine since 1.16.1, 127 tokens more. For a large target, the **entities** dominate. At 150 entities you are paying roughly 110 tokens per entity per call. diff --git a/tests/test_conversation.py b/tests/test_conversation.py index a1230ec..a6c5fd0 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -14,6 +14,7 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import Context, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers import chat_session from homeassistant.helpers import device_registry as dr @@ -45,6 +46,8 @@ def answer_set(**overrides): "action": ChoiceAnswer(choice="turn_on", probabilities={}, confidence=0.97), "compound": NoulAnswer(noul=0.02), "free_text": NoulAnswer(noul=0.01), + "later": NoulAnswer(noul=0.03), + "part": NoulAnswer(noul=0.04), "target_type": ChoiceAnswer(choice="entity", probabilities={}, confidence=0.9), "entity": ChoiceAnswer(choice="light.kitchen", probabilities={}, confidence=1.0), "area": ChoiceAnswer(choice="none_of_these", probabilities={}, confidence=0.4), @@ -148,9 +151,16 @@ async def test_every_question_goes_in_one_request(hass, house, mock_client): assert mock_client.ask.await_count == 1 questions = mock_client.ask.call_args.args[1] - assert {"action", "compound", "free_text", "target_type", "entity", "area"} <= set( - questions - ) + assert { + "action", + "compound", + "free_text", + "later", + "part", + "target_type", + "entity", + "area", + } <= set(questions) async def test_an_area_command_reaches_both_lights_in_that_area(hass, house, mock_client): @@ -207,6 +217,33 @@ async def test_a_compound_command_acts_on_nothing(hass, house, mock_client): assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH +@pytest.mark.parametrize( + ("question", "text"), + [ + # Measured: turn_off 0.97 and turn_on 0.98 with nothing else to stop them. + ("later", "turn off the lamp in 10 minutes"), + ("later", "turn on the kitchen light when I get home"), + # turn_on opens a cover all the way. + ("part", "open the blinds halfway"), + ], +) +async def test_a_command_for_later_or_part_of_the_way_acts_on_nothing( + hass, house, mock_client, question, text +): + mock_client.ask.return_value = build_response( + **answer_set(**{question: NoulAnswer(noul=0.97)}) + ) + calls = [] + hass.services.async_register("light", "turn_on", lambda call: calls.append(call)) + hass.services.async_register("light", "turn_off", lambda call: calls.append(call)) + + result = await converse(hass, text) + await hass.async_block_till_done() + + assert calls == [] + assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH + + async def test_low_confidence_acts_on_nothing(hass, house, mock_client): mock_client.ask.return_value = build_response( **answer_set( @@ -313,6 +350,74 @@ async def test_a_free_text_request_goes_to_the_fallback_agent(hass, house, mock_ assert handovers[0].kwargs["agent_id"] == "conversation.home_assistant" +async def test_an_action_the_device_cannot_do_goes_to_the_fallback_agent( + hass, house, mock_client +): + """A Music Assistant player with no turn_off raised ServiceNotSupported on a real + instance, and the agent answered with an error. Home Assistant raises + IntentHandleError only when no target succeeded, so nothing has changed and the + fallback agent can try its own way. + """ + hass.config_entries.async_update_entry( + house, options={CONF_FALLBACK_AGENT: "conversation.home_assistant"} + ) + await hass.async_block_till_done() + mock_client.ask.return_value = build_response(**answer_set()) + + async def unsupported(call): + raise HomeAssistantError("Entity light.kitchen does not support this action") + + hass.services.async_register("light", "turn_on", unsupported) + + with patch( + "custom_components.jev.conversation.conversation.async_converse", + wraps=conversation.async_converse, + ) as handed_over: + await converse(hass, "kitchen light on") + + handovers = [ + c for c in handed_over.await_args_list if c.kwargs.get("agent_id") != AGENT + ] + assert len(handovers) == 1 + assert handovers[0].args[1] == "kitchen light on" + + +async def test_an_action_the_device_cannot_do_is_an_error_with_no_fallback( + hass, house, mock_client +): + """A satellite reads an action_done reply as a command that went through.""" + mock_client.ask.return_value = build_response(**answer_set()) + + async def unsupported(call): + raise HomeAssistantError("Entity light.kitchen does not support this action") + + hass.services.async_register("light", "turn_on", unsupported) + + result = await converse(hass, "kitchen light on") + + assert result.response.response_type is ha_intent.IntentResponseType.ERROR + assert ( + result.response.error_code is ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE + ) + assert result.response.speech["plain"]["speech"] == "Sorry, that did not work." + + +async def test_playback_is_not_described_as_switching_on_or_off(hass, house, mock_client): + """Measured on hosted Jev: with "or stop it" in turn_off, "stop the music" came + back turn_off at 0.98, and "play Metallica in the salon" turn_on at 0.43. With + playback named under none_of_these, all six playback sentences came back + none_of_these at 0.92 or more, and all six power commands kept their action. + """ + mock_client.ask.return_value = build_response(**answer_set()) + + await converse(hass, "stop the music") + + criteria = mock_client.ask.call_args.args[1]["action"].criteria + assert "stop" not in criteria["turn_off"] + assert "start" not in criteria["turn_on"] + assert "media" in criteria[NONE] + + async def test_the_fallback_never_points_at_itself(hass, house, mock_client): hass.config_entries.async_update_entry(house, options={CONF_FALLBACK_AGENT: AGENT}) await hass.async_block_till_done() @@ -657,6 +762,7 @@ def test_brightness_parsing(text, expected): ("saet lampen til 40 procent", 40), ("nastav lampu na 40 procent", 40), ("установи лампу на 40 процентов", 40), + ("kapcsold fel 40 százalékra", 40), # Chinese puts the marker in front of the number. ("把灯设为百分之40", 40), ("把灯设为 40%", 40), @@ -671,6 +777,7 @@ def test_brightness_parsing(text, expected): ("dæmp lampen til 30", 30), ("ztlum lampu na 30", 30), ("приглуши лампу до 30", 30), + ("állítsd a fényerejét 40-re", 40), ("把灯调暗到 30", 30), # Chinese writes no space in front of the number. ("把灯调暗到30", 30), @@ -1372,6 +1479,9 @@ async def test_two_devices_with_one_name_resolve_by_room(hass, house, mock_clien ("0.5%", None), ("make it 20% brighter", None), ("dim it by 20", None), + # Hungarian says "by" with a suffix on the number. This one set 20%. + ("vedd 20%-kal halványabbra", None), + ("vedd 20 százalékkal halványabbra", None), ], ) def test_a_brightness_is_only_read_when_it_is_a_level(text, expected): @@ -1379,6 +1489,53 @@ def test_a_brightness_is_only_read_when_it_is_a_level(text, expected): assert find_brightness(text) == expected +@pytest.mark.parametrize( + ("text", "expected"), + [ + # A change by an amount. Before, each of these set the amount as the level. + ("increase the brightness by 20%", None), + ("turn the lamp down 20%", None), + ("20% less", None), + ("brightness minus 20%", None), + ("dim the lamp 20 percent", None), + ("set the lamp to 20% brighter", None), + ("erhöhe die Helligkeit um 20%", None), + ("verhoog de helderheid met 20%", None), + ("augmente la luminosité de 20%", None), + ("abbassa la luce del 20 per cento", None), + ("sube el brillo un 20%", None), + ("aumente o brilho em 20%", None), + ("zwiększ jasność o 20%", None), + ("öka ljusstyrkan med 20%", None), + ("gør lampen 20% lysere", None), + ("zvyš jas o 20 %", None), + ("уменьши яркость на 20 процентов", None), + ("把灯调亮20%", None), + ("亮度降低百分之20", None), + ("növeld a fényerőt 20%-kal", None), + # A change word with "to" in front of the number is a level. + ("turn up the lamp to 80%", 80), + ("lower the lamp to 20%", 20), + ("Helligkeit auf 50 Prozent erhöhen", 50), + ("verhoog de helderheid naar 80%", 80), + ("augmente la luminosité à 80%", 80), + ("aumenta la luminosità al 80%", 80), + ("sube el brillo al 80%", 80), + ("aumente o brilho para 80%", 80), + ("zwiększ jasność do 80%", 80), + ("öka ljusstyrkan till 80%", 80), + ("øg lysstyrken til 80%", 80), + ("zvyš jas na 80 %", 80), + ("увеличь яркость до 80%", 80), + ("增加亮度到80%", 80), + ("növeld a fényerőt 80%-ra", 80), + ("legyen világosabb 30-ra", 30), + ], +) +def test_a_change_by_an_amount_is_not_a_level(text, expected): + assert find_brightness(text) == expected + + async def test_a_room_past_the_entity_cap_is_not_offered(hass, config_entry): from custom_components.jev.snapshot import async_snapshot