diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78b73f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.9" + - uses: pre-commit/action@v3.0.0 + + test: + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.10" + - "3.11" + os: + - ubuntu-latest + - windows-latest + - macOS-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: snok/install-poetry@v1.3.3 + - name: Install Dependencies + run: poetry install + shell: bash + - name: Test with Pytest + run: poetry run pytest --cov-report=xml + shell: bash diff --git a/.gitignore b/.gitignore index 8420129..27e37f4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ venv .idea roborock/__pycache__ poetry.lock +*.pyc +.coverage diff --git a/pyproject.toml b/pyproject.toml index c3a5d00..bfc5abc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,7 @@ paho-mqtt = "~1.6.1" requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[project.optional-dependencies] +test = [ + "pytest-asyncio= *" +] diff --git a/roborock/roborock_queue.py b/roborock/roborock_queue.py index 0a4fa30..2614fe0 100644 --- a/roborock/roborock_queue.py +++ b/roborock/roborock_queue.py @@ -1,4 +1,3 @@ -import asyncio from asyncio import Queue from typing import Any import async_timeout @@ -7,7 +6,6 @@ class RoborockQueue(Queue): - def __init__(self, protocol: int, *args): super().__init__(*args) self.protocol = protocol diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data.py b/tests/mock_data.py new file mode 100644 index 0000000..9445394 --- /dev/null +++ b/tests/mock_data.py @@ -0,0 +1,345 @@ +"""Mock data for Roborock tests.""" +# All data is based on a U.S. customer with a Roborock S7 MaxV Ultra +USER_EMAIL = "user@domain.com" + +BASE_URL = "https://usiot.roborock.com" + +USER_DATA = { + "tuyaname": "abc123", + "tuyapwd": "abc123", + "uid": 123456, + "tokentype": "token_type", + "token": "abc123", + "rruid": "abc123", + "region": "us", + "countrycode": "1", + "country": "US", + "nickname": "user_nickname", + "rriot": { + "u": "user123", + "s": "pass123", + "h": "unknown123", + "k": "domain123", + "r": { + "r": "US", + "a": "https://api-us.roborock.com", + "m": "ssl://mqtt-us.roborock.com:8883", + "l": "https://wood-us.roborock.com", + }, + }, + "tuyaDeviceState": 2, + "avatarurl": "https://files.roborock.com/iottest/default_avatar.png", +} + +HOME_DATA_RAW = { + "id": 123456, + "name": "My Home", + "lon": None, + "lat": None, + "geoName": None, + "products": [ + { + "id": "abc123", + "name": "Roborock S7 MaxV", + "code": "a27", + "model": "roborock.vacuum.a27", + "iconUrl": None, + "attribute": None, + "capability": 0, + "category": "robot.vacuum.cleaner", + "schema": [ + { + "id": "101", + "name": "rpc_request", + "code": "rpc_request_code", + "mode": "rw", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "102", + "name": "rpc_response", + "code": "rpc_response", + "mode": "rw", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "120", + "name": "错误代码", + "code": "error_code", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "121", + "name": "设备状态", + "code": "state", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "122", + "name": "设备电量", + "code": "battery", + "mode": "ro", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "123", + "name": "清扫模式", + "code": "fan_power", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "124", + "name": "拖地模式", + "code": "water_box_mode", + "mode": "rw", + "type": "ENUM", + "property": '{"range": []}', + "desc": None, + }, + { + "id": "125", + "name": "主刷寿命", + "code": "main_brush_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "126", + "name": "边刷寿命", + "code": "side_brush_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "127", + "name": "滤网寿命", + "code": "filter_life", + "mode": "rw", + "type": "VALUE", + "property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + "desc": None, + }, + { + "id": "128", + "name": "额外状态", + "code": "additional_props", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "130", + "name": "完成事件", + "code": "task_complete", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "131", + "name": "电量不足任务取消", + "code": "task_cancel_low_power", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "132", + "name": "运动中任务取消", + "code": "task_cancel_in_motion", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "133", + "name": "充电状态", + "code": "charge_status", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + { + "id": "134", + "name": "烘干状态", + "code": "drying_status", + "mode": "ro", + "type": "RAW", + "property": None, + "desc": None, + }, + ], + } + ], + "devices": [ + { + "duid": "abc123", + "name": "Roborock S7 MaxV", + "attribute": None, + "activeTime": 1672364449, + "localKey": "key123", + "runtimeEnv": None, + "timeZoneId": "America/Los_Angeles", + "iconUrl": "no_url", + "productId": "product123", + "lon": None, + "lat": None, + "share": False, + "shareTime": None, + "online": True, + "fv": "02.56.02", + "pv": "1.0", + "roomId": 2362003, + "tuyaUuid": None, + "tuyaMigrated": False, + "extra": '{"RRPhotoPrivacyVersion": "1"}', + "sn": "abc123", + "featureSet": "2234201184108543", + "newFeatureSet": "0000000000002041", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 102, + "124": 203, + "125": 94, + "126": 90, + "127": 87, + "128": 0, + "133": 1, + "120": 0, + }, + "silentOtaSwitch": True, + } + ], + "receivedDevices": [], + "rooms": [ + {"id": 2362048, "name": "Example room 1"}, + {"id": 2362044, "name": "Example room 2"}, + {"id": 2362041, "name": "Example room 3"}, + ], +} + +CLEAN_RECORD = { + "begin": 1672543330, + "end": 1672544638, + "duration": 1176, + "area": 20965000, + "error": 0, + "complete": 1, + "start_type": 2, + "clean_type": 3, + "finish_reason": 56, + "dust_collection_status": 1, + "avoid_count": 19, + "wash_count": 2, + "map_flag": 0, +} + +CLEAN_SUMMARY = { + "clean_time": 74382, + "clean_area": 1159182500, + "clean_count": 31, + "dust_collection_count": 25, + "records": [ + 1672543330, + 1672458041, + ], +} + +CONSUMABLE = { + "main_brush_work_time": 74382, + "side_brush_work_time": 74383, + "filter_work_time": 74384, + "filter_element_work_time": 0, + "sensor_dirty_time": 74385, + "strainer_work_times": 65, + "dust_collection_work_times": 25, + "cleaning_brush_work_times": 66, +} + +DND_TIMER = { + "start_hour": 22, + "start_minute": 0, + "end_hour": 7, + "end_minute": 0, + "enabled": 1, +} + +STATUS = { + "msg_ver": 2, + "msg_seq": 458, + "state": 8, + "battery": 100, + "clean_time": 1176, + "clean_area": 20965000, + "error_code": 0, + "map_present": 1, + "in_cleaning": 0, + "in_returning": 0, + "in_fresh_state": 1, + "lab_status": 1, + "water_box_status": 1, + "back_type": -1, + "wash_phase": 0, + "wash_ready": 0, + "fan_power": 102, + "dnd_enabled": 0, + "map_status": 3, + "is_locating": 0, + "lock_status": 0, + "water_box_mode": 203, + "water_box_carriage_status": 1, + "mop_forbidden_enable": 1, + "camera_status": 3457, + "is_exploring": 0, + "home_sec_status": 0, + "home_sec_enable_password": 0, + "adbumper_status": [0, 0, 0], + "water_shortage_status": 0, + "dock_type": 3, + "dust_collection_status": 0, + "auto_dust_collection": 1, + "avoid_count": 19, + "mop_mode": 300, + "debug_mode": 0, + "collision_avoid_status": 1, + "switch_map_mode": 0, + "dock_error_status": 0, + "charge_status": 1, + "unsave_map_reason": 0, + "unsave_map_flag": 0, +} + +BASE_URL_REQUEST = { + 'code': 200, + 'msg': 'success', + 'data': { + 'url': 'https://sample.com' + } +} + +GET_CODE_RESPONSE = {'code': 200, 'msg': 'success', 'data': None} diff --git a/tests/test_api.py b/tests/test_api.py index e69de29..649cda8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -0,0 +1,49 @@ +from unittest.mock import patch + +import pytest + +from roborock import RoborockClient, UserData, HomeData +from roborock.api import PreparedRequest +from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, USER_DATA, HOME_DATA_RAW + + +def test_can_create_roborock_client(): + RoborockClient("") + + +def test_can_create_prepared_request(): + PreparedRequest("https://sample.com") + + +@pytest.mark.asyncio +async def test_get_base_url_no_url(): + rc = RoborockClient("sample@gmail.com") + with patch("roborock.api.PreparedRequest.request") as mock_request: + mock_request.return_value = BASE_URL_REQUEST + await rc._get_base_url() + assert rc.base_url == "https://sample.com" + + +@pytest.mark.asyncio +async def test_request_code(): + rc = RoborockClient("sample@gmail.com") + with patch("roborock.api.RoborockClient._get_base_url") as mock_url, patch( + "roborock.api.RoborockClient._get_header_client_id") as mock_header_client, patch( + "roborock.api.PreparedRequest.request") as mock_request: + mock_request.return_value = GET_CODE_RESPONSE + await rc.request_code() + + +@pytest.mark.asyncio +async def test_get_home_data(): + rc = RoborockClient("sample@gmail.com") + with patch("roborock.api.RoborockClient._get_base_url") as mock_url, patch( + "roborock.api.RoborockClient._get_header_client_id") as mock_header_client, patch( + "roborock.api.PreparedRequest.request") as mock_prepared_request: + mock_prepared_request.side_effect = [{'code': 200, 'msg': 'success', 'data': {"rrHomeId": 1}}, + {'code': 200, 'success': True, 'result': HOME_DATA_RAW}] + + user_data = UserData(USER_DATA) + result = await rc.get_home_data(user_data) + + assert result == HomeData(HOME_DATA_RAW) diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 0000000..2cd56db --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,168 @@ +from roborock import UserData, HomeData, LoginData, Consumable, Status, DNDTimer, CleanSummary, CleanRecord +from .mock_data import USER_DATA, HOME_DATA_RAW, CONSUMABLE, STATUS, DND_TIMER, CLEAN_SUMMARY, CLEAN_RECORD + + +def test_user_data(): + ud = UserData(USER_DATA) + assert ud.uid == 123456 + assert ud.token_type == "token_type" + assert ud.token == "abc123" + assert ud.rr_uid == "abc123" + assert ud.region == "us" + assert ud.country == "US" + assert ud.nickname == "user_nickname" + assert ud.rriot.user == "user123" + assert ud.rriot.password == "pass123" + assert ud.rriot.h_unknown == "unknown123" + assert ud.rriot.domain == "domain123" + assert ud.rriot.reference.region == "US" + assert ud.rriot.reference.api == "https://api-us.roborock.com" + assert ud.rriot.reference.mqtt == "ssl://mqtt-us.roborock.com:8883" + assert ud.rriot.reference.l_unknown == "https://wood-us.roborock.com" + assert ud.tuya_device_state == 2 + assert ud.avatar_url == "https://files.roborock.com/iottest/default_avatar.png" + +def test_home_data(): + hd = HomeData(HOME_DATA_RAW) + assert hd.id == 123456 + assert hd.name == "My Home" + assert hd.lon is None + assert hd.lat is None + assert hd.geo_name is None + product = hd.products[0] + assert product.id == "abc123" + assert product.name == "Roborock S7 MaxV" + assert product.code == "a27" + assert product.model == "roborock.vacuum.a27" + assert product.iconurl is None + assert product.attribute is None + assert product.capability == 0 + assert product.category == "robot.vacuum.cleaner" + schema = product.schema + assert schema[0].id == "101" + assert schema[0].name == "rpc_request" + assert schema[0].code == "rpc_request_code" + assert schema[0].mode == "rw" + assert schema[0].type == "RAW" + assert schema[0].product_property is None + assert schema[0].desc is None + device = hd.devices[0] + assert device.duid == "abc123" + assert device.name == "Roborock S7 MaxV" + assert device.attribute is None + assert device.activetime == 1672364449 + assert device.local_key == "key123" + assert device.runtime_env is None + assert device.time_zone_id == "America/Los_Angeles" + assert device.icon_url == "no_url" + assert device.product_id == "product123" + assert device.lon is None + assert device.lat is None + assert not device.share + assert device.share_time is None + assert device.online + assert device.fv == "02.56.02" + assert device.pv == "1.0" + assert device.room_id == 2362003 + assert device.tuya_uuid is None + assert not device.tuya_migrated + assert device.extra == '{"RRPhotoPrivacyVersion": "1"}' + assert device.sn == "abc123" + assert device.feature_set == "2234201184108543" + assert device.new_feature_set == "0000000000002041" + # status = device.device_status + # assert status.name == + assert device.silent_ota_switch + assert hd.rooms[0].id == 2362048 + assert hd.rooms[0].name == "Example room 1" + + +def test_consumable(): + c = Consumable(CONSUMABLE) + assert c.main_brush_work_time == 74382 + assert c.side_brush_work_time == 74383 + assert c.filter_work_time == 74384 + assert c.filter_element_work_time == 0 + assert c.sensor_dirty_time == 74385 + assert c.strainer_work_times == 65 + assert c.dust_collection_work_times == 25 + assert c.cleaning_brush_work_times == 66 + + +def test_status(): + s = Status(STATUS) + assert s.msg_ver == 2 + assert s.msg_seq == 458 + assert s.state == 8 + assert s.battery == 100 + assert s.clean_time == 1176 + assert s.clean_area == 20965000 + assert s.error_code == 0 + assert s.map_present == 1 + assert s.in_cleaning == 0 + assert s.in_returning == 0 + assert s.in_fresh_state == 1 + assert s.lab_status == 1 + assert s.water_box_status == 1 + assert s.back_type == -1 + assert s.wash_phase == 0 + assert s.wash_ready == 0 + assert s.fan_power == 102 + assert s.dnd_enabled == 0 + assert s.map_status == 3 + assert s.is_locating == 0 + assert s.lock_status == 0 + assert s.water_box_mode == 203 + assert s.water_box_carriage_status == 1 + assert s.mop_forbidden_enable == 1 + assert s.camera_status == 3457 + assert s.is_exploring == 0 + assert s.home_sec_status == 0 + assert s.home_sec_enable_password == 0 + assert s.adbumper_status == [0, 0, 0] + assert s.water_shortage_status == 0 + assert s.dock_type == 3 + assert s.dust_collection_status == 0 + assert s.auto_dust_collection == 1 + assert s.avoid_count == 19 + assert s.mop_mode == 300 + assert s.debug_mode == 0 + assert s.collision_avoid_status == 1 + assert s.switch_map_mode == 0 + assert s.dock_error_status == 0 + assert s.charge_status == 1 + assert s.unsave_map_reason == 0 + assert s.unsave_map_flag == 0 + +def test_dnd_timer(): + dnd = DNDTimer(DND_TIMER) + assert dnd.start_hour == 22 + assert dnd.start_minute == 0 + assert dnd.end_hour == 7 + assert dnd.end_minute == 0 + assert dnd.enabled == 1 + +def test_clean_summary(): + cs = CleanSummary(CLEAN_SUMMARY) + assert cs.clean_time == 74382 + assert cs.clean_area == 1159182500 + assert cs.clean_count == 31 + assert cs.dust_collection_count == 25 + assert len(cs.records) == 2 + assert cs.records[1] == 1672458041 + +def test_clean_record(): + cr = CleanRecord(CLEAN_RECORD) + assert cr.begin == 1672543330 + assert cr.end == 1672544638 + assert cr.duration == 1176 + assert cr.area == 20965000 + assert cr.error == 0 + assert cr.complete == 1 + assert cr.start_type == 2 + assert cr.clean_type == 3 + assert cr.finish_reason == 56 + assert cr.dust_collection_status == 1 + assert cr.avoid_count == 19 + assert cr.wash_count == 2 + assert cr.map_flag == 0 diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..71761d6 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,35 @@ +import asyncio + +import pytest + +from roborock.roborock_queue import RoborockQueue + + +def test_can_create(): + RoborockQueue(1) + + +@pytest.mark.asyncio +async def test_put(): + rq = RoborockQueue(1) + await rq.async_put(("test", None), 1) + assert await rq.get() == ("test", None) + + +@pytest.mark.asyncio +async def test_get_timeout(): + rq = RoborockQueue(1) + with pytest.raises(asyncio.TimeoutError): + await rq.async_get(.01) + + +@pytest.mark.asyncio +async def test_get(): + rq = RoborockQueue(1) + await rq.async_put(("test", None), 1) + assert await rq.async_get(1) == ("test", None) + + +@pytest.mark.asyncio +async def test_get(): + pass