├── 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("