├── src └── aioswitcher │ ├── py.typed │ ├── __init__.py │ ├── schedule │ ├── __init__.py │ ├── parser.py │ └── tools.py │ ├── api │ ├── packets.py │ └── messages.py │ └── device │ └── tools.py ├── requirements.txt ├── docs ├── install.md ├── img │ ├── logo.png │ └── favicon.ico ├── robots.txt ├── index.md ├── codedocs.md ├── usage_bridge.md ├── supported.md └── usage_api.md ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── auto-me-bot.yml ├── dependabot.yml ├── workflows │ ├── pages.yml │ ├── stage.yml │ ├── release.yml │ └── pr.yml └── CODE_OF_CONDUCT.md ├── tests ├── testresources │ ├── test_udp_datagram_parsing │ │ ├── test_a_faulty_datagram_too_short.txt │ │ ├── test_datagram_state_off_mini.txt │ │ ├── test_datagram_state_off_v4.txt │ │ ├── test_datagram_state_on_mini.txt │ │ ├── test_datagram_state_on_touch.txt │ │ ├── test_datagram_state_on_v4.txt │ │ ├── test_datagram_state_off_touch.txt │ │ ├── test_datagram_state_off_v2_esp.txt │ │ ├── test_datagram_state_off_v2_qca.txt │ │ ├── test_datagram_state_on_power_plug.txt │ │ ├── test_datagram_state_on_v2_esp.txt │ │ ├── test_datagram_state_on_v2_qca.txt │ │ ├── test_a_faulty_datagram_wrong_start.txt │ │ └── test_datagram_state_off_power_plug.txt │ ├── dummy_responses │ │ ├── login2_response.txt │ │ ├── login_response.txt │ │ ├── set_name_response.txt │ │ ├── control_breeze_response.txt │ │ ├── control_breeze_swing_response.txt │ │ ├── delete_schedule_response.txt │ │ ├── set_set_shutter_child_response.txt │ │ ├── stop_shutter_response.txt │ │ ├── turn_off_response.txt │ │ ├── turn_on_response.txt │ │ ├── set_shutter_position_response.txt │ │ ├── turn_on_with_timer_response.txt │ │ ├── get_schedules_response.txt │ │ ├── create_schedule_response.txt │ │ ├── set_light_response.txt │ │ ├── get_shutter_state_response.txt │ │ ├── get_light_state_response.txt │ │ ├── get_breeze_state.txt │ │ ├── get_state_response.txt │ │ └── set_auto_shutdown_response.txt │ ├── breeze_data │ │ ├── breeze_elec7001_turn_off_command.txt │ │ ├── breeze_elec7022_turn_off_command.txt │ │ └── irset_db_invalid_elec7022_data.json │ ├── test_api_messages │ │ ├── test_switcher_login_response_dataclass.txt │ │ └── test_the_state_message_parser_device_off.txt │ ├── test_schedule_parser │ │ └── test_get_schedules_with_a_two_schedules_packet.txt │ ├── test_device_parsing │ │ ├── test_a_runner_datagram_produces_device.txt │ │ ├── test_a_breeze_datagram_produces_device.txt │ │ ├── test_a_power_plug_datagram_produces_device.txt │ │ ├── test_a_water_heater_datagram_produces_device.txt │ │ ├── test_a_light_datagram_produces_device.txt │ │ ├── test_a_dual_light_datagram_produces_device.txt │ │ ├── test_a_triple_light_datagram_produces_device.txt │ │ ├── test_a_dual_runner_single_light_datagram_produces_device.txt │ │ └── test_a_single_runner_dual_light_datagram_produces_device.txt │ └── test_bridge │ │ ├── test_bridge_callback_loading_v2_off.txt │ │ └── test_bridge_callback_loading_power_plug_off.txt ├── __init__.py ├── test_schedule_days.py ├── test_udp_client_protocol.py ├── test_bridge.py ├── test_udp_datagram_parsing.py ├── test_api_messages.py ├── test_api_packet_crc_signing.py ├── test_device_enum_helpers.py ├── test_schedule_parser.py ├── test_device_parsing.py ├── test_schedule_tools.py ├── test_device_dataclasses.py └── test_device_tools.py ├── .codecov.yml ├── .yamllint ├── .editorconfig ├── README.md ├── scripts ├── validate_token.py ├── get_device_login_key.py └── discover_devices.py ├── .gitignore ├── CONTRIBUTING.md ├── .gitattributes ├── mkdocs.yml ├── pyproject.toml └── LICENSE /src/aioswitcher/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | poetry==2.0.1 2 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ```bash 2 | pip install aioswitcher 3 | ``` 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TomerFi @thecode @YogevBokobza 2 | .github @TomerFi 3 | -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerFi/aioswitcher/HEAD/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerFi/aioswitcher/HEAD/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Allow: / 3 | 4 | Sitemap: https://aioswitcher.figenblat.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_a_faulty_datagram_too_short.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/login2_response.txt: -------------------------------------------------------------------------------- 1 | fef02c000400a60000000000ff03021100000000000000005d65966200000000000000000000f0fe1c8a48fa -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/login_response.txt: -------------------------------------------------------------------------------- 1 | fef00000000000fef00000000000fef00000000000fef00000000000fef00000000000fef000000000001111 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/set_name_response.txt: -------------------------------------------------------------------------------- 1 | fef00000000000fef00000000000fef00000000000fef00000000000fef00000000000fef000000000001111 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/control_breeze_response.txt: -------------------------------------------------------------------------------- 1 | fef0300004000102000000000000020000000000000000008d6a966200000000000000000000f0fe01000000f5c7f750 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/control_breeze_swing_response.txt: -------------------------------------------------------------------------------- 1 | fef0300004000102000000000000020000000000000000008d6a966200000000000000000000f0fe01000000f5c7f750 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/delete_schedule_response.txt: -------------------------------------------------------------------------------- 1 | fef00000000000fef00000000000fef00000000000fef00000000000fef00000000000fef00000000000fef00000000000 2 | -------------------------------------------------------------------------------- /tests/testresources/breeze_data/breeze_elec7001_turn_off_command.txt: -------------------------------------------------------------------------------- 1 | 00000000524337327c32317c33327c32367c34437c39387c537c32327c30337c373237325b32325d7c43303132303030303830 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/set_set_shutter_child_response.txt: -------------------------------------------------------------------------------- 1 | fef030000402010200000000ff03020000000000000000006d053b6700000000000000000000f0fe0700000098d998a8 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/stop_shutter_response.txt: -------------------------------------------------------------------------------- 1 | fef0380004020102000000002323020000000000000000007086966200000000000000000000f0fe020008001800000001010000aaf9e8cc -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/turn_off_response.txt: -------------------------------------------------------------------------------- 1 | 000000000000000000000000000000000000000000000000b2c3d96000000000000000000000f0fe01000600000000000000123456ab 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/turn_on_response.txt: -------------------------------------------------------------------------------- 1 | 000000000000000000000000000000000000000000000000a6c3d96000000000000000000000f0fe010006000100000000001a23bc2d 2 | -------------------------------------------------------------------------------- /tests/testresources/test_api_messages/test_switcher_login_response_dataclass.txt: -------------------------------------------------------------------------------- 1 | 0000000000000000f050834e0000000000000000000000000000000000000000000000000000000000000000 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/set_shutter_position_response.txt: -------------------------------------------------------------------------------- 1 | fef038000402010200000000290402000000000000000000b987966200000000000000000000f0fe01000800180001000101000080dca3c0 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/turn_on_with_timer_response.txt: -------------------------------------------------------------------------------- 1 | 000000000000000000000000000000000000000000000000e3c3d96000000000000000000000f0fe010006000100000000001a23bc2d 2 | -------------------------------------------------------------------------------- /tests/testresources/breeze_data/breeze_elec7022_turn_off_command.txt: -------------------------------------------------------------------------------- 1 | 000000004e4543587c32367c33327c31352c31357c31352c33437c31357c54303042457c33307c30317c414241425b33305d7c423234443742383445303146 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/get_schedules_response.txt: -------------------------------------------------------------------------------- 1 | 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012801d8ced960e0d5d960ce0e0000c76bd3cb 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/create_schedule_response.txt: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a201b0abd960b8b2d96000000000b85968c8 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | status: 4 | patch: off 5 | changes: off 6 | project: 7 | default: 8 | branches: 9 | - dev 10 | 11 | comment: 12 | layout: diff 13 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/set_light_response.txt: -------------------------------------------------------------------------------- 1 | fef000000305010200000000000000000000000000000000e616fa6400000000000000000000f0fefd26c70074bbe71100000000000000000000000000000000000000000000000000000000000000370a0600010100000000 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: GitHub Discussions 5 | url: https://github.com/TomerFi/aioswitcher/discussions/ 6 | about: Use Discussions for questions and ideas. 7 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # aioswitcher docs 2 | 3 | --8<-- "install.md" 4 | 5 | ???- info "Supported devices." 6 | --8<-- "supported.md" 7 | 8 | ## Bridge 9 | 10 | --8<-- "usage_bridge.md" 11 | 12 | ## API 13 | 14 | --8<-- "usage_api.md" 15 | 16 | -------------------------------------------------------------------------------- /tests/testresources/test_schedule_parser/test_get_schedules_with_a_two_schedules_packet.txt: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001fc01e871a35cf87fa35cce0e000001010201e06aa35cf078a35cce0e000000000000 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/get_shutter_state_response.txt: -------------------------------------------------------------------------------- 1 | fef0640004020103000000003900020000000000000000001489966200000000000000000000f0fe53776974636865722052756e5f31453432000000000000000000000000000000031500053200000001010000000000000000000000000000db4c3741 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/get_light_state_response.txt: -------------------------------------------------------------------------------- 1 | fef0640004020103000000003900020000000000000000001489966200000000000000000000f0fe53776974636865722052756e5f31453432000000000000000000000000000000031500053200000001010000000000000000000000000000db4c3741 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/get_breeze_state.txt: -------------------------------------------------------------------------------- 1 | fef06d000400010300000000390002000000000000000000c266966200000000000000000000f0fe537769746368657220427265657a655f35363739000000000000000000000000031e00011901000218000007454c45433730323200000000570000000000000002190044d5 -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/get_state_response.txt: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005726b9c0000000000000000000000000302a000001024b38af38 2 | -------------------------------------------------------------------------------- /tests/testresources/dummy_responses/set_auto_shutdown_response.txt: -------------------------------------------------------------------------------- 1 | 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e7bdc0000000000000000000000000282300000102ae73c17f 2 | -------------------------------------------------------------------------------- /tests/testresources/test_api_messages/test_the_state_message_parser_device_off.txt: -------------------------------------------------------------------------------- 1 | 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b71b65000000000000000000000000181500000102e8620000 2 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | locale: en_US.UTF-8 5 | 6 | ignore: | 7 | .git/ 8 | .mypy_cache/ 9 | .venv/ 10 | 11 | rules: 12 | line-length: 13 | max: 100 14 | level: warning 15 | truthy: 16 | allowed-values: ['false', 'off', 'no', 'on', 'true', 'yes'] 17 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_runner_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef09f000402020000000000120701000000f2239a0000006485966200000000000000000000f0fe060053776974636865722052756e5f314534320000000000000000000000000000000c0200c0a8326294b97e011e4202020000010000030253776974636865722052756e5f31453432000000000000000000000000000000020400001500041800000001010000000000000000000000000000ad6b23b9 -------------------------------------------------------------------------------- /tests/testresources/test_bridge/test_bridge_callback_loading_v2_off.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a7c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_mini.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c65720000000000000000000000000000030fc0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_v4.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c657200000000000000000000000000000317c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_mini.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c65720000000000000000000000000000030fc0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_touch.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c65720000000000000000000000000000030bc0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_v4.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c657200000000000000000000000000000317c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_bridge/test_bridge_callback_loading_power_plug_off.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a8c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_breeze_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0a800040002000000000050e0010000003a20b70000009b62966200000000000000000000f0fe0800537769746368657220427265657a655f353637390000000000000000000000000e0100c0a8324dbcff4d4a567900000700000000030253776974636865725f427265657a655f35363739000000000000000000000000020400001e00011901000218000007454c45433730323200000000280000000000000002433ded03 -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_touch.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c65720000000000000000000000000000030bc0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_v2_esp.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a7c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_v2_qca.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a1c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_power_plug.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a8c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000000000000000000000000000000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_v2_esp.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a7c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_v2_qca.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a1c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_power_plug_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a8c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000000000000000000000000000000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_water_heater_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a7c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_a_faulty_datagram_wrong_start.txt: -------------------------------------------------------------------------------- 1 | ffffa500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a7c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000100280a00004b9589c0000000001815000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_power_plug.txt: -------------------------------------------------------------------------------- 1 | fef0a500023c020000000000841201000000aaaaaa0000007ff6c26000000000000000000000f0fe03004d7920537769746368657220426f696c6572000000000000000000000000000001a8c0a8012112a1a21abc1a000000000000000002537769746368657220426f696c65722043463842000000000000000000000000020400001c000000000000004b9589c0000000000000000000000000302a00000102aa3461dd 2 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_light_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0cf0004020200000000003b9d01000000b185c80000008d78026700000000000000000000f0fe04005377697463686572204c696768745f33364242000000000000000000000000000f0700c0a801463494549536bb0103000000000000025377697463686572204c696768745f333642420000000000000000000000000002040000450003000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053fed273 -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_dual_light_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0cf0004020200000000003b9d01000000b185c80000008d78026700000000000000000000f0fe04005377697463686572204c696768745f33364242000000000000000000000000000f0800c0a801463494549536bb0103000000000000025377697463686572204c696768745f333642420000000000000000000000000002040000450003000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053fed273 -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_triple_light_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0cf0004020200000000003b9d01000000b185c80000008d78026700000000000000000000f0fe04005377697463686572204c696768745f33364242000000000000000000000000000f0600c0a801463494549536bb0103000000000000025377697463686572204c696768745f333642420000000000000000000000000002040000450003000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000053fed273 -------------------------------------------------------------------------------- /docs/codedocs.md: -------------------------------------------------------------------------------- 1 | 2 | # Code documentation 3 | 4 | ::: src.aioswitcher 5 | 6 | ::: src.aioswitcher.api 7 | options: 8 | show_source: true 9 | 10 | ::: src.aioswitcher.api.messages 11 | 12 | ::: src.aioswitcher.api.remotes 13 | 14 | ::: src.aioswitcher.bridge 15 | 16 | ::: src.aioswitcher.device 17 | 18 | ::: src.aioswitcher.device.tools 19 | 20 | ::: src.aioswitcher.schedule 21 | 22 | ::: src.aioswitcher.schedule.parser 23 | 24 | ::: src.aioswitcher.schedule.tools 25 | -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_dual_runner_single_light_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0cb000402020000000000bf0401000000fd26c700000092fafa6600000000000000000000f0fe020053776974636865722052756e20706c75735f41394245000000000000000000000f0200c0a801eb483fda26a9be03030c0c000000000253776974636865722052756e6e65725f413942450000000000000000000000000204000041000100000000000400000000000000010000000000000100000000000000000000006400000001000000000000000000000000000000000000000000000000000000c92bc371 -------------------------------------------------------------------------------- /tests/testresources/test_device_parsing/test_a_single_runner_dual_light_datagram_produces_device.txt: -------------------------------------------------------------------------------- 1 | fef0cb000402020000000000e503010000006467c6000000473a986600000000000000000000f0fe040053776974636865722052756e6e65725f364346350000000000000000000000000f0100c0a801fc483fda266cf50303030c000000000253776974636865722052756e6e65725f36434635000000000000000000000000020400004100010100000000202e0000000000000100000100000000212e000000000000010000640000000100000000000000000000000000000000000000000000000000000034936602 -------------------------------------------------------------------------------- /.github/auto-me-bot.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/apps/auto-me-bot 2 | --- 3 | pr: 4 | lifecycleLabels: 5 | ignoreDrafts: true 6 | labels: 7 | reviewRequired: "status: needs review" 8 | changesRequested: "status: changes requested" 9 | moreReviewsRequired: "status: needs more reviews" 10 | reviewStarted: "status: review started" 11 | approved: "status: approved" 12 | merged: "status: merged" 13 | conventionalTitle: 14 | tasksList: 15 | autoApprove: 16 | users: ['dependabot'] 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration main module test cases.""" 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | description: Suggest an idea for this project 4 | labels: ["type: enhancement"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the suggest an idea! 10 | 11 | - type: textarea 12 | id: feature-idea 13 | attributes: 14 | label: What did you have in mind? 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: is-problem 20 | attributes: 21 | label: Are you trying to fix a problem? 22 | description: If so, please provide as much information as you can. 23 | 24 | - type: textarea 25 | id: implementation-idea 26 | attributes: 27 | label: Any lead on how this feature can be implemented? 28 | -------------------------------------------------------------------------------- /tests/testresources/breeze_data/irset_db_invalid_elec7022_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "ELEC7022": { 3 | "IRSetID": "ELEC7022", 4 | "OnOffType": 0, 5 | "IRWaveList": [ 6 | { 7 | "Key": "aa", 8 | "Para": "NECX|26|32|15,15|15,40|15|T00BE|30|01|ABAB[30]", 9 | "HexCode": "B24D1FE048B7" 10 | }, 11 | { 12 | "Key": "ad", 13 | "Para": "NECX|26|32|15,15|15,40|15|T00BE|30|01|ABAB[30]", 14 | "HexCode": "B24D1FE044BB" 15 | }, 16 | { 17 | "Key": "aw_f0", 18 | "Para": "NECX|26|32|15,15|15,40|15|T00BE|30|01|ABAB[30]", 19 | "HexCode": "B24DBF40E41B" 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: /.github/workflows 6 | schedule: 7 | interval: weekly 8 | labels: 9 | - "type: dependencies" 10 | commit-message: 11 | prefix: "ci" 12 | rebase-strategy: disabled 13 | assignees: 14 | - "TomerFi" 15 | - "thecode" 16 | - "YogevBokobza" 17 | 18 | - package-ecosystem: "pip" 19 | directory: "/" 20 | schedule: 21 | interval: daily 22 | labels: 23 | - "type: dependencies" 24 | commit-message: 25 | prefix: "build" 26 | include: "scope" 27 | rebase-strategy: disabled 28 | versioning-strategy: increase-if-necessary 29 | assignees: 30 | - "TomerFi" 31 | - "thecode" 32 | - "YogevBokobza" 33 | -------------------------------------------------------------------------------- /docs/usage_bridge.md: -------------------------------------------------------------------------------- 1 | Use the Bridge to discover devices and their state. The following excerpt will print all discovered devices for 60 seconds. 2 | 3 | ```python linenums="1" hl_lines="9" 4 | import asyncio 5 | from dataclasses import asdict 6 | from aioswitcher.bridge import SwitcherBridge 7 | 8 | async def print_devices(delay): 9 | def on_device_found_callback(device): 10 | print(asdict(device)) # (1) 11 | 12 | async with SwitcherBridge(on_device_found_callback): 13 | await asyncio.sleep(delay) 14 | 15 | asyncio.run(print_devices(60)) 16 | ``` 17 | 18 | 1. for the callback types, check the device pacakge for implementations of 19 | [SwitcherBase](./codedocs.md#src.aioswitcher.device.SwitcherBase). 20 | 21 | !!!note 22 | Switcher devices broadcast a state message approximately every 4 seconds. 23 | -------------------------------------------------------------------------------- /src/aioswitcher/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration main module.""" 16 | 17 | name = "aioswitcher" 18 | 19 | __all__ = ["api", "bridge", "device", "schedule"] 20 | 21 | 22 | # the following enum should be under the schedule module 23 | # it is here to avoid a circular imports issue 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [requirements.txt] 10 | end_of_line = lf 11 | trim_trailing_whitespace = false 12 | 13 | [**.py] 14 | end_of_line = crlf 15 | indent_style = space 16 | indent_size = 4 17 | max_line_length = 88 18 | trim_trailing_whitespace = true 19 | 20 | [**.xml] 21 | indent_style = space 22 | indent_size = 2 23 | max_line_length = 80 24 | trim_trailing_whitespace = true 25 | 26 | [**.{yaml,yml,yamllint}] 27 | indent_style = space 28 | indent_size = 2 29 | max_line_length = 100 30 | trim_trailing_whitespace = true 31 | 32 | [**.md] 33 | max_line_length = 120 34 | trim_trailing_whitespace = false 35 | 36 | [**.rst] 37 | end_of_line = lf 38 | max_line_length = 100 39 | trim_trailing_whitespace = true 40 | 41 | [**.{json,all-contributorsrc}] 42 | indent_size = 2 43 | 44 | [Makefile*] 45 | indent_style = tab 46 | indent_size = 4 47 | -------------------------------------------------------------------------------- /tests/test_schedule_days.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Verify the custom enum of Days.""" 16 | 17 | from assertpy import assert_that 18 | from pytest import mark 19 | 20 | from aioswitcher.schedule import Days 21 | 22 | 23 | @mark.parametrize("sut_day,expected_value,expected_hex_rep,expected_bit_rep,expected_weekday", [ 24 | (Days.MONDAY, "Monday", 0x2, 2, 0), 25 | (Days.TUESDAY, "Tuesday", 0x4, 4, 1), 26 | (Days.WEDNESDAY, "Wednesday", 0x8, 8, 2), 27 | (Days.THURSDAY, "Thursday", 0x10, 16, 3), 28 | (Days.FRIDAY, "Friday", 0x20, 32, 4), 29 | (Days.SATURDAY, "Saturday", 0x40, 64, 5), 30 | (Days.SUNDAY, "Sunday", 0x80, 128, 6), 31 | ]) 32 | def test_the_and_verify_the_paramerized_member_of_the_days_enum(sut_day, expected_value, expected_hex_rep, expected_bit_rep, expected_weekday): 33 | assert_that(sut_day.value).is_equal_to(expected_value) 34 | assert_that(sut_day.hex_rep).is_equal_to(expected_hex_rep) 35 | assert_that(sut_day.bit_rep).is_equal_to(expected_bit_rep) 36 | assert_that(sut_day.weekday).is_equal_to(expected_weekday) 37 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pages Deploy 3 | 4 | on: 5 | workflow_dispatch: 6 | release: 7 | types: [published] 8 | 9 | env: 10 | MAIN_PY_VER: "3.13" 11 | 12 | jobs: 13 | deploy-pages: 14 | runs-on: ubuntu-latest 15 | environment: github-pages 16 | name: Build documentation site and deploy to GH-Pages 17 | steps: 18 | - name: Checkout sources 19 | uses: actions/checkout@v6 20 | with: 21 | ref: ${{ github.ref }} 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: ${{ env.MAIN_PY_VER }} 27 | 28 | - name: Cache pip repository 29 | uses: actions/cache@v5 30 | with: 31 | path: ~/.cache/pip 32 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}-${{ env.MAIN_PY_VER }} 33 | 34 | - name: Prepare python environment 35 | run: | 36 | pip install -r requirements.txt 37 | poetry config virtualenvs.create true 38 | poetry config virtualenvs.in-project true 39 | 40 | - name: Cache poetry virtual environment 41 | uses: actions/cache@v5 42 | with: 43 | path: .venv 44 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/pyproject.toml') }}-${{ env.MAIN_PY_VER }} 45 | 46 | - name: Build documentation site 47 | run: | 48 | poetry lock 49 | poetry install --no-interaction 50 | poetry run poe docs_build 51 | 52 | - name: Deploy to GH-Pages 53 | uses: peaceiris/actions-gh-pages@v4.0.0 54 | with: 55 | github_token: ${{ secrets.GITHUB_TOKEN }} 56 | publish_dir: ./site 57 | cname: aioswitcher.figenblat.com 58 | commit_message: 'docs: deployed documentation site ' 59 | -------------------------------------------------------------------------------- /.github/workflows/stage.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stage 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - dev 9 | paths: 10 | - src/** 11 | - tests/** 12 | - pyproject.toml 13 | - requirements.txt 14 | 15 | env: 16 | MAIN_PY_VER: "3.13" 17 | 18 | jobs: 19 | test: 20 | runs-on: ubuntu-latest 21 | environment: staging 22 | name: Stage project 23 | steps: 24 | - name: Checkout sources 25 | uses: actions/checkout@v6 26 | 27 | - name: Setup timezone 28 | uses: zcong1993/setup-timezone@v2.0.0 29 | with: 30 | timezone: Asia/Jerusalem 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v6 34 | with: 35 | python-version: ${{ env.MAIN_PY_VER }} 36 | 37 | - name: Cache pip repository 38 | uses: actions/cache@v5 39 | with: 40 | path: ~/.cache/pip 41 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}-${{ env.MAIN_PY_VER }} 42 | 43 | - name: Prepare python environment 44 | run: | 45 | pip install -r requirements.txt 46 | poetry config virtualenvs.create true 47 | poetry config virtualenvs.in-project true 48 | 49 | - name: Cache poetry virtual environment 50 | uses: actions/cache@v5 51 | with: 52 | path: .venv 53 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/pyproject.toml') }}-${{ env.MAIN_PY_VER }} 54 | 55 | - name: Install, test with coverage report, and build 56 | run: | 57 | poetry lock 58 | poetry install --no-interaction 59 | poetry run poe test_rep 60 | poetry build 61 | 62 | - name: Push to CodeCov 63 | uses: codecov/codecov-action@v5 64 | with: 65 | token: ${{ secrets.CODECOV_TOKEN }} 66 | files: coverage.xml 67 | fail_ci_if_error: true 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: File a bug report 4 | labels: ["type: bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the time to fill out this bug report! 10 | 11 | - type: textarea 12 | id: what-happened 13 | attributes: 14 | label: What happened? 15 | description: Also share, what did you expect to happen? 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | id: module-version 21 | attributes: 22 | label: Module Version 23 | description: What version of `aioswitcher` are you using? 24 | placeholder: ex. 5.1.1 25 | validations: 26 | required: true 27 | 28 | - type: dropdown 29 | id: device-type 30 | attributes: 31 | label: Device Type 32 | description: What device are you trying to work with? 33 | options: 34 | - Switcher V2 35 | - Switcher Mini 36 | - Switcher Touch (V3) 37 | - Switcher V4 38 | - Switcher Power Plug 39 | - Switcher Breeze 40 | - Switcher Runner 41 | - Switcher Runner Mini 42 | - Switcher Runner S11 43 | - Switcher Runner S12 44 | - Switcher Light SL01 45 | - Switcher Light SL01 Mini 46 | - Switcher Light SL02 47 | - Switcher Light SL02 Mini 48 | - Switcher Light SL03 49 | validations: 50 | required: true 51 | 52 | - type: input 53 | id: firmware-version 54 | attributes: 55 | label: Firmware Version 56 | description: What the firmware version of the device in question? 57 | placeholder: ex. 3.2.1 58 | validations: 59 | required: true 60 | 61 | - type: textarea 62 | id: log-output 63 | attributes: 64 | label: Relevant log output 65 | description: Please provide any relevant log output. Check for private info before submitting. 66 | render: shell 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switcher Python Integration 2 | 3 | [![pypi-ver-badge]][pypi-aioswitcher] [![pypi-down-badge]][pypi-aioswitcher] [![poetry-badge]][poetry-url] [![license-badge]][repo-aioswitcher]
4 | [![gh-build-badge]][ci-stage] [![pages-badge]][docs-aioswitcher] [![codecov-badge]][codecov-aioswitcher] 5 | 6 | PyPi module integrating with various [Switcher][switcher] devices.
7 | 8 | ```shell 9 | pip install aioswitcher 10 | ``` 11 | 12 | Check the docs: [https://aioswitcher.figenblat.com][docs-aioswitcher]. 13 | 14 | Looking for a containerized solution? -Check [https://switcher-webapi.figenblat.com][switcher-webapi]. 15 | 16 | > [!Note] 17 | > This is a community-driven open-source project; the vendor does not officially support it.
18 | > Thanks to all the people at [Switcher][switcher] for their cooperation and general support. 19 | 20 | > [!Note] 21 | > This project has a couple of ancestors; the most important one is the [Switcher-V2-Python][switcher-v2-script] project. 22 | 23 | 24 | [ci-stage]: https://github.com/TomerFi/aioswitcher/actions/workflows/stage.yml 25 | [pages-badge]: https://github.com/TomerFi/aioswitcher/actions/workflows/pages.yml/badge.svg 26 | [codecov-aioswitcher]: https://codecov.io/gh/TomerFi/aioswitcher 27 | [docs-aioswitcher]: https://aioswitcher.figenblat.com/ 28 | [poetry-url]: https://python-poetry.org/ 29 | [pypi-aioswitcher]: https://pypi.org/project/aioswitcher 30 | [repo-aioswitcher]: https://github.com/TomerFi/aioswitcher 31 | [switcher]: https://www.switcher.co.il/ 32 | [switcher-v2-script]: https://github.com/NightRang3r/Switcher-V2-Python 33 | [switcher-webapi]: https://switcher-webapi.figenblat.com 34 | 35 | [codecov-badge]: https://codecov.io/gh/TomerFi/aioswitcher/graph/badge.svg 36 | [gh-build-badge]: https://github.com/TomerFi/aioswitcher/actions/workflows/stage.yml/badge.svg 37 | [license-badge]: https://img.shields.io/github/license/tomerfi/aioswitcher 38 | [poetry-badge]: https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json 39 | [pypi-down-badge]: https://img.shields.io/pypi/dm/aioswitcher.svg?logo=pypi&color=1082C2 40 | [pypi-ver-badge]: https://img.shields.io/pypi/v/aioswitcher?logo=pypi 41 | -------------------------------------------------------------------------------- /scripts/validate_token.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | 3 | # Copyright Tomer Figenblat. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Python script for validating a Token from Switcher.""" 18 | 19 | import asyncio 20 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 21 | 22 | from aioswitcher.device.tools import validate_token 23 | 24 | _examples = """example usage: 25 | 26 | python validate_token.py -u "email" -t "zvVvd7JxtN7CgvkD1Psujw=="\n 27 | python validate_token.py --username "email" --token "zvVvd7JxtN7CgvkD1Psujw=="\n 28 | """ # noqa E501 29 | 30 | parser = ArgumentParser( 31 | description="Validate a Token from Switcher by username and token", 32 | epilog=_examples, 33 | formatter_class=RawDescriptionHelpFormatter, 34 | ) 35 | 36 | parser.add_argument( 37 | "-u", 38 | "--username", 39 | required=True, 40 | help="the username of the user (Email address)", 41 | type=str, 42 | ) 43 | 44 | parser.add_argument( 45 | "-t", 46 | "--token", 47 | required=True, 48 | help="the token of the user sent by Email", 49 | type=str, 50 | ) 51 | 52 | 53 | async def main() -> None: 54 | """Validate the personal Token of the user.""" 55 | try: 56 | args = parser.parse_args() 57 | 58 | response = await validate_token(args.username, args.token) 59 | if response: 60 | print("Your Personal Token is valid") 61 | else: 62 | print("Your Personal Token is invalid") 63 | 64 | except KeyboardInterrupt: 65 | exit() 66 | 67 | 68 | if __name__ == "__main__": 69 | asyncio.run(main()) 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom for project 2 | node_modules 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | poetry.lock 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # mkdocs documentation 74 | */site/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | # coverage reports 123 | coverage.xml 124 | junit.xml 125 | 126 | # project stuff 127 | CHANGELOG.md 128 | release-context.json 129 | 130 | # OSX stuff 131 | .DS_Store 132 | 133 | .idea 134 | -------------------------------------------------------------------------------- /src/aioswitcher/schedule/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration schedule module.""" 16 | 17 | from enum import Enum, unique 18 | 19 | 20 | @unique 21 | class ScheduleState(Enum): 22 | """Enum representing the status of the schedule.""" 23 | 24 | ENABLED = "01" 25 | DISABLED = "00" 26 | 27 | 28 | @unique 29 | class Days(Enum): 30 | """Enum class representing the day entity.""" 31 | 32 | MONDAY = ("Monday", 0x02, 2, 0) 33 | TUESDAY = ("Tuesday", 0x04, 4, 1) 34 | WEDNESDAY = ("Wednesday", 0x08, 8, 2) 35 | THURSDAY = ("Thursday", 0x10, 16, 3) 36 | FRIDAY = ("Friday", 0x20, 32, 4) 37 | SATURDAY = ("Saturday", 0x40, 64, 5) 38 | SUNDAY = ("Sunday", 0x80, 128, 6) 39 | 40 | def __new__(cls, value: str, hex_rep: int, bit_rep: int, weekday: int) -> "Days": 41 | """Override the default enum constructor and include extra properties.""" 42 | new_enum = object.__new__(cls) 43 | new_enum._value_ = value 44 | new_enum._hex_rep = hex_rep # type: ignore 45 | new_enum._bit_rep = bit_rep # type: ignore 46 | new_enum._weekday = weekday # type: ignore 47 | return new_enum 48 | 49 | @property 50 | def bit_rep(self) -> int: 51 | """Return the bit representation of the day.""" 52 | return self._bit_rep # type: ignore 53 | 54 | @property 55 | def hex_rep(self) -> int: 56 | """Return the hexadecimal representation of the day.""" 57 | return self._hex_rep # type: ignore 58 | 59 | @property 60 | def weekday(self) -> int: 61 | """Return the weekday of the day.""" 62 | return self._weekday # type: ignore 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to *aioswitcher* 2 | 3 | :clap: First off, thank you for taking the time to contribute. :clap: 4 | 5 | Contributing is pretty straight-forward: 6 | 7 | - Fork the repository 8 | - Create a new branch on your fork 9 | - Commit your changes 10 | - Create a pull request against the `dev` branch 11 | 12 | ## Installing 13 | 14 | ### Install remote development version 15 | 16 | Contributions are welcome in all shapes or forms. If you're a user, you can play around with the current development 17 | version and report back any findings. 18 | 19 | Install the remote development version using `pip`: 20 | 21 | ```shell 22 | pip install git+https://github.com/TomerFi/aioswitcher#dev 23 | ``` 24 | 25 | ### Install local development version 26 | 27 | If you need to test your changes locally, you can install your work-in-progress from your active working branch. 28 | 29 | Install the local development version using `pip`: 30 | 31 | ```shell 32 | pip install --upgrade . 33 | ``` 34 | 35 | ## Developing 36 | 37 | ### Prepare the development environment 38 | 39 | With [Python >= 3.10][python-site] use [pip][pip-docs] to install [poetry][poetry-site]: 40 | 41 | ```shell 42 | pip install -r requirements.txt 43 | ``` 44 | 45 | ### Get started with poetry 46 | 47 | After installing, run [poe][poethepoet-site] for _help_, this will list the available tasks: 48 | 49 | ```shell 50 | poetry install # install all dependencies and the current project 51 | poetry run poe --help # display all available tasks 52 | ``` 53 | 54 | Common [poe][poethepoet-site] tasks: 55 | 56 | ```shell 57 | poetry run poe test # will run all unit-tests 58 | poetry run poe lint # will lint the project using black, flake8, isort, mypy, and yamllint 59 | poetry run poe docs_serve # will build and serve a local version of the documentation site 60 | ``` 61 | 62 | ## Documentation 63 | 64 | We use [MkDocs][mkdocs-site] and [Material][material-site] for building our documentation site, 65 | https://aioswitcher.figenblat.com/. See [docs](docs) and [mkdocs.yml](mkdocs.yml). 66 | 67 | > [!NOTE] 68 | > We're generating [code documentation][aioswitcher-code-docs] from _docstrings_. 69 | 70 | 71 | [aioswitcher-code-docs]: https://aioswitcher.figenblat.com/codedocs/ 72 | [material-site]: https://squidfunk.github.io/mkdocs-material/ 73 | [mkdocs-site]: https://www.mkdocs.org/ 74 | [pip-docs]: https://pypi.org/project/pip/ 75 | [poethepoet-site]: https://github.com/nat-n/poethepoet 76 | [poetry-site]: https://poetry.eustace.io/ 77 | [python-site]: https://www.python.org/ 78 | -------------------------------------------------------------------------------- /tests/test_udp_client_protocol.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration udp client protocol test cases.""" 16 | 17 | from unittest.mock import Mock, patch 18 | 19 | from assertpy import assert_that 20 | from pytest import fixture, warns 21 | 22 | from aioswitcher.bridge import UdpClientProtocol 23 | 24 | 25 | @fixture 26 | def mock_callback(): 27 | return Mock() 28 | 29 | 30 | @fixture 31 | def sut_protocol(mock_callback): 32 | return UdpClientProtocol(mock_callback) 33 | 34 | 35 | def test_given_transport_when_connection_made_then_transport_should_be_served(sut_protocol): 36 | mock_transport = Mock() 37 | sut_protocol.connection_made(mock_transport) 38 | assert_that(sut_protocol.transport).is_equal_to(mock_transport) 39 | 40 | 41 | def test_given_datagram_when_sut_received_then_the_callback_is_called(sut_protocol, mock_callback): 42 | mock_datagram = Mock() 43 | sut_protocol.datagram_received(mock_datagram, Mock()) 44 | mock_callback.assert_called_once_with(mock_datagram) 45 | 46 | 47 | def test_error_received_with_no_error_should_issue_a_warning(sut_protocol): 48 | with warns(UserWarning, match="udp client received error"): 49 | sut_protocol.error_received(None) 50 | 51 | 52 | @patch("logging.Logger.error") 53 | def test_error_received_with_an_actual_error_should_write_to_the_error_output(mock_error, sut_protocol): 54 | sut_protocol.error_received(Exception("dummy")) 55 | mock_error.assert_called_once_with("udp client received error dummy") 56 | 57 | 58 | @patch("logging.Logger.info") 59 | def test_connection_lost_with_no_error_should_write_to_the_info_output(mock_info, sut_protocol): 60 | sut_protocol.connection_lost(None) 61 | mock_info.assert_called_once_with("udp connection stopped") 62 | 63 | 64 | @patch("logging.Logger.critical") 65 | def test_connection_lost_with_an_actual_error_should_write_to_the_critical_output(mock_critical, sut_protocol): 66 | sut_protocol.connection_lost(Exception("dummy")) 67 | mock_critical.assert_called_once_with("udp bridge lost its connection dummy") 68 | -------------------------------------------------------------------------------- /docs/supported.md: -------------------------------------------------------------------------------- 1 | | Name | Link | Included from version | 2 | |--------------------------|:-----------------------------------:|:---------------------:| 3 | | Switcher V2 | [product][switcher-v2] | 1.x.x | 4 | | Switcher Mini | [product][switcher-mini] | 1.x.x | 5 | | Switcher Touch (V3) | [product][switcher-touch] | 1.x.x | 6 | | Switcher V4 | [product][switcher-v4] | 1.x.x | 7 | | Switcher Power Plug | [product][switcher-power-plug] | 2.x.x | 8 | | Switcher Breeze | [product][switcher-breeze] | 3.x.x | 9 | | Switcher Runner | [product][switcher-runner] | 3.x.x | 10 | | Switcher Runner Mini | [product][switcher-runner-mini] | 3.x.x | 11 | | Switcher Runner S11 | [product][switcher-runner-s11] | 4.x.x | 12 | | Switcher Runner S12 | [product][switcher-runner-s12] | 4.1.x | 13 | | Switcher Light SL01 | [product][switcher-light-sl01] | 4.2.x | 14 | | Switcher Light SL01 Mini | [product][switcher-light-sl01-mini] | 4.2.x | 15 | | Switcher Light SL02 | [product][switcher-light-sl02] | 4.3.x | 16 | | Switcher Light SL02 Mini | [product][switcher-light-sl02-mini] | 4.3.x | 17 | | Switcher Light SL03 | [product][switcher-light-sl03] | 4.4.x | 18 | 19 | !!!note 20 | Newer Switcher devices, such as Runner S11, require a token. Get yours at: https://switcher.co.il/GetKey 21 | 22 | [switcher-v2]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%d7%a1%d7%95%d7%95%d7%99%d7%a6%d7%a8/ 23 | [switcher-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-mini/ 24 | [switcher-touch]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%D7%A1%D7%95%D7%95%D7%99%D7%A6%D7%A8-touch/ 25 | [switcher-v4]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-v4/ 26 | [switcher-power-plug]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/%d7%a1%d7%95%d7%95%d7%99%d7%a6%d7%a8-smart-plug/ 27 | [switcher-breeze]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-breeze/ 28 | [switcher-runner]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-runner/ 29 | [switcher-runner-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-runner-55/ 30 | [switcher-runner-s11]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/runner-lights-s11/ 31 | [switcher-runner-s12]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/runner-lights-s12/ 32 | [switcher-light-sl01]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl01/ 33 | [switcher-light-sl01-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-slmini01/ 34 | [switcher-light-sl02]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl02/ 35 | [switcher-light-sl02-mini]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-slmini02/ 36 | [switcher-light-sl03]: https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/switcher-light-sl03/ 37 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize text files (lf -> crlf) 2 | # * text=auto 3 | 4 | # Language specific 5 | *.py text eol=crlf diff=python 6 | *.pyc text eol=crlf diff=python 7 | *.cs text eol=crlf diff=csharp 8 | *.java text eol=crlf diff=java 9 | 10 | # Documents 11 | *.csv text eol=crlf 12 | *.doc text eol=crlf diff=astextplain 13 | *.docx text eol=crlf diff=astextplain 14 | *.pdf text eol=crlf diff=astextplain 15 | *.rtf text eol=crlf diff=astextplain 16 | 17 | # Documentation 18 | *.markdown text eol=lf 19 | *.md text eol=lf 20 | *.mdtxt text eol=lf 21 | *.mdtext text eol=lf 22 | *.txt text eol=lf 23 | *.rst text eol=lf 24 | CHANGELOG text eol=lf 25 | CONTRIBUTING text eol=lf 26 | *COPYRIGHT* text eol=lf 27 | LICENSE text eol=lf 28 | *README* text eol=lf 29 | 30 | # Configs 31 | *.cnf text eol=lf 32 | *.conf text eol=lf 33 | *.config text eol=lf 34 | *.lock binary 35 | *.npmignore text eol=lf 36 | *.properties text eol=lf 37 | *.toml text eol=lf 38 | *.yaml text eol=lf 39 | .yamllint text eol=lf 40 | *.yml text eol=lf 41 | .editorconfig text eol=lf 42 | .env text eol=lf 43 | package-lock.json binary 44 | Makefile text eol=lf 45 | makefile text eol=lf 46 | 47 | # Graphics 48 | *.bmp binary 49 | *.gif binary 50 | *.gifv binary 51 | *.jpg binary 52 | *.jpeg binary 53 | *.ico binary 54 | *.png binary 55 | *.svg text eol=lf 56 | *.svgz binary 57 | *.tif binary 58 | *.tiff binary 59 | *.wbmp binary 60 | *.webp binary 61 | 62 | # Scripts 63 | *.bash text eol=lf 64 | *.sh text eol=lf 65 | *.sql text eol=lf 66 | 67 | # Windows 68 | *.bat text eol=crlf 69 | *.cmd text eol=crlf 70 | *.ps1 text eol=crlf 71 | 72 | # Archives 73 | *.7z binary 74 | *.gz binary 75 | *.tar binary 76 | *.zip binary 77 | 78 | # Docker 79 | *.dockerignore text eol=crlf 80 | Dockerfile text eol=crlf 81 | 82 | # Git 83 | *.gitattributes text eol=lf 84 | .gitignore text eol=lf 85 | 86 | # Web files 87 | *.coffee text eol=lf 88 | *.css text eol=lf diff=css 89 | *.htm text eol=lf diff=html 90 | *.html text eol=lf diff=html 91 | *.ini text eol=lf 92 | *.js text eol=lf 93 | *.json text eol=lf 94 | *.jsp text eol=lf 95 | *.jspf text eol=lf 96 | *.jspx text eol=lf 97 | *.jsx text eol=lf 98 | *.less text eol=lf 99 | *.ls text eol=lf 100 | *.map binary 101 | *.php text eol=lf diff=php 102 | *.scss text eol=lf diff=css 103 | *.xml text eol=lf 104 | *.xhtml text eol=lf diff=html 105 | 106 | # Binary files 107 | *.class binary 108 | *.dll binary 109 | *.ear binary 110 | *.jar binary 111 | *.so binary 112 | *.war binary 113 | *.db binary 114 | *.p binary 115 | *.pkl binary 116 | *.pickle binary 117 | *.pyc binary 118 | *.pyd binary 119 | *.pyo binary 120 | 121 | # Visual studio specific 122 | *.sln text eol=crlf 123 | *.csproj text eol=crlf 124 | *.vbproj text eol=crlf 125 | *.vcxproj text eol=crlf 126 | *.vcproj text eol=crlf 127 | *.dbproj text eol=crlf 128 | *.fsproj text eol=crlf 129 | *.lsproj text eol=crlf 130 | *.wixproj text eol=crlf 131 | *.modelproj text eol=crlf 132 | *.sqlproj text eol=crlf 133 | *.wmaproj text eol=crlf 134 | *.xproj text eol=crlf 135 | *.props text eol=crlf 136 | *.filters text eol=crlf 137 | *.vcxitems text eol=crlf 138 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: aioswitcher 3 | site_url: https://aioswitcher.figenblat.com 4 | site_author: Tomer Figenblat 5 | site_description: PyPi module integrating with various Switcher devices. 6 | 7 | repo_name: TomerFi/aioswitcher 8 | repo_url: https://github.com/TomerFi/aioswitcher 9 | edit_uri: "" 10 | 11 | nav: 12 | - Home: index.md 13 | - Install: install.md 14 | - Usage: 15 | - Bridge: usage_bridge.md 16 | - API: usage_api.md 17 | - Supported devices: supported.md 18 | - Code documentation: codedocs.md 19 | - Helper scripts: scripts.md 20 | 21 | markdown_extensions: 22 | - admonition 23 | - def_list 24 | - pymdownx.betterem 25 | - pymdownx.caret 26 | - pymdownx.critic 27 | - pymdownx.details 28 | - pymdownx.mark 29 | - pymdownx.highlight: 30 | anchor_linenums: true 31 | line_spans: __span 32 | pygments_lang_class: true 33 | - pymdownx.inlinehilite 34 | - pymdownx.magiclink 35 | - pymdownx.smartsymbols 36 | - pymdownx.snippets: 37 | base_path: ["docs"] 38 | - pymdownx.superfences 39 | - pymdownx.tabbed: 40 | alternate_style: true 41 | - tables 42 | - toc: 43 | permalink: ⚓︎ 44 | 45 | extra: 46 | social: 47 | - icon: fontawesome/brands/github 48 | link: https://github.com/TomerFi 49 | name: TomerFi on GitHub 50 | - icon: fontawesome/brands/dev 51 | link: https://dev.to/tomerfi 52 | name: tomerfi on Dev.to 53 | - icon: fontawesome/brands/redhat 54 | link: https://developers.redhat.com/author/tomerfi 55 | name: tomerfi on Red Hat Developer 56 | - icon: fontawesome/brands/linkedin 57 | link: https://www.linkedin.com/in/tomerfi/ 58 | name: tomerfi on LinkedIn 59 | analytics: 60 | provider: google 61 | property: G-8965F1P656 62 | 63 | plugins: 64 | - search 65 | - git-revision-date 66 | - autorefs 67 | - mkdocstrings: 68 | handlers: 69 | python: 70 | options: 71 | filters: 72 | - "!^_" 73 | summary: 74 | attributes: false 75 | functions: true 76 | modules: true 77 | 78 | theme: 79 | name: material 80 | logo: img/logo.png 81 | favicon: img/favicon.ico 82 | font: 83 | code: Fira Code 84 | text: Open Sans 85 | palette: 86 | - media: "(prefers-color-scheme)" 87 | primary: red 88 | toggle: 89 | icon: material/brightness-auto 90 | name: Switch to system preference 91 | - media: "(prefers-color-scheme: light)" 92 | primary: red 93 | scheme: default 94 | toggle: 95 | icon: material/brightness-7 96 | name: Switch to dark mode 97 | - media: "(prefers-color-scheme: dark)" 98 | primary: red 99 | scheme: slate 100 | toggle: 101 | icon: material/brightness-4 102 | name: Switch to light mode 103 | features: 104 | - content.code.annotate 105 | - header.autohide 106 | - navigation.indexes 107 | - navigation.instant 108 | - navigation.instant.progress 109 | - navigation.instant.preview 110 | - navigation.tracking 111 | - navigation.top 112 | - toc.follow 113 | - search.highlight 114 | - search.share 115 | - search.suggest 116 | - content.code.copy 117 | -------------------------------------------------------------------------------- /scripts/get_device_login_key.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | 3 | # Copyright Tomer Figenblat. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Python script for retrieving Switcher device login key.""" 18 | 19 | import socket 20 | import time 21 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 22 | from pprint import PrettyPrinter 23 | 24 | from aioswitcher.bridge import ( 25 | SWITCHER_UDP_PORT_TYPE1, 26 | SWITCHER_UDP_PORT_TYPE1_NEW_VERSION, 27 | SWITCHER_UDP_PORT_TYPE2, 28 | SWITCHER_UDP_PORT_TYPE2_NEW_VERSION, 29 | ) 30 | 31 | printer = PrettyPrinter(indent=4) 32 | 33 | _examples = """example usage: 34 | 35 | python get_device_login_key.py -i "111.222.11.22" -p 10002\n 36 | """ # noqa E501 37 | 38 | parser = ArgumentParser( 39 | description="Get the login key of your Switcher device", 40 | epilog=_examples, 41 | formatter_class=RawDescriptionHelpFormatter, 42 | ) 43 | parser.add_argument( 44 | "-i", 45 | "--ip-address", 46 | required=True, 47 | help="the ip address assigned to the device", 48 | type=str, 49 | ) 50 | possible_ports = [ 51 | SWITCHER_UDP_PORT_TYPE1, 52 | SWITCHER_UDP_PORT_TYPE1_NEW_VERSION, 53 | SWITCHER_UDP_PORT_TYPE2, 54 | SWITCHER_UDP_PORT_TYPE2_NEW_VERSION, 55 | ] 56 | parser.add_argument( 57 | "-p", 58 | "--port", 59 | required=True, 60 | choices=possible_ports, 61 | help="the UDP port of the device", 62 | type=int, 63 | ) 64 | 65 | 66 | def extract_device_key(data: bytes) -> str: 67 | """Get the device_key from the UDP message.""" 68 | return data[40:41].hex() 69 | 70 | 71 | def listen_udp(specific_ip: str, port: int) -> None: 72 | """Listen to UDP and try to extract the device_key.""" 73 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 74 | sock.bind(("", port)) 75 | 76 | start_time = time.time() 77 | while True: 78 | if time.time() - start_time > 2: 79 | print("Stopping the server.") 80 | break 81 | 82 | sock.settimeout(2 - (time.time() - start_time)) 83 | 84 | try: 85 | data, addr = sock.recvfrom(1024) 86 | if addr[0] == specific_ip: 87 | print("Received device key:", extract_device_key(data)) 88 | break 89 | except socket.timeout: 90 | print("No packets received, still waiting...") 91 | 92 | sock.close() 93 | 94 | 95 | def main() -> None: 96 | """Fetch the devices's login key.""" 97 | args = parser.parse_args() 98 | print("ip address: " + args.ip_address) 99 | print("port: " + str(args.port)) 100 | listen_udp(args.ip_address, args.port) 101 | 102 | 103 | if __name__ == "__main__": 104 | main() 105 | -------------------------------------------------------------------------------- /tests/test_bridge.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration UDP bridge module test cases.""" 16 | import socket 17 | from asyncio import sleep 18 | from binascii import unhexlify 19 | from pathlib import Path 20 | from unittest.mock import Mock, patch 21 | 22 | from assertpy import assert_that 23 | from pytest import fixture, mark 24 | 25 | from aioswitcher.bridge import SwitcherBridge 26 | 27 | pytestmark = mark.asyncio 28 | 29 | 30 | @fixture 31 | def mock_callback(): 32 | return Mock() 33 | 34 | 35 | @fixture 36 | def udp_broadcast_server(): 37 | server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 38 | server.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 39 | server.settimeout(0.2) 40 | return server 41 | 42 | 43 | @fixture 44 | def unused_udp_broadcast_port(unused_udp_port_factory): 45 | port = unused_udp_port_factory() 46 | with patch("aioswitcher.bridge.SWITCHER_UDP_BROADCAST_PORTS", [port]): 47 | yield port 48 | 49 | 50 | @patch("logging.Logger.info") 51 | async def test_stopping_before_started_and_establishing_a_connection_should_write_to_the_info_output(mock_info, unused_udp_broadcast_port, mock_callback): 52 | port = unused_udp_broadcast_port 53 | bridge = SwitcherBridge(mock_callback) 54 | assert_that(bridge.is_running).is_false() 55 | await bridge.stop() 56 | mock_info.assert_called_with("udp bridge on port %s not started", port) 57 | 58 | 59 | async def test_bridge_operation_as_a_context_manager(unused_udp_broadcast_port, mock_callback): 60 | async with SwitcherBridge(mock_callback) as bridge: 61 | assert_that(bridge.is_running).is_true() 62 | 63 | 64 | async def test_bridge_start_and_stop_operations(unused_udp_broadcast_port, mock_callback): 65 | bridge = SwitcherBridge(mock_callback) 66 | assert_that(bridge.is_running).is_false() 67 | await bridge.start() 68 | assert_that(bridge.is_running).is_true() 69 | await bridge.stop() 70 | assert_that(bridge.is_running).is_false() 71 | 72 | 73 | async def test_bridge_callback_loading(udp_broadcast_server, unused_udp_broadcast_port, mock_callback, resource_path): 74 | port = unused_udp_broadcast_port 75 | sut_v2_off_datagram = Path(f'{resource_path}_v2_off.txt').read_text().replace('\n', '').encode() 76 | sut_power_plug_off_datagram = Path(f'{resource_path}_power_plug_off.txt').read_text().replace('\n', '').encode() 77 | 78 | async with SwitcherBridge(mock_callback): 79 | udp_broadcast_server.sendto(unhexlify(sut_v2_off_datagram), ("localhost", port)) 80 | await sleep(0.2) 81 | udp_broadcast_server.sendto(unhexlify(sut_power_plug_off_datagram), ("localhost", port)) 82 | await sleep(0.2) 83 | 84 | assert_that(mock_callback.call_count).is_equal_to(2) 85 | -------------------------------------------------------------------------------- /scripts/discover_devices.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | 3 | # Copyright Tomer Figenblat. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Python script for discovering Switcher devices.""" 18 | 19 | import asyncio 20 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 21 | from dataclasses import asdict 22 | from pprint import PrettyPrinter 23 | 24 | from aioswitcher.bridge import SwitcherBridge 25 | from aioswitcher.device import SwitcherBase 26 | 27 | printer = PrettyPrinter(indent=4) 28 | 29 | _examples = """Executing this script will print a serialized version of the discovered Switcher 30 | devices broadcasting on the local network for 60 seconds. 31 | You can change the delay by passing an int argument: discover_devices.py 30 32 | 33 | Note: 34 | WILL PRINT PRIVATE INFO SUCH AS DEVICE ID AND MAC. 35 | 36 | Example output: 37 | Switcher devices broadcast a status message every approximately 4 seconds. This 38 | script listens for these messages and prints a serialized version of the to the 39 | standard output, for example (note the ``device_id`` and ``mac_address`` properties):: 40 | ``` 41 | { 'auto_shutdown': '03:00:00', 42 | 'device_id': 'aaaaaa', 43 | 'device_state': , 44 | 'device_type': )>, 45 | 'electric_current': 0.0, 46 | 'ip_address': '192.168.1.33', 47 | 'last_data_update': datetime.datetime(2021, 6, 13, 11, 11, 44, 883003), 48 | 'mac_address': '12:A1:A2:1A:BC:1A', 49 | 'name': 'My Switcher Boiler', 50 | 'power_consumption': 0, 51 | 'remaining_time': '00:00:00'} 52 | ``` 53 | Print devices for 30 seconds: 54 | python discover_devices.py 30\n 55 | """ # noqa E501 56 | 57 | parser = ArgumentParser( 58 | description="Discover and print info of Switcher devices", 59 | epilog=_examples, 60 | formatter_class=RawDescriptionHelpFormatter, 61 | ) 62 | parser.add_argument( 63 | "delay", 64 | help="number of seconds to run, defaults to 60", 65 | type=int, 66 | nargs="?", 67 | default=60, 68 | ) 69 | 70 | 71 | async def print_devices(delay: int) -> None: 72 | """Run the Switcher bridge and register callback for discovered devices.""" 73 | 74 | def on_device_found_callback(device: SwitcherBase) -> None: 75 | """Use as a callback printing found devices.""" 76 | printer.pprint(asdict(device)) 77 | print() 78 | 79 | async with SwitcherBridge(on_device_found_callback): 80 | await asyncio.sleep(delay) 81 | 82 | 83 | def main() -> None: 84 | """Run the device discovery script.""" 85 | args = parser.parse_args() 86 | 87 | try: 88 | asyncio.run(print_devices(args.delay)) 89 | except KeyboardInterrupt: 90 | exit() 91 | 92 | 93 | if __name__ == "__main__": 94 | main() 95 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socioeconomic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 26 | - Trolling, insulting/derogatory comments, and personal or political attacks 27 | - Public or private harassment 28 | - Publishing others' private information, such as a physical or electronic address, without explicit 29 | permission 30 | - Other conduct which could reasonably be considered inappropriate in a professional setting 31 | 32 | ## Our Responsibilities 33 | 34 | Project maintainers are responsible for clarifying the standards of acceptable 35 | behavior and are expected to take appropriate and fair corrective action in 36 | response to any instances of unacceptable behavior. 37 | 38 | Project maintainers have the right and responsibility to remove, edit, or 39 | reject comments, commits, code, wiki edits, issues, and other contributions 40 | that are not aligned to this Code of Conduct, or to ban temporarily or 41 | permanently any contributor for other behaviors that they deem inappropriate, 42 | threatening, offensive, or harmful. 43 | 44 | ## Scope 45 | 46 | This Code of Conduct applies both within project spaces and in public spaces 47 | when an individual is representing the project or its community. Examples of 48 | representing a project or community include using an official project e-mail 49 | address, posting via an official social media account, or acting as an appointed 50 | representative at an online or offline event. Representation of a project may be 51 | further defined and clarified by project maintainers. 52 | 53 | ## Enforcement 54 | 55 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 56 | reported by contacting the project team at tomer@figenblat.com. All complaints will 57 | be reviewed and investigated and will result in a response that is deemed 58 | necessary and appropriate to the circumstances. The project team is obligated 59 | to maintain confidentiality with regard to the reporter of an incident. 60 | Further details of specific enforcement policies may be posted separately. 61 | 62 | Project maintainers who do not follow or enforce the Code of Conduct in good 63 | faith may face temporary or permanent repercussions as determined by other 64 | members of the project's leadership. 65 | 66 | ## Attribution 67 | 68 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), 69 | version 1.4, available [here](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html). 70 | 71 | For answers to common questions about this code of conduct, see [faq](https://www.contributor-covenant.org/faq). 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aioswitcher" 3 | version = "6.0.4-dev" 4 | description = "PyPi module integrating with various Switcher devices" 5 | readme = "README.md" 6 | requires-python = ">=3.12.0,<4.0" 7 | keywords = [ "home-automation", "smarthome", "switcher" ] 8 | classifiers = [ 9 | "Intended Audience :: Developers", 10 | "Operating System :: OS Independent", 11 | "Topic :: Home Automation", 12 | "Topic :: Software Development :: Libraries :: Python Modules", 13 | "Typing :: Typed" 14 | ] 15 | dependencies = [ "pycryptodome>=3.18.0,<4.0.0", "aiohttp>=3.10.3,<4.0.0" ] 16 | 17 | [project.license] 18 | text = "Apache-2.0" 19 | 20 | [[project.authors]] 21 | name = "Tomer Figenblat" 22 | email = "tomer@figenblat.com" 23 | 24 | [[project.maintainers]] 25 | name = "Shay Levy" 26 | 27 | [[project.maintainers]] 28 | name = "Yogev Bokobza" 29 | 30 | [project.urls] 31 | homepage = "https://pypi.org/project/aioswitcher/" 32 | repository = "https://github.com/tomerfi/aioswitcher" 33 | documentation = "https://aioswitcher.figenblat.com" 34 | 35 | [project.scripts] 36 | control_device = "scripts.control_device:main" 37 | discover_devices = "scripts.discover_devices:main" 38 | get_device_login_key = "scripts.get_device_login_key:main" 39 | validate_token = "scripts.validate_token:main" 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | assertpy = "^1.1" 43 | black = "^24.10.0" 44 | flake8 = "^7.1.1" 45 | flake8-docstrings = "^1.7.0" 46 | Flake8-pyproject = "^1.2.3" 47 | isort = "^5.13.2" 48 | mypy = "^1.14.0" 49 | poethepoet = "^0.32.1" 50 | pytest = "^8.3.4" 51 | pytest-asyncio = "^0.25.0" 52 | pytest-cov = "^6.0.0" 53 | pytest-resource-path = "^1.3.0" 54 | pytest-mockservers = "^0.6.0" 55 | pytest-sugar = "^1.0.0" 56 | pytz = "^2024.2" 57 | time-machine = "^2.16.0" 58 | yamllint = "^1.35.1" 59 | freezegun = "^1.5.1" 60 | 61 | [tool.poetry.group.docs.dependencies] 62 | mkdocs = "^1.6.1" 63 | mkdocs-git-revision-date-plugin = "^0.3.2" 64 | mkdocs-material = "^9.5.49" 65 | mkdocstrings = "^0.27.0" 66 | mkdocstrings-python = "^1.12.2" 67 | poethepoet = "^0.32.1" 68 | 69 | [tool.poe.tasks] 70 | test = "poetry run pytest -v" 71 | test_cov = "poetry run pytest -v --cov --cov-report=term" 72 | test_rep = "poetry run pytest -v --cov --cov-report=xml:coverage.xml --junit-xml junit.xml" 73 | test_pub = "poetry publish --build --repository testpypi" 74 | lint = [ "black", "flake8", "isort", "mypy", "yamllint" ] 75 | black = "poetry run black --check src/ docs/ scripts/" 76 | black_fix = "poetry run black src/ docs/ scripts/" 77 | flake8 = "poetry run flake8 src/ tests/ docs/ scripts/" 78 | isort = "poetry run isort --check-only src/ tests/ docs/ scripts/" 79 | isort_fix = "poetry run isort src/ tests/ docs/ scripts/" 80 | mypy = "poetry run mypy src/ scripts/" 81 | yamllint = "poetry run yamllint --format colored --strict ." 82 | docs_build = "poetry run mkdocs build --strict" 83 | docs_serve = "poetry run mkdocs serve" 84 | 85 | [tool.pytest.ini_options] 86 | asyncio_mode = "strict" 87 | 88 | [tool.mypy] 89 | check_untyped_defs = true 90 | ignore_missing_imports = true 91 | disallow_untyped_calls = true 92 | disallow_untyped_defs = true 93 | disallow_incomplete_defs = true 94 | disallow_untyped_decorators = true 95 | no_implicit_optional = true 96 | warn_redundant_casts = true 97 | warn_unused_ignores = true 98 | warn_no_return = true 99 | warn_return_any = true 100 | warn_unreachable = true 101 | strict_concatenate = true 102 | strict_equality = true 103 | strict = true 104 | 105 | [tool.isort] 106 | profile = "black" 107 | 108 | [tool.flake8] 109 | max-line-length = 88 110 | per-file-ignores = "tests/*.py:E501,D103" 111 | count = true 112 | statistics = true 113 | 114 | [tool.coverage.run] 115 | source = [ "aioswitcher" ] 116 | 117 | [tool.coverage.report] 118 | fail_under = 85 119 | precision = 2 120 | skip_covered = true 121 | 122 | [build-system] 123 | requires = [ "poetry-core>=2.0.0,<3.0.0" ] 124 | build-backend = "poetry.core.masonry.api" 125 | -------------------------------------------------------------------------------- /tests/test_udp_datagram_parsing.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration broadcast message parsing utility functions test cases.""" 16 | 17 | from binascii import unhexlify 18 | from pathlib import Path 19 | 20 | from assertpy import assert_that 21 | from pytest import mark 22 | 23 | from aioswitcher.bridge import DatagramParser 24 | from aioswitcher.device import DeviceState, DeviceType 25 | 26 | 27 | @mark.parametrize("type_suffix, expected_type", [ 28 | ("mini", DeviceType.MINI), 29 | ("power_plug", DeviceType.POWER_PLUG), 30 | ("touch", DeviceType.TOUCH), 31 | ("v2_esp", DeviceType.V2_ESP), 32 | ("v2_qca", DeviceType.V2_QCA), 33 | ("v4", DeviceType.V4), 34 | ]) 35 | def test_datagram_state_off(resource_path, type_suffix, expected_type): 36 | sut_datagram = Path(f'{resource_path}_{type_suffix}.txt').read_text().replace('\n', '').encode() 37 | 38 | sut_parser = DatagramParser(unhexlify(sut_datagram)) 39 | 40 | assert_that(sut_parser.is_switcher_originator()).is_true() 41 | assert_that(sut_parser.get_ip_type1()).is_equal_to("192.168.1.33") 42 | assert_that(sut_parser.get_mac_type1()).is_equal_to("12:A1:A2:1A:BC:1A") 43 | assert_that(sut_parser.get_name()).is_equal_to("My Switcher Boiler") 44 | assert_that(sut_parser.get_device_id()).is_equal_to("aaaaaa") 45 | assert_that(sut_parser.get_device_state()).is_equal_to(DeviceState.OFF) 46 | assert_that(sut_parser.get_device_type()).is_equal_to(expected_type) 47 | assert_that(sut_parser.get_power_consumption()).is_equal_to(0) 48 | if not expected_type == DeviceType.POWER_PLUG: 49 | assert_that(sut_parser.get_remaining()).is_equal_to("00:00:00") 50 | assert_that(sut_parser.get_auto_shutdown()).is_equal_to("03:00:00") 51 | 52 | 53 | @mark.parametrize("type_suffix, expected_type", [ 54 | ("mini", DeviceType.MINI), 55 | ("power_plug", DeviceType.POWER_PLUG), 56 | ("touch", DeviceType.TOUCH), 57 | ("v2_esp", DeviceType.V2_ESP), 58 | ("v2_qca", DeviceType.V2_QCA), 59 | ("v4", DeviceType.V4), 60 | ]) 61 | def test_datagram_state_on(resource_path, type_suffix, expected_type): 62 | sut_datagram = Path(f'{resource_path}_{type_suffix}.txt').read_text().replace('\n', '').encode() 63 | 64 | sut_parser = DatagramParser(unhexlify(sut_datagram)) 65 | 66 | assert_that(sut_parser.is_switcher_originator()).is_true() 67 | assert_that(sut_parser.get_ip_type1()).is_equal_to("192.168.1.33") 68 | assert_that(sut_parser.get_mac_type1()).is_equal_to("12:A1:A2:1A:BC:1A") 69 | assert_that(sut_parser.get_name()).is_equal_to("My Switcher Boiler") 70 | assert_that(sut_parser.get_device_id()).is_equal_to("aaaaaa") 71 | assert_that(sut_parser.get_device_state()).is_equal_to(DeviceState.ON) 72 | assert_that(sut_parser.get_device_type()).is_equal_to(expected_type) 73 | assert_that(sut_parser.get_power_consumption()).is_equal_to(2600) 74 | if not expected_type == DeviceType.POWER_PLUG: 75 | assert_that(sut_parser.get_remaining()).is_equal_to("01:30:00") 76 | assert_that(sut_parser.get_auto_shutdown()).is_equal_to("03:00:00") 77 | 78 | 79 | @mark.parametrize("type_suffix", ["too_short", "wrong_start"]) 80 | def test_a_faulty_datagram(resource_path, type_suffix): 81 | sut_datagram = Path(f'{resource_path}_{type_suffix}.txt').read_text().replace('\n', '').encode() 82 | sut_parser = DatagramParser(unhexlify(sut_datagram)) 83 | assert_that(sut_parser.is_switcher_originator()).is_false() 84 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | title: 8 | description: "Release title defaults to the semantic version" 9 | required: false 10 | bump: 11 | description: "Bump type (major/minor/patch) defaults to auto" 12 | default: "auto" 13 | required: true 14 | 15 | env: 16 | MAIN_PY_VER: "3.13" 17 | 18 | jobs: 19 | release: 20 | runs-on: ubuntu-latest 21 | environment: deployment 22 | name: Build, publish, and release 23 | steps: 24 | - name: Checkout sources 25 | uses: actions/checkout@v6 26 | with: 27 | fetch-depth: 0 28 | ssh-key: ${{ secrets.DEPLOY_KEY }} 29 | 30 | - name: Setup Python 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: ${{ env.MAIN_PY_VER }} 34 | 35 | - name: Cache pip repository 36 | uses: actions/cache@v5 37 | with: 38 | path: ~/.cache/pip 39 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}-${{ env.MAIN_PY_VER }} 40 | 41 | - name: Prepare python environment 42 | run: | 43 | pip install -r requirements.txt 44 | poetry config virtualenvs.create true 45 | poetry config virtualenvs.in-project true 46 | 47 | - name: Cache poetry virtual environment 48 | uses: actions/cache@v5 49 | with: 50 | path: .venv 51 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/pyproject.toml') }}-${{ env.MAIN_PY_VER }} 52 | 53 | - name: Configure git 54 | run: | 55 | git config user.name "${{ github.actor }}" 56 | git config user.email "${{ github.actor }}@users.noreply.github.com" 57 | 58 | - name: Determine version and create changelog 59 | id: bumper 60 | uses: tomerfi/version-bumper-action@2.0.5 61 | with: 62 | bump: '${{ github.event.inputs.bump }}' 63 | 64 | - name: Set new project version 65 | uses: sandstromviktor/toml-editor@2.0.0 66 | with: 67 | file: pyproject.toml 68 | key: project.version 69 | value: ${{ steps.bumper.outputs.next }} 70 | 71 | - name: Commit, tag, and push 72 | run: | 73 | git add pyproject.toml 74 | git commit -m "build: bump version to ${{ steps.bumper.outputs.next }} [skip ci]" 75 | git push 76 | git tag ${{ steps.bumper.outputs.next }} -m "${{ steps.bumper.outputs.next }}" 77 | git push origin ${{ steps.bumper.outputs.next }} 78 | 79 | - name: Verify documentation site build 80 | run: | 81 | poetry lock 82 | poetry install --no-interaction 83 | poetry run poe docs_build 84 | 85 | - name: Publish build to PyPi 86 | run: | 87 | rm -rf ./dist 88 | poetry publish --build --no-interaction -u __token__ -p ${{ secrets.PYPI_TOKEN }} 89 | 90 | - name: Set development project version 91 | uses: sandstromviktor/toml-editor@2.0.0 92 | with: 93 | file: pyproject.toml 94 | key: project.version 95 | value: ${{ steps.bumper.outputs.dev }} 96 | 97 | - name: Commit and push 98 | run: | 99 | git add pyproject.toml 100 | git commit -m "build: bump version to ${{ steps.bumper.outputs.dev }} [skip ci]" 101 | git push 102 | 103 | - name: Create a release name 104 | id: release_name 105 | uses: actions/github-script@v8 106 | with: 107 | script: | 108 | var retval = '${{ steps.bumper.outputs.next }}' 109 | if ('${{ github.event.inputs.title }}') { 110 | retval = retval.concat(' - ${{ github.event.inputs.title }}') 111 | } 112 | core.setOutput('value', retval) 113 | 114 | - name: Create a release 115 | id: gh_release 116 | uses: actions/github-script@v8 117 | with: 118 | github-token: ${{ secrets.RELEASE_PAT }} 119 | script: | 120 | const repo_name = context.payload.repository.full_name 121 | const response = await github.request('POST /repos/' + repo_name + '/releases', { 122 | tag_name: '${{ steps.bumper.outputs.next }}', 123 | name: '${{ steps.release_name.outputs.value }}', 124 | generate_release_notes: true 125 | }) 126 | core.setOutput('html_url', response.data.html_url) 127 | -------------------------------------------------------------------------------- /src/aioswitcher/schedule/parser.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration schedule parser module.""" 16 | 17 | from binascii import hexlify 18 | from dataclasses import dataclass, field 19 | from textwrap import wrap 20 | from typing import Set, final 21 | 22 | from . import Days, ScheduleState, tools 23 | 24 | 25 | @final 26 | @dataclass 27 | class SwitcherSchedule: 28 | """representation of the Switcher schedule slot. 29 | 30 | Args: 31 | schedule_id: the id of the schedule 32 | recurring: is a recurring schedule 33 | days: a set of schedule days, or empty set for non recurring schedules 34 | start_time: the start time of the schedule 35 | end_time: the end time of the schedule 36 | 37 | """ 38 | 39 | schedule_id: str 40 | recurring: bool 41 | days: Set[Days] 42 | start_time: str 43 | end_time: str 44 | duration: str = field(init=False) 45 | display: str = field(init=False) 46 | 47 | def __post_init__(self) -> None: 48 | """Post initialization, set duration and display.""" 49 | self.duration = tools.calc_duration(self.start_time, self.end_time) 50 | self.display = tools.pretty_next_run(self.start_time, self.days) 51 | 52 | def __hash__(self) -> int: 53 | """For usage with set, implementation of the __hash__ magic method.""" 54 | return hash(self.schedule_id) 55 | 56 | def __eq__(self, obj: object) -> bool: 57 | """For usage with set, implementation of the __eq__ magic method.""" 58 | if isinstance(obj, SwitcherSchedule): 59 | return self.schedule_id == obj.schedule_id 60 | return False 61 | 62 | 63 | @final 64 | @dataclass(frozen=True) 65 | class ScheduleParser: 66 | """Schedule parsing tool.""" 67 | 68 | schedule: bytes 69 | 70 | def get_id(self) -> str: 71 | """Return the id of the schedule.""" 72 | return str(int(self.schedule[0:2], 16)) 73 | 74 | def is_enabled(self) -> bool: 75 | """Return true if enbaled.""" 76 | return int(self.schedule[2:4], 16) == 1 77 | 78 | def is_recurring(self) -> bool: 79 | """Return true if a recurring schedule.""" 80 | return self.schedule[4:6] != b"00" 81 | 82 | def get_days(self) -> Set[Days]: 83 | """Retun a set of the scheduled Days.""" 84 | return ( 85 | tools.bit_summary_to_days(int(self.schedule[4:6], 16)) 86 | if self.is_recurring() 87 | else set() 88 | ) 89 | 90 | def get_state(self) -> ScheduleState: 91 | """Return the current state of the device. 92 | 93 | Not sure if this needs to be included in the schedule object. 94 | """ 95 | return ScheduleState(self.schedule[6:8].decode()) 96 | 97 | def get_start_time(self) -> str: 98 | """Return the schedule start time in %H:%M format.""" 99 | return tools.hexadecimale_timestamp_to_localtime(self.schedule[8:16]) 100 | 101 | def get_end_time(self) -> str: 102 | """Return the schedule end time in %H:%M format.""" 103 | return tools.hexadecimale_timestamp_to_localtime(self.schedule[16:24]) 104 | 105 | 106 | def get_schedules(message: bytes) -> Set[SwitcherSchedule]: 107 | """Use to create a list of schedule from a response message from the device.""" 108 | hex_data = hexlify(message)[90:-8].decode() 109 | hex_data_split = wrap(hex_data, 32) 110 | ret_set = set() 111 | for schedule in hex_data_split: 112 | parser = ScheduleParser(schedule.encode()) 113 | ret_set.add( 114 | SwitcherSchedule( 115 | parser.get_id(), 116 | parser.is_recurring(), 117 | parser.get_days(), 118 | parser.get_start_time(), 119 | parser.get_end_time(), 120 | ) 121 | ) 122 | return ret_set 123 | -------------------------------------------------------------------------------- /tests/test_api_messages.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration TCP socket API messages test cases.""" 16 | 17 | from binascii import unhexlify 18 | from pathlib import Path 19 | from unittest.mock import Mock, patch 20 | 21 | from assertpy import assert_that 22 | from pytest import mark 23 | 24 | from aioswitcher.api import messages 25 | from aioswitcher.api.messages import ( 26 | StateMessageParser, 27 | SwitcherBaseResponse, 28 | SwitcherGetSchedulesResponse, 29 | SwitcherLoginResponse, 30 | SwitcherStateResponse, 31 | ) 32 | from aioswitcher.device import DeviceState 33 | 34 | 35 | @mark.parametrize("faulty_response", [b'', bytearray(), None]) 36 | def test_switcher_base_response_with_an_empty_bytes_value_should_return_not_succefull(faulty_response): 37 | assert_that(SwitcherBaseResponse(faulty_response).successful).is_false() 38 | 39 | 40 | def test_switcher_login_response_dataclass(resource_path): 41 | response = Path(f'{resource_path}.txt').read_text().replace('\n', '').encode() 42 | sut = SwitcherLoginResponse(unhexlify(response)) 43 | 44 | assert_that(sut.unparsed_response).is_equal_to(unhexlify(response)) 45 | assert_that(sut.session_id).is_equal_to("f050834e") 46 | 47 | 48 | def test_switcher_login_response_dataclass_without_a_valid_input_will_throw_an_error(): 49 | assert_that(SwitcherLoginResponse).raises( 50 | ValueError 51 | ).when_called_with("this message will generate an excetpion").is_equal_to("failed to parse login response message") 52 | 53 | 54 | @patch.object(StateMessageParser, "get_state", return_value=DeviceState.ON) 55 | @patch.object(StateMessageParser, "get_time_left", return_value="00:45") 56 | @patch.object(StateMessageParser, "get_time_on", return_value="00:45") 57 | @patch.object(StateMessageParser, "get_auto_shutdown", return_value="03:00") 58 | @patch.object(StateMessageParser, "get_power_consumption", return_value=1640) 59 | def test_switcher_state_response_dataclass(get_power_consumption, get_auto_shutdown, get_time_on, get_time_left, get_state): 60 | sut = SwitcherStateResponse(b'moot binary data1') 61 | 62 | assert_that(sut.state).is_equal_to(DeviceState.ON) 63 | assert_that(sut.time_left).is_equal_to("00:45") 64 | assert_that(sut.time_on).is_equal_to("00:45") 65 | assert_that(sut.auto_shutdown).is_equal_to("03:00") 66 | assert_that(sut.power_consumption).is_equal_to(1640) 67 | assert_that(sut.electric_current).is_equal_to(7.5) 68 | 69 | for mock_method in [get_power_consumption, get_auto_shutdown, get_time_on, get_time_left, get_state]: 70 | mock_method.assert_called_once() 71 | 72 | 73 | @patch(messages.__name__ + ".get_schedules", return_value={Mock(), Mock()}) 74 | def test_switcher_get_schedules_response_dataclass_with_two_schedules(get_schedules): 75 | sut = SwitcherGetSchedulesResponse(b'moot binary data2') 76 | 77 | assert_that(sut.found_schedules).is_true() 78 | assert_that(sut.schedules).is_length(2) 79 | for schedule in sut.schedules: 80 | assert_that(schedule).is_instance_of(Mock) 81 | get_schedules.assert_called_once() 82 | 83 | 84 | @patch(messages.__name__ + ".get_schedules", return_value=set()) 85 | def test_switcher_get_schedules_response_dataclass_with_no_schedules(get_schedules): 86 | sut = SwitcherGetSchedulesResponse(b'moot binary data3') 87 | 88 | assert_that(sut.found_schedules).is_false() 89 | assert_that(sut.schedules).is_length(0) 90 | get_schedules.assert_called_once() 91 | 92 | 93 | def test_the_state_message_parser(resource_path): 94 | response = Path(f'{resource_path}_device_off.txt').read_text().replace('\n', '').encode() 95 | sut = StateMessageParser(unhexlify(response)) 96 | 97 | assert_that(sut.get_state()).is_equal_to(DeviceState.OFF) 98 | assert_that(sut.get_time_left()).is_equal_to("00:00:00") 99 | assert_that(sut.get_time_on()).is_equal_to("00:00:00") 100 | assert_that(sut.get_auto_shutdown()).is_equal_to("01:30:00") 101 | assert_that(sut.get_power_consumption()).is_equal_to(0) 102 | -------------------------------------------------------------------------------- /tests/test_api_packet_crc_signing.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration packet crc signing test cases.""" 16 | 17 | from binascii import hexlify 18 | from struct import pack 19 | 20 | from assertpy import assert_that 21 | 22 | from aioswitcher.api import Command, packets 23 | from aioswitcher.device.tools import sign_packet_with_crc_key 24 | 25 | SUT_TIMESTAMP = "ef8db35c" 26 | SUT_SESSION_ID = "01000000" 27 | SUT_DEVICE_ID = "a123bc" 28 | SUT_DEVICE_KEY = "18" 29 | 30 | 31 | def test_sign_packet_with_crc_key_for_a_random_string_throws_error(): 32 | """Test the sign_packet_with_crc_key tool with a random string unqualified as a packet.""" 33 | assert_that(sign_packet_with_crc_key).raises( 34 | ValueError 35 | ).when_called_with("just a regular string").is_equal_to("Odd-length string") 36 | 37 | 38 | def test_sign_packet_with_crc_key_for_LOGIN_PACKET_TYPE1_returns_signed_packet(): 39 | """Test the sign_packet_with_crc_key tool for the LOGIN_PACKET_TYPE1.""" 40 | packet = packets.LOGIN_PACKET_TYPE1.format(SUT_TIMESTAMP, SUT_DEVICE_KEY) 41 | assert_that(sign_packet_with_crc_key(packet)).is_equal_to(packet + "6ddd0cc0") 42 | 43 | 44 | def test_sign_packet_with_crc_key_for_GET_STATE_PACKET_TYPE1_returns_signed_packet(): 45 | """Test the sign_packet_with_crc_key tool for the GET_STATE_PACKET_TYPE1.""" 46 | packet = packets.GET_STATE_PACKET_TYPE1.format(SUT_SESSION_ID, SUT_TIMESTAMP, SUT_DEVICE_ID) 47 | assert_that(sign_packet_with_crc_key(packet)).is_equal_to(packet + "42a9a1b2") 48 | 49 | 50 | def test_sign_packet_with_crc_key_for_send_control_on_with_no_timer_packet_returns_signed_packet(): 51 | """Test the sign_packet_with_crc_key tool for the SEND_CONTROL_PACKET for on state with no timer.""" 52 | packet = packets.SEND_CONTROL_PACKET.format( 53 | SUT_SESSION_ID, SUT_TIMESTAMP, SUT_DEVICE_ID, Command.ON.value, packets.NO_TIMER_REQUESTED) 54 | assert_that(sign_packet_with_crc_key(packet)).is_equal_to(packet + "cc06bb10") 55 | 56 | 57 | def test_sign_packet_with_crc_key_for_send_control_off_packet_returns_signed_packet(): 58 | """Test the sign_packet_with_crc_key tool for the SEND_CONTROL_PACKET for off state.""" 59 | packet = packets.SEND_CONTROL_PACKET.format( 60 | SUT_SESSION_ID, SUT_TIMESTAMP, SUT_DEVICE_ID, Command.OFF.value, packets.NO_TIMER_REQUESTED) 61 | assert_that(sign_packet_with_crc_key(packet)).is_equal_to(packet + "6c432cf4") 62 | 63 | 64 | def test_sign_packet_with_crc_key_for_send_control_on_with_timer_packet_returns_signed_packet(): 65 | """Test the sign_packet_with_crc_key tool for the SEND_CONTROL_PACKET for on state with a 90 minutes timer.""" 66 | timer_minutes = hexlify(pack(" 100 | if [ ${{ matrix.python }} == ${{ env.MAIN_PY_VER }} ]; 101 | then poetry run poe test_rep; 102 | else poetry run poe test; fi 103 | 104 | - name: Report test summary 105 | uses: EnricoMi/publish-unit-test-result-action@v2 106 | if: ${{ matrix.python == env.MAIN_PY_VER && always() }} 107 | with: 108 | test_changes_limit: 0 109 | junit_files: ./junit.xml 110 | report_individual_runs: true 111 | 112 | - name: Push to CodeCov 113 | uses: codecov/codecov-action@v5 114 | if: ${{ matrix.python == env.MAIN_PY_VER }} 115 | with: 116 | token: ${{ secrets.CODECOV_TOKEN }} 117 | files: ./coverage.xml 118 | 119 | docs: 120 | runs-on: ubuntu-latest 121 | needs: [lint] 122 | name: Verify documentation site 123 | permissions: 124 | pull-requests: read 125 | steps: 126 | - name: Source checkout 127 | uses: actions/checkout@v6 128 | 129 | - name: Set up Python 130 | uses: actions/setup-python@v6 131 | with: 132 | python-version: ${{ env.MAIN_PY_VER }} 133 | 134 | - name: Cache pip repository 135 | uses: actions/cache@v5 136 | with: 137 | path: ~/.cache/pip 138 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}-${{ env.MAIN_PY_VER }} 139 | 140 | - name: Prepare python environment 141 | run: | 142 | pip install -r requirements.txt 143 | poetry config virtualenvs.create true 144 | poetry config virtualenvs.in-project true 145 | 146 | - name: Cache poetry virtual environment 147 | uses: actions/cache@v5 148 | with: 149 | path: .venv 150 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/pyproject.toml') }}-${{ env.MAIN_PY_VER }} 151 | 152 | - name: Build documentation site 153 | run: | 154 | poetry lock 155 | poetry install --no-interaction 156 | poetry run poe docs_build 157 | -------------------------------------------------------------------------------- /tests/test_device_enum_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration devices enum helpers test cases.""" 16 | 17 | from assertpy import assert_that 18 | from pytest import mark 19 | 20 | from aioswitcher.device import ( 21 | DeviceCategory, 22 | DeviceState, 23 | DeviceType, 24 | ShutterDirection, 25 | ThermostatFanLevel, 26 | ThermostatMode, 27 | ThermostatSwing, 28 | ) 29 | 30 | 31 | @mark.parametrize( 32 | "sut_type, expected_value, expected_hex_rep, expected_protocol_type, expected_category, expected_token_needed", 33 | [ 34 | (DeviceType.MINI, "Switcher Mini", "030f", 1, DeviceCategory.WATER_HEATER, False), 35 | ( 36 | DeviceType.POWER_PLUG, 37 | "Switcher Power Plug", 38 | "01a8", 39 | 1, 40 | DeviceCategory.POWER_PLUG, 41 | False, 42 | ), 43 | (DeviceType.TOUCH, "Switcher Touch", "030b", 1, DeviceCategory.WATER_HEATER, False), 44 | (DeviceType.V2_ESP, "Switcher V2 (esp)", "01a7", 1, DeviceCategory.WATER_HEATER, False), 45 | ( 46 | DeviceType.V2_QCA, 47 | "Switcher V2 (qualcomm)", 48 | "01a1", 49 | 1, 50 | DeviceCategory.WATER_HEATER, 51 | False, 52 | ), 53 | (DeviceType.V4, "Switcher V4", "0317", 1, DeviceCategory.WATER_HEATER, False), 54 | (DeviceType.BREEZE, "Switcher Breeze", "0e01", 2, DeviceCategory.THERMOSTAT, False), 55 | (DeviceType.RUNNER, "Switcher Runner", "0c01", 2, DeviceCategory.SHUTTER, False), 56 | (DeviceType.RUNNER_MINI, "Switcher Runner Mini", "0c02", 2, DeviceCategory.SHUTTER, False), 57 | (DeviceType.RUNNER_S11, "Switcher Runner S11", "0f01", 2, DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, True), 58 | (DeviceType.RUNNER_S12, "Switcher Runner S12", "0f02", 2, DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, True), 59 | (DeviceType.LIGHT_SL01, "Switcher Light SL01", "0f04", 2, DeviceCategory.LIGHT, True), 60 | (DeviceType.LIGHT_SL01_MINI, "Switcher Light SL01 Mini", "0f07", 2, DeviceCategory.LIGHT, True), 61 | (DeviceType.LIGHT_SL02, "Switcher Light SL02", "0f05", 2, DeviceCategory.LIGHT, True), 62 | (DeviceType.LIGHT_SL02_MINI, "Switcher Light SL02 Mini", "0f08", 2, DeviceCategory.LIGHT, True), 63 | (DeviceType.LIGHT_SL03, "Switcher Light SL03", "0f06", 2, DeviceCategory.LIGHT, True), 64 | ], 65 | ) 66 | def test_the_given_type_custom_properties_are_returning_the_expected_data( 67 | sut_type, expected_value, expected_hex_rep, expected_protocol_type, expected_category, expected_token_needed 68 | ): 69 | assert_that(sut_type.value).is_equal_to(expected_value) 70 | assert_that(sut_type.hex_rep).is_equal_to(expected_hex_rep) 71 | assert_that(sut_type.protocol_type).is_equal_to(expected_protocol_type) 72 | assert_that(sut_type.category).is_equal_to(expected_category) 73 | assert_that(sut_type.token_needed).is_equal_to(expected_token_needed) 74 | 75 | 76 | @mark.parametrize( 77 | "sut_state, expected_value, expected_display", 78 | [(DeviceState.ON, "01", "on"), (DeviceState.OFF, "00", "off")], 79 | ) 80 | def test_the_given_state_custom_properties_are_returning_the_expected_data( 81 | sut_state, expected_value, expected_display 82 | ): 83 | assert_that(sut_state.value).is_equal_to(expected_value) 84 | assert_that(sut_state.display).is_equal_to(expected_display) 85 | 86 | 87 | @mark.parametrize( 88 | "sut_fan_level, expected_value, expected_display", 89 | [(ThermostatFanLevel.AUTO, "0", "auto"), (ThermostatFanLevel.LOW, "1", "low"), (ThermostatFanLevel.MEDIUM, "2", "medium"), (ThermostatFanLevel.HIGH, "3", "high")], 90 | ) 91 | def test_the_given_fan_level_custom_properties_are_returning_the_expected_data( 92 | sut_fan_level, expected_value, expected_display 93 | ): 94 | assert_that(sut_fan_level.value).is_equal_to(expected_value) 95 | assert_that(sut_fan_level.display).is_equal_to(expected_display) 96 | 97 | 98 | @mark.parametrize( 99 | "sut_mode, expected_value, expected_display", 100 | [(ThermostatMode.AUTO, "01", "auto"), (ThermostatMode.DRY, "02", "dry"), (ThermostatMode.FAN, "03", "fan"), (ThermostatMode.COOL, "04", "cool"), (ThermostatMode.HEAT, "05", "heat")], 101 | ) 102 | def test_the_given_thermostat_mode_custom_properties_are_returning_the_expected_data( 103 | sut_mode, expected_value, expected_display 104 | ): 105 | assert_that(sut_mode.value).is_equal_to(expected_value) 106 | assert_that(sut_mode.display).is_equal_to(expected_display) 107 | 108 | 109 | @mark.parametrize( 110 | "sut_swing, expected_value, expected_display", 111 | [(ThermostatSwing.OFF, "0", "off"), (ThermostatSwing.ON, "1", "on")], 112 | ) 113 | def test_the_given_thermostat_swing_custom_properties_are_returning_the_expected_data( 114 | sut_swing, expected_value, expected_display 115 | ): 116 | assert_that(sut_swing.value).is_equal_to(expected_value) 117 | assert_that(sut_swing.display).is_equal_to(expected_display) 118 | 119 | 120 | @mark.parametrize( 121 | "sut_shutter_dir, expected_value, expected_display", 122 | [(ShutterDirection.SHUTTER_STOP, "0000", "stop"), (ShutterDirection.SHUTTER_DOWN, "0001", "down"), (ShutterDirection.SHUTTER_UP, "0100", "up")], 123 | ) 124 | def test_the_given_shutter_direction_custom_properties_are_returning_the_expected_data( 125 | sut_shutter_dir, expected_value, expected_display 126 | ): 127 | assert_that(sut_shutter_dir.value).is_equal_to(expected_value) 128 | assert_that(sut_shutter_dir.display).is_equal_to(expected_display) 129 | -------------------------------------------------------------------------------- /tests/test_schedule_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration schedule parser module test cases.""" 16 | 17 | import time 18 | from binascii import unhexlify 19 | from datetime import datetime, timedelta 20 | from pathlib import Path 21 | 22 | import pytz 23 | from assertpy import assert_that 24 | 25 | from aioswitcher.schedule import Days, ScheduleState 26 | from aioswitcher.schedule.parser import ScheduleParser, SwitcherSchedule, get_schedules 27 | 28 | 29 | def test_switcher_schedule_dataclass_to_verify_the_post_initialization_of_the_dispaly_and_duration(): 30 | sut = SwitcherSchedule("1", False, set(), "13:00", "14:00") 31 | assert_that(sut.duration).is_equal_to("1:00:00") 32 | assert_that(sut.display).is_equal_to("Due today at 13:00") 33 | 34 | 35 | def test_switcher_schedule_dataclass_to_verify_equality_and_hashing(): 36 | sut0 = SwitcherSchedule("0", False, set(), "13:00", "14:00") 37 | sut1 = SwitcherSchedule("1", False, set(), "13:00", "14:00") 38 | assert_that(sut0.__eq__(sut0)).is_true() 39 | assert_that(sut0.__eq__(sut1)).is_false() 40 | assert_that(sut0.__eq__(object())).is_false() 41 | 42 | 43 | def test_schedule_parser_with_a_weekly_recurring_enabled_schedule_data(): 44 | schedule_data = b"01010201e06aa35cf078a35cce0e0000" 45 | sut = ScheduleParser(schedule_data) 46 | assert_that(sut.get_id()).is_equal_to("1") 47 | assert_that(sut.is_enabled()).is_true() 48 | assert_that(sut.is_recurring()).is_true() 49 | assert_that(sut.get_days()).contains_only(Days.MONDAY) 50 | assert_that(sut.get_start_time()).is_equal_to( 51 | local_time_for_time_in_timezone("17:00", "Asia/Jerusalem")) 52 | assert_that(sut.get_end_time()).is_equal_to( 53 | local_time_for_time_in_timezone("18:00", "Asia/Jerusalem")) 54 | assert_that(sut.get_state()).is_equal_to(ScheduleState.ENABLED) 55 | assert_that(sut.schedule).is_equal_to(schedule_data) 56 | 57 | 58 | def test_schedule_parser_with_a_daily_recurring_enabled_schedule_data(): 59 | schedule_data = b"0101fe01e06aa35cf078a35cce0e0000" 60 | sut = ScheduleParser(schedule_data) 61 | assert_that(sut.get_id()).is_equal_to("1") 62 | assert_that(sut.is_enabled()).is_true() 63 | assert_that(sut.is_recurring()).is_true() 64 | assert_that(sut.get_days()).is_equal_to(set(Days)) 65 | assert_that(sut.get_start_time()).is_equal_to( 66 | local_time_for_time_in_timezone("17:00", "Asia/Jerusalem")) 67 | assert_that(sut.get_end_time()).is_equal_to( 68 | local_time_for_time_in_timezone("18:00", "Asia/Jerusalem")) 69 | assert_that(sut.get_state()).is_equal_to(ScheduleState.ENABLED) 70 | assert_that(sut.schedule).is_equal_to(schedule_data) 71 | 72 | 73 | def test_schedule_parser_with_a_partial_daily_recurring_enabled_schedule_data(): 74 | schedule_data = b"0001fc01e871a35cf87fa35cce0e0000" 75 | sut = ScheduleParser(schedule_data) 76 | assert_that(sut.get_id()).is_equal_to("0") 77 | assert_that(sut.is_enabled()).is_true() 78 | assert_that(sut.is_recurring()).is_true() 79 | assert_that(sut.get_days()).contains_only(Days.SUNDAY, Days.SATURDAY, Days.FRIDAY, Days.THURSDAY, Days.TUESDAY, Days.WEDNESDAY) 80 | assert_that(sut.get_start_time()).is_equal_to( 81 | local_time_for_time_in_timezone("17:30", "Asia/Jerusalem")) 82 | assert_that(sut.get_end_time()).is_equal_to( 83 | local_time_for_time_in_timezone("18:30", "Asia/Jerusalem")) 84 | assert_that(sut.get_state()).is_equal_to(ScheduleState.ENABLED) 85 | assert_that(sut.schedule).is_equal_to(schedule_data) 86 | 87 | 88 | def test_schedule_parser_with_a_non_recurring_enabled_schedule_data(): 89 | schedule_data = b"01010001e06aa35cf078a35cce0e0000" 90 | sut = ScheduleParser(schedule_data) 91 | assert_that(sut.get_id()).is_equal_to("1") 92 | assert_that(sut.is_enabled()).is_true() 93 | assert_that(sut.is_recurring()).is_false() 94 | assert_that(sut.get_days()).is_empty() 95 | assert_that(sut.get_start_time()).is_equal_to( 96 | local_time_for_time_in_timezone("17:00", "Asia/Jerusalem")) 97 | assert_that(sut.get_end_time()).is_equal_to( 98 | local_time_for_time_in_timezone("18:00", "Asia/Jerusalem")) 99 | assert_that(sut.get_state()).is_equal_to(ScheduleState.ENABLED) 100 | assert_that(sut.schedule).is_equal_to(schedule_data) 101 | 102 | 103 | def test_get_schedules_with_a_two_schedules_packet(resource_path): 104 | response = Path(f'{resource_path}.txt').read_text().replace('\n', '').encode() 105 | set_of_schedules = get_schedules(unhexlify(response)) 106 | assert_that(set_of_schedules).is_length(2) 107 | for schedule in set_of_schedules: 108 | assert_that(schedule).is_instance_of(SwitcherSchedule) 109 | 110 | 111 | # local_time_for_time_in_timezone is a utility function taking a time and a timezone, 112 | # and returning the local time matching the given time in the given timezone. 113 | # 114 | # Examples, assuming my local timezone is EST: 115 | # local_time_for_time_in_timezone("17:00", "Asia/Jerusalem") will return 10:00 116 | # local_time_for_time_in_timezone("17:00", "Asia/Kolkata") will return 06:30 117 | # local_time_for_time_in_timezone("17:00", "US/Pacific") will return 20:00 118 | # local_time_for_time_in_timezone("17:00", "EST") will return 17:00 119 | def local_time_for_time_in_timezone(val, tz): 120 | local_ts = datetime.now().replace(tzinfo=None, second=0, microsecond=0) 121 | remote_ts = (datetime.now(pytz.timezone(tz)) 122 | .replace(tzinfo=None, second=0, microsecond=0)) 123 | 124 | fmt = "%H:%M" 125 | parsed = time.strptime(val, fmt) 126 | target = timedelta(hours=parsed.tm_hour, minutes=parsed.tm_min) 127 | delta = local_ts - remote_ts 128 | diff = target + timedelta(seconds=delta.total_seconds()) 129 | 130 | return (datetime.min + diff).strftime(fmt) 131 | -------------------------------------------------------------------------------- /src/aioswitcher/schedule/tools.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration schedule module tools.""" 16 | 17 | import time 18 | from binascii import hexlify 19 | from datetime import UTC, datetime, timedelta 20 | from struct import pack 21 | from typing import Set, Union 22 | 23 | from . import Days 24 | 25 | 26 | def pretty_next_run(start_time: str, days: Set[Days] = set()) -> str: 27 | """Create a literal for displaying the next run time. 28 | 29 | Args: 30 | start_time: the start of the schedule in "%H:%M" format, e.g. "17:00". 31 | days: for recurring schedules, a list of days when none, will be today. 32 | 33 | Returns: 34 | A pretty string describing the next due run. 35 | e.g. "Due next Sunday at 17:00". 36 | 37 | """ 38 | if not days: 39 | return f"Due today at {start_time}" 40 | 41 | current_datetime = datetime.now(UTC) 42 | current_weekday = current_datetime.weekday() 43 | 44 | current_time = datetime.strptime( 45 | current_datetime.time().strftime("%H:%M"), "%H:%M" 46 | ).time() 47 | schedule_time = datetime.strptime(start_time, "%H:%M").time() 48 | current_time_plus_one_hour = ( 49 | datetime.combine(datetime.today(), current_time) + timedelta(hours=1) 50 | ).time() 51 | 52 | execution_days = [d.weekday for d in days] 53 | # if scheduled for later on today, return "due today" 54 | if current_weekday in execution_days and ( 55 | current_time < schedule_time or current_time_plus_one_hour >= schedule_time 56 | ): 57 | return f"Due today at {start_time}" 58 | 59 | execution_days.sort() 60 | if current_weekday > execution_days[-1]: 61 | next_exc_day = execution_days[0] 62 | else: 63 | next_exc_day = list(filter(lambda d: d >= current_weekday, execution_days))[0] 64 | 65 | # if next excution day is tomorrow for the current day, or this is the week end 66 | # (today is sunday and tomorrow is monday) return "due tomorrow" 67 | if next_exc_day - 1 == current_weekday or ( 68 | next_exc_day == Days.MONDAY.weekday and current_weekday == Days.SUNDAY.weekday 69 | ): 70 | return f"Due tomorrow at {start_time}" 71 | 72 | # if here, then the scuedle is due some other day this week, return "due at..." 73 | weekdays = dict(map(lambda d: (d.weekday, d), Days)) 74 | return f"Due next {weekdays[next_exc_day].value} at {start_time}" 75 | 76 | 77 | def calc_duration(start_time: str, end_time: str) -> str: 78 | """Use to calculate the delta between two time values formated as %H:%M.""" 79 | start_datetime = datetime.strptime(start_time, "%H:%M") 80 | end_datetime = datetime.strptime(end_time, "%H:%M") 81 | if end_datetime < start_datetime: 82 | end_datetime += timedelta(days=1) 83 | return str(end_datetime - start_datetime) 84 | 85 | 86 | def bit_summary_to_days(sum_weekdays_bit: int) -> Set[Days]: 87 | """Decode a weekdays bit summary to a set of weekdays. 88 | 89 | Args: 90 | sum_weekdays_bit: the sum of all weekdays 91 | 92 | Return: 93 | Set of Weekday members decoded from the summary value. 94 | 95 | Todo: 96 | Should an existing remainder in the sum value throw an error? 97 | E.g. 3 will result in a set of MONDAY and the remainder will be 1. 98 | 99 | """ 100 | if 1 < sum_weekdays_bit < 255: 101 | return_weekdays = set() 102 | weekdays_by_hex = map(lambda w: (w.hex_rep, w), Days) 103 | for weekday_hex in weekdays_by_hex: 104 | if weekday_hex[0] & sum_weekdays_bit != 0: 105 | return_weekdays.add(weekday_hex[1]) 106 | return return_weekdays 107 | raise ValueError("weekdays bit sum should be between 2 and 254") 108 | 109 | 110 | def hexadecimale_timestamp_to_localtime(hex_timestamp: bytes) -> str: 111 | """Decode an hexadecimale timestamp to localtime with the format %H:%M. 112 | 113 | Args: 114 | hex_timestamp: the hexadecimale timestamp. 115 | 116 | Return: 117 | Localtime string with %H:%M format. e.g. "20:30". 118 | """ 119 | hex_time = ( 120 | hex_timestamp[6:8] 121 | + hex_timestamp[4:6] 122 | + hex_timestamp[2:4] 123 | + hex_timestamp[0:2] 124 | ) 125 | int_time = int(hex_time, 16) 126 | local_time = time.localtime(int_time) 127 | return time.strftime("%H:%M", local_time) 128 | 129 | 130 | def weekdays_to_hexadecimal(days: Union[Days, Set[Days]]) -> str: 131 | """Sum the requested weekdays bit representation and return as hexadecimal value. 132 | 133 | Args: 134 | days: the requested Weekday members. 135 | 136 | Return: 137 | Hexadecimale representation of the sum of all requested days. 138 | 139 | """ 140 | if days: 141 | if type(days) is Days: 142 | return "{:02x}".format(days.bit_rep) 143 | elif type(days) is set or len(days) == len(set(days)): # type: ignore 144 | map_to_bits = map(lambda w: w.bit_rep, days) # type: ignore 145 | return "{:02x}".format(int(sum(map_to_bits))) 146 | raise ValueError("no days requested") 147 | 148 | 149 | def time_to_hexadecimal_timestamp(time_value: str) -> str: 150 | """Convert hours and minutes to a timestamp with the current date and encode. 151 | 152 | Args: 153 | time_value: time to convert. e.g. "21:00". 154 | 155 | Return: 156 | Hexadecimal representation of the timestamp. 157 | 158 | """ 159 | tsplit = time_value.split(":") 160 | str_timedate = time.strftime("%d/%m/%Y") + " " + tsplit[0] + ":" + tsplit[1] 161 | struct_timedate = time.strptime(str_timedate, "%d/%m/%Y %H:%M") 162 | timestamp = time.mktime(struct_timedate) 163 | binary_timestamp = pack(" str: 35 | """Convert seconds to iso time. 36 | 37 | Args: 38 | all_seconds: the total number of seconds to convert. 39 | 40 | Return: 41 | A string representing the converted iso time in %H:%M:%S format. 42 | e.g. "02:24:37". 43 | 44 | """ 45 | minutes, seconds = divmod(int(all_seconds), 60) 46 | hours, minutes = divmod(minutes, 60) 47 | 48 | return datetime.time(hour=hours, minute=minutes, second=seconds).isoformat() 49 | 50 | 51 | def sign_packet_with_crc_key(hex_packet: str) -> str: 52 | """Sign the packets with the designated crc key. 53 | 54 | Args: 55 | hex_packet: packet to sign. 56 | 57 | Return: 58 | The calculated and signed packet. 59 | 60 | """ 61 | binary_packet = unhexlify(hex_packet) 62 | binary_packet_crc = pack(">I", crc_hqx(binary_packet, 0x1021)) 63 | hex_packet_crc = hexlify(binary_packet_crc).decode() 64 | hex_packet_crc_sliced = hex_packet_crc[6:8] + hex_packet_crc[4:6] 65 | 66 | binary_key = unhexlify(hex_packet_crc_sliced + "30" * 32) 67 | binary_key_crc = pack(">I", crc_hqx(binary_key, 0x1021)) 68 | hex_key_crc = hexlify(binary_key_crc).decode() 69 | hex_key_crc_sliced = hex_key_crc[6:8] + hex_key_crc[4:6] 70 | 71 | return hex_packet + hex_packet_crc_sliced + hex_key_crc_sliced 72 | 73 | 74 | def minutes_to_hexadecimal_seconds(minutes: int) -> str: 75 | """Encode minutes to an hexadecimal packed as little endian unsigned int. 76 | 77 | Args: 78 | minutes: minutes to encode. 79 | 80 | Return: 81 | Hexadecimal representation of the minutes argument. 82 | 83 | """ 84 | return hexlify(pack(" str: 88 | """Encode timedelta as seconds to an hexadecimal packed as little endian unsigned. 89 | 90 | Args: 91 | full_time: timedelta time between 1 and 24 hours, seconds are ignored. 92 | 93 | Return: 94 | Hexadecimal representation of the seconds built fom the full_time argument. 95 | 96 | """ 97 | minutes = full_time.total_seconds() / 60 98 | hours, minutes = divmod(minutes, 60) 99 | seconds = int(hours) * 3600 + int(minutes) * 60 100 | 101 | if 3599 < seconds < 86341: 102 | return hexlify(pack(" str: 108 | """Encode string device name to an appropriate hexadecimal value. 109 | 110 | Args: 111 | name: the desired name for encoding. 112 | 113 | Return: 114 | Hexadecimal representation of the name argument. 115 | 116 | """ 117 | length = len(name) 118 | if 1 < length < 33: 119 | hex_name = hexlify(name.encode()) 120 | zeros_pad = ("00" * (32 - length)).encode() 121 | return (hex_name + zeros_pad).decode() 122 | raise ValueError("name length can vary from 2 to 32") 123 | 124 | 125 | def current_timestamp_to_hexadecimal() -> str: 126 | """Generate hexadecimal representation of the current timestamp. 127 | 128 | Return: 129 | Hexadecimal representation of the current unix time retrieved by ``time.time``. 130 | 131 | """ 132 | round_timestamp = int(round(time.time())) 133 | binary_timestamp = pack(" float: 139 | """Convert power consumption to watts to electric current in amps.""" 140 | return round((watts / float(220)), 1) 141 | 142 | 143 | def set_message_length(message: str) -> str: 144 | """Set the message length.""" 145 | length = "{:x}".format(len(unhexlify(message + "00000000"))).ljust(4, "0") 146 | return "fef0" + str(length) + message[8:] 147 | 148 | 149 | def convert_token_to_packet(token: str) -> str: 150 | """Convert a token to token packet. 151 | 152 | Args: 153 | token: the token of the user sent by Email 154 | 155 | Return: 156 | Token packet if token is valid, 157 | otherwise empty string or raise error. 158 | 159 | """ 160 | try: 161 | token_key = b"jzNrAOjc%lpg3pVr5cF!5Le06ZgOdWuJ" 162 | encrypted_value = b64decode(bytes(token, "utf-8")) 163 | cipher = AES.new(token_key, AES.MODE_ECB) 164 | decrypted_value = cipher.decrypt(encrypted_value) 165 | unpadded_decrypted_value = unpad(decrypted_value, AES.block_size) 166 | return hexlify(unpadded_decrypted_value).decode() 167 | except (KeyError, ValueError) as ve: 168 | raise RuntimeError("convert token to packet was not successful") from ve 169 | 170 | 171 | async def validate_token(username: str, token: str) -> bool: 172 | """Make an asynchronous API call to validate a Token by username and token.""" 173 | request_url = "https://switcher.co.il/ValidateToken/" 174 | request_data = {"email": username, "token": token} 175 | is_token_valid = False 176 | # Preload the SSL context 177 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 178 | 179 | logger.debug("calling API call for Switcher to validate the token") 180 | 181 | async with aiohttp.ClientSession() as session: 182 | async with session.post( 183 | request_url, data=request_data, ssl=ssl_context 184 | ) as response: 185 | if response.status == 200: 186 | logger.debug("request successful") 187 | try: 188 | response_json = await response.json() 189 | result = response_json.get("result", None) 190 | if result is not None: 191 | is_token_valid = result.lower() == "true" 192 | except aiohttp.ContentTypeError: 193 | logger.debug("response content is not valid JSON") 194 | else: 195 | logger.debug("request failed with status code: %s", response.status) 196 | 197 | return is_token_valid 198 | 199 | 200 | # More info about get_shutter_discovery_packet_index 201 | # and get_light_discovery_packet_index functions 202 | # Those functions return the index of the circuit sub device, 203 | # used in retreving state from the packet. 204 | # Used for Switcher Runners and Switcher Lights 205 | # Runner and Runner Mini: has no lights circuits & one shutter circuits -> 206 | # shutter circuit is 0, get_light_discovery_packet_index would raise an error. 207 | # Runner S11: has two lights circuits & one shutter circuits -> 208 | # Lights circuits are numbered 0 & 1, shutter circuit is 2. 209 | # Runner S12: has one lights circuits & two shutter circuits -> 210 | # Lights circuit is 0, shutter circuits are numbered 1 & 2. 211 | # Light SL01 and Light SL01 Mini: has one lights circuits & has no shutter circuits -> 212 | # Lights circuit is 0, get_shutter_discovery_packet_index would raise an error. 213 | # Light SL02 and Light SL02 Mini: has two lights circuits & has no shutter circuits -> 214 | # Lights circuits are numbered 0 & 1, 215 | # get_shutter_discovery_packet_index would raise an error. 216 | # Light SL03: has three lights circuits & has no shutter circuits -> 217 | # Lights circuits are numbered 0, 1 & 2, 218 | # get_shutter_discovery_packet_index would raise an error. 219 | def get_shutter_discovery_packet_index( 220 | device_type: DeviceType, circuit_number: int 221 | ) -> int: 222 | """Return the correct shutter discovery packet index. 223 | 224 | Used in retriving the shutter position/direction from the packet 225 | (based of device type and circuit number). 226 | """ 227 | if device_type != DeviceType.RUNNER_S12 and circuit_number != 0: 228 | raise ValueError("Invalid circuit number") 229 | if device_type == DeviceType.RUNNER_S12 and circuit_number not in [0, 1]: 230 | raise ValueError("Invalid circuit number") 231 | 232 | if device_type in (DeviceType.RUNNER, DeviceType.RUNNER_MINI): 233 | return 0 234 | elif device_type == DeviceType.RUNNER_S11: 235 | return 2 236 | elif device_type == DeviceType.RUNNER_S12: 237 | return circuit_number + 1 238 | 239 | raise ValueError("only shutters are allowed") 240 | 241 | 242 | def get_light_discovery_packet_index( 243 | device_type: DeviceType, circuit_number: int 244 | ) -> int: 245 | """Return the correct light discovery packet index. 246 | 247 | Used in retriving the light on/off status from the packet 248 | (based of device type and circuit number). 249 | """ 250 | if device_type == DeviceType.LIGHT_SL03: 251 | if circuit_number not in [0, 1, 2]: 252 | raise ValueError("Invalid circuit number") 253 | return circuit_number 254 | if device_type in ( 255 | DeviceType.RUNNER_S11, 256 | DeviceType.LIGHT_SL02, 257 | DeviceType.LIGHT_SL02_MINI, 258 | ): 259 | if circuit_number not in [0, 1]: 260 | raise ValueError("Invalid circuit number") 261 | return circuit_number 262 | if device_type in ( 263 | DeviceType.RUNNER_S12, 264 | DeviceType.LIGHT_SL01, 265 | DeviceType.LIGHT_SL01_MINI, 266 | ): 267 | if circuit_number != 0: 268 | raise ValueError("Invalid circuit number") 269 | return 0 270 | 271 | raise ValueError("only devices that has lights are allowed") 272 | 273 | 274 | def get_shutter_api_packet_index(device_type: DeviceType, circuit_number: int) -> int: 275 | """Return the correct shutter api packet index. 276 | 277 | Used in sending the shutter position/direction with the packet 278 | (based of device type and circuit number). 279 | """ 280 | # We need to convert selected circuit number to actual place in the packet. 281 | # That is why we add + 1 282 | return get_shutter_discovery_packet_index(device_type, circuit_number) + 1 283 | 284 | 285 | def get_light_api_packet_index(device_type: DeviceType, circuit_number: int) -> int: 286 | """Return the correct light api packet index. 287 | 288 | Used in sending the light on/off status with the packet 289 | (based of device type and circuit number). 290 | """ 291 | # We need to convert selected circuit number to actual place in the packet. 292 | # That is why we add + 1 293 | return get_light_discovery_packet_index(device_type, circuit_number) + 1 294 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/aioswitcher/api/messages.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration TCP socket API messages.""" 16 | 17 | from binascii import hexlify, unhexlify 18 | from dataclasses import InitVar, dataclass, field 19 | from typing import Set, final 20 | 21 | from ..device import ( 22 | DeviceState, 23 | DeviceType, 24 | ShutterChildLock, 25 | ShutterDirection, 26 | ThermostatFanLevel, 27 | ThermostatMode, 28 | ThermostatSwing, 29 | ) 30 | from ..device.tools import ( 31 | get_light_discovery_packet_index, 32 | get_shutter_discovery_packet_index, 33 | seconds_to_iso_time, 34 | watts_to_amps, 35 | ) 36 | from ..schedule.parser import SwitcherSchedule, get_schedules 37 | 38 | 39 | @final 40 | @dataclass 41 | class StateMessageParser: 42 | """Use for parsing api messages.""" 43 | 44 | response: InitVar[bytes] 45 | 46 | def __post_init__(self, response: bytes) -> None: 47 | """Post initialization of the parser.""" 48 | self._hex_response = hexlify(response) 49 | 50 | def get_power_consumption(self) -> int: 51 | """Return the current power consumption of the device.""" 52 | hex_power = self._hex_response[154:162] 53 | return int(hex_power[2:4] + hex_power[0:2], 16) 54 | 55 | def get_time_left(self) -> str: 56 | """Return the time left for the device current run.""" 57 | hex_time_left = self._hex_response[178:186] 58 | time_left_seconds = int( 59 | hex_time_left[6:8] 60 | + hex_time_left[4:6] 61 | + hex_time_left[2:4] 62 | + hex_time_left[0:2], 63 | 16, 64 | ) 65 | return seconds_to_iso_time(time_left_seconds) 66 | 67 | def get_time_on(self) -> str: 68 | """Return how long the device has been on.""" 69 | hex_time_on = self._hex_response[186:194] 70 | time_on_seconds = int( 71 | hex_time_on[6:8] + hex_time_on[4:6] + hex_time_on[2:4] + hex_time_on[0:2], 72 | 16, 73 | ) 74 | return seconds_to_iso_time(time_on_seconds) 75 | 76 | def get_auto_shutdown(self) -> str: 77 | """Return the value of the auto shutdown configuration.""" 78 | hex_auto_off = self._hex_response[194:202] 79 | auto_off_seconds = int( 80 | hex_auto_off[6:8] 81 | + hex_auto_off[4:6] 82 | + hex_auto_off[2:4] 83 | + hex_auto_off[0:2], 84 | 16, 85 | ) 86 | return seconds_to_iso_time(auto_off_seconds) 87 | 88 | def get_state(self) -> DeviceState: 89 | """Return the current device state.""" 90 | hex_state = self._hex_response[150:152].decode() 91 | states = dict(map(lambda s: (s.value, s), DeviceState)) 92 | return states[hex_state] 93 | 94 | def get_thermostat_state(self) -> DeviceState: 95 | """Return the current thermostat state.""" 96 | hex_power = self._hex_response[156:158].decode() 97 | return DeviceState.OFF if hex_power == DeviceState.OFF.value else DeviceState.ON 98 | 99 | def get_thermostat_mode(self) -> ThermostatMode: 100 | """Return the current thermostat mode.""" 101 | hex_mode = self._hex_response[158:160] 102 | modes = dict(map(lambda s: (s.value, s), ThermostatMode)) 103 | try: 104 | return modes[hex_mode.decode()] 105 | except KeyError: 106 | return ThermostatMode.COOL 107 | 108 | def get_thermostat_temp(self) -> float: 109 | """Return the current temp of the thermostat.""" 110 | return int(self._hex_response[154:156] + self._hex_response[152:154], 16) / 10 111 | 112 | def get_thermostat_target_temp(self) -> int: 113 | """Return the current temperature of the thermostat.""" 114 | hex_temp = self._hex_response[160:162] 115 | return int(hex_temp, 16) 116 | 117 | def get_thermostat_fan_level(self) -> ThermostatFanLevel: 118 | """Return the current thermostat fan level.""" 119 | hex_level = self._hex_response[162:163].decode() 120 | levels = dict(map(lambda s: (s.value, s), ThermostatFanLevel)) 121 | try: 122 | return levels[hex_level] 123 | except KeyError: 124 | return ThermostatFanLevel.LOW 125 | 126 | def get_thermostat_swing(self) -> ThermostatSwing: 127 | """Return the current thermostat fan swing.""" 128 | hex_swing = self._hex_response[163:164].decode() 129 | return ( 130 | ThermostatSwing.OFF 131 | if hex_swing == ThermostatSwing.OFF.value 132 | else ThermostatSwing.ON 133 | ) 134 | 135 | def get_thermostat_remote_id(self) -> str: 136 | """Return the current thermostat remote.""" 137 | remote_hex = unhexlify(self._hex_response) 138 | return remote_hex[84:92].decode().rstrip("\x00") 139 | 140 | def get_shutter_position(self, index: int) -> int: 141 | """Return the current shutter position.""" 142 | start_index = 152 + (index * 32) 143 | end_index = start_index + 2 144 | hex_pos = self._hex_response[start_index:end_index].decode() 145 | return int(hex_pos, 16) 146 | 147 | def get_shutter_direction(self, index: int) -> ShutterDirection: 148 | """Return the current shutter direction.""" 149 | start_index = 156 + (index * 32) 150 | end_index = start_index + 4 151 | hex_dir = self._hex_response[start_index:end_index].decode() 152 | directions = dict(map(lambda s: (s.value, s), ShutterDirection)) 153 | return directions[hex_dir] 154 | 155 | def get_shutter_child_lock(self, index: int) -> ShutterChildLock: 156 | """Return the current shutter child lock.""" 157 | start_index = 154 + (index * 32) 158 | end_index = start_index + 2 159 | hex_pos = self._hex_response[start_index:end_index].decode() 160 | hex_device_state = hex_pos[0:2] 161 | return ( 162 | ShutterChildLock.ON 163 | if hex_device_state == ShutterChildLock.ON.value 164 | else ShutterChildLock.OFF 165 | ) 166 | 167 | def get_light_state(self, index: int) -> DeviceState: 168 | """Return the current light state.""" 169 | start_index = 152 + (index * 32) 170 | end_index = start_index + 2 171 | hex_pos = self._hex_response[start_index:end_index].decode() 172 | hex_device_state = hex_pos[0:2] 173 | return ( 174 | DeviceState.ON 175 | if hex_device_state == DeviceState.ON.value 176 | else DeviceState.OFF 177 | ) 178 | 179 | 180 | @dataclass 181 | class SwitcherBaseResponse: 182 | """Representation of the switcher base response message. 183 | 184 | Applicable for all messages that do no require post initialization. 185 | e.g. not applicable for SwitcherLoginResponse, SwitcherStateResponse, 186 | SwitcherGetScheduleResponse. 187 | 188 | Args: 189 | unparsed_response: the raw response from the device. 190 | 191 | """ 192 | 193 | unparsed_response: bytes 194 | 195 | @property 196 | def successful(self) -> bool: 197 | """Return true if the response is not empty. 198 | 199 | Partially indicating the request was successful. 200 | """ 201 | return self.unparsed_response is not None and len(self.unparsed_response) > 0 202 | 203 | 204 | @final 205 | @dataclass 206 | class SwitcherLoginResponse(SwitcherBaseResponse): 207 | """Representations of the switcher login response message.""" 208 | 209 | session_id: str = field(init=False) 210 | 211 | def __post_init__(self) -> None: 212 | """Post initialization of the response.""" 213 | try: 214 | self.session_id = hexlify(self.unparsed_response)[16:24].decode() 215 | except Exception as exc: 216 | raise ValueError("failed to parse login response message") from exc 217 | 218 | 219 | @final 220 | @dataclass 221 | class SwitcherStateResponse(SwitcherBaseResponse): 222 | """Representation of the switcher state response message.""" 223 | 224 | state: DeviceState = field(init=False) 225 | time_left: str = field(init=False) 226 | time_on: str = field(init=False) 227 | auto_shutdown: str = field(init=False) 228 | power_consumption: int = field(init=False) 229 | electric_current: float = field(init=False) 230 | 231 | def __post_init__(self) -> None: 232 | """Post initialization of the message.""" 233 | parser = StateMessageParser(self.unparsed_response) 234 | 235 | self.state = parser.get_state() 236 | self.time_left = parser.get_time_left() 237 | self.time_on = parser.get_time_on() 238 | self.auto_shutdown = parser.get_auto_shutdown() 239 | self.power_consumption = parser.get_power_consumption() 240 | self.electric_current = watts_to_amps(self.power_consumption) 241 | 242 | 243 | @final 244 | @dataclass 245 | class SwitcherGetSchedulesResponse(SwitcherBaseResponse): 246 | """Representation of the switcher get schedule message.""" 247 | 248 | schedules: Set[SwitcherSchedule] = field(init=False) 249 | 250 | def __post_init__(self) -> None: 251 | """Post initialization of the message.""" 252 | self.schedules = get_schedules(self.unparsed_response) 253 | 254 | @property 255 | def found_schedules(self) -> bool: 256 | """Return true if found schedules in the response.""" 257 | return len(self.schedules) > 0 258 | 259 | 260 | @final 261 | @dataclass 262 | class SwitcherThermostatStateResponse(SwitcherBaseResponse): 263 | """Representation of the Switcher thermostat device state response message.""" 264 | 265 | state: DeviceState = field(init=False) 266 | mode: ThermostatMode = field(init=False) 267 | fan_level: ThermostatFanLevel = field(init=False) 268 | temperature: float = field(init=False) 269 | target_temperature: int = field(init=False) 270 | swing: ThermostatSwing = field(init=False) 271 | remote_id: str = field(init=False) 272 | 273 | def __post_init__(self) -> None: 274 | """Post initialization of the message.""" 275 | parser = StateMessageParser(self.unparsed_response) 276 | 277 | self.state = parser.get_thermostat_state() 278 | self.mode = parser.get_thermostat_mode() 279 | self.fan_level = parser.get_thermostat_fan_level() 280 | self.temperature = parser.get_thermostat_temp() 281 | self.target_temperature = parser.get_thermostat_target_temp() 282 | self.swing = parser.get_thermostat_swing() 283 | self.remote_id = parser.get_thermostat_remote_id() 284 | 285 | 286 | @final 287 | @dataclass 288 | class SwitcherShutterStateResponse(SwitcherBaseResponse): 289 | """Representation of the Switcher shutter devices state response message.""" 290 | 291 | position: int = field(init=False) 292 | direction: ShutterDirection = field(init=False) 293 | child_lock: ShutterChildLock = field(init=False) 294 | device_type: DeviceType 295 | index: int 296 | 297 | def __post_init__(self) -> None: 298 | """Post initialization of the message.""" 299 | parser = StateMessageParser(self.unparsed_response) 300 | index = get_shutter_discovery_packet_index(self.device_type, self.index) 301 | 302 | self.direction = parser.get_shutter_direction(index) 303 | self.position = parser.get_shutter_position(index) 304 | self.child_lock = parser.get_shutter_child_lock(index) 305 | 306 | 307 | @final 308 | @dataclass 309 | class SwitcherLightStateResponse(SwitcherBaseResponse): 310 | """Representation of the Switcher light devices state response message.""" 311 | 312 | state: DeviceState = field(init=False) 313 | device_type: DeviceType 314 | index: int 315 | 316 | def __post_init__(self) -> None: 317 | """Post initialization of the message.""" 318 | parser = StateMessageParser(self.unparsed_response) 319 | index = get_light_discovery_packet_index(self.device_type, self.index) 320 | 321 | self.state = parser.get_light_state(index) 322 | -------------------------------------------------------------------------------- /tests/test_device_dataclasses.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration devices dataclasses test cases.""" 16 | 17 | from dataclasses import dataclass, field 18 | from typing import List 19 | 20 | from assertpy import assert_that 21 | from pytest import fixture, mark 22 | 23 | from aioswitcher.device import ( 24 | DeviceState, 25 | DeviceType, 26 | ShutterChildLock, 27 | ShutterDirection, 28 | SwitcherDualShutterSingleLight, 29 | SwitcherLight, 30 | SwitcherPowerPlug, 31 | SwitcherShutter, 32 | SwitcherSingleShutterDualLight, 33 | SwitcherThermostat, 34 | SwitcherWaterHeater, 35 | ThermostatFanLevel, 36 | ThermostatMode, 37 | ThermostatSwing, 38 | ) 39 | 40 | 41 | @dataclass(frozen=True) 42 | class FakeData: 43 | """Fake data for unit tests.""" 44 | 45 | device_id: str = "aaaaaa" 46 | device_key: str = "18" 47 | ip_address: str = "192.168.1.33" 48 | mac_address: str = "12:A1:A2:1A:BC:1A" 49 | name: str = "My Switcher Boiler" 50 | token_needed: bool = False 51 | power_consumption: int = 2600 52 | electric_current: float = 11.8 53 | remaining_time: str = "01:30:00" 54 | auto_shutdown: str = "03:00:00" 55 | mode: ThermostatMode = ThermostatMode.COOL 56 | fan_level: ThermostatFanLevel = ThermostatFanLevel.HIGH 57 | swing: ThermostatSwing = ThermostatSwing.OFF 58 | target_temperature: int = 24 59 | temperature: float = 26.5 60 | remote_id: str = "ELEC7022" 61 | position: List[int] = field(default_factory=lambda: [50]) 62 | position2: List[int] = field(default_factory=lambda: [50, 50]) 63 | direction: List[ShutterDirection] = field(default_factory=lambda: [ShutterDirection.SHUTTER_STOP]) 64 | direction2: List[ShutterDirection] = field(default_factory=lambda: [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP]) 65 | child_lock: List[ShutterChildLock] = field(default_factory=lambda: [ShutterChildLock.OFF]) 66 | child_lock2: List[ShutterChildLock] = field(default_factory=lambda: [ShutterChildLock.OFF, ShutterChildLock.OFF]) 67 | light: List[DeviceState] = field(default_factory=lambda: [DeviceState.ON]) 68 | light2: List[DeviceState] = field(default_factory=lambda: [DeviceState.ON, DeviceState.ON]) 69 | 70 | 71 | @fixture 72 | def fake_data(): 73 | return FakeData() 74 | 75 | 76 | @mark.parametrize("device_type", [DeviceType.MINI, DeviceType.TOUCH, DeviceType.V2_ESP, DeviceType.V2_QCA, DeviceType.V4]) 77 | def test_given_a_device_of_type_water_heater_when_instantiating_as_a_water_heater_should_be_instatiated_properly(fake_data, device_type): 78 | sut = SwitcherWaterHeater( 79 | device_type, 80 | DeviceState.ON, 81 | fake_data.device_id, 82 | fake_data.device_key, 83 | fake_data.ip_address, 84 | fake_data.mac_address, 85 | fake_data.name, 86 | fake_data.token_needed, 87 | fake_data.power_consumption, 88 | fake_data.electric_current, 89 | fake_data.remaining_time, 90 | fake_data.auto_shutdown, 91 | ) 92 | 93 | assert_that(sut.device_type).is_equal_to(device_type) 94 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 95 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 96 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 97 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 98 | assert_that(sut.name).is_equal_to(fake_data.name) 99 | assert_that(sut.power_consumption).is_equal_to(fake_data.power_consumption) 100 | assert_that(sut.electric_current).is_equal_to(fake_data.electric_current) 101 | assert_that(sut.remaining_time).is_equal_to(fake_data.remaining_time) 102 | assert_that(sut.auto_shutdown).is_equal_to(fake_data.auto_shutdown) 103 | assert_that(sut.auto_off_set).is_equal_to(fake_data.auto_shutdown) 104 | 105 | 106 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_power_plug_should_be_instatiated_properly(fake_data): 107 | sut = SwitcherPowerPlug( 108 | DeviceType.POWER_PLUG, 109 | DeviceState.ON, 110 | fake_data.device_id, 111 | fake_data.device_key, 112 | fake_data.ip_address, 113 | fake_data.mac_address, 114 | fake_data.name, 115 | fake_data.token_needed, 116 | fake_data.power_consumption, 117 | fake_data.electric_current, 118 | ) 119 | 120 | assert_that(sut.device_type).is_equal_to(DeviceType.POWER_PLUG) 121 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 122 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 123 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 124 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 125 | assert_that(sut.name).is_equal_to(fake_data.name) 126 | assert_that(sut.power_consumption).is_equal_to(fake_data.power_consumption) 127 | assert_that(sut.electric_current).is_equal_to(fake_data.electric_current) 128 | 129 | 130 | def test_given_a_device_of_type_thermostat_when_instantiating_as_a_thermostat_should_be_instatiated_properly(fake_data): 131 | sut = SwitcherThermostat( 132 | DeviceType.BREEZE, 133 | DeviceState.ON, 134 | fake_data.device_id, 135 | fake_data.device_key, 136 | fake_data.ip_address, 137 | fake_data.mac_address, 138 | fake_data.name, 139 | fake_data.token_needed, 140 | fake_data.mode, 141 | fake_data.temperature, 142 | fake_data.target_temperature, 143 | fake_data.fan_level, 144 | fake_data.swing, 145 | fake_data.remote_id 146 | ) 147 | 148 | assert_that(sut.device_type).is_equal_to(DeviceType.BREEZE) 149 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 150 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 151 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 152 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 153 | assert_that(sut.name).is_equal_to(fake_data.name) 154 | assert_that(sut.mode).is_equal_to(fake_data.mode) 155 | assert_that(sut.temperature).is_equal_to(fake_data.temperature) 156 | assert_that(sut.target_temperature).is_equal_to(fake_data.target_temperature) 157 | assert_that(sut.fan_level).is_equal_to(fake_data.fan_level) 158 | assert_that(sut.swing).is_equal_to(fake_data.swing) 159 | assert_that(sut.remote_id).is_equal_to(fake_data.remote_id) 160 | 161 | 162 | def test_given_a_device_of_type_shutter_when_instantiating_as_a_shutter_should_be_instatiated_properly(fake_data): 163 | sut = SwitcherShutter( 164 | DeviceType.RUNNER, 165 | DeviceState.ON, 166 | fake_data.device_id, 167 | fake_data.device_key, 168 | fake_data.ip_address, 169 | fake_data.mac_address, 170 | fake_data.name, 171 | fake_data.token_needed, 172 | fake_data.position, 173 | fake_data.direction, 174 | fake_data.child_lock 175 | ) 176 | 177 | assert_that(sut.device_type).is_equal_to(DeviceType.RUNNER) 178 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 179 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 180 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 181 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 182 | assert_that(sut.name).is_equal_to(fake_data.name) 183 | assert_that(sut.position).is_equal_to(fake_data.position) 184 | assert_that(sut.direction).is_equal_to(fake_data.direction) 185 | assert_that(sut.child_lock).is_equal_to(fake_data.child_lock) 186 | 187 | 188 | def test_given_a_device_of_type_single_shutter_dual_light_when_instantiating_as_a_shutter_should_be_instatiated_properly(fake_data): 189 | sut = SwitcherSingleShutterDualLight( 190 | DeviceType.RUNNER_S11, 191 | DeviceState.ON, 192 | fake_data.device_id, 193 | fake_data.device_key, 194 | fake_data.ip_address, 195 | fake_data.mac_address, 196 | fake_data.name, 197 | fake_data.token_needed, 198 | fake_data.position, 199 | fake_data.direction, 200 | fake_data.child_lock, 201 | fake_data.light 202 | ) 203 | 204 | assert_that(sut.device_type).is_equal_to(DeviceType.RUNNER_S11) 205 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 206 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 207 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 208 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 209 | assert_that(sut.name).is_equal_to(fake_data.name) 210 | assert_that(sut.position).is_equal_to(fake_data.position) 211 | assert_that(sut.direction).is_equal_to(fake_data.direction) 212 | assert_that(sut.child_lock).is_equal_to(fake_data.child_lock) 213 | assert_that(sut.light).is_equal_to(fake_data.light) 214 | 215 | 216 | def test_given_a_device_of_type_dual_shutter_single_light_when_instantiating_as_a_shutter_should_be_instatiated_properly(fake_data): 217 | sut = SwitcherDualShutterSingleLight( 218 | DeviceType.RUNNER_S12, 219 | DeviceState.ON, 220 | fake_data.device_id, 221 | fake_data.device_key, 222 | fake_data.ip_address, 223 | fake_data.mac_address, 224 | fake_data.name, 225 | fake_data.token_needed, 226 | fake_data.position, 227 | fake_data.direction, 228 | fake_data.child_lock, 229 | fake_data.light 230 | ) 231 | 232 | assert_that(sut.device_type).is_equal_to(DeviceType.RUNNER_S12) 233 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 234 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 235 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 236 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 237 | assert_that(sut.name).is_equal_to(fake_data.name) 238 | assert_that(sut.position).is_equal_to(fake_data.position) 239 | assert_that(sut.direction).is_equal_to(fake_data.direction) 240 | assert_that(sut.child_lock).is_equal_to(fake_data.child_lock) 241 | assert_that(sut.light).is_equal_to(fake_data.light) 242 | 243 | 244 | def test_given_a_device_of_type_light_when_instantiating_as_a_shutter_should_be_instatiated_properly(fake_data): 245 | sut = SwitcherLight( 246 | DeviceType.LIGHT_SL01, 247 | DeviceState.ON, 248 | fake_data.device_id, 249 | fake_data.device_key, 250 | fake_data.ip_address, 251 | fake_data.mac_address, 252 | fake_data.name, 253 | fake_data.token_needed, 254 | fake_data.light 255 | ) 256 | 257 | assert_that(sut.device_type).is_equal_to(DeviceType.LIGHT_SL01) 258 | assert_that(sut.device_state).is_equal_to(DeviceState.ON) 259 | assert_that(sut.device_id).is_equal_to(fake_data.device_id) 260 | assert_that(sut.ip_address).is_equal_to(fake_data.ip_address) 261 | assert_that(sut.mac_address).is_equal_to(fake_data.mac_address) 262 | assert_that(sut.name).is_equal_to(fake_data.name) 263 | assert_that(sut.light).is_equal_to(fake_data.light) 264 | 265 | 266 | @mark.parametrize("device_type", [DeviceType.MINI, DeviceType.TOUCH, DeviceType.V2_ESP, DeviceType.V2_QCA, DeviceType.V4]) 267 | def test_given_a_device_of_type_water_heater_when_instantiating_as_a_power_plug_should_raise_an_error(fake_data, device_type): 268 | assert_that(SwitcherPowerPlug).raises(ValueError).when_called_with( 269 | device_type, 270 | DeviceState.ON, 271 | fake_data.device_id, 272 | fake_data.device_key, 273 | fake_data.ip_address, 274 | fake_data.mac_address, 275 | fake_data.name, 276 | fake_data.token_needed, 277 | fake_data.power_consumption, 278 | fake_data.electric_current, 279 | ).is_equal_to("only power plugs are allowed") 280 | 281 | 282 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_water_heater_should_raise_an_error(fake_data): 283 | assert_that(SwitcherWaterHeater).raises(ValueError).when_called_with( 284 | DeviceType.POWER_PLUG, 285 | DeviceState.ON, 286 | fake_data.device_id, 287 | fake_data.device_key, 288 | fake_data.ip_address, 289 | fake_data.mac_address, 290 | fake_data.name, 291 | fake_data.token_needed, 292 | fake_data.power_consumption, 293 | fake_data.electric_current, 294 | fake_data.remaining_time, 295 | fake_data.auto_shutdown, 296 | ).is_equal_to("only water heaters are allowed") 297 | 298 | 299 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_thermostatr_should_raise_an_error(fake_data): 300 | assert_that(SwitcherThermostat).raises(ValueError).when_called_with( 301 | DeviceType.POWER_PLUG, 302 | DeviceState.ON, 303 | fake_data.device_id, 304 | fake_data.device_key, 305 | fake_data.ip_address, 306 | fake_data.mac_address, 307 | fake_data.name, 308 | fake_data.token_needed, 309 | fake_data.mode, 310 | fake_data.temperature, 311 | fake_data.target_temperature, 312 | fake_data.fan_level, 313 | fake_data.swing, 314 | fake_data.remote_id 315 | ).is_equal_to("only thermostats are allowed") 316 | 317 | 318 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_shutter_should_raise_an_error(fake_data): 319 | assert_that(SwitcherShutter).raises(ValueError).when_called_with( 320 | DeviceType.POWER_PLUG, 321 | DeviceState.ON, 322 | fake_data.device_id, 323 | fake_data.device_key, 324 | fake_data.ip_address, 325 | fake_data.mac_address, 326 | fake_data.name, 327 | fake_data.token_needed, 328 | fake_data.position, 329 | fake_data.direction, 330 | fake_data.child_lock 331 | ).is_equal_to("only shutters are allowed") 332 | 333 | 334 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_single_shutter_dual_light_should_raise_an_error(fake_data): 335 | assert_that(SwitcherSingleShutterDualLight).raises(ValueError).when_called_with( 336 | DeviceType.POWER_PLUG, 337 | DeviceState.ON, 338 | fake_data.device_id, 339 | fake_data.device_key, 340 | fake_data.ip_address, 341 | fake_data.mac_address, 342 | fake_data.name, 343 | fake_data.token_needed, 344 | fake_data.position, 345 | fake_data.direction, 346 | fake_data.child_lock, 347 | fake_data.light2 348 | ).is_equal_to("only shutters with dual lights are allowed") 349 | 350 | 351 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_dual_shutter_single_light_should_raise_an_error(fake_data): 352 | assert_that(SwitcherDualShutterSingleLight).raises(ValueError).when_called_with( 353 | DeviceType.POWER_PLUG, 354 | DeviceState.ON, 355 | fake_data.device_id, 356 | fake_data.device_key, 357 | fake_data.ip_address, 358 | fake_data.mac_address, 359 | fake_data.name, 360 | fake_data.token_needed, 361 | fake_data.position2, 362 | fake_data.direction2, 363 | fake_data.child_lock2, 364 | fake_data.light 365 | ).is_equal_to("only dual shutters with single lights are allowed") 366 | 367 | 368 | def test_given_a_device_of_type_power_plug_when_instantiating_as_a_light_should_raise_an_error(fake_data): 369 | assert_that(SwitcherLight).raises(ValueError).when_called_with( 370 | DeviceType.POWER_PLUG, 371 | DeviceState.ON, 372 | fake_data.device_id, 373 | fake_data.device_key, 374 | fake_data.ip_address, 375 | fake_data.mac_address, 376 | fake_data.name, 377 | fake_data.token_needed, 378 | fake_data.light 379 | ).is_equal_to("only lights are allowed") 380 | -------------------------------------------------------------------------------- /tests/test_device_tools.py: -------------------------------------------------------------------------------- 1 | # Copyright Tomer Figenblat. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Switcher integration device module tools test cases.""" 16 | 17 | from binascii import unhexlify 18 | from datetime import datetime, timedelta 19 | from struct import unpack 20 | from unittest.mock import AsyncMock, patch 21 | 22 | import pytest 23 | from aiohttp import ClientSession, ContentTypeError 24 | from assertpy import assert_that 25 | from pytest import mark 26 | 27 | from aioswitcher.device import DeviceType, tools 28 | 29 | 30 | def test_seconds_to_iso_time_with_a_valid_seconds_value_should_return_a_time_string(): 31 | assert_that(tools.seconds_to_iso_time(86399)).is_equal_to("23:59:59") 32 | 33 | 34 | def test_seconds_to_iso_time_with_a_nagative_value_should_throw_an_error(): 35 | assert_that(tools.seconds_to_iso_time).raises( 36 | ValueError 37 | ).when_called_with(-1).is_equal_to("hour must be in 0..23") 38 | 39 | 40 | def test_minutes_to_hexadecimal_seconds_with_correct_minutes_should_return_expected_hex_seconds(): 41 | # TODO: replace the equality assertion with an unhexlified unpacked value 42 | hex_sut = tools.minutes_to_hexadecimal_seconds(90) 43 | assert_that(hex_sut).is_equal_to("18150000") 44 | 45 | 46 | def test_minutes_to_hexadecimal_seconds_with_a_negative_value_should_throw_an_error(): 47 | assert_that(tools.minutes_to_hexadecimal_seconds).raises( 48 | Exception 49 | ).when_called_with(-1).is_equal_to("'I' format requires 0 <= number <= 4294967295") 50 | 51 | 52 | def test_timedelta_to_hexadecimal_seconds_with_an_allowed_timedelta_should_return_an_hex_timestamp(): 53 | # TODO: replace the equality assertion with an unhexlified unpacked value 54 | hex_timestamp = tools.timedelta_to_hexadecimal_seconds(timedelta(hours=1, minutes=30)) 55 | assert_that(hex_timestamp).is_equal_to("18150000") 56 | 57 | 58 | @mark.parametrize("out_of_range", [timedelta(minutes=59), timedelta(hours=24)]) 59 | def test_timedelta_to_hexadecimal_seconds_with_an_out_of_range_value_should_throw_an_error(out_of_range): 60 | assert_that(tools.timedelta_to_hexadecimal_seconds).raises( 61 | ValueError 62 | ).when_called_with(out_of_range).starts_with("can only handle 1 to 24 hours") 63 | 64 | 65 | def test_string_to_hexadecimale_device_name_with_a_correct_length_name_should_return_a_right_zero_padded_hex_name(): 66 | str_name = "my device cool name" 67 | hex_name = tools.string_to_hexadecimale_device_name(str_name) 68 | unhexed_name = unhexlify(hex_name.rstrip("0")).decode() 69 | assert_that(unhexed_name).is_equal_to(str_name) 70 | 71 | 72 | @mark.parametrize("unsupported_length_value", ["t", "t" * 33]) 73 | def test_string_to_hexadecimale_device_name_with_an_unsupported_length_value_should_throw_an_error(unsupported_length_value): 74 | assert_that(tools.string_to_hexadecimale_device_name).raises( 75 | ValueError 76 | ).when_called_with(unsupported_length_value).starts_with("name length can vary from 2 to 32") 77 | 78 | 79 | def test_current_timestamp_to_hexadecimal_should_return_the_current_timestamp(): 80 | hex_timestamp = tools.current_timestamp_to_hexadecimal() 81 | 82 | binary_timestamp = unhexlify(hex_timestamp.encode()) 83 | unpacked_timestamp = unpack("