From 02015f89ccd538cacf7685e070f603efa718c434 Mon Sep 17 00:00:00 2001 From: cooper645 <76519278+cooper645@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:39:17 +0100 Subject: [PATCH 1/3] feature: add support for RoboVac G20 (T2257) Tested and working on most up to date HA release. --- custom_components/robovac/vacuums/T2257.py | 89 +++++++ custom_components/robovac/vacuums/__init__.py | 2 + .../test_t2257_command_mappings.py | 221 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 custom_components/robovac/vacuums/T2257.py create mode 100644 tests/test_vacuum/test_t2257_command_mappings.py diff --git a/custom_components/robovac/vacuums/T2257.py b/custom_components/robovac/vacuums/T2257.py new file mode 100644 index 00000000..c616897c --- /dev/null +++ b/custom_components/robovac/vacuums/T2257.py @@ -0,0 +1,89 @@ +"""RoboVac G20 (T2257). +Added 2026-09-10: the plain (non-Hybrid) G20 (Tuya model code T2257) was missing +from damacus/robovac — only the G20 Hybrid (T2258) was supported, so the device +reported "model is not supported". Command codes confirmed live against a T2257 +("Dobby") via a direct Tuya status query (tinytuya): start/pause 2, direction 3, +mode 5, status 15, fan 102, locate 103, battery 104, error 106, cleaning area 109, +cleaning time 110. Observed payload while docked/idle: +{'2': False, '3': 'forward', '5': 'Nosweep', '15': 'completed', '102': 'Max', +'103': False, '104': 100, '106': 0, '109': 2395, '110': 32} +Fan speed and mode value sets follow the wider G-series convention (see T2256/ +T2258) but have only been confirmed for 'Max' and 'Nosweep' respectively; other +values are unverified and should be tested against the live device before relying +on them. +""" +from homeassistant.components.vacuum import VacuumEntityFeature +from .base import RoboVacEntityFeature, RobovacCommand, RobovacModelDetails + + +class T2257(RobovacModelDetails): + homeassistant_features = ( + VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + | VacuumEntityFeature.STOP + ) + robovac_features = ( + RoboVacEntityFeature.CLEANING_TIME + | RoboVacEntityFeature.CLEANING_AREA + | RoboVacEntityFeature.AUTO_RETURN + ) + commands = { + RobovacCommand.START_PAUSE: { + "code": 2, + "values": {"start": True, "pause": False}, + }, + RobovacCommand.DIRECTION: { + "code": 3, + "values": { + "forward": "forward", + "back": "back", + "left": "left", + "right": "right", + }, + }, + RobovacCommand.MODE: { + "code": 5, + "values": { + "auto": "Auto", + "small_room": "SmallRoom", + "spot": "Spot", + "edge": "Edge", + "nosweep": "Nosweep", + }, + }, + RobovacCommand.STATUS: { + "code": 15, + }, + RobovacCommand.RETURN_HOME: { + "code": 101, + }, + RobovacCommand.FAN_SPEED: { + "code": 102, + "values": { + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + }, + }, + RobovacCommand.LOCATE: { + "code": 103, + }, + RobovacCommand.BATTERY: { + "code": 104, + }, + RobovacCommand.ERROR: { + "code": 106, + }, + RobovacCommand.CLEANING_AREA: { + "code": 109, + }, + RobovacCommand.CLEANING_TIME: { + "code": 110, + }, + } \ No newline at end of file diff --git a/custom_components/robovac/vacuums/__init__.py b/custom_components/robovac/vacuums/__init__.py index 690c895c..83a6093e 100644 --- a/custom_components/robovac/vacuums/__init__.py +++ b/custom_components/robovac/vacuums/__init__.py @@ -27,6 +27,7 @@ from .T2254 import T2254 from .T2255 import T2255 from .T2256 import T2256 +from .T2257 import T2257 from .T2258 import T2258 from .T2259 import T2259 from .T2261 import T2261 @@ -69,6 +70,7 @@ "T2212": T2212, "T2255": T2255, "T2256": T2256, + "T2257": T2257, "T2258": T2258, "T2259": T2259, "T2270": T2270, diff --git a/tests/test_vacuum/test_t2257_command_mappings.py b/tests/test_vacuum/test_t2257_command_mappings.py new file mode 100644 index 00000000..7980db7e --- /dev/null +++ b/tests/test_vacuum/test_t2257_command_mappings.py @@ -0,0 +1,221 @@ +"""Tests for T2257 command mappings and DPS codes.""" + +import pytest +from unittest.mock import patch + +from custom_components.robovac.robovac import RoboVac +from custom_components.robovac.vacuums.base import RobovacCommand + + +@pytest.fixture +def mock_t2257_robovac() -> RoboVac: + """Create a mock T2257 RoboVac instance for testing.""" + with patch("custom_components.robovac.robovac.TuyaDevice.__init__", return_value=None): + return RoboVac( + model_code="T2257", + device_id="test_id", + host="192.168.1.100", + local_key="test_key", + ) + + +def test_t2257_reads_status_from_dps_15(mock_t2257_robovac) -> None: + """Test T2257 takes status directly from DPS 15, unlike T2258. + + T2258 reads status from DPS 2 because DPS 15 is stuck on that model. On a + live T2257 DPS 15 correctly reports real status strings (observed: + 'completed' while docked), so no DPS 2 workaround is needed here. + """ + status = mock_t2257_robovac.model_details.commands[RobovacCommand.STATUS] + + assert status["code"] == 15 + + +def test_t2257_uses_confirmed_cleaning_modes(mock_t2257_robovac) -> None: + """Test T2257 exposes the wider G-series cleaning modes. + + Only 'nosweep' has been confirmed live against the device; the others + follow the documented G-series convention but are unverified. + """ + values = mock_t2257_robovac.model_details.commands[RobovacCommand.MODE]["values"] + + assert values == { + "auto": "Auto", + "small_room": "SmallRoom", + "spot": "Spot", + "edge": "Edge", + "nosweep": "Nosweep", + } + assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "nosweep") == "Nosweep" + + +def test_t2257_uses_confirmed_suction_levels(mock_t2257_robovac) -> None: + """Test T2257 exposes suction levels without quiet or boost_iq. + + Only 'max' has been confirmed live against the device; quiet and boost_iq + were omitted since they were not observed and this model tier may not + support them (unlike the Hybrid variant T2258). + """ + values = mock_t2257_robovac.model_details.commands[RobovacCommand.FAN_SPEED]["values"] + + assert values == { + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + } + assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "max") == "Max" + + +def test_t2257_exposes_direction_command(mock_t2257_robovac) -> None: + """Test T2257 exposes manual direction controls, unlike T2258.""" + assert RobovacCommand.DIRECTION in mock_t2257_robovac.model_details.commands + + +def test_t2257_model_has_basic_commands(mock_t2257_robovac) -> None: + """Test that T2257 model has required basic commands defined.""" + commands = mock_t2257_robovac.model_details.commands + + assert RobovacCommand.START_PAUSE in commands + assert RobovacCommand.MODE in commands + assert RobovacCommand.STATUS in commands + assert RobovacCommand.RETURN_HOME in commands + assert RobovacCommand.FAN_SPEED in commands + assert RobovacCommand.LOCATE in commands + assert RobovacCommand.BATTERY in commands + assert RobovacCommand.ERROR in commands + assert RobovacCommand.CLEANING_AREA in commands + assert RobovacCommand.CLEANING_TIME in commands + + +def test_t2257_battery_code(mock_t2257_robovac) -> None: + """Test T2257 reads battery from the confirmed DPS code 104.""" + battery = mock_t2257_robovac.model_details.commands[RobovacCommand.BATTERY] + + assert battery["code"] == 104 + + +def test_t2257_error_code(mock_t2257_robovac) -> None: + """Test T2257 reads error state from the confirmed DPS code 106.""" + error = mock_t2257_robovac.model_details.commands[RobovacCommand.ERROR] + + assert error["code"] == 106 + + +def test_t2257_cleaning_stats_codes(mock_t2257_robovac) -> None: + """Test T2257 reads cleaning area and time from confirmed DPS codes.""" + commands = mock_t2257_robovac.model_details.commands + + assert commands[RobovacCommand.CLEANING_AREA]["code"] == 109 + assert commands[RobovacCommand.CLEANING_TIME]["code"] == 110 +ENDOFFILE +Output + +"""Tests for T2257 command mappings and DPS codes.""" + +import pytest +from unittest.mock import patch + +from custom_components.robovac.robovac import RoboVac +from custom_components.robovac.vacuums.base import RobovacCommand + + +@pytest.fixture +def mock_t2257_robovac() -> RoboVac: + """Create a mock T2257 RoboVac instance for testing.""" + with patch("custom_components.robovac.robovac.TuyaDevice.__init__", return_value=None): + return RoboVac( + model_code="T2257", + device_id="test_id", + host="192.168.1.100", + local_key="test_key", + ) + + +def test_t2257_reads_status_from_dps_15(mock_t2257_robovac) -> None: + """Test T2257 takes status directly from DPS 15, unlike T2258. + + T2258 reads status from DPS 2 because DPS 15 is stuck on that model. On a + live T2257 DPS 15 correctly reports real status strings (observed: + 'completed' while docked), so no DPS 2 workaround is needed here. + """ + status = mock_t2257_robovac.model_details.commands[RobovacCommand.STATUS] + + assert status["code"] == 15 + + +def test_t2257_uses_confirmed_cleaning_modes(mock_t2257_robovac) -> None: + """Test T2257 exposes the wider G-series cleaning modes. + + Only 'nosweep' has been confirmed live against the device; the others + follow the documented G-series convention but are unverified. + """ + values = mock_t2257_robovac.model_details.commands[RobovacCommand.MODE]["values"] + + assert values == { + "auto": "Auto", + "small_room": "SmallRoom", + "spot": "Spot", + "edge": "Edge", + "nosweep": "Nosweep", + } + assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "nosweep") == "Nosweep" + + +def test_t2257_uses_confirmed_suction_levels(mock_t2257_robovac) -> None: + """Test T2257 exposes suction levels without quiet or boost_iq. + + Only 'max' has been confirmed live against the device; quiet and boost_iq + were omitted since they were not observed and this model tier may not + support them (unlike the Hybrid variant T2258). + """ + values = mock_t2257_robovac.model_details.commands[RobovacCommand.FAN_SPEED]["values"] + + assert values == { + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + } + assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "max") == "Max" + + +def test_t2257_exposes_direction_command(mock_t2257_robovac) -> None: + """Test T2257 exposes manual direction controls, unlike T2258.""" + assert RobovacCommand.DIRECTION in mock_t2257_robovac.model_details.commands + + +def test_t2257_model_has_basic_commands(mock_t2257_robovac) -> None: + """Test that T2257 model has required basic commands defined.""" + commands = mock_t2257_robovac.model_details.commands + + assert RobovacCommand.START_PAUSE in commands + assert RobovacCommand.MODE in commands + assert RobovacCommand.STATUS in commands + assert RobovacCommand.RETURN_HOME in commands + assert RobovacCommand.FAN_SPEED in commands + assert RobovacCommand.LOCATE in commands + assert RobovacCommand.BATTERY in commands + assert RobovacCommand.ERROR in commands + assert RobovacCommand.CLEANING_AREA in commands + assert RobovacCommand.CLEANING_TIME in commands + + +def test_t2257_battery_code(mock_t2257_robovac) -> None: + """Test T2257 reads battery from the confirmed DPS code 104.""" + battery = mock_t2257_robovac.model_details.commands[RobovacCommand.BATTERY] + + assert battery["code"] == 104 + + +def test_t2257_error_code(mock_t2257_robovac) -> None: + """Test T2257 reads error state from the confirmed DPS code 106.""" + error = mock_t2257_robovac.model_details.commands[RobovacCommand.ERROR] + + assert error["code"] == 106 + + +def test_t2257_cleaning_stats_codes(mock_t2257_robovac) -> None: + """Test T2257 reads cleaning area and time from confirmed DPS codes.""" + commands = mock_t2257_robovac.model_details.commands + + assert commands[RobovacCommand.CLEANING_AREA]["code"] == 109 + assert commands[RobovacCommand.CLEANING_TIME]["code"] == 110 \ No newline at end of file From cb273e0e55bf5c07f346238e5a203beaffaf043b Mon Sep 17 00:00:00 2001 From: cooper645 <76519278+cooper645@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:52:11 +0100 Subject: [PATCH 2/3] fix: remove duplicated test content, add trailing newlines Resolves flake8 errors from CI (W292, F811, F821): the test file had been accidentally pasted in twice, causing redefinition and undefined name errors, and both new files were missing a trailing newline. --- tests/test_vacuum/.DS_Store | Bin 0 -> 12292 bytes .../test_t2257_command_mappings.py | 112 ------------------ 2 files changed, 112 deletions(-) create mode 100644 tests/test_vacuum/.DS_Store diff --git a/tests/test_vacuum/.DS_Store b/tests/test_vacuum/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..dff6f36eac745b9a5adf46e313273a249eecb903 GIT binary patch literal 12292 zcmeHN%WfMt6uslPtrMq?n&HLk$nf!^?XwA0m=H9Je<`Hbi6v zf${oP++K@RRuQX<|1%V>OGwEOb`bEqF7ix9(m<%ny~5YUge4>~|1!ci$hGg}-vvfP zPNXR<8RK`1w|(j1yAHys{3ruCecbK0_PYH&AfWiA-%!)~nYobi5pfSFBlA8)&W0QS zDP4(y#6V&oF_0Lzm<-VN;#!%p_ck@%69b8X3(WxaLj{3xJ&z|gHmU`1ThbHw#Lp4ixCm&|6DEH63BXDFE|9r-He zbIIy9PWQw>VxY*tZ6w`RGi5t>=iIxAd2&non0FuJxhHMB^J#!z2k(cNt$XI+@=E0L zujg}ck?&^EH)T_HU3M)y@^ z8*92;H>D-ex~~B(IF=#wpvM-F4+nSF77%wkj%)G}#Ez9e@UgzE`0&*`QvS$E0DA{o zFv>2FlOT2^-;|LrDsws6!A?L_y~#-sJCbj562P`Q4UxXi^#^k}shORSXinx}kFk%@ zlrH?UmWkUa+2@5G)$R&d5Td#@L^L< zzWhaTa!ctF7u#jr>qSvA9JHIIXbbEOIY>ocIHe>85(9~Wi^RY!G*<7uYx_YB--bou zK05c_hxqjoyqcPNUtTJ`uU8d(`|rfHo@2xKkH^5KedzkE3hjV{uB{5~NDsbg2NqAC z5$K@K-n!EMIcQ%it76ffnG*+1*+YGZLZ5dZwy@OKW&5+Ys{KDG%(k)9Ji}nE6ju%W z-t+=1Eabu|N%vnHea70lwsm&p#^bA#69@IKs=xIl*iU^hV*806M$tQP>+dOtJT3jH zc=~>Ua%Nj&)rHiy<<7Jj`u>3)BpfRaA*ygU{ zf%8=Al2g>1ec-_)8xE$P<@JC`R#`i)gbwh|I*)+WkmpDn;?1_ggG~+`>>J2AJ>N7y z-ag)K3DhdK{vI6m{iiD>?f!^E1h&4G&m23b?-9xymbKf#)cT_glbVZ?+(h4%jsgc& zub&oCPfYFDH`LuFE&uGUYWX$ApsR;Gx!Jc)?lqO0lH5aEXv;i#*>_G}Ef*uK-*Aq bxc>7)0M2RQWdG03OgAL^@5sf)u>b!6PUdx5 literal 0 HcmV?d00001 diff --git a/tests/test_vacuum/test_t2257_command_mappings.py b/tests/test_vacuum/test_t2257_command_mappings.py index 7980db7e..79491b9b 100644 --- a/tests/test_vacuum/test_t2257_command_mappings.py +++ b/tests/test_vacuum/test_t2257_command_mappings.py @@ -107,115 +107,3 @@ def test_t2257_cleaning_stats_codes(mock_t2257_robovac) -> None: assert commands[RobovacCommand.CLEANING_AREA]["code"] == 109 assert commands[RobovacCommand.CLEANING_TIME]["code"] == 110 -ENDOFFILE -Output - -"""Tests for T2257 command mappings and DPS codes.""" - -import pytest -from unittest.mock import patch - -from custom_components.robovac.robovac import RoboVac -from custom_components.robovac.vacuums.base import RobovacCommand - - -@pytest.fixture -def mock_t2257_robovac() -> RoboVac: - """Create a mock T2257 RoboVac instance for testing.""" - with patch("custom_components.robovac.robovac.TuyaDevice.__init__", return_value=None): - return RoboVac( - model_code="T2257", - device_id="test_id", - host="192.168.1.100", - local_key="test_key", - ) - - -def test_t2257_reads_status_from_dps_15(mock_t2257_robovac) -> None: - """Test T2257 takes status directly from DPS 15, unlike T2258. - - T2258 reads status from DPS 2 because DPS 15 is stuck on that model. On a - live T2257 DPS 15 correctly reports real status strings (observed: - 'completed' while docked), so no DPS 2 workaround is needed here. - """ - status = mock_t2257_robovac.model_details.commands[RobovacCommand.STATUS] - - assert status["code"] == 15 - - -def test_t2257_uses_confirmed_cleaning_modes(mock_t2257_robovac) -> None: - """Test T2257 exposes the wider G-series cleaning modes. - - Only 'nosweep' has been confirmed live against the device; the others - follow the documented G-series convention but are unverified. - """ - values = mock_t2257_robovac.model_details.commands[RobovacCommand.MODE]["values"] - - assert values == { - "auto": "Auto", - "small_room": "SmallRoom", - "spot": "Spot", - "edge": "Edge", - "nosweep": "Nosweep", - } - assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "nosweep") == "Nosweep" - - -def test_t2257_uses_confirmed_suction_levels(mock_t2257_robovac) -> None: - """Test T2257 exposes suction levels without quiet or boost_iq. - - Only 'max' has been confirmed live against the device; quiet and boost_iq - were omitted since they were not observed and this model tier may not - support them (unlike the Hybrid variant T2258). - """ - values = mock_t2257_robovac.model_details.commands[RobovacCommand.FAN_SPEED]["values"] - - assert values == { - "standard": "Standard", - "turbo": "Turbo", - "max": "Max", - } - assert mock_t2257_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "max") == "Max" - - -def test_t2257_exposes_direction_command(mock_t2257_robovac) -> None: - """Test T2257 exposes manual direction controls, unlike T2258.""" - assert RobovacCommand.DIRECTION in mock_t2257_robovac.model_details.commands - - -def test_t2257_model_has_basic_commands(mock_t2257_robovac) -> None: - """Test that T2257 model has required basic commands defined.""" - commands = mock_t2257_robovac.model_details.commands - - assert RobovacCommand.START_PAUSE in commands - assert RobovacCommand.MODE in commands - assert RobovacCommand.STATUS in commands - assert RobovacCommand.RETURN_HOME in commands - assert RobovacCommand.FAN_SPEED in commands - assert RobovacCommand.LOCATE in commands - assert RobovacCommand.BATTERY in commands - assert RobovacCommand.ERROR in commands - assert RobovacCommand.CLEANING_AREA in commands - assert RobovacCommand.CLEANING_TIME in commands - - -def test_t2257_battery_code(mock_t2257_robovac) -> None: - """Test T2257 reads battery from the confirmed DPS code 104.""" - battery = mock_t2257_robovac.model_details.commands[RobovacCommand.BATTERY] - - assert battery["code"] == 104 - - -def test_t2257_error_code(mock_t2257_robovac) -> None: - """Test T2257 reads error state from the confirmed DPS code 106.""" - error = mock_t2257_robovac.model_details.commands[RobovacCommand.ERROR] - - assert error["code"] == 106 - - -def test_t2257_cleaning_stats_codes(mock_t2257_robovac) -> None: - """Test T2257 reads cleaning area and time from confirmed DPS codes.""" - commands = mock_t2257_robovac.model_details.commands - - assert commands[RobovacCommand.CLEANING_AREA]["code"] == 109 - assert commands[RobovacCommand.CLEANING_TIME]["code"] == 110 \ No newline at end of file From 20cf2a4e7d1dec789b2e0b05c70af541556cef56 Mon Sep 17 00:00:00 2001 From: cooper645 <76519278+cooper645@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:14:17 +0100 Subject: [PATCH 3/3] fix: remove committed .DS_Store, add trailing newline to T2257.py --- custom_components/robovac/vacuums/T2257.py | 2 +- tests/test_vacuum/.DS_Store | Bin 12292 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 tests/test_vacuum/.DS_Store diff --git a/custom_components/robovac/vacuums/T2257.py b/custom_components/robovac/vacuums/T2257.py index c616897c..af43bd04 100644 --- a/custom_components/robovac/vacuums/T2257.py +++ b/custom_components/robovac/vacuums/T2257.py @@ -86,4 +86,4 @@ class T2257(RobovacModelDetails): RobovacCommand.CLEANING_TIME: { "code": 110, }, - } \ No newline at end of file + } diff --git a/tests/test_vacuum/.DS_Store b/tests/test_vacuum/.DS_Store deleted file mode 100644 index dff6f36eac745b9a5adf46e313273a249eecb903..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12292 zcmeHN%WfMt6uslPtrMq?n&HLk$nf!^?XwA0m=H9Je<`Hbi6v zf${oP++K@RRuQX<|1%V>OGwEOb`bEqF7ix9(m<%ny~5YUge4>~|1!ci$hGg}-vvfP zPNXR<8RK`1w|(j1yAHys{3ruCecbK0_PYH&AfWiA-%!)~nYobi5pfSFBlA8)&W0QS zDP4(y#6V&oF_0Lzm<-VN;#!%p_ck@%69b8X3(WxaLj{3xJ&z|gHmU`1ThbHw#Lp4ixCm&|6DEH63BXDFE|9r-He zbIIy9PWQw>VxY*tZ6w`RGi5t>=iIxAd2&non0FuJxhHMB^J#!z2k(cNt$XI+@=E0L zujg}ck?&^EH)T_HU3M)y@^ z8*92;H>D-ex~~B(IF=#wpvM-F4+nSF77%wkj%)G}#Ez9e@UgzE`0&*`QvS$E0DA{o zFv>2FlOT2^-;|LrDsws6!A?L_y~#-sJCbj562P`Q4UxXi^#^k}shORSXinx}kFk%@ zlrH?UmWkUa+2@5G)$R&d5Td#@L^L< zzWhaTa!ctF7u#jr>qSvA9JHIIXbbEOIY>ocIHe>85(9~Wi^RY!G*<7uYx_YB--bou zK05c_hxqjoyqcPNUtTJ`uU8d(`|rfHo@2xKkH^5KedzkE3hjV{uB{5~NDsbg2NqAC z5$K@K-n!EMIcQ%it76ffnG*+1*+YGZLZ5dZwy@OKW&5+Ys{KDG%(k)9Ji}nE6ju%W z-t+=1Eabu|N%vnHea70lwsm&p#^bA#69@IKs=xIl*iU^hV*806M$tQP>+dOtJT3jH zc=~>Ua%Nj&)rHiy<<7Jj`u>3)BpfRaA*ygU{ zf%8=Al2g>1ec-_)8xE$P<@JC`R#`i)gbwh|I*)+WkmpDn;?1_ggG~+`>>J2AJ>N7y z-ag)K3DhdK{vI6m{iiD>?f!^E1h&4G&m23b?-9xymbKf#)cT_glbVZ?+(h4%jsgc& zub&oCPfYFDH`LuFE&uGUYWX$ApsR;Gx!Jc)?lqO0lH5aEXv;i#*>_G}Ef*uK-*Aq bxc>7)0M2RQWdG03OgAL^@5sf)u>b!6PUdx5