diff --git a/TasmotaManager.py b/TasmotaManager.py index 9411d54..aea07cc 100755 --- a/TasmotaManager.py +++ b/TasmotaManager.py @@ -1362,6 +1362,17 @@ class TasmotaDiscovery: if param in console_params: try: final_value = console_params[param] + # Pre-check current value; skip if already at desired state + current_val, ok = self._get_console_param_value(ip, name, param) + if ok: + desired_cmp = str(final_value).strip().lower() + current_cmp = str(current_val or "").strip().lower() + # Map 1/0 to on/off for retain-like responses + if current_cmp in ("1", "0") and desired_cmp in ("on", "off"): + current_cmp = "on" if current_cmp == "1" else "off" + if current_cmp == desired_cmp: + self.logger.debug(f"{name}: {param} already {final_value}, skipping retain toggle") + continue # Set opposite state first opposite_value = "On" if final_value.lower() == "off" else "Off" @@ -1508,6 +1519,41 @@ class TasmotaDiscovery: # If this is in the config, we'll respect it, but log that it's not needed self.logger.debug(f"{name}: Note: {param} is not needed with auto-enable feature") + # Determine if update is needed before sending + should_send = True + try: + current_val, ok = self._get_console_param_value(ip, name, param) + if ok: + if param.lower().startswith('rule') and param.lower() == param and param[-1].isdigit(): + # Compare rule definitions (whitespace-insensitive, case-insensitive) + import re as _re + def _norm_rule(s): + s = str(s or "") + s = s.strip().lower() + s = _re.sub(r"\s+", " ", s) + return s + if _norm_rule(current_val) == _norm_rule(value): + self.logger.debug(f"{name}: {param} rule already matches definition, skipping") + should_send = False + else: + # Generic comparison + desired_cmp = str(value).strip().lower() + current_cmp = str(current_val or "").strip().lower() + # Normalize common ON/OFF vs 1/0 forms + if current_cmp in ("on", "off") and desired_cmp in ("1", "0"): + current_cmp = "1" if current_cmp == "on" else "0" + if current_cmp in ("1", "0") and desired_cmp in ("on", "off"): + current_cmp = "on" if current_cmp == "1" else "off" + if current_cmp == desired_cmp: + self.logger.debug(f"{name}: {param} already set to {value}, skipping") + should_send = False + except Exception: + # If pre-check fails, fall back to sending command + pass + + if not should_send: + continue + # Regular console parameter # Special handling for rule parameters to properly encode the URL if param.lower().startswith('rule') and param.lower() == param and param[-1].isdigit(): @@ -1601,6 +1647,16 @@ class TasmotaDiscovery: continue # Rule auto-enabling + # Pre-check if rule already enabled + try: + current_val, ok = self._get_console_param_value(ip, name, rule_enable_param) + if ok: + state = str(current_val or "").strip().lower() + if state in ("on", "1", "enabled", "active"): + self.logger.debug(f"{name}: {rule_enable_param} already enabled, skipping") + continue + except Exception: + pass url = f"http://{ip}/cm?cmnd={rule_enable_param}%201" if with_retry: @@ -1655,6 +1711,81 @@ class TasmotaDiscovery: return console_updated + def _get_console_param_value(self, ip, name, param): + """Query the device for the current value of a console parameter. + Returns (value, True) on success, (None, False) on failure. + Special handling: + - Lowercase ruleN: returns the current rule definition text using RuleN 5 + - Uppercase RuleN: returns the current enable state (ON/OFF or 1/0) + """ + try: + # Rules handling + if param.lower().startswith('rule') and param[-1].isdigit(): + rule_num = param[-1] + # If lowercase 'ruleN', fetch rule definition using 'RuleN 5' + if param.islower(): + url = f"http://{ip}/cm?cmnd=Rule{rule_num}%205" + response = requests.get(url, timeout=5) + # Try to parse JSON first + try: + data = response.json() + # Common keys: 'Rules' may contain the definition + if isinstance(data, dict): + for k, v in data.items(): + if str(k).lower() == 'rules': + return v, True + # Fallback: first string value + for v in data.values(): + if isinstance(v, str): + return v, True + return str(data), True + except Exception: + # Fallback to text parsing + text = response.text or '' + return text, True + else: + # Uppercase 'RuleN' - fetch enable state + url = f"http://{ip}/cm?cmnd=Rule{rule_num}" + response = requests.get(url, timeout=5) + try: + data = response.json() + # Expect something like {"Rule1":"ON"} + if isinstance(data, dict): + # Try exact key first + key = f"Rule{rule_num}" + if key in data: + return data[key], True + # Fallback: any ON/OFF value + for v in data.values(): + if isinstance(v, (str, int)): + return v, True + return str(data), True + except Exception: + text = response.text or '' + return text, True + # Generic parameter query - send the command name without a value + url = f"http://{ip}/cm?cmnd={param}" + response = requests.get(url, timeout=5) + try: + data = response.json() + if isinstance(data, dict) and data: + # Prefer an exact key match (case-insensitive) + param_lower = param.lower() + for k, v in data.items(): + if str(k).lower() == param_lower: + return v, True + # Fallback: first value + first_val = next(iter(data.values())) + return first_val, True + # If not a dict, return as string + return str(data), True + except Exception: + # Fallback to raw text + return response.text, True + except requests.exceptions.RequestException as e: + self.logger.debug(f"{name}: Failed to query current value for {param}: {e}") + return None, False + def apply_config_other(self, ip, name): """Wrapper for applying config_other (template) settings.""" return self.check_and_update_template(ip, name)