From 31ce7030ee01b25e500f798f1fc1e687f660797a Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:47:09 +0100 Subject: [PATCH 01/12] update version --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 961d471..be6d6a4 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ [![Custom integration](https://img.shields.io/badge/custom%20integration-%2341BDF5.svg)](https://www.home-assistant.io/getting-started/concepts-terminology) [![HACS](https://img.shields.io/badge/HACS%20listed-not_yet-red.svg)](https://github.com/hacs) [![HACS](https://img.shields.io/badge/HACS%20install-verified-green.svg)](https://github.com/hacs) -[![Version](https://img.shields.io/badge/Version-v2025.01.1-green.svg)](https://github.com/Tom-Bom-badil/home-assistant_helios-vallox/releases) +[![Version](https://img.shields.io/badge/Version-v2025.01.2-green.svg)](https://github.com/Tom-Bom-badil/home-assistant_helios-vallox/releases) [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/Tom-Bom-badil/home-assistant_helios-vallox/graphs/commit-activity) # Integration for Helios / Vallox central house ventilation systems with RS-485 bus (pre-EasyControls aka pre-2014 models) From 62d9729ba8041eb909aac653475e5da064db6ad3 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:48:35 +0100 Subject: [PATCH 02/12] minor adjustments --- custom_components/helios_vallox_ventilation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/helios_vallox_ventilation/__init__.py b/custom_components/helios_vallox_ventilation/__init__.py index 185e2ce..d8424f2 100644 --- a/custom_components/helios_vallox_ventilation/__init__.py +++ b/custom_components/helios_vallox_ventilation/__init__.py @@ -45,7 +45,7 @@ async def update_data(_): await coordinator._coordinator.async_request_refresh() except Exception as e: _LOGGER.error(f"Error during data refresh: {e}", exc_info=True) - async_track_time_interval(hass, update_data, timedelta(seconds=58)) + async_track_time_interval(hass, update_data, timedelta(seconds=57)) # Register the write service async def handle_write_service(call): From 0889466e7d5c905f28bb4a2165807c41c19ab408 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:49:13 +0100 Subject: [PATCH 03/12] minor adjustments From 4fcc628411459d7e3be4e503630723bdb639b10b Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:50:05 +0100 Subject: [PATCH 04/12] formatting of REGISTERS_AND_COILS --- custom_components/helios_vallox_ventilation/const.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/helios_vallox_ventilation/const.py b/custom_components/helios_vallox_ventilation/const.py index 852c7b9..9fa58ed 100644 --- a/custom_components/helios_vallox_ventilation/const.py +++ b/custom_components/helios_vallox_ventilation/const.py @@ -31,7 +31,7 @@ "FB*": 0x20, # alle remote controls "FB1": 0x21, # remote control 1 "LON": 0x28, # LON bus module (if any) - "_HA": 0x2E, # this HA Python script; we are simulating a remote + "_HA": 0x2D, # this HA Python script; we are simulating a remote "_SH": 0x2F # SmartHomeNG Python script; also simulating a remote } @@ -78,11 +78,11 @@ # FB LED1: on/off Caution: Remotes will not be switched back on automatically; initial_fanspeed set if done manually. "powerstate": {"varid": 0xA3, 'type': 'bit', 'bitposition': 0, 'read': True, 'write': True }, # FB LED2: CO2 warning - "co2_indicator": {"varid": 0xA3, 'type': 'bit', 'bitposition': 1, 'read': True, 'write': False}, + "co2_indicator": {"varid": 0xA3, 'type': 'bit', 'bitposition': 1, 'read': True, 'write': True }, # FB LED3: Humidity warning - "rh_indicator": {"varid": 0xA3, 'type': 'bit', 'bitposition': 2, 'read': True, 'write': False}, + "rh_indicator": {"varid": 0xA3, 'type': 'bit', 'bitposition': 2, 'read': True, 'write': True }, # FB LED4: 0 = summer mode with bypass, 1 = wintermode with heat regeneration (LED is on in winter mode) - "summer_winter_mode": {"varid": 0xA3, 'type': 'bit', 'bitposition': 3, 'read': True, 'write': False}, + "winter_mode": {"varid": 0xA3, 'type': 'bit', 'bitposition': 3, 'read': True, 'write': True }, # FB icon 1: "Clean filter" warning "clean_filter": {"varid": 0xA3, 'type': 'bit', 'bitposition': 4, 'read': True, 'write': False}, # FB icon 2 2: Pre-/Post heating active @@ -103,8 +103,8 @@ "defrost_hysteresis": {"varid": 0xB2, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, # Boost mode: 0=fireplace (ignition - no exhaust air in the first 15 minutes of boost); 1=normal boost mode "boost_mode": {"varid": 0xAA, 'type': 'bit', 'bitposition': 5, 'read': True, 'write': True }, - # Switch boost on for 45 minutes (set to 1; will be reset automatically) - "boost_on_switch": {"varid": 0x71, 'type': 'bit', 'bitposition': 5, 'read': True, 'write': True }, + # Switch boost on for 45 minutes (set to 1; will be reset by mainboard automatically) + "activate_boost": {"varid": 0x71, 'type': 'bit', 'bitposition': 5, 'read': True, 'write': True }, # Current boost status (off/on) "boost_status": {"varid": 0x71, 'type': 'bit', 'bitposition': 6, 'read': True, 'write': False}, # Remaining minutes of boost if on From 30bc9002ff2d1eef3afd5814e48374208335b8b0 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:50:58 +0100 Subject: [PATCH 05/12] add turn on/off for switches --- .../helios_vallox_ventilation/coordinator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/helios_vallox_ventilation/coordinator.py b/custom_components/helios_vallox_ventilation/coordinator.py index 5fd8b22..ded4590 100644 --- a/custom_components/helios_vallox_ventilation/coordinator.py +++ b/custom_components/helios_vallox_ventilation/coordinator.py @@ -88,3 +88,11 @@ def write_value(self, variable, value): return False finally: self.disconnect() + + + async def turn_on(self, variable): + self._hass.async_add_executor_job(self.write_value, variable, 1) + + + async def turn_off(self, variable): + self._hass.async_add_executor_job(self.write_value, variable, 0) From 811161704cb56ad081b6e351816417ec4f8025b6 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:51:28 +0100 Subject: [PATCH 06/12] update version no. --- custom_components/helios_vallox_ventilation/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/helios_vallox_ventilation/manifest.json b/custom_components/helios_vallox_ventilation/manifest.json index 49880ed..816ab3d 100644 --- a/custom_components/helios_vallox_ventilation/manifest.json +++ b/custom_components/helios_vallox_ventilation/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Tom-Bom-badil/home-assistant_helios-vallox/issues", "requirements": [], - "version": "2025.01.1" + "version": "2025.01.2" } From 683abc161e6a6412fd792d3dbbb06277f537691f Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:52:10 +0100 Subject: [PATCH 07/12] code formatting From b62da5ad1861e4e1f815210e6767792fc09ebbdc Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:53:04 +0100 Subject: [PATCH 08/12] comment out logger From 70cb60410509f40f54117c7b30b21b282e7a4119 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:54:20 +0100 Subject: [PATCH 09/12] rework turn on/off From 07d22e5a2f4813fc18bace49df2edc0bd321d527 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:55:25 +0100 Subject: [PATCH 10/12] move bunch of binary_sensors to switches --- .../helios_vallox_ventilation/vent_conf.yaml | 79 ++++++++++--------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/custom_components/helios_vallox_ventilation/vent_conf.yaml b/custom_components/helios_vallox_ventilation/vent_conf.yaml index 0cb7fd8..690d395 100644 --- a/custom_components/helios_vallox_ventilation/vent_conf.yaml +++ b/custom_components/helios_vallox_ventilation/vent_conf.yaml @@ -12,12 +12,14 @@ sensors: + # DE Lüftungsstufe - name: fanspeed description: "Fan speed" min_value: 1 max_value: 8 icon: "mdi:speedometer-medium" + # DE Einschaltstufe - name: "initial_fanspeed" description: "Initial fan speed after switching on" unit_of_measurement: "level" @@ -26,6 +28,7 @@ default_value: 1 icon: "mdi:speedometer-slow" + # DE Maximalstufe - name: "max_fanspeed" description: "Maximum fan speed availabe to remotes" unit_of_measurement: "level" @@ -34,7 +37,6 @@ default_value: 8 icon: "mdi:speedometer" - # now using the ISO names for temperatures # DE: Außenlufttemperatur - name: "temperature_outdoor_air" unit_of_measurement: "°C" @@ -70,18 +72,20 @@ default_value: 10 icon: "mdi:thermometer" + # set to +5 to activate with pre-heating with defrost defaults below - name: "preheat_setpoint" unit_of_measurement: "°C" - min_value: -10 # none according to manual, but limiting input options - max_value: 10 # none according to manual, but limiting input options - default_value: -3 # set to +5 to activate with defrost defaults below + min_value: -10 + max_value: 10 + default_value: -3 icon: "mdi:thermometer" + # can be reduced below 0 in case of enthalpy exchanger - name: "defrost_setpoint" unit_of_measurement: "°C" min_value: -6 max_value: 15 - default_value: 3 # can be reduced below 0 in case of enthalpy exchanger + default_value: 3 icon: "mdi:thermometer" - name: "defrost_hysteresis" @@ -91,12 +95,6 @@ default_value: 3 icon: "mdi:thermometer" - - name: "boost_mode" - min_value: 0 # Fireplace mode - max_value: 1 # Boost mode - default_value: 1 - icon: "mdi:fan-speed-2" - - name: "boost_remaining" device_class: "duration" state_class: "measurement" @@ -134,11 +132,11 @@ unit_of_measurement: "" icon: "mdi:alert" + # no reading - set by vent_functions.py, see also const.py - name: "fault_text" - # state_class: "measurement" icon: "mdi:alert" - # calculated by ventcontrol.py + # no reading - calculated by vent_functions.py - name: "temperature_reduction" description: "Heat recovery - reduction of outgoing air temperature" unit_of_measurement: "°C" @@ -146,7 +144,7 @@ state_class: "measurement" icon: "mdi:thermometer" - # calculated by ventcontrol.py + # no reading - calculated by vent_functions.py - name: "temperature_gain" description: "Heat recovery - gain of incoming air temperature" unit_of_measurement: "°C" @@ -154,7 +152,7 @@ state_class: "measurement" icon: "mdi:thermometer" - # calculated by ventcontrol.py + # no reading - calculated by vent_functions.py - name: "temperature_balance" description: "Difference temperature reduction ./. temperature gain" unit_of_measurement: "°C" @@ -162,53 +160,62 @@ state_class: "measurement" icon: "mdi:thermometer" - # calculated by ventcontrol.py + # no reading - calculated by vent_functions.py - name: "efficiency" unit_of_measurement: "%" state_class: "measurement" icon: "mdi:percent" - - name: "preheat_status" # ??????????????? --> binary_sensor??? - min_value: 0 - max_value: 1 - icon: "mdi:tooltip-question" - binary_sensors: - - name: "powerstate" # switch ???? --> throws errors on writing - device_class: "power" - icon: "mdi:power-settings" - - - name: "post_heating_on" # Vallox only? - device_class: "heat" - icon: "mdi:heating-coil" - - name: "boost_status" icon: "mdi:tooltip-question" - - name: "co2_indicator" + - name: "fault_detected" device_class: "problem" icon: "mdi:alert" - - name: "rh_indicator" + - name: "clean_filter" device_class: "problem" icon: "mdi:alert" - - name: "clean_filter" + - name: "service_requested" device_class: "problem" icon: "mdi:alert" - - name: "fault_detected" + - name: "post_heating_on" + device_class: "heat" + icon: "mdi:heating-coil" + + switches: + + # ???? --> throws errors on writing + - name: "powerstate" + device_class: "power" + icon: "mdi:power-settings" + + - name: "co2_indicator" device_class: "problem" icon: "mdi:alert" - - name: "service_requested" + - name: "rh_indicator" device_class: "problem" icon: "mdi:alert" - switches: + - name: "winter_mode" + device_class: "switch" + icon: "mdi:alert" + + - name: "preheat_status" + device_class: "switch" + icon: "mdi:tooltip-question" + + # 0=Fireplace mode, 1=Normal boost + - name: "boost_mode" + device_class: "switch" + icon: "mdi:fan-speed-2" - - name: "boost_on_switch" + - name: "activate_boost" device_class: "switch" icon: "mdi:fan" From 9bfed86050fda5620ef0392a11719f0fc69fa473 Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:57:26 +0100 Subject: [PATCH 11/12] improve code readability --- .../vent_functions.py | 121 +++++++----------- 1 file changed, 49 insertions(+), 72 deletions(-) diff --git a/custom_components/helios_vallox_ventilation/vent_functions.py b/custom_components/helios_vallox_ventilation/vent_functions.py index 7148f32..43ffbde 100644 --- a/custom_components/helios_vallox_ventilation/vent_functions.py +++ b/custom_components/helios_vallox_ventilation/vent_functions.py @@ -38,6 +38,7 @@ def __init__(self, ip, port): self._socket = None self._lock = threading.Lock() self._cache = {} + self._all_values = {} ###### Exposed functions (read from outside) ############################### @@ -60,56 +61,43 @@ def readAllValues(self): return None try: start_time = time.time() - all_values = {} + self._all_values = {} self._cache = {} - multiple_reads = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] for varname in REGISTERS_AND_COILS.keys(): value = self._readVal(varname) if value is not None: - all_values[varname] = value + self._all_values[varname] = value else: self.logger.error(f"Failed to read value for '{varname}'") - all_values = self._add_calculations(all_values) + self._all_values = self._add_calculations(self._all_values) end_time = time.time() self.logger.info("Full read took {:.2f}s.".format(end_time - start_time)) + self.logger.debug(f"Cache contains: {self._cache}. All read values: {self._all_values}.") finally: self._disconnect() - return all_values + return self._all_values def writeValue(self, varname, value): if not self._connect(): - self.logger.error(f"Helios: Could not connect to the device.") + self.logger.error(f"Could not connect to the ventilation device.") return None - if not self._validate_value(varname, value): - self.logger.error(f"Invalid value {value} for variable: {varname}.") + if not self._check_value(varname, value): + self.logger.error(f"Write operation cancelled, invalid value.") + return False + if REGISTERS_AND_COILS.get(varname) is None: + self.logger.error(f"Write operation cancelled, invalid variable.") return False - try: - + self.logger.info("Writing {0} to {1}".format(value, varname)) success = False self._lock.acquire() try: - self.logger.info("Writing {0} to {1}".format(value, varname)) - rawvalue = None if REGISTERS_AND_COILS[varname]["type"] == "bit": - currentval = None - if self._syncWithRS485(): - telegram = self._createTelegram( - BUS_ADDRESSES["_HA"], - BUS_ADDRESSES["MB1"], - 0, - REGISTERS_AND_COILS[varname]["varid"] - ) - self._sendTelegram(telegram) - currentval = self._readTelegram( - BUS_ADDRESSES["MB1"], - BUS_ADDRESSES["_HA"], - REGISTERS_AND_COILS[varname]["varid"] - ) + currentval = self._cache[REGISTERS_AND_COILS[varname]["varid"]] if currentval is None: - self.logger.error("Writing failed. Cannot read '{0}'.".format(varname)) + self.logger.error("Writing failed. Cannot read {varname} from cache.") return False rawvalue = self._convertToRaw(varname, value, currentval) else: @@ -117,13 +105,16 @@ def writeValue(self, varname, value): if self._syncWithRS485(): if rawvalue is not None: telegram = self._createTelegram( - BUS_ADDRESSES["_HA"], - BUS_ADDRESSES["MB1"], - REGISTERS_AND_COILS[varname]["varid"], - rawvalue + BUS_ADDRESSES["_HA"], BUS_ADDRESSES["MB1"], + REGISTERS_AND_COILS[varname]["varid"], rawvalue ) self._sendTelegram(telegram) self.logger.debug("Telegram sent: {0}".format(self._telegramToString(telegram))) + + self._all_values[varname] = value + if REGISTERS_AND_COILS[varname]["type"] == "bit": + self._cache[REGISTERS_AND_COILS[varname]["varid"]] = rawvalue + success = True else: self.logger.error("Writing failed. Cannot convert value '{0}'.".format(value)) @@ -188,7 +179,7 @@ def _convertFromRaw(self, varname, rawvalue): elif vardef["type"] == "fanspeed": return int(FANSPEEDS.get(rawvalue)) elif vardef["type"] == "bit": - return rawvalue >> vardef["bitposition"] & 0x01 + return bool(rawvalue >> vardef["bitposition"] & 0x01) elif vardef["type"] == "dec": if varname == "defrost_hysteresis": rawvalue = rawvalue // 3 @@ -196,7 +187,7 @@ def _convertFromRaw(self, varname, rawvalue): return None - def _convertToRaw(self, varname, value, prevvalue): + def _convertToRaw(self, varname, value, currentval): vardef = REGISTERS_AND_COILS[varname] if vardef['type'] == "temperature": return int(NTC5K_TEMPERATURES.index(int(value))) @@ -204,12 +195,11 @@ def _convertToRaw(self, varname, value, prevvalue): return int({v: k for k, v in FANSPEEDS.items()}.get(int(value))) elif vardef["type"] == "bit": if value in (True, 1, "true", "True", "1", "On", "on"): - return prevvalue | (1 << vardef["bitposition"]) - return prevvalue & ~(1 << vardef["bitposition"]) + return currentval | (1 << vardef["bitposition"]) + return currentval & ~(1 << vardef["bitposition"]) elif vardef["type"] == "dec": if varname == "defrost_hysteresis": - rawvalue = rawvalue * 3 - return int(rawvalue) + return int(value*3) return None @@ -254,7 +244,6 @@ def _readTelegram(self, sender, receiver, datapoint): except socket.timeout: self.logger.info("Communication error - socket timeout.") return None - # without a successful read self.logger.debug("Timeout while awaiting answer - bus busy.") self.logger.debug("Protocols received: '{0}'".format(self._telegramToString(all_bytes_received))) return None @@ -277,9 +266,6 @@ def _syncWithRS485(self): else: # silence on bus gotSlot = True break - #strong debugging only - #elapsed_time = (time.time() - start_time) * 1000 - #self.logger.info(f"Got free sending slot in ({elapsed_time:.2f} ms).") return gotSlot @@ -287,14 +273,10 @@ def _readVal(self, varname): varid = REGISTERS_AND_COILS[varname]["varid"] value = None if REGISTERS_AND_COILS[varname]["type"] == "bit" and varid in self._cache: - rawvalue = self._cache[varid] - value = (rawvalue >> REGISTERS_AND_COILS[varname]["bitposition"]) & 0x01 - self.logger.debug("Value for {} retrieved from cache: {}".format(varname, value)) - return value + return self._convertFromRaw(varname, self._cache[varid]) def attempt_read(): max_retries = 10 for attempt in range(max_retries-1): - self.logger.debug("Reading value: {0} (Attempt {1})".format(varname, attempt + 1)) if self._syncWithRS485(): telegram = self._createTelegram( BUS_ADDRESSES["_HA"], BUS_ADDRESSES["MB1"], 0, varid @@ -312,57 +294,52 @@ def attempt_read(): self.logger.info(f"Reading '{varname}' again ({attempt + 1})") time.sleep(0.05) else: - self.logger.warning("Reading failed. No free slot to send poll request.") + self.logger.warning("Reading failed. No free sending slot found.") return None self._lock.acquire() try: value = attempt_read() if value is None: self.logger.error(f"Failed to read value for '{varname}'.") - except: - self.logger.error("Exception in _readVal() occurred") + except Exception as e: + self.logger.error("Exception in _readVal(): {0}".format(e)) finally: self._lock.release() return value - def _validate_value(self, varname, value): - # Prevent writing to register 06h + def _check_value(self, varname, value): + # Prevent writing to register 06h (may cause irrepairable damage) if REGISTERS_AND_COILS[varname]["varid"] == 0x06: self.logger.critical("Writing to register 06h is prohibited. Write operation aborted.") return False - # Check if the variable is read-only + # Prevent writing read-only variables if REGISTERS_AND_COILS[varname]["write"] != True: - self.logger.critical("Variable {0} is read-only and cannot be written to. Write operation aborted.".format(varname)) + self.logger.critical(f"Variable {varname} is read-only and cannot be written to.") return False - # Make sure value is an integer + # Make sure value is int or bool if not isinstance(value, int): - if value in [1, '1', True, true, 'True', 'true', 'On', 'on', 'ON']: - value = 1 - elif value in [0, '0', False, false, 'False', 'false', 'Off', 'off', 'OFF']: - value = 0 + if REGISTERS_AND_COILS[varname] ["type"] == "bit": + if value in [1, '1', True, true, 'True', 'true', 'On', 'on', 'ON'] or \ + value in [0, '0', False, false, 'False', 'false', 'Off', 'off', 'OFF']: + self.logger.debug(f"Valid bool {value} for {varname} detected.") + else: + self.logger.error(f"Value {value} for {varname} is not a valid binary.") + return False else: - self.logger.error(f"Value {value} for {varname} is not a valid integer or binary representation.") + self.logger.error(f"Invalid value {value} for {varname}, expected an integer.") return False - # min/max + # Check for value limits min/max entity = self._get_entity(varname) min_value = getattr(entity, 'min_value', None) - max_value = getattr(entity, 'max_value', None) if min_value is not None and value < min_value: - self.logger.error(f"Value {value} for {varname} is below the minimum allowed value of {min_value}.") + self.logger.error(f"Value {value} for {varname} is below the minimum of {min_value}.") return False + max_value = getattr(entity, 'max_value', None) if max_value is not None and value > max_value: - self.logger.error(f"Value {value} for {varname} is above the maximum allowed value of {max_value}.") + self.logger.error(f"Value {value} for {varname} is above the maximum of {max_value}.") return False - # return value - vardef = REGISTERS_AND_COILS[varname] - if vardef["type"] == "bit": - return value in (0, 1) - # elif vardef["type"] in ["temperature", "fanspeed", "dec"]: - else: - return int(value) - # just in case it didn't meet any pattern - return False + return True def _add_calculations(self, all_values): From 4cef7aea92714ed2ca014834b7ca1980c71860de Mon Sep 17 00:00:00 2001 From: Tom Bombadil Date: Fri, 31 Jan 2025 18:57:55 +0100 Subject: [PATCH 12/12] Rename 2025.01.1_update_notes.md to 2025.01_update_notes.md --- 2025.01.1_update_notes.md => 2025.01_update_notes.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename 2025.01.1_update_notes.md => 2025.01_update_notes.md (100%) diff --git a/2025.01.1_update_notes.md b/2025.01_update_notes.md similarity index 100% rename from 2025.01.1_update_notes.md rename to 2025.01_update_notes.md