From 979b80a77a02aee286c732d88e7be51992a9eca9 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Tue, 12 Sep 2023 05:57:43 +0200 Subject: [PATCH] 23.09 (#412) --- .flake8 | 5 +- .pre-commit-config.yaml | 4 +- Dockerfile | 4 +- docs/advanced_usage.rst | 14 +- docs/class_reference.rst | 16 +- docs/conf.py | 56 +- docs/configuration.rst | 3 + docs/installation.rst | 2 +- docs/interface_openhab.rst | 45 +- docs/requirements.txt | 8 +- docs/util.rst | 4 +- readme.md | 16 +- requirements_setup.txt | 19 +- requirements_tests.txt | 4 +- run/conf_listen/config.yml | 2 +- run/conf_testing/config.yml | 2 +- .../lib/HABAppTests/openhab_tmp_item.py | 4 +- .../HABAppTests/test_rule/_rest_patcher.py | 41 +- run/conf_testing/logging.yml | 9 +- .../rules/habapp/test_scheduler.py | 12 +- .../rules/openhab/test_habapp_internals.py | 10 +- .../rules/openhab/test_interface.py | 10 +- .../rules/openhab/test_interface_links.py | 54 +- .../rules/openhab/test_item_change.py | 2 +- run/conf_testing/rules/openhab/test_items.py | 10 +- run/conf_testing/rules/openhab/test_links.py | 29 + .../rules/openhab/test_persistence.py | 51 +- .../rules/openhab/test_transformations.py | 17 + run/conf_testing/rules/test_mqtt.py | 12 +- src/HABApp/__check_dependency_packages__.py | 4 +- src/HABApp/__version__.py | 13 +- src/HABApp/config/models/mqtt.py | 2 +- src/HABApp/config/models/openhab.py | 19 +- src/HABApp/core/__init__.py | 5 + src/HABApp/core/asyncio.py | 26 +- src/HABApp/core/connections/__init__.py | 15 + src/HABApp/core/connections/_definitions.py | 33 ++ .../core/connections/base_connection.py | 237 +++++++++ src/HABApp/core/connections/base_plugin.py | 41 ++ .../core/connections/connection_task.py | 31 ++ src/HABApp/core/connections/manager.py | 50 ++ .../core/connections/plugin_callback.py | 71 +++ .../core/connections/plugins/__init__.py | 2 + .../connections/plugins/auto_reconnect.py | 57 ++ .../connections/plugins/state_to_event.py | 28 + .../core/connections/status_transitions.py | 87 +++ src/HABApp/core/const/json.py | 6 + src/HABApp/core/const/loop.py | 10 + src/HABApp/core/const/topics.py | 4 +- src/HABApp/core/events/filter/event.py | 6 +- src/HABApp/core/events/habapp_events.py | 17 + src/HABApp/core/files/file/properties.py | 8 +- src/HABApp/core/files/watcher/file_watcher.py | 11 +- src/HABApp/core/internals/__init__.py | 6 +- src/HABApp/core/internals/context/__init__.py | 2 +- src/HABApp/core/internals/context/context.py | 17 +- .../core/internals/context/get_context.py | 4 +- .../core/internals/event_bus/__init__.py | 2 +- .../core/internals/event_bus/event_bus.py | 3 - .../core/internals/event_bus_listener.py | 4 +- .../core/internals/item_registry/__init__.py | 2 +- .../internals/item_registry/item_registry.py | 11 +- src/HABApp/core/internals/proxy/proxies.py | 10 +- .../core/internals/wrapped_function/base.py | 4 +- .../wrapped_function/wrapped_async.py | 4 +- .../wrapped_function/wrapped_sync.py | 4 +- .../wrapped_function/wrapped_thread.py | 6 +- .../internals/wrapped_function/wrapper.py | 14 +- src/HABApp/core/lib/__init__.py | 3 +- src/HABApp/core/lib/exceptions/__init__.py | 2 +- src/HABApp/core/lib/exceptions/format.py | 16 +- .../core/lib/exceptions/format_frame.py | 3 + .../core/lib/exceptions/format_frame_vars.py | 48 +- src/HABApp/core/lib/priority_list.py | 51 ++ src/HABApp/core/lib/single_task.py | 67 ++- src/HABApp/core/wrapper.py | 3 +- src/HABApp/mqtt/__init__.py | 6 +- src/HABApp/mqtt/connection/__init__.py | 1 + src/HABApp/mqtt/connection/connection.py | 71 +++ src/HABApp/mqtt/connection/handler.py | 79 +++ src/HABApp/mqtt/connection/publish.py | 90 ++++ src/HABApp/mqtt/connection/subscribe.py | 212 ++++++++ src/HABApp/mqtt/interface.py | 1 - src/HABApp/mqtt/interface_async.py | 2 + src/HABApp/mqtt/interface_sync.py | 2 + src/HABApp/mqtt/items/mqtt_item.py | 3 +- src/HABApp/mqtt/items/mqtt_pair_item.py | 2 +- src/HABApp/mqtt/mqtt_connection.py | 193 ------- src/HABApp/mqtt/mqtt_interface.py | 2 +- src/HABApp/mqtt/mqtt_payload.py | 8 +- src/HABApp/openhab/__init__.py | 2 +- src/HABApp/openhab/connection/__init__.py | 2 + src/HABApp/openhab/connection/connection.py | 82 +++ .../openhab/connection/handler/__init__.py | 7 + .../openhab/connection/handler/func_async.py | 422 +++++++++++++++ .../openhab/connection/handler/func_sync.py | 278 ++++++++++ .../openhab/connection/handler/handler.py | 188 +++++++ .../openhab/connection/handler/helper.py | 42 ++ .../openhab/connection/plugins/__init__.py | 9 + .../openhab/connection/plugins/events_sse.py | 77 +++ .../openhab/connection/plugins/load_items.py | 163 ++++++ .../plugins/load_transformations.py | 45 ++ src/HABApp/openhab/connection/plugins/out.py | 133 +++++ .../plugins/overview_things.py} | 39 +- src/HABApp/openhab/connection/plugins/ping.py | 95 ++++ .../plugins/plugin_things/__init__.py | 1 + .../plugins}/plugin_things/_log.py | 0 .../plugins}/plugin_things/cfg_validator.py | 65 ++- .../plugin_things/file_writer/__init__.py | 0 .../plugin_things/file_writer/formatter.py | 0 .../file_writer/formatter_builder.py | 2 +- .../plugin_things/file_writer/writer.py | 2 +- .../plugins}/plugin_things/filters.py | 0 .../plugins}/plugin_things/item_worker.py | 32 +- .../plugins}/plugin_things/plugin_things.py | 43 +- .../plugins}/plugin_things/str_builder.py | 0 .../plugins}/plugin_things/thing_config.py | 4 +- .../plugins}/plugin_things/thing_worker.py | 0 .../connection/plugins/wait_for_restore.py | 51 ++ .../connection/plugins/wait_for_startlevel.py | 74 +++ .../openhab/connection_handler/__init__.py | 1 - .../openhab/connection_handler/func_async.py | 332 ------------ .../openhab/connection_handler/func_sync.py | 313 ----------- .../connection_handler/http_connection.py | 501 ------------------ .../http_connection_waiter.py | 27 - .../openhab/connection_logic/__init__.py | 7 - .../openhab/connection_logic/_plugin.py | 77 --- .../openhab/connection_logic/connection.py | 26 - .../connection_logic/plugin_load_items.py | 153 ------ .../openhab/connection_logic/plugin_ping.py | 118 ----- .../plugin_things/__init__.py | 1 - .../openhab/connection_logic/wait_startup.py | 73 --- .../definitions/helpers/persistence_data.py | 24 +- src/HABApp/openhab/definitions/items.py | 1 + .../openhab/definitions/rest/__init__.py | 11 +- .../openhab/definitions/rest/habapp_data.py | 18 +- src/HABApp/openhab/definitions/rest/items.py | 76 +-- src/HABApp/openhab/definitions/rest/links.py | 20 +- .../openhab/definitions/rest/persistence.py | 25 + src/HABApp/openhab/definitions/rest/root.py | 24 + .../openhab/definitions/rest/systeminfo.py | 26 + src/HABApp/openhab/definitions/rest/things.py | 65 ++- .../definitions/rest/transformations.py | 14 + src/HABApp/openhab/errors.py | 43 +- src/HABApp/openhab/interface.py | 7 - src/HABApp/openhab/interface_async.py | 15 +- src/HABApp/openhab/interface_sync.py | 9 + src/HABApp/openhab/item_to_reg.py | 4 +- src/HABApp/openhab/items/base_item.py | 2 +- src/HABApp/openhab/items/color_item.py | 20 +- src/HABApp/openhab/items/commands.py | 2 +- src/HABApp/openhab/items/contact_item.py | 14 +- src/HABApp/openhab/items/datetime_item.py | 15 +- src/HABApp/openhab/items/dimmer_item.py | 12 +- src/HABApp/openhab/items/group_item.py | 12 +- src/HABApp/openhab/items/image_item.py | 16 +- src/HABApp/openhab/items/number_item.py | 12 +- .../openhab/items/rollershutter_item.py | 12 +- src/HABApp/openhab/items/string_item.py | 24 +- src/HABApp/openhab/items/switch_item.py | 12 +- src/HABApp/openhab/items/thing_item.py | 2 +- src/HABApp/openhab/items/tuple_items.py | 24 +- .../sse_handler.py => process_events.py} | 33 +- .../openhab/transformations/__init__.py | 7 + .../openhab/transformations/_map/__init__.py | 2 + .../openhab/transformations/_map/classes.py | 27 + .../openhab/transformations/_map/registry.py | 55 ++ src/HABApp/openhab/transformations/base.py | 50 ++ src/HABApp/rule/__init__.py | 2 +- src/HABApp/rule/rule.py | 28 +- .../rule/scheduler/habappschedulerview.py | 14 +- src/HABApp/rule/scheduler/jobs.py | 114 ++++ .../rule_manager/benchmark/bench_mqtt.py | 2 +- src/HABApp/rule_manager/rule_manager.py | 13 +- src/HABApp/runtime/runtime.py | 19 +- tests/test_core/test_connections.py | 118 +++++ tests/test_core/test_items/test_item_times.py | 12 +- tests/test_core/test_items/tests_all_items.py | 4 +- tests/test_core/test_wrapped_func.py | 15 + tests/test_mqtt/test_interface.py | 9 + tests/test_mqtt/test_mqtt_connect.py | 20 - tests/test_mqtt/test_retain.py | 10 +- tests/test_mqtt/test_values.py | 6 +- tests/test_openhab/test_conections.py | 10 - .../test_connection/test_connection_waiter.py | 43 -- tests/test_openhab/test_interface_sync.py | 20 +- tests/test_openhab/test_items/test_mapping.py | 25 +- tests/test_openhab/test_items/test_thing.py | 26 +- .../test_plugins/test_load_items.py | 130 +++-- .../test_plugins/test_thing/test_errors.py | 4 +- .../test_thing/test_file_format.py | 6 +- .../test_file_writer/test_builder.py | 2 +- .../test_file_writer/test_formatter.py | 4 +- .../test_file_writer/test_writer.py | 4 +- .../test_plugins/test_thing/test_filter.py | 2 +- .../test_thing/test_str_builder.py | 2 +- .../test_plugins/test_thing/test_thing_cfg.py | 2 +- tests/test_openhab/test_rest/test_grp_func.py | 12 +- tests/test_openhab/test_rest/test_items.py | 48 +- tests/test_openhab/test_rest/test_links.py | 15 +- tests/test_openhab/test_rest/test_things.py | 12 +- .../test_transformations/__init__.py | 0 .../test_transformations/test_base.py | 13 + .../test_transformations/test_map.py | 69 +++ tests/test_rule/test_item_search.py | 8 +- tests/test_rule/test_process.py | 7 + tests/test_rule/test_rule_factory.py | 22 + 207 files changed, 4682 insertions(+), 2618 deletions(-) create mode 100644 run/conf_testing/rules/openhab/test_links.py create mode 100644 run/conf_testing/rules/openhab/test_transformations.py create mode 100644 src/HABApp/core/connections/__init__.py create mode 100644 src/HABApp/core/connections/_definitions.py create mode 100644 src/HABApp/core/connections/base_connection.py create mode 100644 src/HABApp/core/connections/base_plugin.py create mode 100644 src/HABApp/core/connections/connection_task.py create mode 100644 src/HABApp/core/connections/manager.py create mode 100644 src/HABApp/core/connections/plugin_callback.py create mode 100644 src/HABApp/core/connections/plugins/__init__.py create mode 100644 src/HABApp/core/connections/plugins/auto_reconnect.py create mode 100644 src/HABApp/core/connections/plugins/state_to_event.py create mode 100644 src/HABApp/core/connections/status_transitions.py create mode 100644 src/HABApp/core/lib/priority_list.py create mode 100644 src/HABApp/mqtt/connection/__init__.py create mode 100644 src/HABApp/mqtt/connection/connection.py create mode 100644 src/HABApp/mqtt/connection/handler.py create mode 100644 src/HABApp/mqtt/connection/publish.py create mode 100644 src/HABApp/mqtt/connection/subscribe.py delete mode 100644 src/HABApp/mqtt/interface.py create mode 100644 src/HABApp/mqtt/interface_async.py create mode 100644 src/HABApp/mqtt/interface_sync.py delete mode 100644 src/HABApp/mqtt/mqtt_connection.py create mode 100644 src/HABApp/openhab/connection/__init__.py create mode 100644 src/HABApp/openhab/connection/connection.py create mode 100644 src/HABApp/openhab/connection/handler/__init__.py create mode 100644 src/HABApp/openhab/connection/handler/func_async.py create mode 100644 src/HABApp/openhab/connection/handler/func_sync.py create mode 100644 src/HABApp/openhab/connection/handler/handler.py create mode 100644 src/HABApp/openhab/connection/handler/helper.py create mode 100644 src/HABApp/openhab/connection/plugins/__init__.py create mode 100644 src/HABApp/openhab/connection/plugins/events_sse.py create mode 100644 src/HABApp/openhab/connection/plugins/load_items.py create mode 100644 src/HABApp/openhab/connection/plugins/load_transformations.py create mode 100644 src/HABApp/openhab/connection/plugins/out.py rename src/HABApp/openhab/{connection_logic/plugin_thing_overview.py => connection/plugins/overview_things.py} (80%) create mode 100644 src/HABApp/openhab/connection/plugins/ping.py create mode 100644 src/HABApp/openhab/connection/plugins/plugin_things/__init__.py rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/_log.py (100%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/cfg_validator.py (73%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/file_writer/__init__.py (100%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/file_writer/formatter.py (100%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/file_writer/formatter_builder.py (97%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/file_writer/writer.py (97%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/filters.py (100%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/item_worker.py (83%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/plugin_things.py (86%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/str_builder.py (100%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/thing_config.py (96%) rename src/HABApp/openhab/{connection_logic => connection/plugins}/plugin_things/thing_worker.py (100%) create mode 100644 src/HABApp/openhab/connection/plugins/wait_for_restore.py create mode 100644 src/HABApp/openhab/connection/plugins/wait_for_startlevel.py delete mode 100644 src/HABApp/openhab/connection_handler/__init__.py delete mode 100644 src/HABApp/openhab/connection_handler/func_async.py delete mode 100644 src/HABApp/openhab/connection_handler/func_sync.py delete mode 100644 src/HABApp/openhab/connection_handler/http_connection.py delete mode 100644 src/HABApp/openhab/connection_handler/http_connection_waiter.py delete mode 100644 src/HABApp/openhab/connection_logic/__init__.py delete mode 100644 src/HABApp/openhab/connection_logic/_plugin.py delete mode 100644 src/HABApp/openhab/connection_logic/connection.py delete mode 100644 src/HABApp/openhab/connection_logic/plugin_load_items.py delete mode 100644 src/HABApp/openhab/connection_logic/plugin_ping.py delete mode 100644 src/HABApp/openhab/connection_logic/plugin_things/__init__.py delete mode 100644 src/HABApp/openhab/connection_logic/wait_startup.py create mode 100644 src/HABApp/openhab/definitions/rest/persistence.py create mode 100644 src/HABApp/openhab/definitions/rest/root.py create mode 100644 src/HABApp/openhab/definitions/rest/systeminfo.py create mode 100644 src/HABApp/openhab/definitions/rest/transformations.py delete mode 100644 src/HABApp/openhab/interface.py create mode 100644 src/HABApp/openhab/interface_sync.py rename src/HABApp/openhab/{connection_handler/sse_handler.py => process_events.py} (84%) create mode 100644 src/HABApp/openhab/transformations/__init__.py create mode 100644 src/HABApp/openhab/transformations/_map/__init__.py create mode 100644 src/HABApp/openhab/transformations/_map/classes.py create mode 100644 src/HABApp/openhab/transformations/_map/registry.py create mode 100644 src/HABApp/openhab/transformations/base.py create mode 100644 src/HABApp/rule/scheduler/jobs.py create mode 100644 tests/test_core/test_connections.py create mode 100644 tests/test_mqtt/test_interface.py delete mode 100644 tests/test_mqtt/test_mqtt_connect.py delete mode 100644 tests/test_openhab/test_conections.py delete mode 100644 tests/test_openhab/test_connection/test_connection_waiter.py create mode 100644 tests/test_openhab/test_transformations/__init__.py create mode 100644 tests/test_openhab/test_transformations/test_base.py create mode 100644 tests/test_openhab/test_transformations/test_map.py create mode 100644 tests/test_rule/test_rule_factory.py diff --git a/.flake8 b/.flake8 index 8fabb397..0910b731 100644 --- a/.flake8 +++ b/.flake8 @@ -28,6 +28,9 @@ exclude = tests/conftest.py, # the interfaces will throw unused imports - src/HABApp/openhab/interface.py, + src/HABApp/openhab/connection_handler/*, + src/HABApp/openhab/interface_sync.py, src/HABApp/openhab/interface_async.py, + src/HABApp/mqtt/interface_sync.py, + src/HABApp/mqtt/interface_async.py, src/HABApp/rule/interfaces/http_interface.py, diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 258c5c8e..67fcdb42 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - repo: https://github.com/PyCQA/flake8 - rev: '6.0.0' + rev: '6.1.0' hooks: - id: flake8 # additional_dependencies: @@ -35,7 +35,7 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.10.1 hooks: - id: pyupgrade args: ["--py38-plus"] diff --git a/Dockerfile b/Dockerfile index bad8bbe6..77ad757b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10 as buildimage +FROM python:3.11 as buildimage COPY . /tmp/app_install @@ -7,7 +7,7 @@ RUN set -eux; \ cd /tmp/app_install; \ pip wheel --wheel-dir=/root/wheels . -FROM python:3.10 +FROM python:3.11 COPY --from=buildimage /root/wheels /root/wheels COPY container/entrypoint.sh /entrypoint.sh diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst index b521ea42..d6ea1c9c 100644 --- a/docs/advanced_usage.rst +++ b/docs/advanced_usage.rst @@ -175,20 +175,20 @@ Finally, define the HABApp functions to indirectly invoke the actions: def play_local_audio_file(sink_name: str, file_location: str): """ Plays a local audio file on the given audio sink. """ - HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) - HABApp.openhab.interface.send_command(ACTION_AUDIO_LOCAL_FILE_LOCATION_ITEM_NAME, file_location) + HABApp.openhab.interface_sync.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) + HABApp.openhab.interface_sync.send_command(ACTION_AUDIO_LOCAL_FILE_LOCATION_ITEM_NAME, file_location) def play_stream_url(sink_name: str, url: str): """ Plays a stream URL on the given audio sink. """ - HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) - HABApp.openhab.interface.send_command(ACTION_AUDIO_STREAM_URL_ITEM_NAME, url) + HABApp.openhab.interface_sync.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) + HABApp.openhab.interface_sync.send_command(ACTION_AUDIO_STREAM_URL_ITEM_NAME, url) def play_text_to_speech_message(sink_name: str, tts: str): """ Plays a text to speech message on the given audio sink. """ - HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) - HABApp.openhab.interface.send_command(ACTION_TEXT_TO_SPEECH_MESSAGE_ITEM_NAME, tts) + HABApp.openhab.interface_sync.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name) + HABApp.openhab.interface_sync.send_command(ACTION_TEXT_TO_SPEECH_MESSAGE_ITEM_NAME, tts) Mocking openHAB items and events for tests @@ -215,7 +215,7 @@ Add an openHAB mock item to the item registry item = SwitchItem('my_switch', 'ON') HABApp.core.Items.add_item(item) -Remove the mock item from the registry +Remove the mock item from the registry: .. exec_code:: :hide_output: diff --git a/docs/class_reference.rst b/docs/class_reference.rst index 1991faa3..054df370 100644 --- a/docs/class_reference.rst +++ b/docs/class_reference.rst @@ -33,7 +33,7 @@ Scheduler OneTimeJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.OneTimeJob +.. autoclass:: eascheduler.scheduler_view.OneTimeJob :members: :inherited-members: :member-order: groupwise @@ -41,7 +41,7 @@ OneTimeJob CountdownJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.CountdownJob +.. autoclass:: eascheduler.scheduler_view.CountdownJob :members: :inherited-members: :member-order: groupwise @@ -49,7 +49,7 @@ CountdownJob ReoccurringJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.ReoccurringJob +.. autoclass:: eascheduler.scheduler_view.ReoccurringJob :members: :inherited-members: :member-order: groupwise @@ -57,7 +57,7 @@ ReoccurringJob DayOfWeekJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.DayOfWeekJob +.. autoclass:: eascheduler.scheduler_view.DayOfWeekJob :members: :inherited-members: :member-order: groupwise @@ -65,7 +65,7 @@ DayOfWeekJob DawnJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.DawnJob +.. autoclass:: eascheduler.scheduler_view.DawnJob :members: :inherited-members: :member-order: groupwise @@ -73,7 +73,7 @@ DawnJob SunriseJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.SunriseJob +.. autoclass:: eascheduler.scheduler_view.SunriseJob :members: :inherited-members: :member-order: groupwise @@ -81,7 +81,7 @@ SunriseJob SunsetJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.SunsetJob +.. autoclass:: eascheduler.scheduler_view.SunsetJob :members: :inherited-members: :member-order: groupwise @@ -89,7 +89,7 @@ SunsetJob DuskJob """""""""""""""""""""""""""""""""""""" -.. autoclass:: eascheduler.jobs.DuskJob +.. autoclass:: eascheduler.scheduler_view.DuskJob :members: :inherited-members: :member-order: groupwise diff --git a/docs/conf.py b/docs/conf.py index 141f4004..a9847ee9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,25 +6,41 @@ # full list see the documentation: # http://www.sphinx-doc.org/en/master/config -# -- Path setup -------------------------------------------------------------- -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# +import logging import os import re import sys +import sphinx from docutils.nodes import Text, Node +from sphinx.addnodes import desc_signature + +IS_RTD_BUILD = os.environ.get('READTHEDOCS', '-').lower() == 'true' +IS_CI = os.environ.get('CI', '-') == 'true' -# required for autodoc + +# https://www.sphinx-doc.org/en/master/extdev/logging.html +sphinx_logger = sphinx.util.logging.getLogger('post') +logger_lvl = logging.DEBUG if IS_RTD_BUILD or IS_CI else logging.INFO # set level to DEBUG for CI + + +def log(msg: str): + sphinx_logger.log(logger_lvl, f'[POST] {msg:s}') + + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.join(os.path.abspath('..'), 'src')) # -- Project information ----------------------------------------------------- project = 'HABApp' -copyright = '2022, spacemanspiff2007' +copyright = '2023, spacemanspiff2007' author = 'spacemanspiff2007' # The short X.Y version @@ -248,6 +264,8 @@ regex_path = re.compile(r"^\w+Path\('([^']+)'\)") assert regex_path.search('WindowsPath(\'lib\')').group(1) == 'lib' +regex_item = re.compile(r'(class \w+Item)\(.+\)') + # nicer type values TYPE_REPLACEMENTS = { '_MissingType.MISSING': '', @@ -274,9 +292,16 @@ def replace_node_contents(node: Node): replacement = TYPE_REPLACEMENTS.get(node_text) + # https://www.sphinx-doc.org/en/master/extdev/nodes.html + if isinstance(node, desc_signature) and node.attributes.get('fullname', '').endswith('Item'): + log(f'Removing constructor signature of {", ".join(node.attributes["ids"])}') + assert len(node.children) == 3 + signature_node = node.children[2] + signature_node.children = [] + # Replace default value # WindowsPath('config') -> 'config' - if node_text.endswith(')') and (m := regex_path.search(node_text)) is not None: + if replacement is None and node_text.endswith(')') and (m := regex_path.search(node_text)) is not None: replacement = f"'{m.group(1)}'" # # Type hints @@ -305,3 +330,18 @@ def transform_desc(app, domain, objtype: str, contentnode): def setup(app): app.connect('object-description-transform', transform_desc) + + +# -- Options for intersphinx ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html +if IS_RTD_BUILD: + intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None) + } + + +# Don't show warnings for missing python references since these are created via intersphinx during the RTD build +if not IS_RTD_BUILD: + nitpick_ignore_regex.append( + (re.compile(r'py:data|py:class'), re.compile(r'typing\..+')) + ) diff --git a/docs/configuration.rst b/docs/configuration.rst index a96d8d96..09360962 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -80,6 +80,9 @@ Example # Useful for testing rules from another machine. +It's possible to use environment variables and files (e.g. docker secrets) in the configuration. +See `the easyconfig documentation `_ for the exact syntax and examples. + Configuration Reference ====================================== diff --git a/docs/installation.rst b/docs/installation.rst index 690044b9..1b8a30d6 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -262,7 +262,7 @@ Example Dockerfile installing scipy, pandas and numpy libraries: # Install required build dependencies (Optional) apt-get update; \ DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - build-essentials; \ + build-essential; \ # Prepare python packages pip3 wheel \ --wheel-dir=/root/wheels \ diff --git a/docs/interface_openhab.rst b/docs/interface_openhab.rst index 1db6825a..28211ac7 100644 --- a/docs/interface_openhab.rst +++ b/docs/interface_openhab.rst @@ -37,6 +37,14 @@ It can be enabled through the gui in ``settings`` -> ``API Security`` -> ``Allow openHAB item types ************************************** +.. |oh_item_desc_name| replace:: Item name +.. |oh_item_desc_value| replace:: Current item value (or state in openHAB wording) +.. |oh_item_desc_label| replace:: Item label or ``None`` if not configured +.. |oh_item_desc_tags| replace:: Item tags +.. |oh_item_desc_group| replace:: The groups the item is in +.. |oh_item_desc_metadata| replace:: Item metadata + + Description and example ====================================== Items that are created from openHAB inherit all from :class:`~HABApp.openHAB.items.OpenhabItem` and @@ -233,7 +241,7 @@ or through an ``OpenhabItem``. Function parameters ====================================== -.. automodule:: HABApp.openhab.interface +.. automodule:: HABApp.openhab.interface_sync :members: :imported-members: @@ -466,6 +474,41 @@ ItemCommandEventFilter :inherited-members: :member-order: groupwise +************************************** +Transformations +************************************** + +From openHAB 4 on it's possible to use the existing transformations in HABApp. +Transformations are loaded every time when HABApp connects to openHAB. +OpenHAB does not issue an event when the transformations change so in order for HABApp to +pick up the changes either HABApp or openHAB has to be restarted. +Available transformations are logged on connect. + +map +====================================== +The `map transformation `_ is returned as a dict. +If the map transformation is defined with a default the default is used accordingly. + +Example: + +.. exec_code:: + hide_output + + # ------------ hide: start ------------ + from HABApp.openhab.transformations._map.registry import MAP_REGISTRY + MAP_REGISTRY.objs['test.map'] = {'test_key': 'test_value'}, None + MAP_REGISTRY.objs['numbers.map'] = {1: 'test number meaning'}, None + + # ------------ hide: stop ------------- + from HABApp.openhab import transformations + + TEST_MAP = transformations.map['test.map'] # load the transformation, can be used anywhere + print(TEST_MAP['test_key']) # It's a normal dict with keys as str and values as str + + # if all keys or values are numbers they are automatically casted to an int + NUMBERS = transformations.map['numbers.map'] + print(NUMBERS[1]) # Note that the key is an int + ************************************** Textual thing configuration diff --git a/docs/requirements.txt b/docs/requirements.txt index 689f89c2..b3f65219 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ # Packages required to build the documentation -sphinx == 6.2.1 -sphinx-autodoc-typehints == 1.23.0 -sphinx_rtd_theme == 1.2.0 +sphinx == 7.2.5 +sphinx-autodoc-typehints == 1.24.0 +sphinx_rtd_theme == 1.3.0 sphinx-exec-code == 0.10 -autodoc_pydantic == 1.8.0 +autodoc_pydantic == 2.0.1 sphinx-copybutton == 0.5.2 diff --git a/docs/util.rst b/docs/util.rst index 23e3ca14..4e5f04ed 100644 --- a/docs/util.rst +++ b/docs/util.rst @@ -202,7 +202,7 @@ The lights will only turn on after 4 and before 8 and two movement sensors are u from datetime import time from HABApp import Rule - from HABApp.core.events import ValueChangeEvent + from HABApp.core.events import ValueChangeEventFilter from HABApp.openhab.items import SwitchItem, NumberItem from HABApp.util import EventListenerGroup @@ -216,7 +216,7 @@ The lights will only turn on after 4 and before 8 and two movement sensors are u # use a list of items which will be subscribed with the same callback and event self.listeners = EventListenerGroup().add_listener( - [self.sensor_move_1, self.sensor_move_2], self.sensor_changed, ValueChangeEvent) + [self.sensor_move_1, self.sensor_move_2], self.sensor_changed, ValueChangeEventFilter()) self.run.on_every_day(time(4), self.listen_sensors) self.run.on_every_day(time(8), self.sensors_cancel) diff --git a/readme.md b/readme.md index 245981e7..ddcaf195 100644 --- a/readme.md +++ b/readme.md @@ -5,11 +5,10 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/habapp) ![PyPI](https://img.shields.io/pypi/v/HABapp) -[![Downloads](https://pepy.tech/badge/habapp/month)](https://pepy.tech/project/habapp) +[![Downloads](https://static.pepy.tech/badge/habapp/month)](https://pepy.tech/project/habapp) ![Docker Image Version (latest by date)](https://img.shields.io/docker/v/spacemanspiff2007/habapp?label=docker) ![Docker Pulls](https://img.shields.io/docker/pulls/spacemanspiff2007/habapp) - _Easy automation with MQTT and/or openHAB_ @@ -128,6 +127,19 @@ MyOpenhabRule() ``` # Changelog +#### 23.09.0 (2023-XX-XX) +- Switched version number scheme to CalVer (Calendar Versioning): ``YEAR.MONTH.COUNTER`` +- Fail fast when a value instead of a callback is passed to the event listener / scheduler +- Completely removed types and type hints from traceback +- Completely reworked connection logic for openHAB and mqtt +- Added support for transformations +- Updated dependencies: + - Improved performance + - Support for docker secrets and environment variables in the config file +- Support sending scheduler datetimes to an item +- Search in the docs finally works again +- Updated dependencies + #### 1.1.2 (2023-06-19) - Re-added `ItemStateEventFilter` - Improved parsing of `DateTime` values diff --git a/requirements_setup.txt b/requirements_setup.txt index 60a9beaf..cd8247b8 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,19 +1,22 @@ -aiohttp == 3.8.4 -pydantic == 1.10.9 +aiohttp == 3.8.5 +pydantic == 2.3.0 +msgspec == 0.18.2 pendulum == 2.1.2 bidict == 0.22.1 watchdog == 3.0.0 ujson == 5.8.0 -paho-mqtt == 1.6.1 +aiomqtt == 1.2.0 -immutables == 0.19 -eascheduler == 0.1.8 -easyconfig == 0.2.8 +immutables == 0.20 +eascheduler == 0.1.11 +easyconfig == 0.3.0 stack_data == 0.6.2 colorama == 0.4.6 voluptuous == 0.13.1 -typing-extensions == 4.6.3 +typing-extensions == 4.7.1 -aiohttp-sse-client == 0.2.1 +aiohttp-sse-client == 0.2.1 + +javaproperties == 0.8.1 diff --git a/requirements_tests.txt b/requirements_tests.txt index 4399430b..04e181d7 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -7,5 +7,5 @@ # Packages to run source tests # ----------------------------------------------------------------------------- packaging == 23.1 -pytest == 7.3.2 -pytest-asyncio == 0.21.0 +pytest == 7.4.2 +pytest-asyncio == 0.21.1 diff --git a/run/conf_listen/config.yml b/run/conf_listen/config.yml index 68caca6a..cb831b10 100644 --- a/run/conf_listen/config.yml +++ b/run/conf_listen/config.yml @@ -39,7 +39,7 @@ openhab: verify_ssl: true # Check certificates when using https general: listen_only: false - wait_for_openhab: true + wait_for_openhab: false ping: enabled: false interval: 30 diff --git a/run/conf_testing/config.yml b/run/conf_testing/config.yml index 53d7490c..ac30233d 100644 --- a/run/conf_testing/config.yml +++ b/run/conf_testing/config.yml @@ -13,7 +13,7 @@ location: mqtt: connection: client_id: HABAppTesting - host: localhost + host: 'localhost' port: 1883 user: '' password: '' diff --git a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py index 6fec971b..48574966 100644 --- a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -47,12 +47,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.remove() def remove(self): - HABApp.openhab.interface.remove_item(self.name) + HABApp.openhab.interface_sync.remove_item(self.name) def _create(self, label="", category="", tags: List[str] = [], groups: List[str] = [], group_type: str = '', group_function: str = '', group_function_params: List[str] = []): - interface = HABApp.openhab.interface + interface = HABApp.openhab.interface_sync interface.create_item(self.type, self.name, label=label, category=category, tags=tags, groups=groups, group_type=group_type, group_function=group_function, group_function_params=group_function_params) diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py index 382699d3..46b74004 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py @@ -2,12 +2,12 @@ import logging import pprint -import HABApp.openhab.connection_handler.http_connection -import HABApp.openhab.connection_logic.connection -from HABApp.config import CONFIG +from pytest import MonkeyPatch -FUNC_PATH = HABApp.openhab.connection_handler.func_async -SSE_PATH = HABApp.openhab.connection_handler.sse_handler +import HABApp.openhab.connection.handler +import HABApp.openhab.connection.handler.func_async +import HABApp.openhab.process_events +from HABApp.config import CONFIG def shorten_url(url: str): @@ -24,6 +24,8 @@ def __init__(self, name: str): self.logged_name = False self._log = logging.getLogger('HABApp.Rest') + self.monkeypatch = MonkeyPatch() + def log(self, msg: str): # Log name when we log the first message if not self.logged_name: @@ -86,25 +88,22 @@ def new_call(_dict): return new_call def __enter__(self): - self._get = FUNC_PATH.get - self._put = FUNC_PATH.put - self._post = FUNC_PATH.post - self._delete = FUNC_PATH.delete + m = self.monkeypatch - self._sse = SSE_PATH.get_event + # event handler + module = HABApp.openhab.process_events + m.setattr(module, 'get_event', self.wrap_sse(module.get_event)) - FUNC_PATH.get = self.wrap(self._get) - FUNC_PATH.put = self.wrap(self._put) - FUNC_PATH.post = self.wrap(self._post) - FUNC_PATH.delete = self.wrap(self._delete) + # http functions + for module in (HABApp.openhab.connection.handler, HABApp.openhab.connection.handler.func_async,): + for name in ('get', 'put', 'post', 'delete'): + m.setattr(module, name, self.wrap(getattr(module, name))) - SSE_PATH.get_event = self.wrap_sse(self._sse) + # additional communication + module = HABApp.openhab.connection.plugins.out + m.setattr(module, 'put', self.wrap(getattr(module, 'put'))) + m.setattr(module, 'post', self.wrap(getattr(module, 'post'))) def __exit__(self, exc_type, exc_val, exc_tb): - FUNC_PATH.get = self._get - FUNC_PATH.put = self._put - FUNC_PATH.post = self._post - FUNC_PATH.delete = self._delete - - SSE_PATH.get_event = self._sse + self.monkeypatch.undo() return False diff --git a/run/conf_testing/logging.yml b/run/conf_testing/logging.yml index 37718410..079dcc1e 100644 --- a/run/conf_testing/logging.yml +++ b/run/conf_testing/logging.yml @@ -42,13 +42,6 @@ handlers: formatter: HABApp_REST level: DEBUG - BufferEventFile: - class: logging.handlers.MemoryHandler - capacity: 10 - formatter: HABApp_format - target: EventFile - level: DEBUG - loggers: HABApp: @@ -72,7 +65,7 @@ loggers: HABApp.EventBus: level: DEBUG handlers: - - BufferEventFile + - EventFile propagate: False HABApp.Tests: diff --git a/run/conf_testing/rules/habapp/test_scheduler.py b/run/conf_testing/rules/habapp/test_scheduler.py index d8afb5e7..2230fdb2 100644 --- a/run/conf_testing/rules/habapp/test_scheduler.py +++ b/run/conf_testing/rules/habapp/test_scheduler.py @@ -1,7 +1,9 @@ import logging import time -from HABAppTests import TestBaseRule +from HABApp.core.events import ValueUpdateEventFilter +from HABApp.core.items import Item +from HABAppTests import TestBaseRule, get_random_name log = logging.getLogger('HABApp.TestParameterFiles') @@ -20,6 +22,10 @@ def __init__(self): f = self.run.on_sunset(print, 'sunset') print(f'Sunset : {f.get_next_run()}') + self.item = Item.get_create_item(get_random_name('HABApp')) + self.item.listen_event(lambda x: self.item_states.append(x), ValueUpdateEventFilter()) + self.item_states = [] + def test_scheduler_every(self): executions = 5 @@ -29,6 +35,8 @@ def called(): calls.append(time.time()) job = self.run.every(None, 0.5, called) + job.to_item(self.item) + try: started = time.time() while time.time() - started < 7: @@ -46,5 +54,7 @@ def called(): finally: job.cancel() + assert len(self.item_states) == 6 + TestScheduler() diff --git a/run/conf_testing/rules/openhab/test_habapp_internals.py b/run/conf_testing/rules/openhab/test_habapp_internals.py index 7aa9585c..72022e98 100644 --- a/run/conf_testing/rules/openhab/test_habapp_internals.py +++ b/run/conf_testing/rules/openhab/test_habapp_internals.py @@ -1,4 +1,4 @@ -from HABApp.openhab.connection_handler.func_async import async_get_item_with_habapp_meta, async_set_habapp_metadata, \ +from HABApp.openhab.connection.handler.func_async import async_get_item_with_habapp_meta, async_set_habapp_metadata, \ async_remove_habapp_metadata from HABApp.openhab.definitions.rest.habapp_data import HABAppThingPluginData from HABAppTests import TestBaseRule, OpenhabTmpItem, run_coro @@ -13,13 +13,13 @@ def __init__(self): def create_meta(self): with OpenhabTmpItem('String') as tmpitem: d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) - assert d['metadata']['HABApp'] is None + assert d.metadata['HABApp'] is None # create empty set run_coro(async_set_habapp_metadata(tmpitem.name, HABAppThingPluginData())) d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) - assert isinstance(d['metadata']['HABApp'], HABAppThingPluginData) + assert isinstance(d.metadata['HABApp'], HABAppThingPluginData) # create valid data run_coro(async_set_habapp_metadata( @@ -27,7 +27,7 @@ def create_meta(self): ) d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) - d = d['metadata']['HABApp'] + d = d.metadata['HABApp'] assert isinstance(d, HABAppThingPluginData) assert d.created_link == 'asdf' assert d.created_ns == ['a', 'b'] @@ -35,7 +35,7 @@ def create_meta(self): # remove metadata again run_coro(async_remove_habapp_metadata(tmpitem.name)) d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) - assert d['metadata']['HABApp'] is None + assert d.metadata['HABApp'] is None OpenhabMetaData() diff --git a/run/conf_testing/rules/openhab/test_interface.py b/run/conf_testing/rules/openhab/test_interface.py index 992ea80e..ac9564ea 100644 --- a/run/conf_testing/rules/openhab/test_interface.py +++ b/run/conf_testing/rules/openhab/test_interface.py @@ -45,7 +45,7 @@ def test_item_create_delete(self): test_defs = [] for type in get_openhab_test_types(): test_defs.append((type, get_random_name(type))) - test_defs.append(('Number', 'HABApp_Ping')) + # test_defs.append(('Number', 'HABApp_Ping')) for item_type, item_name in test_defs: assert not self.openhab.item_exists(item_name) @@ -120,13 +120,7 @@ def test_umlaute(self, item: OpenhabTmpItem): def test_openhab_item_not_found(self): test_item = get_random_name('String') - try: - self.openhab.get_item(test_item) - except Exception as e: - if isinstance(e, HABApp.openhab.errors.ItemNotFoundError): - return None - - return 'Exception not raised!' + assert self.openhab.get_item(test_item) is None def test_item_definition(self): self.openhab.get_item('TestGroupAVG') diff --git a/run/conf_testing/rules/openhab/test_interface_links.py b/run/conf_testing/rules/openhab/test_interface_links.py index 7c168259..4cd046c2 100644 --- a/run/conf_testing/rules/openhab/test_interface_links.py +++ b/run/conf_testing/rules/openhab/test_interface_links.py @@ -1,5 +1,6 @@ +from HABApp.openhab.errors import LinkNotFoundError from HABApp.openhab.items import Thing -from HABAppTests import TestBaseRule +from HABAppTests import TestBaseRule, find_astro_sun_thing class TestOpenhabInterfaceLinks(TestBaseRule): @@ -23,9 +24,7 @@ def __create_test_item(self): def set_up(self): self.item_name: str = "TestOpenhabInterfaceLinksItem" - self.astro_sun_thing: str = self.__find_astro_sun_thing() - if self.astro_sun_thing == "": - raise Exception("no astro:sun thing found") + self.astro_sun_thing: str = find_astro_sun_thing() self.channel_uid: str = f"{self.astro_sun_thing}:rise#start" self.__create_test_item() @@ -33,8 +32,11 @@ def set_up(self): raise Exception("item could not be created") def tear_down(self): - if self.oh.channel_link_exists(self.channel_uid, self.item_name): - self.oh.remove_channel_link(self.channel_uid, self.item_name) + try: + self.oh.get_link(self.item_name, self.channel_uid) + self.oh.remove_link(self.item_name, self.channel_uid) + except LinkNotFoundError: + pass self.openhab.remove_item(self.item_name) @@ -46,38 +48,46 @@ def __find_astro_sun_thing(self) -> str: return found_uid def test_update_link(self): - assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) - assert self.oh.channel_link_exists(self.channel_uid, self.item_name) + assert self.oh.create_link(self.item_name, self.channel_uid, {"profile": "system:default"}) + assert self.oh.get_link(self.item_name, self.channel_uid) new_cfg = {'profile': 'system:offset', 'offset': 7.0} - assert self.oh.create_channel_link(self.channel_uid, self.item_name, new_cfg) + assert self.oh.create_link(self.item_name, self.channel_uid, new_cfg) - channel_link = self.oh.get_channel_link(self.channel_uid, self.item_name) + channel_link = self.oh.get_link(self.item_name, self.channel_uid) assert channel_link.configuration == new_cfg def test_get_link(self): target = {"profile": "system:default"} - assert self.oh.create_channel_link(self.channel_uid, self.item_name, target) - link = self.oh.get_channel_link(self.channel_uid, self.item_name) + assert self.oh.create_link(self.item_name, self.channel_uid, target) + link = self.oh.get_link(self.item_name, self.channel_uid) - assert link.item_name == self.item_name - assert link.channel_uid == self.channel_uid + assert link.item == self.item_name + assert link.channel == self.channel_uid assert link.configuration == target def test_remove_link(self): - assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) - assert self.oh.remove_channel_link(self.channel_uid, self.item_name) - assert not self.oh.channel_link_exists(self.channel_uid, self.item_name) + assert self.oh.create_link(self.item_name, self.channel_uid, {"profile": "system:default"}) + self.oh.remove_link(self.item_name, self.channel_uid) + try: + self.oh.get_link(self.item_name, self.channel_uid) + assert False + except LinkNotFoundError: + pass def test_link_existence(self): - assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) - assert self.oh.channel_link_exists(self.channel_uid, self.item_name) + assert self.oh.create_link(self.item_name, self.channel_uid, {"profile": "system:default"}) + assert self.oh.get_link(self.item_name, self.channel_uid) - assert self.oh.remove_channel_link(self.channel_uid, self.item_name) - assert not self.oh.channel_link_exists(self.channel_uid, self.item_name) + self.oh.remove_link(self.item_name, self.channel_uid) + try: + self.oh.get_link(self.item_name, self.channel_uid) + assert False + except LinkNotFoundError: + pass def test_create_link(self): - assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) + assert self.oh.create_link(self.item_name, self.channel_uid, {"profile": "system:default"}) TestOpenhabInterfaceLinks() diff --git a/run/conf_testing/rules/openhab/test_item_change.py b/run/conf_testing/rules/openhab/test_item_change.py index 136de4e9..f00f802c 100644 --- a/run/conf_testing/rules/openhab/test_item_change.py +++ b/run/conf_testing/rules/openhab/test_item_change.py @@ -1,7 +1,7 @@ from HABApp.core.events import EventFilter from HABApp.openhab.definitions.topics import TOPIC_ITEMS from HABApp.openhab.events import ItemUpdatedEvent -from HABApp.openhab.interface import create_item +from HABApp.openhab.interface_sync import create_item from HABApp.openhab.items import StringItem, NumberItem, DatetimeItem from HABAppTests import TestBaseRule, OpenhabTmpItem, EventWaiter diff --git a/run/conf_testing/rules/openhab/test_items.py b/run/conf_testing/rules/openhab/test_items.py index 0e4c53fa..709a20b7 100644 --- a/run/conf_testing/rules/openhab/test_items.py +++ b/run/conf_testing/rules/openhab/test_items.py @@ -59,12 +59,12 @@ def test_existing(self): def test_api(self): self.openhab.get_item(self.item_string.name) - self.openhab.get_item(self.item_number.name, all_metadata=True) - self.openhab.get_item(self.item_string.name, all_metadata=True) - self.openhab.get_item(self.item_switch.name, all_metadata=True) + self.openhab.get_item(self.item_number.name) + self.openhab.get_item(self.item_string.name) + self.openhab.get_item(self.item_switch.name) - self.openhab.get_item(self.item_group.name, all_metadata=True) - asyncio.run_coroutine_threadsafe(async_get_items(all_metadata=True), loop).result() + self.openhab.get_item(self.item_group.name) + asyncio.run_coroutine_threadsafe(async_get_items(), loop).result() @OpenhabTmpItem.use('String', arg_name='oh_item') def test_tags(self, oh_item: OpenhabTmpItem): diff --git a/run/conf_testing/rules/openhab/test_links.py b/run/conf_testing/rules/openhab/test_links.py new file mode 100644 index 00000000..87d3821c --- /dev/null +++ b/run/conf_testing/rules/openhab/test_links.py @@ -0,0 +1,29 @@ +from HABApp.openhab.connection.handler.func_async import async_get_links, async_get_link +from HABAppTests import TestBaseRule +from HABAppTests.utils import find_astro_sun_thing, run_coro + + +class OpenhabLinkApi(TestBaseRule): + + def __init__(self): + super().__init__() + self.add_test('AllLinks', self.wrap_async, self.api_get_links) + + def wrap_async(self, coro, *args, **kwargs): + # create valid data + run_coro(coro(*args, **kwargs)) + + def set_up(self): + self.thing = self.openhab.get_thing(find_astro_sun_thing()) + + async def api_get_links(self): + objs = await async_get_links() + assert objs + + obj = objs[0] + + single = await async_get_link(obj.item, obj.channel) + assert single == obj + + +OpenhabLinkApi() diff --git a/run/conf_testing/rules/openhab/test_persistence.py b/run/conf_testing/rules/openhab/test_persistence.py index fe40ea4a..be5827c6 100644 --- a/run/conf_testing/rules/openhab/test_persistence.py +++ b/run/conf_testing/rules/openhab/test_persistence.py @@ -8,33 +8,40 @@ class TestPersistence(TestBaseRule): def __init__(self): super().__init__() - self.item = 'MapDBItem' + self.item = 'RRD4J_Item' - self.add_test('Persistence MapDB get', self.test_get) - self.add_test('Persistence MapDB set', self.test_set) + self.add_test('RRD4J configured', self.test_configured) + self.add_test('RRD4J get', self.test_get) def set_up(self): - NumberItem.get_item(self.item).oh_post_update('1') + i = NumberItem.get_item(self.item) + i.oh_post_update(i.value + 1 if i.value < 10 else 0) + + def test_configured(self): + for cfg in self.oh.get_persistence_services(): + if cfg.id == 'rrd4j': + break + else: + raise ValueError('rrd4j not found!') def test_get(self): now = datetime.now() - d = self.openhab.get_persistence_data(self.item, 'mapdb', now - timedelta(seconds=5), now) + d = self.openhab.get_persistence_data(self.item, 'rrd4j', now - timedelta(seconds=60), now) assert d.get_data() - def test_set(self): - now = datetime.now() - d = self.openhab.get_persistence_data(self.item, 'mapdb', now - timedelta(seconds=5), now) - was = d.get_data() - - assert list(was.values()) == [1] - - self.openhab.set_persistence_data(self.item, 'mapdb', now, 2) - - d = self.openhab.get_persistence_data(self.item, 'mapdb', now - timedelta(seconds=5), - now + timedelta(seconds=5)) - ist = d.get_data() - assert list(ist.values()) == [2], ist - - -# Todo: Enable when OH3.3 supports this -# TestPersistence() + # def test_set(self): + # now = datetime.now() + # d = self.openhab.get_persistence_data(self.item, 'mapdb', now - timedelta(seconds=5), now) + # was = d.get_data() + # + # assert list(was.values()) == [1] + # + # self.openhab.set_persistence_data(self.item, 'mapdb', now, 2) + # + # d = self.openhab.get_persistence_data(self.item, 'mapdb', now - timedelta(seconds=5), + # now + timedelta(seconds=5)) + # ist = d.get_data() + # assert list(ist.values()) == [2], ist + + +TestPersistence() diff --git a/run/conf_testing/rules/openhab/test_transformations.py b/run/conf_testing/rules/openhab/test_transformations.py new file mode 100644 index 00000000..437ec77a --- /dev/null +++ b/run/conf_testing/rules/openhab/test_transformations.py @@ -0,0 +1,17 @@ +from HABApp.openhab import transformations +from HABAppTests import TestBaseRule + +obj = transformations.map['de.map'] + + +class OpenhabTransformations(TestBaseRule): + + def __init__(self): + super().__init__() + self.add_test('TestMap', self.test_map) + + def test_map(self): + assert list(obj.keys()) + + +OpenhabTransformations() diff --git a/run/conf_testing/rules/test_mqtt.py b/run/conf_testing/rules/test_mqtt.py index b8d34454..94cd346b 100644 --- a/run/conf_testing/rules/test_mqtt.py +++ b/run/conf_testing/rules/test_mqtt.py @@ -3,10 +3,10 @@ import HABApp from HABApp.core.events import ValueUpdateEventFilter +from HABApp.mqtt.connection.handler import CONNECTION_HANDLER from HABApp.mqtt.events import MqttValueUpdateEventFilter from HABApp.mqtt.items import MqttItem, MqttPairItem -from HABApp.mqtt.mqtt_connection import connect, disconnect -from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule +from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule, run_coro log = logging.getLogger('HABApp.MqttTestEvents') @@ -71,10 +71,10 @@ def test_mqtt_item_creation(self): assert self.mqtt.publish(topic, 'asdf', retain=True) # We need to reconnect to receive the message - disconnect() - connect() - - time.sleep(0.1) + connection = CONNECTION_HANDLER.plugin_connection + run_coro(CONNECTION_HANDLER.on_disconnected(connection, connection.context)) + run_coro(CONNECTION_HANDLER.on_connecting(connection, connection.context)) + time.sleep(0.2) assert HABApp.core.Items.item_exists(topic) is True HABApp.core.Items.pop_item(topic) diff --git a/src/HABApp/__check_dependency_packages__.py b/src/HABApp/__check_dependency_packages__.py index e4d2e62c..083c4ed7 100644 --- a/src/HABApp/__check_dependency_packages__.py +++ b/src/HABApp/__check_dependency_packages__.py @@ -9,17 +9,19 @@ def get_dependencies() -> List[str]: return [ 'aiohttp-sse-client', 'aiohttp', + 'aiomqtt', 'bidict', 'colorama', 'eascheduler', 'easyconfig', - 'paho-mqtt', 'pydantic', 'stack_data', 'voluptuous', 'watchdog', 'ujson', 'immutables', + 'javaproperties', + 'msgspec', 'pendulum', 'typing-extensions', diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index e973e12f..460e109e 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1,4 +1,13 @@ # Version scheme: -# X.X[.X] or X.X[.X].DEV-X +# YEAR.MONTH.COUNTER +# YEAR, MONTH are two digits and zero-padded +# COUNTER resets every month and starts with 0 +# For example: +# - 23.09.0 +# - 23.09.1 +# - 23.10.0 +# +# Development versions contain the DEV-COUNTER postfix: +# - 23.09.0.DEV-1 -__version__ = '1.1.2' +__version__ = '23.09.0' diff --git a/src/HABApp/config/models/mqtt.py b/src/HABApp/config/models/mqtt.py index 9d673401..e9146309 100644 --- a/src/HABApp/config/models/mqtt.py +++ b/src/HABApp/config/models/mqtt.py @@ -34,7 +34,7 @@ class Subscribe(BaseModel): qos: QOS = Field(default=0, description='Default QoS for subscribing') topics: Tuple[Tuple[str, Optional[QOS]], ...] = Field(default=('#', )) - @pydantic.validator('topics', pre=True) + @pydantic.field_validator('topics', mode='before') def parse_topics(cls, v): if not isinstance(v, (list, tuple, set)): raise ValueError('must be a list') diff --git a/src/HABApp/config/models/openhab.py b/src/HABApp/config/models/openhab.py index 5775a64a..95b1d959 100644 --- a/src/HABApp/config/models/openhab.py +++ b/src/HABApp/config/models/openhab.py @@ -1,7 +1,8 @@ -from typing import Literal, Union +from typing import Union + +from pydantic import AnyHttpUrl, ByteSize, Field, field_validator, TypeAdapter from easyconfig.models import BaseModel -from pydantic import AnyHttpUrl, ByteSize, Field, validator class Ping(BaseModel): @@ -9,7 +10,7 @@ class Ping(BaseModel): 'an update from HABApp and get the updated value back from openHAB ' 'in milliseconds') item: str = Field('HABApp_Ping', description='Name of the Numberitem') - interval: int = Field(10, description='Seconds between two pings', ge=0.1) + interval: Union[int, float] = Field(10, description='Seconds between two pings', ge=0.1) class General(BaseModel): @@ -29,7 +30,7 @@ class General(BaseModel): class Connection(BaseModel): - url: Union[AnyHttpUrl, Literal['']] = Field( + url: str = Field( 'http://localhost:8080', description='Connect to this url. Empty string ("") disables the connection.') user: str = '' password: str = '' @@ -53,7 +54,12 @@ class Connection(BaseModel): 'matching this filter will be sent to HABApp.' ) - @validator('buffer') + @field_validator('url') + def validate_url(cls, value: str): + TypeAdapter(AnyHttpUrl).validate_python(value) + return value + + @field_validator('buffer') def validate_see_buffer(cls, value: ByteSize): valid_values = ( '64kib', '128kib', '256kib', '512kib', @@ -61,7 +67,8 @@ def validate_see_buffer(cls, value: ByteSize): ) for _v in valid_values: - if value == ByteSize.validate(_v): + # noinspection PyProtectedMember + if value == ByteSize._validate(_v, None): return value raise ValueError(f'Value must be one of {", ".join(valid_values)}') diff --git a/src/HABApp/core/__init__.py b/src/HABApp/core/__init__.py index e62c8d30..cf87263d 100644 --- a/src/HABApp/core/__init__.py +++ b/src/HABApp/core/__init__.py @@ -6,6 +6,11 @@ from HABApp.core import asyncio +# isort: split + +# The connection manager has no dependencies - that's why we can set it up before the internals +from HABApp.core.connections import Connections + # isort: split from HABApp.core import internals diff --git a/src/HABApp/core/asyncio.py b/src/HABApp/core/asyncio.py index 7cd48f08..a3ed5442 100644 --- a/src/HABApp/core/asyncio.py +++ b/src/HABApp/core/asyncio.py @@ -1,11 +1,12 @@ from asyncio import Future as _Future from asyncio import run_coroutine_threadsafe as _run_coroutine_threadsafe -from contextvars import ContextVar as _ContextVar -from typing import Any as _Any, Callable +from contextvars import ContextVar as _ContextVar, Token +from typing import Any as _Any, Callable, Final, Optional from typing import Callable as _Callable from typing import Coroutine as _Coroutine from typing import Optional as _Optional from typing import TypeVar as _TypeVar + from HABApp.core.const import loop from HABApp.core.const.const import PYTHON_310 @@ -18,6 +19,27 @@ async_context = _ContextVar('async_ctx') +class AsyncContext: + def __init__(self, value: str): + self.value: Final = value + self.token: Optional[Token[str]] = None + self.parent: Optional[AsyncContext] = None + + def __enter__(self): + assert self.token is None, self + self.parent = async_context.get(None) + self.token = async_context.set(self.value) + + def __exit__(self, exc_type, exc_val, exc_tb): + async_context.reset(self.token) + + def __repr__(self): + parent: str = '' + if self.parent: + parent = f'{self.parent} -> ' + return f'<{self.__class__.__name__} {parent:s}{self.value:s}>' + + class AsyncContextError(Exception): def __init__(self, func: _Callable) -> None: super().__init__() diff --git a/src/HABApp/core/connections/__init__.py b/src/HABApp/core/connections/__init__.py new file mode 100644 index 00000000..b9e177ac --- /dev/null +++ b/src/HABApp/core/connections/__init__.py @@ -0,0 +1,15 @@ +from ._definitions import ConnectionStatus, CONNECTION_HANDLER_NAME + +# isort: split + +from .base_plugin import BaseConnectionPlugin +from .plugin_callback import PluginCallbackHandler +from .base_connection import BaseConnection + +# isort: split + +from .manager import connection_manager as Connections + +# isort: split + +from .plugins import ConnectionStateToEventBusPlugin, AutoReconnectPlugin diff --git a/src/HABApp/core/connections/_definitions.py b/src/HABApp/core/connections/_definitions.py new file mode 100644 index 00000000..f286bf8a --- /dev/null +++ b/src/HABApp/core/connections/_definitions.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import logging +from typing import Final + +from HABApp.core.const.const import StrEnum + +connection_log = logging.getLogger('HABApp.connection') + + +class ConnectionStatus(StrEnum): + STARTUP = 'STARTUP' + + SETUP = 'SETUP' + + # connection flow + CONNECTING = 'CONNECTING' + CONNECTED = 'CONNECTED' + ONLINE = 'ONLINE' + + # unexpected disconnect or error + DISCONNECTED = 'DISCONNECTED' + OFFLINE = 'OFFLINE' + + # connection is disabled + DISABLED = 'DISABLED' + + # normal shutdown flow + SHUTDOWN = 'SHUTDOWN' + + +# Handler which manages the connection +CONNECTION_HANDLER_NAME: Final = 'ConnectionHandler' diff --git a/src/HABApp/core/connections/base_connection.py b/src/HABApp/core/connections/base_connection.py new file mode 100644 index 00000000..884c655b --- /dev/null +++ b/src/HABApp/core/connections/base_connection.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from asyncio import CancelledError +from typing import Final, TYPE_CHECKING, Callable, Literal + +import HABApp +from HABApp.core.connections._definitions import ConnectionStatus, connection_log +from HABApp.core.connections.status_transitions import StatusTransitions +from HABApp.core.lib import SingleTask, PriorityList +from ..wrapper import process_exception + +if TYPE_CHECKING: + from .base_plugin import BaseConnectionPlugin + from .plugin_callback import PluginCallbackHandler + + +class AlreadyHandledException(Exception): + pass + + +class HandleExceptionInConnection: + def __init__(self, connection: BaseConnection, func_name: Callable): + self._connection: Final = connection + self._func_name: Final = func_name + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_val, exc_tb): + # no exception -> we exit gracefully + if exc_type is None and exc_val is None: + return True + + if isinstance(exc_val, CancelledError): + return None + + self._connection.process_exception(exc_val, self._func_name) + raise AlreadyHandledException from None + + +class BaseConnection: + def __init__(self, name: str): + self.name: Final = name + self.log: Final = connection_log.getChild(name) + self.status: Final = StatusTransitions() + + # this can be an arbitrary obj that can be reused + self.context = None + + # Plugin handling + self.plugins: list[BaseConnectionPlugin] = [] + self.plugin_callbacks: dict[ConnectionStatus, PriorityList[PluginCallbackHandler]] = { + name: PriorityList() for name in ConnectionStatus} + + # Tasks + self.plugin_task: Final = SingleTask(self._task_plugin, f'{name.title():s}PluginTask') + self.advance_status_task: Final = SingleTask(self._task_next_status, f'{name.title():s}AdvanceStatusTask') + + @property + def is_online(self) -> bool: + return self.status.status == ConnectionStatus.ONLINE + + @property + def is_connected(self) -> bool: + return self.status.status == ConnectionStatus.CONNECTED + + @property + def is_disconnected(self) -> bool: + return self.status.status == ConnectionStatus.DISCONNECTED + + @property + def is_disabled(self) -> bool: + return self.status.status == ConnectionStatus.DISABLED + + @property + def is_shutdown(self) -> bool: + return self.status.status == ConnectionStatus.SHUTDOWN + + @property + def has_errors(self) -> bool: + return self.status.error + + def handle_exception(self, func: Callable) -> HandleExceptionInConnection: + return HandleExceptionInConnection(self, func) + + def is_silent_exception(self, e: Exception): + return False + + def process_exception(self, e: Exception, func: Callable | str | None): + self.set_error() + + if self.is_silent_exception(e): + if func is None: + self.log.debug(f'Error: {e} ({e.__class__.__name__})') + else: + name = func if isinstance(func, str) else func.__qualname__ + self.log.debug(f'Error in {name:s}: {e} ({e.__class__.__name__})') + else: + process_exception(func, e, self.log) + + def register_plugin(self, obj: BaseConnectionPlugin, priority: int | Literal['first', 'last'] | None = None): + from .plugin_callback import get_plugin_callbacks + + # Possibility to specify default priority as a class variable + if priority is None: + priority = getattr(obj, '_DEFAULT_PRIORITY', None) + + # Check that it's not already registered + assert not obj.plugin_callbacks + + for p in self.plugins: + if p.plugin_name == obj.plugin_name: + raise ValueError(f'Plugin with the same name already registered: {p}') + + for status, handler in get_plugin_callbacks(obj): + if priority is None: + # Handler runs first for every step, except disconnect & offline - there it runs last. + # That way it's possible to do some cleanup in the plugins when we gracefully disconnect + if status is ConnectionStatus.DISCONNECTED or status is ConnectionStatus.OFFLINE: + self.plugin_callbacks[status].append(handler, 'last') + else: + self.plugin_callbacks[status].append(handler, 'first') + else: + self.plugin_callbacks[status].append(handler, priority) + + obj.plugin_connection = self + self.plugins.append(obj) + + self.log.debug(f'Added plugin {obj.plugin_name:s}') + return self + + def remove_plugin(self, obj: BaseConnectionPlugin): + self.plugins.remove(obj) + obj.plugin_connection = None + + for cb_list in self.plugin_callbacks.values(): + rem = [cb for cb in cb_list if cb.plugin is obj] + for to_rem in rem: + cb_list.remove(to_rem) + + async def _task_next_status(self): + with HABApp.core.wrapper.ExceptionToHABApp(logger=self.log): + + # if we are currently running stop the task + await self.plugin_task.cancel_wait() + + while (next_value := self.status.advance_status()) is not None: + self.log.debug(next_value.value) + + assert not self.plugin_task.is_running + self.plugin_task.start() + await self.plugin_task.wait() + + self.advance_status_task.task = None + + async def _task_plugin(self): + status = self.status + status_enum = status.status + + self.log.debug(f'Task {status_enum.value:s} start') + + callbacks = self.plugin_callbacks[status_enum] + for cb in callbacks: + + error = True + + try: + await cb.run(self, self.context) + error = False + except AlreadyHandledException: + pass + except Exception as e: + self.process_exception(e, cb.coro) + + if error: + # Fail fast during connection + if status.is_connecting_or_connected(): + break + + self.log.debug(f'Task {status_enum.value:s} done') + + def clear_error(self): + if not self.status.error: + return None + self.log.debug('Cleared error') + self.status.error = False + + def set_error(self): + if self.status.error: + self.log.debug('Error on connection status is already set') + else: + self.status.error = True + self.log.debug('Set error on connection status') + self.advance_status_task.start_if_not_running() + + def status_from_setup_to_disabled(self): + self.status.from_setup_to_disabled() + self.advance_status_task.start_if_not_running() + + def status_from_connected_to_disconnected(self): + self.status.from_connected_to_disconnected() + self.advance_status_task.start_if_not_running() + + def status_configuration_changed(self): + self.log.debug('Requesting setup') + self.status.setup = True + self.advance_status_task.start_if_not_running() + + def on_application_shutdown(self): + if self.status.shutdown: + return None + self.log.debug('Requesting shutdown') + self.status.shutdown = True + + for p in self.plugins: + p.on_application_shutdown() + + self.advance_status_task.start_if_not_running() + + def application_startup_complete(self): + self.log.debug('Overview') + for status, objs in self.plugin_callbacks.items(): + if not objs: + continue + coros = [] + for obj in objs: + name = obj.coro.__name__ + for replace in (f'on_{status.value.lower():s}', f'_on_{status.value.lower():s}', ): + if name.startswith(replace): + name = name[len(replace):] + break + coros.append(f'{obj.plugin.plugin_name}{"." if name else ""}{name}') + + self.log.debug(f' - {status}: {", ".join(coros)}') + + self.status.setup = True + self.advance_status_task.start_if_not_running() diff --git a/src/HABApp/core/connections/base_plugin.py b/src/HABApp/core/connections/base_plugin.py new file mode 100644 index 00000000..5e777646 --- /dev/null +++ b/src/HABApp/core/connections/base_plugin.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Final, TYPE_CHECKING, Generic, TypeVar, Callable, Awaitable, Any + +from HABApp.core.lib import SingleTask + +if TYPE_CHECKING: + from .plugin_callback import PluginCallbackHandler + from .base_connection import BaseConnection + +T = TypeVar('T', bound='BaseConnection') + + +class BaseConnectionPlugin(Generic[T]): + def __init__(self, name: str | None = None): + super().__init__() + + if name is None: + name = self.__class__.__name__ + if name[-6:].lower() == 'plugin': + name = name[:-6] + + self.plugin_connection: T = None + self.plugin_name: Final = name + self.plugin_callbacks: dict[str, PluginCallbackHandler] = {} + + def on_application_shutdown(self): + pass + + +class BaseConnectionPluginConnectedTask(BaseConnectionPlugin[T]): + def __init__(self, task_coro: Callable[[], Awaitable[Any]], + task_name: str, name: str | None = None): + super().__init__(name) + self.task: Final = SingleTask(task_coro, name=task_name) + + async def on_connected(self): + self.task.start() + + async def on_disconnected(self): + await self.task.cancel_wait() diff --git a/src/HABApp/core/connections/connection_task.py b/src/HABApp/core/connections/connection_task.py new file mode 100644 index 00000000..5d5db590 --- /dev/null +++ b/src/HABApp/core/connections/connection_task.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging +from typing import Final, Callable, Awaitable, Any + +from HABApp.core.lib import SingleTask + + +class PluginTask(SingleTask): + def __init__(self, coro: Callable[[], Awaitable[Any]], name: str | None, + logger: logging.Logger | None, + exception_handler: Callable[[Exception, Callable | str | None], Any]): + super().__init__(coro, name) + + self.log: Final = logger + self.exception_handler: Final = exception_handler + + async def _task_wrap(self): + if self.log is not None: + self.log.debug(f'Task {self.name} start') + + suffix = '' + + try: + await super()._task_wrap() + except Exception as e: + suffix = ' (with error)' + self.exception_handler(e, self.name) + finally: + if self.log is not None: + self.log.debug(f'Task {self.name} done {suffix}') diff --git a/src/HABApp/core/connections/manager.py b/src/HABApp/core/connections/manager.py new file mode 100644 index 00000000..5c133817 --- /dev/null +++ b/src/HABApp/core/connections/manager.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import asyncio +from typing import Final, TypeVar + +import HABApp +from HABApp.core.connections import BaseConnection +from HABApp.core.connections._definitions import connection_log + + +T = TypeVar('T', bound=BaseConnection) + + +class ConnectionManager: + def __init__(self): + self.connections: dict[str, BaseConnection] = {} + + def add(self, connection: T) -> T: + assert connection.name not in self.connections + self.connections[connection.name] = connection + connection_log.debug(f'Added {connection.name:s}') + + return connection + + def get(self, name: str) -> BaseConnection: + return self.connections[name] + + def remove(self, name): + con = self.get(name) + if not con.is_shutdown: + raise ValueError() + self.connections.pop(name) + + async def on_application_shutdown(self): + for c in self.connections.values(): + c.on_application_shutdown() + + tasks = [t.advance_status_task.wait() for t in self.connections.values()] + await asyncio.gather(*tasks) + + def application_startup_complete(self): + for c in self.connections.values(): + with HABApp.core.wrapper.ExceptionToHABApp(logger=c.log): + c.application_startup_complete() + + def __repr__(self): + return f'<{self.__class__.__name__}>' + + +connection_manager: Final = ConnectionManager() diff --git a/src/HABApp/core/connections/plugin_callback.py b/src/HABApp/core/connections/plugin_callback.py new file mode 100644 index 00000000..70c9c5f0 --- /dev/null +++ b/src/HABApp/core/connections/plugin_callback.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from inspect import signature, iscoroutinefunction, getmembers +from typing import Awaitable, Callable, Any, TYPE_CHECKING + +from ._definitions import ConnectionStatus + +if TYPE_CHECKING: + from .base_connection import BaseConnection + from .base_plugin import BaseConnectionPlugin + + +def get_plugin_callbacks(obj: BaseConnectionPlugin) -> list[tuple[ConnectionStatus, PluginCallbackHandler]]: + name_to_status = {obj.lower(): obj for obj in ConnectionStatus} + name_regex = re.compile(f'on_({"|".join(name_to_status)})') + + ret = [] + for m_name, member in getmembers(obj, predicate=lambda x: callable(x)): + if not m_name.lower().startswith('on_'): + continue + + if m_name in ('on_application_shutdown', ): + continue + + if (m := name_regex.fullmatch(m_name)) is None: + raise ValueError(f'Invalid name: {m_name} in {obj.plugin_name}') + + status = name_to_status[m.group(1)] + cb = PluginCallbackHandler.create(obj, member) + + ret.append((status, cb)) + + return ret + + +@dataclass +class PluginCallbackHandler: + plugin: BaseConnectionPlugin + coro: Callable[[...], Awaitable] + kwargs: tuple[str, ...] + + async def run(self, connection: BaseConnection, context: Any): + kwargs = {} + if self.kwargs: + if 'connection' in self.kwargs: + kwargs['connection'] = connection + if 'context' in self.kwargs: + kwargs['context'] = context + + return await self.coro(**kwargs) + + @staticmethod + def _get_coro_kwargs(plugin: BaseConnectionPlugin, coro: Callable[[...], Awaitable]): + if not iscoroutinefunction(coro): + raise ValueError(f'Coroutine function expected for {plugin.plugin_name}.{coro.__name__}') + + sig = signature(coro) + + kwargs = [] + for name in sig.parameters: + if name in ('connection', 'context'): + kwargs.append(name) + else: + raise ValueError(f'Invalid parameter name "{name:s}" for {plugin.plugin_name}.{coro.__name__}') + return tuple(kwargs) + + @classmethod + def create(cls, plugin: BaseConnectionPlugin, coro: Callable[[...], Awaitable]): + return cls(plugin, coro, cls._get_coro_kwargs(plugin, coro)) diff --git a/src/HABApp/core/connections/plugins/__init__.py b/src/HABApp/core/connections/plugins/__init__.py new file mode 100644 index 00000000..b31868ed --- /dev/null +++ b/src/HABApp/core/connections/plugins/__init__.py @@ -0,0 +1,2 @@ +from .state_to_event import ConnectionStateToEventBusPlugin +from .auto_reconnect import AutoReconnectPlugin diff --git a/src/HABApp/core/connections/plugins/auto_reconnect.py b/src/HABApp/core/connections/plugins/auto_reconnect.py new file mode 100644 index 00000000..da7f0936 --- /dev/null +++ b/src/HABApp/core/connections/plugins/auto_reconnect.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from asyncio import sleep, Task, create_task, CancelledError + +from HABApp.core.connections import BaseConnection, BaseConnectionPlugin + + +class WaitBetweenConnects: + wait_max = 600 + + def __init__(self): + self.wait_time: int = 0 + self.task: Task | None = None + + def reset_wait(self): + self.wait_time = 0 + + async def wait(self): + wait = self.wait_time + wait = wait * 2 if wait <= 16 else wait * 1.5 + wait = max(1, min(wait, self.wait_max)) + + self.wait_time = wait + + try: + self.task = create_task(sleep(self.wait_time)) + await self.task + except CancelledError: + pass + finally: + self.task = None + + def cancel(self): + if task := self.task: + task.cancel() + + +class AutoReconnectPlugin(BaseConnectionPlugin): + _DEFAULT_PRIORITY = 110_000 + + def __init__(self, name: str | None = None): + super().__init__(name) + self.waiter = WaitBetweenConnects() + + def on_application_shutdown(self): + self.waiter.cancel() + + async def on_online(self): + self.waiter.reset_wait() + + async def on_offline(self, connection: BaseConnection): + if connection.is_shutdown: + return None + + if connection.has_errors: + await self.waiter.wait() + connection.clear_error() diff --git a/src/HABApp/core/connections/plugins/state_to_event.py b/src/HABApp/core/connections/plugins/state_to_event.py new file mode 100644 index 00000000..03e0dd2a --- /dev/null +++ b/src/HABApp/core/connections/plugins/state_to_event.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from HABApp.core.connections import BaseConnection, BaseConnectionPlugin +from HABApp.core.const.topics import TOPIC_CONNECTIONS +from HABApp.core.events.habapp_events import HABAppConnectionStateEvent +from HABApp.core.internals import uses_post_event + + +post_event = uses_post_event() + + +class ConnectionStateToEventBusPlugin(BaseConnectionPlugin): + _DEFAULT_PRIORITY = 100_000 + + def __init__(self, name: str | None = None): + super().__init__(name) + self.__last_report = None + + def __post_event(self, connection: BaseConnection): + if (status := connection.status.status.value) != self.__last_report: + post_event(TOPIC_CONNECTIONS, HABAppConnectionStateEvent(connection.name, status)) + self.__last_report = status + + async def on_online(self, connection: BaseConnection): + self.__post_event(connection) + + async def on_disconnected(self, connection: BaseConnection): + self.__post_event(connection) diff --git a/src/HABApp/core/connections/status_transitions.py b/src/HABApp/core/connections/status_transitions.py new file mode 100644 index 00000000..f02b8e6d --- /dev/null +++ b/src/HABApp/core/connections/status_transitions.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from ._definitions import ConnectionStatus + + +class StatusTransitions: + def __init__(self): + self.status = ConnectionStatus.STARTUP + self.manual: ConnectionStatus | None = None + + # Flags + self.error = False + self.setup = False + self.shutdown = False + + def advance_status(self) -> ConnectionStatus | None: + if self.manual is not None: + self.status = status = self.manual + self.manual = None + else: + if (status := self._next_step()) is None: + return None + self.status = status + + return status + + def is_connecting_or_connected(self): + return self.status in (ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTED, ConnectionStatus.ONLINE) + + def _set_manual(self, status: ConnectionStatus): + assert self.manual is None + self.manual = status + + def from_setup_to_disabled(self): + assert self.status == ConnectionStatus.SETUP + self._set_manual(ConnectionStatus.DISABLED) + + def from_connected_to_disconnected(self): + assert self.status == ConnectionStatus.CONNECTED + self._set_manual(ConnectionStatus.DISCONNECTED) + + def _next_step(self) -> ConnectionStatus: + status = self.status + + if self.error: + if self.is_connecting_or_connected(): + return ConnectionStatus.DISCONNECTED + if status == ConnectionStatus.SETUP: + return ConnectionStatus.DISABLED + + if self.setup: + if self.is_connecting_or_connected(): + return ConnectionStatus.DISCONNECTED + if status in (ConnectionStatus.STARTUP, ConnectionStatus.OFFLINE, ConnectionStatus.DISABLED): + self.setup = False + return ConnectionStatus.SETUP + + if self.shutdown: + if self.is_connecting_or_connected(): + return ConnectionStatus.DISCONNECTED + if status in (ConnectionStatus.STARTUP, ConnectionStatus.OFFLINE, ConnectionStatus.DISABLED): + return ConnectionStatus.SHUTDOWN + + # Automatically reconnect if there are no errors + if not self.error and status is ConnectionStatus.OFFLINE: + return ConnectionStatus.CONNECTING + + # automatic transitions if no flags are set + transitions = { + ConnectionStatus.CONNECTING: ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED: ConnectionStatus.ONLINE, + + ConnectionStatus.DISCONNECTED: ConnectionStatus.OFFLINE, + + ConnectionStatus.SETUP: ConnectionStatus.CONNECTING, + } + return transitions.get(status) + + def __repr__(self): + return f'<{self.__class__.__name__} {self.status} ' \ + f'[{"x" if self.error else " "}] Error, ' \ + f'[{"x" if self.setup else " "}] Setup>' + + def __eq__(self, other: ConnectionStatus): + if not isinstance(other, ConnectionStatus): + return NotImplemented + return self.status == other diff --git a/src/HABApp/core/const/json.py b/src/HABApp/core/const/json.py index 6e3278d4..e31c9431 100644 --- a/src/HABApp/core/const/json.py +++ b/src/HABApp/core/const/json.py @@ -1,5 +1,7 @@ from typing import Any, Callable +import msgspec + try: import ujson load_json: Callable[[str], Any] = ujson.loads @@ -8,3 +10,7 @@ import json load_json: Callable[[str], Any] = json.loads dump_json: Callable[[str], Any] = json.dumps + + +decode_struct = msgspec.json.decode +encode_struct = msgspec.json.encode diff --git a/src/HABApp/core/const/loop.py b/src/HABApp/core/const/loop.py index 351a759b..97eaaed2 100644 --- a/src/HABApp/core/const/loop.py +++ b/src/HABApp/core/const/loop.py @@ -1,4 +1,14 @@ import asyncio +import os +import sys + + +# we can have subprocesses (https://docs.python.org/3/library/asyncio-platforms.html#subprocess-support-on-windows) +# or mqtt support (https://github.com/sbtinstruments/aiomqtt#note-for-windows-users) +# but not both. For testing, it makes sense to use mqtt support as a default +if (sys.platform.lower() == "win32" or os.name.lower() == "nt") and os.environ.get('HABAPP_NO_MQTT') is None: + from asyncio import set_event_loop_policy, WindowsSelectorEventLoopPolicy + set_event_loop_policy(WindowsSelectorEventLoopPolicy()) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/src/HABApp/core/const/topics.py b/src/HABApp/core/const/topics.py index b855fae8..0e040b91 100644 --- a/src/HABApp/core/const/topics.py +++ b/src/HABApp/core/const/topics.py @@ -6,10 +6,12 @@ TOPIC_ERRORS: Final = 'HABApp.Errors' TOPIC_FILES: Final = 'HABApp.Files' +TOPIC_CONNECTIONS: Final = 'HABApp.Connections' ALL_TOPICS: Tuple[str, ...] = ( TOPIC_INFOS, TOPIC_WARNINGS, TOPIC_ERRORS, - TOPIC_FILES + TOPIC_FILES, + TOPIC_CONNECTIONS ) diff --git a/src/HABApp/core/events/filter/event.py b/src/HABApp/core/events/filter/event.py index ab487c77..b013ff58 100644 --- a/src/HABApp/core/events/filter/event.py +++ b/src/HABApp/core/events/filter/event.py @@ -1,5 +1,6 @@ -from typing import Optional +from typing import Optional, Final from typing import get_type_hints as _get_type_hints +from inspect import isclass from HABApp.core.const import MISSING from HABApp.core.const.hints import HINT_ANY_CLASS @@ -11,8 +12,9 @@ class EventFilter(EventFilterBase): def __init__(self, event_class: HINT_ANY_CLASS, **kwargs): assert len(kwargs) < 3, 'EventFilter only allows up to two args that will be used to filter' + assert isclass(event_class), f'Class for event required! Passed {event_class} ({type(event_class)})' - self.event_class = event_class + self.event_class: Final = event_class # Property filters self.attr_name1: Optional[str] = None diff --git a/src/HABApp/core/events/habapp_events.py b/src/HABApp/core/events/habapp_events.py index ac968308..71ccf7b4 100644 --- a/src/HABApp/core/events/habapp_events.py +++ b/src/HABApp/core/events/habapp_events.py @@ -1,3 +1,6 @@ +from typing import Final + + class __FileEventBase: def __init__(self, name: str): self.name: str = name @@ -38,3 +41,17 @@ def __repr__(self): def to_str(self) -> str: """Create a readable str with all information""" return f'Exception in {self.func_name}: {self.exception}\n{self.traceback}' + + +class HABAppConnectionStateEvent: + """Contains information about a connection managed by HABApp + + :ivar str connection: name of the connection + :ivar str state: state of the connection + """ + def __init__(self, connection: str, state: str): + self.connection: Final = connection + self.state: Final = state + + def __repr__(self): + return f'<{self.__class__.__name__} connection: {self.connection:s}, state: {self.state:s}>' diff --git a/src/HABApp/core/files/file/properties.py b/src/HABApp/core/files/file/properties.py index 016ae702..56189207 100644 --- a/src/HABApp/core/files/file/properties.py +++ b/src/HABApp/core/files/file/properties.py @@ -1,7 +1,7 @@ import re from typing import List -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, Field, ConfigDict from HABApp.core.const import yml @@ -10,9 +10,7 @@ class FileProperties(BaseModel): depends_on: List[str] = Field(alias='depends on', default_factory=list) reloads_on: List[str] = Field(alias='reloads on', default_factory=list) - class Config: - extra = Extra.forbid - allow_population_by_field_name = True + model_config = ConfigDict(extra='forbid', populate_by_name=True) RE_START = re.compile(r'^#(\s*)HABApp\s*:', re.IGNORECASE) @@ -59,4 +57,4 @@ def get_properties(_str: str) -> FileProperties: data = yml.load('\n'.join(cfg)) if data is None: data = {} - return FileProperties.parse_obj(data.get('habapp', {})) + return FileProperties.model_validate(data.get('habapp', {})) diff --git a/src/HABApp/core/files/watcher/file_watcher.py b/src/HABApp/core/files/watcher/file_watcher.py index 0d83feba..ba5438ee 100644 --- a/src/HABApp/core/files/watcher/file_watcher.py +++ b/src/HABApp/core/files/watcher/file_watcher.py @@ -1,12 +1,13 @@ from asyncio import run_coroutine_threadsafe, sleep from pathlib import Path -from time import time +from time import monotonic from typing import Any, List, Set, Awaitable, Callable import HABApp from HABApp.core.wrapper import ignore_exception from .base_watcher import EventFilterBase from .base_watcher import FileSystemEventHandler +from HABApp.core.asyncio import AsyncContext DEBOUNCE_TIME: float = 0.6 @@ -28,7 +29,7 @@ def file_changed(self, dst: str): @ignore_exception async def _event_waiter(self, dst: Path): - self.last_event = ts = time() + self.last_event = ts = monotonic() self._files.add(dst) # debounce time @@ -43,8 +44,10 @@ async def _event_waiter(self, dst: Path): self._files.clear() # process - await self.func(HABApp.core.lib.sort_files(files)) + with AsyncContext('FileWatcherEvent'): + await self.func(HABApp.core.lib.sort_files(files)) async def trigger_all(self): files = HABApp.core.lib.list_files(self.folder, self.filter, self.watch_subfolders) - await self.func(files) + with AsyncContext('FileWatcherAll'): + await self.func(files) diff --git a/src/HABApp/core/internals/__init__.py b/src/HABApp/core/internals/__init__.py index 38a9aff6..fad6ef24 100644 --- a/src/HABApp/core/internals/__init__.py +++ b/src/HABApp/core/internals/__init__.py @@ -1,11 +1,11 @@ from .proxy import uses_get_item, uses_item_registry, uses_post_event, uses_event_bus, setup_internals -from .context import ContextProvidingObj, Context, ContextBoundObj, get_current_context, HINT_CONTEXT_OBJ, AutoContextBoundObj +from .context import ContextProvidingObj, Context, ContextBoundObj, get_current_context, Context, AutoContextBoundObj # isort: split from .event_filter import EventFilterBase, HINT_EVENT_FILTER_OBJ -from .event_bus import EventBus, HINT_EVENT_BUS -from .item_registry import HINT_ITEM_REGISTRY, ItemRegistry, ItemRegistryItem +from .event_bus import EventBus +from .item_registry import ItemRegistry, ItemRegistryItem # isort: split diff --git a/src/HABApp/core/internals/context/__init__.py b/src/HABApp/core/internals/context/__init__.py index 5f6a7572..dc61e54c 100644 --- a/src/HABApp/core/internals/context/__init__.py +++ b/src/HABApp/core/internals/context/__init__.py @@ -1,4 +1,4 @@ -from .context import Context, HINT_CONTEXT_OBJ, ContextBoundObj, HINT_CONTEXT_BOUND_OBJ, ContextProvidingObj +from .context import Context, Context, ContextBoundObj, ContextProvidingObj # isort: split diff --git a/src/HABApp/core/internals/context/context.py b/src/HABApp/core/internals/context/context.py index 1664641a..34951192 100644 --- a/src/HABApp/core/internals/context/context.py +++ b/src/HABApp/core/internals/context/context.py @@ -5,13 +5,13 @@ class ContextBoundObj: - def __init__(self, parent_ctx: Optional['HINT_CONTEXT_OBJ'], **kwargs): + def __init__(self, parent_ctx: Optional['Context'], **kwargs): super().__init__(**kwargs) self._parent_ctx = parent_ctx if parent_ctx is not None: parent_ctx.add_obj(self) - def _ctx_link(self, parent_ctx: 'HINT_CONTEXT_OBJ'): + def _ctx_link(self, parent_ctx: 'Context'): assert isinstance(parent_ctx, Context) if self._parent_ctx is not None: raise ContextBoundObjectIsAlreadyLinkedError() @@ -32,13 +32,13 @@ def _ctx_unlink(self): class Context: def __init__(self): - self.objs: Set[HINT_CONTEXT_BOUND_OBJ] = set() + self.objs: Set[ContextBoundObj] = set() - def add_obj(self, obj: HINT_CONTEXT_BOUND_OBJ): + def add_obj(self, obj: ContextBoundObj): assert isinstance(obj, ContextBoundObj) self.objs.add(obj) - def remove_obj(self, obj: HINT_CONTEXT_BOUND_OBJ): + def remove_obj(self, obj: ContextBoundObj): assert isinstance(obj, ContextBoundObj) self.objs.remove(obj) @@ -52,10 +52,7 @@ def get_callback_name(self, callback: Callable) -> Optional[str]: raise NotImplementedError() -HINT_CONTEXT_OBJ = TypeVar('HINT_CONTEXT_OBJ', bound=Context) - - class ContextProvidingObj: - def __init__(self, context: Optional[HINT_CONTEXT_OBJ] = None, **kwargs): + def __init__(self, context: Optional[Context] = None, **kwargs): super().__init__(**kwargs) - self._habapp_ctx: HINT_CONTEXT_OBJ = context + self._habapp_ctx: Context = context diff --git a/src/HABApp/core/internals/context/get_context.py b/src/HABApp/core/internals/context/get_context.py index 3227b147..897a6f4a 100644 --- a/src/HABApp/core/internals/context/get_context.py +++ b/src/HABApp/core/internals/context/get_context.py @@ -4,7 +4,7 @@ from typing import Optional, Union, TYPE_CHECKING from HABApp.core.errors import ContextNotSetError, ContextNotFoundError -from HABApp.core.internals.context import ContextProvidingObj, ContextBoundObj, HINT_CONTEXT_OBJ +from HABApp.core.internals.context import ContextProvidingObj, ContextBoundObj, Context if TYPE_CHECKING: import HABApp @@ -32,7 +32,7 @@ def get_current_context(obj: Optional[ContextProvidingObj] = None) -> 'HABApp.ru class AutoContextBoundObj(ContextBoundObj): - def __init__(self, parent_ctx: Optional['HINT_CONTEXT_OBJ'] = None, **kwargs): + def __init__(self, parent_ctx: Optional['Context'] = None, **kwargs): if parent_ctx is None: parent_ctx = get_current_context() super().__init__(parent_ctx=parent_ctx, **kwargs) diff --git a/src/HABApp/core/internals/event_bus/__init__.py b/src/HABApp/core/internals/event_bus/__init__.py index d04dd6aa..9b91009b 100644 --- a/src/HABApp/core/internals/event_bus/__init__.py +++ b/src/HABApp/core/internals/event_bus/__init__.py @@ -1,2 +1,2 @@ from .base_listener import EventBusBaseListener -from .event_bus import EventBus, HINT_EVENT_BUS +from .event_bus import EventBus diff --git a/src/HABApp/core/internals/event_bus/event_bus.py b/src/HABApp/core/internals/event_bus/event_bus.py index dd5fa33a..06828840 100644 --- a/src/HABApp/core/internals/event_bus/event_bus.py +++ b/src/HABApp/core/internals/event_bus/event_bus.py @@ -84,6 +84,3 @@ def remove_listener(self, listener: _TYPE_LISTENER): def remove_all_listeners(self): with self._lock: self._listeners.clear() - - -HINT_EVENT_BUS = TypeVar('HINT_EVENT_BUS', bound=EventBus) diff --git a/src/HABApp/core/internals/event_bus_listener.py b/src/HABApp/core/internals/event_bus_listener.py index f81e67cc..085bb306 100644 --- a/src/HABApp/core/internals/event_bus_listener.py +++ b/src/HABApp/core/internals/event_bus_listener.py @@ -2,7 +2,7 @@ from HABApp.core.internals.event_bus import EventBusBaseListener from HABApp.core.internals.wrapped_function import TYPE_WRAPPED_FUNC_OBJ, WrappedFunctionBase -from HABApp.core.internals import uses_event_bus, HINT_CONTEXT_OBJ +from HABApp.core.internals import uses_event_bus, Context from HABApp.core.internals import HINT_EVENT_FILTER_OBJ, AutoContextBoundObj @@ -34,7 +34,7 @@ def cancel(self): class ContextBoundEventBusListener(EventBusListener, AutoContextBoundObj): def __init__(self, topic: str, callback: TYPE_WRAPPED_FUNC_OBJ, event_filter: HINT_EVENT_FILTER_OBJ, - parent_ctx: Optional[HINT_CONTEXT_OBJ] = None): + parent_ctx: Optional[Context] = None): super().__init__(topic=topic, callback=callback, event_filter=event_filter, parent_ctx=parent_ctx) assert isinstance(callback, WrappedFunctionBase) diff --git a/src/HABApp/core/internals/item_registry/__init__.py b/src/HABApp/core/internals/item_registry/__init__.py index 50892127..e54dd83a 100644 --- a/src/HABApp/core/internals/item_registry/__init__.py +++ b/src/HABApp/core/internals/item_registry/__init__.py @@ -2,4 +2,4 @@ # isort: split -from .item_registry import ItemRegistry, HINT_ITEM_REGISTRY +from .item_registry import ItemRegistry diff --git a/src/HABApp/core/internals/item_registry/item_registry.py b/src/HABApp/core/internals/item_registry/item_registry.py index 00d6420e..88082886 100644 --- a/src/HABApp/core/internals/item_registry/item_registry.py +++ b/src/HABApp/core/internals/item_registry/item_registry.py @@ -15,20 +15,20 @@ class ItemRegistry: def __init__(self): self._lock = threading.Lock() - self._items: Dict[str, _HINT_ITEM_OBJ] = {} + self._items: Dict[str, ItemRegistryItem] = {} - def item_exists(self, name: Union[str, _HINT_ITEM_OBJ]) -> bool: + def item_exists(self, name: Union[str, ItemRegistryItem]) -> bool: if not isinstance(name, str): name = name.name return name in self._items - def get_item(self, name: str) -> _HINT_ITEM_OBJ: + def get_item(self, name: str) -> ItemRegistryItem: try: return self._items[name] except KeyError: raise ItemNotFoundException(name) from None - def get_items(self) -> Tuple[_HINT_ITEM_OBJ, ...]: + def get_items(self) -> Tuple[ItemRegistryItem, ...]: return tuple(self._items.values()) def get_item_names(self) -> Tuple[str, ...]: @@ -67,6 +67,3 @@ def pop_item(self, name: Union[str, _HINT_ITEM_OBJ]) -> _HINT_ITEM_OBJ: log.debug(f'Removed {name} ({item.__class__.__name__})') item._on_item_removed() return item - - -HINT_ITEM_REGISTRY = TypeVar('HINT_ITEM_REGISTRY', bound=ItemRegistry) diff --git a/src/HABApp/core/internals/proxy/proxies.py b/src/HABApp/core/internals/proxy/proxies.py index 957a7a5d..f3608a73 100644 --- a/src/HABApp/core/internals/proxy/proxies.py +++ b/src/HABApp/core/internals/proxy/proxies.py @@ -9,20 +9,20 @@ def uses_post_event() -> Callable[[str, Any], None]: return create_proxy(uses_post_event) -def uses_event_bus() -> 'HABApp.core.internals.HINT_EVENT_BUS': +def uses_event_bus() -> 'HABApp.core.internals.EventBus': return create_proxy(uses_event_bus) -def uses_get_item() -> Callable[[str], 'HABApp.core.internals.item_registry.item_registry._HINT_ITEM_OBJ']: +def uses_get_item() -> Callable[[str], 'HABApp.core.internals.item_registry.item_registry.ItemRegistryItem']: return create_proxy(uses_get_item) -def uses_item_registry() -> 'HABApp.core.internals.HINT_ITEM_REGISTRY': +def uses_item_registry() -> 'HABApp.core.internals.ItemRegistry': return create_proxy(uses_item_registry) -def setup_internals(ir: 'HABApp.core.internals.HINT_ITEM_REGISTRY', - eb: 'HABApp.core.internals.HINT_EVENT_BUS', final=True): +def setup_internals(ir: 'HABApp.core.internals.ItemRegistry', + eb: 'HABApp.core.internals.EventBus', final=True): """Replace the proxy objects with the real thing""" replacements = { uses_item_registry: ir, uses_get_item: ir.get_item, diff --git a/src/HABApp/core/internals/wrapped_function/base.py b/src/HABApp/core/internals/wrapped_function/base.py index 7b5a8f44..df1923fc 100644 --- a/src/HABApp/core/internals/wrapped_function/base.py +++ b/src/HABApp/core/internals/wrapped_function/base.py @@ -3,7 +3,7 @@ from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS from HABApp.core.events.habapp_events import HABAppException -from HABApp.core.internals import HINT_CONTEXT_OBJ, ContextProvidingObj, uses_event_bus +from HABApp.core.internals import Context, ContextProvidingObj, uses_event_bus from HABApp.core.lib import format_exception default_logger = logging.getLogger('HABApp.Worker') @@ -14,7 +14,7 @@ class WrappedFunctionBase(ContextProvidingObj): def __init__(self, func: Callable, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[HINT_CONTEXT_OBJ] = None): + context: Optional[Context] = None): # Allow setting of the rule context super().__init__(context) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_async.py b/src/HABApp/core/internals/wrapped_function/wrapped_async.py index 02fe7398..40438d08 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_async.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_async.py @@ -3,7 +3,7 @@ from HABApp.core.asyncio import async_context, create_task from HABApp.core.const.hints import HINT_FUNC_ASYNC -from HABApp.core.internals import HINT_CONTEXT_OBJ +from HABApp.core.internals import Context from .base import WrappedFunctionBase @@ -12,7 +12,7 @@ class WrappedAsyncFunction(WrappedFunctionBase): def __init__(self, func: HINT_FUNC_ASYNC, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[HINT_CONTEXT_OBJ] = None): + context: Optional[Context] = None): super().__init__(name=name, func=func, logger=logger, context=context) assert callable(func) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_sync.py b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py index fdd341dc..87f85daa 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_sync.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py @@ -2,7 +2,7 @@ from typing import Optional, Callable from HABApp.core.asyncio import async_context, create_task -from HABApp.core.internals import HINT_CONTEXT_OBJ +from HABApp.core.internals import Context from .base import WrappedFunctionBase @@ -12,7 +12,7 @@ def __init__(self, func: Callable, warn_too_long=True, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[HINT_CONTEXT_OBJ] = None): + context: Optional[Context] = None): super().__init__(name=name, func=func, logger=logger, context=context) assert callable(func) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_thread.py b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py index 666278b9..c133da11 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_thread.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py @@ -10,7 +10,7 @@ from typing import Optional from HABApp.core.const import loop -from HABApp.core.internals import HINT_CONTEXT_OBJ, ContextProvidingObj +from HABApp.core.internals import Context, ContextProvidingObj from .base import WrappedFunctionBase, default_logger POOL: Optional[ThreadPoolExecutor] = None @@ -50,7 +50,7 @@ async def run_in_thread_pool(func: Callable): class PoolFunc(ContextProvidingObj): def __init__(self, parent: 'WrappedThreadFunction', func_obj: HINT_FUNC_SYNC, func_args: Tuple[Any, ...], - func_kwargs: Dict[str, Any], context: Optional[HINT_CONTEXT_OBJ] = None, **kwargs): + func_kwargs: Dict[str, Any], context: Optional[Context] = None, **kwargs): super().__init__(context=context, **kwargs) self.parent: Final = parent self.func_obj: Final = func_obj @@ -125,7 +125,7 @@ def __init__(self, func: HINT_FUNC_SYNC, warn_too_long=True, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[HINT_CONTEXT_OBJ] = None): + context: Optional[Context] = None): super().__init__(name=name, func=func, logger=logger, context=context) assert callable(func) diff --git a/src/HABApp/core/internals/wrapped_function/wrapper.py b/src/HABApp/core/internals/wrapped_function/wrapper.py index 460d7557..295445d0 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapper.py +++ b/src/HABApp/core/internals/wrapped_function/wrapper.py @@ -3,7 +3,7 @@ from typing import Union, Optional, Callable, Type from HABApp.config import CONFIG -from HABApp.core.internals import HINT_CONTEXT_OBJ +from HABApp.core.internals import Context from HABApp.core.internals.wrapped_function.base import TYPE_WRAPPED_FUNC_OBJ from HABApp.core.internals.wrapped_function.wrapped_async import WrappedAsyncFunction, HINT_FUNC_ASYNC from HABApp.core.internals.wrapped_function.wrapped_sync import WrappedSyncFunction @@ -15,7 +15,17 @@ def wrap_func(func: Union[HINT_FUNC_SYNC, HINT_FUNC_ASYNC], warn_too_long=True, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[HINT_CONTEXT_OBJ] = None) -> TYPE_WRAPPED_FUNC_OBJ: + context: Optional[Context] = None) -> TYPE_WRAPPED_FUNC_OBJ: + + # Check that it's actually a callable, so we fail fast and not when we try to run the function. + # Some users pass the result of the function call (e.g. func()) by accident + # which will inevitably fail once we try to run the function. + if not callable(func): + try: + type_name: str = func.__class__.__name__ + except Exception: + type_name = type(func) + raise ValueError(f'Callable or coroutine function expected! Got "{func}" (type {type_name:s})') if iscoroutinefunction(func): return WrappedAsyncFunction(func, name=name, logger=logger, context=context) diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py index 8d0331e8..bfc1d5b7 100644 --- a/src/HABApp/core/lib/__init__.py +++ b/src/HABApp/core/lib/__init__.py @@ -3,4 +3,5 @@ from .pending_future import PendingFuture from .single_task import SingleTask from .rgb_hsv import hsb_to_rgb, rgb_to_hsb -from .exceptions import format_exception +from .exceptions import format_exception, HINT_EXCEPTION +from .priority_list import PriorityList diff --git a/src/HABApp/core/lib/exceptions/__init__.py b/src/HABApp/core/lib/exceptions/__init__.py index b4d128de..f3b19a7d 100644 --- a/src/HABApp/core/lib/exceptions/__init__.py +++ b/src/HABApp/core/lib/exceptions/__init__.py @@ -1 +1 @@ -from .format import format_exception +from .format import format_exception, HINT_EXCEPTION diff --git a/src/HABApp/core/lib/exceptions/format.py b/src/HABApp/core/lib/exceptions/format.py index 614a13f2..965c0a50 100644 --- a/src/HABApp/core/lib/exceptions/format.py +++ b/src/HABApp/core/lib/exceptions/format.py @@ -1,5 +1,14 @@ from traceback import format_exception as _format_exception -from typing import Tuple, Union, Any, List +from types import TracebackType +from typing import Tuple, Union, Any, List, Type + +from HABApp.core.const.const import PYTHON_310 + +if PYTHON_310: + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + from stack_data import FrameInfo, Options @@ -33,7 +42,10 @@ def fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: return new_tb -def format_exception(e: Union[Exception, Tuple[Any, Any, Any]]) -> List[str]: +HINT_EXCEPTION: TypeAlias = Union[Exception, Tuple[Type[Exception], Exception, TracebackType]] + + +def format_exception(e: HINT_EXCEPTION) -> List[str]: tb: List[str] = [] try: diff --git a/src/HABApp/core/lib/exceptions/format_frame.py b/src/HABApp/core/lib/exceptions/format_frame.py index 0dc2c058..0d57c682 100644 --- a/src/HABApp/core/lib/exceptions/format_frame.py +++ b/src/HABApp/core/lib/exceptions/format_frame.py @@ -20,6 +20,9 @@ # Item registry re.compile(r'[/\\]HABApp[/\\]core[/\\]internals[/\\]item_registry[/\\]'), + + # Connection wrappers + re.compile(r'[/\\]HABApp[/\\]core[/\\]connections[/\\]'), ) SUPPRESSED_PATHS = ( diff --git a/src/HABApp/core/lib/exceptions/format_frame_vars.py b/src/HABApp/core/lib/exceptions/format_frame_vars.py index c4cdc2a5..3efb2bde 100644 --- a/src/HABApp/core/lib/exceptions/format_frame_vars.py +++ b/src/HABApp/core/lib/exceptions/format_frame_vars.py @@ -1,7 +1,8 @@ import ast +import datetime from inspect import ismodule, isclass -from typing import List, Tuple, Callable, Any, Set, TypeVar from pathlib import Path +from typing import List, Tuple, Callable, Any, Set from immutables import Map from stack_data import Variable @@ -10,22 +11,35 @@ from easyconfig.config_objs import ConfigObj from .const import SEPARATOR_VARIABLES, PRE_INDENT +# don't show these types in the traceback +SKIPPED_TYPES = ( + bool, bytearray, bytes, complex, dict, float, frozenset, int, list, memoryview, set, str, tuple, type(None), + datetime.date, datetime.datetime, datetime.time, datetime.timedelta, + Map, Path, +) -def _filter_expressions(name: str, value: Any) -> bool: - # a is None = True - if name.endswith(' is None'): - return True - # These types show no explicit types - skipped_types = (type(None), str, float, int, list, dict, set, frozenset, Map, Path, bytes) +def is_type_hint_or_type(value: Any) -> bool: + if isinstance(value, tuple): + return all(is_type_hint_or_type(obj) for obj in value) - # type(b) = - if name.startswith('type(') and value in skipped_types: + if value in SKIPPED_TYPES: return True - # (str, int, bytes) = (, ) - # str, int = (, ) - if isinstance(value, tuple) and all(map(lambda x: x in skipped_types, value)): + # check if it's something from the typing module + # we can't do that with isinstance, so we try this + try: + if value.__module__ == 'typing' or value.__class__.__module__ == 'typing': + return True + except AttributeError: + pass + + return False + + +def _filter_expressions(name: str, value: Any) -> bool: + # a is None = True + if name.endswith(' is None'): return True return False @@ -35,10 +49,8 @@ def _filter_expressions(name: str, value: Any) -> bool: # module imports lambda name, value: ismodule(value), - # type hints - lambda name, value: name.startswith('typing.'), - # type vars - lambda name, value: isinstance(value, TypeVar), + # type hints and type tuples + lambda name, value: is_type_hint_or_type(value), # functions lambda name, value: value is dump_json or value is load_json, @@ -56,9 +68,9 @@ def _filter_expressions(name: str, value: Any) -> bool: def skip_variable(var: Variable) -> bool: + name = var.name + value = var.value for func in SKIP_VARIABLE: - name = var.name - value = var.value if func(name, value): return True return False diff --git a/src/HABApp/core/lib/priority_list.py b/src/HABApp/core/lib/priority_list.py new file mode 100644 index 00000000..fbd9601d --- /dev/null +++ b/src/HABApp/core/lib/priority_list.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, Literal, Union, Iterator, Tuple + +from HABApp.core.const.const import PYTHON_310 + +if PYTHON_310: + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + + +T = TypeVar('T') + +T_PRIO: TypeAlias = Union[Literal['first', 'last'], int] +T_ENTRY: TypeAlias = Tuple[T_PRIO, T] + + +def sort_func(obj: T_ENTRY): + prio = {'first': 0, 'last': 2} + key = obj[0] + assert isinstance(key, int) or key in prio + return prio.get(key, 1), key + + +class PriorityList(Generic[T]): + def __init__(self): + self._objs: list[T_ENTRY] = [] + + def append(self, obj: T, priority: T_PRIO): + for o in self._objs: + assert o[0] != priority, priority + self._objs.append((priority, obj)) + self._objs.sort(key=sort_func) + + def remove(self, obj: T): + for i, (_, existing) in self._objs: + if existing is obj: + self._objs.pop(i) + return None + + def __iter__(self) -> Iterator[T]: + for p, o in self._objs: + yield o + + def reversed(self) -> Iterator[T]: + for p, o in reversed(self._objs): + yield o + + def __repr__(self): + return f'<{self.__class__.__name__} {[o for o in self]}>' diff --git a/src/HABApp/core/lib/single_task.py b/src/HABApp/core/lib/single_task.py index 0d73140d..532b4221 100644 --- a/src/HABApp/core/lib/single_task.py +++ b/src/HABApp/core/lib/single_task.py @@ -1,8 +1,10 @@ -from asyncio import Task +from asyncio import Task, current_task, CancelledError from typing import Callable, Awaitable, Any, Final, Optional from HABApp.core.const import loop +_TASK_REFS = set() + class SingleTask: def __init__(self, coro: Callable[[], Awaitable[Any]], name: Optional[str] = None): @@ -10,24 +12,61 @@ def __init__(self, coro: Callable[[], Awaitable[Any]], name: Optional[str] = Non name = f'{self.__class__.__name__}_{coro.__name__}' self.coro: Final = coro - self.task: Optional[Task] = None self.name: Final = name + self.task: Optional[Task] = None + + @property + def is_running(self) -> bool: + return self.task is not None + + def cancel(self) -> Optional[Task]: + if (task := self.task) is None: + return None + + self.task = None + + # we need the reference only when we cancel a task, otherwise the task is saved in the attribute + _TASK_REFS.add(task) + task.add_done_callback(_TASK_REFS.discard) + + task.cancel() + return task + + async def cancel_wait(self): + if task := self.cancel(): + try: + await task + except CancelledError: + pass - def cancel(self): - if self.task is not None: - task = self.task - self.task = None - task.cancel() + async def wait(self): + if self.task is None: + return None - def start(self): + try: + await self.task + except CancelledError: + pass + + def start(self) -> Task: self.cancel() - self.task = loop.create_task(self._task_wrap(), name=self.name) + self.task = task = loop.create_task(self._task_wrap(), name=self.name) + return task + + def start_if_not_running(self) -> Task: + if (task := self.task) is not None: + return task + + self.task = task = loop.create_task(self._task_wrap(), name=self.name) + return task async def _task_wrap(self): - # don't use try-finally because this also catches the asyncio.CancelledError + task = current_task(loop) + + # don't use try-finally because try: await self.coro() - except Exception: - pass - - self.task = None + finally: + # This also runs on asyncio.CancelledError so we have to make sure it's the same task + if self.task is task: + self.task = None diff --git a/src/HABApp/core/wrapper.py b/src/HABApp/core/wrapper.py index 6428f042..d9e1b85e 100644 --- a/src/HABApp/core/wrapper.py +++ b/src/HABApp/core/wrapper.py @@ -5,6 +5,7 @@ from logging import Logger # noinspection PyProtectedMember from sys import _getframe as sys_get_frame +from typing import Union, Callable from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS from HABApp.core.const.topics import TOPIC_WARNINGS as TOPIC_WARNINGS @@ -17,7 +18,7 @@ post_event = uses_post_event() -def process_exception(func: typing.Union[typing.Callable, str], e: Exception, +def process_exception(func: Union[Callable, str], e: Exception, do_print=False, logger: logging.Logger = log): lines = format_exception(e) diff --git a/src/HABApp/mqtt/__init__.py b/src/HABApp/mqtt/__init__.py index 2f3d99e1..cb74775d 100644 --- a/src/HABApp/mqtt/__init__.py +++ b/src/HABApp/mqtt/__init__.py @@ -1,3 +1,7 @@ from . import events from . import items -from . import interface + +# isort: split + +import HABApp.mqtt.interface_async +import HABApp.mqtt.interface_sync diff --git a/src/HABApp/mqtt/connection/__init__.py b/src/HABApp/mqtt/connection/__init__.py new file mode 100644 index 00000000..4b638e50 --- /dev/null +++ b/src/HABApp/mqtt/connection/__init__.py @@ -0,0 +1 @@ +from .connection import setup diff --git a/src/HABApp/mqtt/connection/connection.py b/src/HABApp/mqtt/connection/connection.py new file mode 100644 index 00000000..44415ae9 --- /dev/null +++ b/src/HABApp/mqtt/connection/connection.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from aiomqtt import Client, MqttError + +import HABApp +from HABApp.core.asyncio import AsyncContext +from HABApp.core.connections import BaseConnection, Connections, ConnectionStateToEventBusPlugin, AutoReconnectPlugin +from HABApp.core.connections.base_plugin import BaseConnectionPluginConnectedTask +from HABApp.core.const.const import PYTHON_310 + +log = logging.getLogger('HABApp.mqtt.connection') + +if PYTHON_310: + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +CONTEXT_TYPE: TypeAlias = Optional[Client] + + +def setup(): + config = HABApp.config.CONFIG.mqtt + + from HABApp.mqtt.connection.handler import CONNECTION_HANDLER + from HABApp.mqtt.connection.subscribe import SUBSCRIPTION_HANDLER + from HABApp.mqtt.connection.publish import PUBLISH_HANDLER + + connection = Connections.add(MqttConnection()) + + connection.register_plugin(CONNECTION_HANDLER, 0) + connection.register_plugin(SUBSCRIPTION_HANDLER, 10) + connection.register_plugin(PUBLISH_HANDLER, 20) + + connection.register_plugin(ConnectionStateToEventBusPlugin()) + connection.register_plugin(AutoReconnectPlugin()) + + # config changes + config.subscribe.subscribe_for_changes(SUBSCRIPTION_HANDLER.subscription_cfg_changed) + config.connection.subscribe_for_changes(connection.status_configuration_changed) + + +class MqttConnection(BaseConnection): + def __init__(self): + super().__init__('mqtt') + self.context: CONTEXT_TYPE = None + + def is_silent_exception(self, e: Exception): + return isinstance(e, MqttError) + + +class MqttPlugin(BaseConnectionPluginConnectedTask[MqttConnection]): + + def __init__(self, task_name: str): + super().__init__(self._mqtt_wrap_task, task_name) + + async def mqtt_task(self): + raise NotImplementedError() + + async def _mqtt_wrap_task(self): + + connection = self.plugin_connection + log = connection.log + log.debug(f'{self.task.name} task start') + try: + with AsyncContext('MQTT'), connection.handle_exception(self.mqtt_task): + await self.mqtt_task() + finally: + log.debug(f'{self.task.name} task stop') diff --git a/src/HABApp/mqtt/connection/handler.py b/src/HABApp/mqtt/connection/handler.py new file mode 100644 index 00000000..5c53b9a2 --- /dev/null +++ b/src/HABApp/mqtt/connection/handler.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from aiomqtt import Client, TLSParameters + +from HABApp.config import CONFIG +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.connections._definitions import CONNECTION_HANDLER_NAME + +from HABApp.core.internals import uses_post_event, uses_get_item, uses_item_registry +from HABApp.mqtt.connection.connection import CONTEXT_TYPE, MqttConnection + +post_event = uses_post_event() +get_item = uses_get_item() +Items = uses_item_registry() + + +class ConnectionHandler(BaseConnectionPlugin[MqttConnection]): + def __init__(self): + super().__init__(name=CONNECTION_HANDLER_NAME) + + async def on_setup(self, connection: MqttConnection): + log = connection.log + config = CONFIG.mqtt.connection + + if not config.host: + log.info('MQTT disabled') + connection.status_from_setup_to_disabled() + return None + + tls_insecure: bool | None = None + tls_ca_cert: str | None = None + if tls_enabled := config.tls.enabled: + log.debug("TLS enabled") + + # add option to specify tls certificate + ca_cert = config.tls.ca_cert + if ca_cert is not None and ca_cert.name: + if not ca_cert.is_file(): + log.error(f'Ca cert file does not exist: {ca_cert}') + # don't connect without the properly set certificate + connection.set_error() + return None + + log.debug(f"CA cert path: {ca_cert}") + tls_ca_cert = str(ca_cert) + + # we can only set tls_insecure if we have a tls connection + if config.tls.insecure: + log.warning('Verification of server hostname in server certificate disabled!') + log.warning('Use this only for testing, not for a real system!') + tls_insecure = True + + connection.context = Client( + hostname=config.host, port=config.port, + username=config.user if config.user else None, password=config.password if config.password else None, + client_id=config.client_id, + + tls_insecure=tls_insecure, + tls_params=None if not tls_enabled else TLSParameters(ca_certs=tls_ca_cert), + + # clean_session=False + ) + + # noinspection PyProtectedMember + async def on_connecting(self, connection: MqttConnection, context: CONTEXT_TYPE): + assert context is not None + + connection.log.info(f'Connecting to {context._hostname}:{context._port}') + await context.__aenter__() + connection.log.info('Connection successful') + + async def on_disconnected(self, connection: MqttConnection, context: CONTEXT_TYPE): + assert context is not None + + connection.log.info('Disconnected') + await context.__aexit__(None, None, None) + + +CONNECTION_HANDLER = ConnectionHandler() diff --git a/src/HABApp/mqtt/connection/publish.py b/src/HABApp/mqtt/connection/publish.py new file mode 100644 index 00000000..a9a02907 --- /dev/null +++ b/src/HABApp/mqtt/connection/publish.py @@ -0,0 +1,90 @@ +from asyncio import Queue +from typing import Optional, Union + +from HABApp.config import CONFIG +from HABApp.config.models.mqtt import QOS +from HABApp.core.asyncio import run_func_from_async +from HABApp.core.const.json import dump_json +from HABApp.core.internals import ItemRegistryItem +from HABApp.mqtt.connection.connection import MqttPlugin + + +class PublishHandler(MqttPlugin): + def __init__(self): + super().__init__(task_name='MqttPublish') + + async def mqtt_task(self): + connection = self.plugin_connection + with connection.handle_exception(self.mqtt_task): + client = self.plugin_connection.context + assert client is not None + + cfg = CONFIG.mqtt.publish + queue = QUEUE + assert queue is not None + + # worker to publish things + while True: + topic, value, qos, retain = await queue.get() + if qos is None: + qos = cfg.qos + if retain is None: + retain = cfg.retain + + await client.publish(topic, value, qos, retain) + queue.task_done() + + async def on_connected(self): + global QUEUE + + QUEUE = Queue() + await super().on_connected() + + async def on_disconnected(self): + global QUEUE + + await super().on_disconnected() + QUEUE = None + + +QUEUE: Optional[Queue] = Queue() + + +PUBLISH_HANDLER = PublishHandler() + + +def async_publish(topic: Union[str, ItemRegistryItem], payload, qos: Optional[QOS] = None, + retain: Optional[bool] = None): + """ + Publish a value under a certain topic. + If qos and/or retain is not set the value from the configuration file will be used. + + :param topic: MQTT topic or item + :param payload: MQTT Payload + :param qos: QoS, can be 0, 1 or 2. If not specified the value from configuration file will be used. + :param retain: retain message. If not specified the value from configuration file will be used. + """ + if (queue := QUEUE) is None: + return None + + if isinstance(topic, ItemRegistryItem): + topic = topic.name + + # convert these to string + if isinstance(payload, (dict, list, set, frozenset)): + payload = dump_json(payload) + + queue.put_nowait((topic, payload, qos, retain)) + + +def publish(topic: Union[str, ItemRegistryItem], payload, qos: Optional[QOS] = None, retain: Optional[bool] = None): + """ + Publish a value under a certain topic. + If qos and/or retain is not set the value from the configuration file will be used. + + :param topic: MQTT topic or item + :param payload: MQTT Payload + :param qos: QoS, can be 0, 1 or 2. If not specified the value from configuration file will be used. + :param retain: retain message. If not specified the value from configuration file will be used. + """ + run_func_from_async(topic, payload, qos, retain) diff --git a/src/HABApp/mqtt/connection/subscribe.py b/src/HABApp/mqtt/connection/subscribe.py new file mode 100644 index 00000000..3356657f --- /dev/null +++ b/src/HABApp/mqtt/connection/subscribe.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from typing import Any, Iterable + +import HABApp +from HABApp.config import CONFIG +from HABApp.config.models.mqtt import QOS +from HABApp.core.asyncio import run_func_from_async +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.internals import uses_post_event, uses_get_item, uses_item_registry +from HABApp.core.lib import SingleTask +from HABApp.core.wrapper import process_exception +from HABApp.mqtt.connection.connection import MqttPlugin +from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueUpdateEvent +from HABApp.mqtt.mqtt_payload import get_msg_payload + +SUBSCRIBE_CFG = CONFIG.mqtt.subscribe + + +class SubscriptionHandler(MqttPlugin): + def __init__(self): + super().__init__(task_name='MqttSubscribe') + self.runtime_subs: dict[str, int] = {} + self.subscribed_to: dict[str, int] = {} + + self.sub_task = SingleTask(self.apply_subscriptions, 'ApplySubscriptionsTask') + + async def interface_subscribe(self, topic_or_topics: str | Iterable[tuple[str, int | None]], + qos: QOS | None = None): + """ + Subscribe to a MQTT topic. Note that subscriptions made this way are volatile and will only remain until + the next restart. + + :param topic_or_topics: MQTT topic or multiple topic qos pairs to subscribe to + :param qos: QoS, can be 0, 1 or 2. If not specified value from configuration file will be used. + """ + + if qos is None: + qos = SUBSCRIBE_CFG.qos + if not isinstance(topic_or_topics, str): + for _t, _q in topic_or_topics: + self.runtime_subs[_t] = _q if _q is not None else qos + else: + self.runtime_subs[topic_or_topics] = qos + + if self.plugin_connection.context is not None: + await self.apply_subscriptions() + + async def interface_unsubscribe(self, topic_or_topics: str | Iterable[str]): + """ + Unsubscribe from a MQTT topic + + :param topic_or_topics: MQTT topic + """ + + if isinstance(topic_or_topics, str): + topic_or_topics = [topic_or_topics] + + for topic in topic_or_topics: + self.runtime_subs.pop(topic, None) + + if self.plugin_connection.context is not None: + await self.apply_subscriptions() + + def subscription_cfg_changed(self): + if not self.plugin_connection.is_online: + return None + self.sub_task.start_if_not_running() + + async def unsubscribe(self, topics: list[str] | None): + log = self.plugin_connection.log + + if (client := self.plugin_connection.context) is None: + return None + + if topics is None: + topics = sorted(set(self.runtime_subs) | set(self.subscribed_to)) + + if not topics: + return None + + log.debug('Unsubscribing from:') + for topic in topics: + log.debug(f' - "{topic:s}"') + + await client.unsubscribe(topics) + + for topic in topics: + self.subscribed_to.pop(topic) + + async def apply_subscriptions(self): + log = self.plugin_connection.log + default_qos = SUBSCRIBE_CFG.qos + + client = self.plugin_connection.context + assert client is not None + + target: dict[str, int] = {} + + # If our connection has errors we'll do a disconnect cycle anyway, so we don't even try to subscribe to anything + # Unsubscribing has the corresponding handling, so we call that every time + if not self.plugin_connection.has_errors: + # subscription from config + for topic, qos in CONFIG.mqtt.subscribe.topics: + target[topic] = qos if qos is not None else default_qos + # runtime subscriptions overwrite the subscriptions from the config file + for topic, qos in self.runtime_subs.items(): + target[topic] = qos + + unsubscribe = [] + for sub_topic, sub_qos in sorted(self.subscribed_to.items()): + if sub_topic not in target or target[sub_topic] != sub_qos: + unsubscribe.append(sub_topic) + + await self.unsubscribe(unsubscribe) + + if subscribe := [(topic, qos) for topic, qos in target.items() if topic not in self.subscribed_to]: + log.debug('Subscribing to:') + for topic, qos in subscribe: + log.debug(f' - "{topic}" (QoS {qos:d})') + + await client.subscribe(subscribe) + + for topic, qos in subscribe: + self.subscribed_to[topic] = qos + + log.debug('Subscriptions successfully updated') + + async def on_connected(self): + await super().on_connected() + self.sub_task.start_if_not_running() + await self.sub_task.wait() + + async def on_disconnected(self): + await super().on_disconnected() + await self.sub_task.cancel_wait() + + # without errors, it's a graceful disconnect + if not self.plugin_connection.has_errors: + await self.unsubscribe(None) + + async def mqtt_task(self): + client = self.plugin_connection.context + assert client is not None + + async with client.messages() as messages: + async for message in messages: + + try: + topic, payload = get_msg_payload(message) + if topic is None: + continue + + msg_to_event(topic, payload, message.retain) + except Exception as e: + process_exception('mqtt payload handling', e, logger=self.plugin_connection.log) + + +post_event = uses_post_event() +get_item = uses_get_item() +Items = uses_item_registry() + + +def msg_to_event(topic: str, payload: Any, retain: bool): + + _item = None # type: HABApp.mqtt.items.MqttBaseItem | None + try: + _item = get_item(topic) # type: HABApp.mqtt.items.MqttBaseItem + except ItemNotFoundException: + # only create items for if the message has the retain flag + if retain: + _item = Items.add_item(HABApp.mqtt.items.MqttItem(topic)) + + # we don't have an item -> we process only the event + if _item is None: + post_event(topic, MqttValueUpdateEvent(topic, payload)) + return None + + # Remember state and update item before doing callbacks + _old_state = _item.value + _item.set_value(payload) + + post_event(topic, MqttValueUpdateEvent(topic, payload)) + if payload != _old_state: + post_event(topic, MqttValueChangeEvent(topic, payload, _old_state)) + + +SUBSCRIPTION_HANDLER = SubscriptionHandler() + + +async_subscribe = SUBSCRIPTION_HANDLER.interface_subscribe +async_unsubscribe = SUBSCRIPTION_HANDLER.interface_unsubscribe + + +def subscribe(topic_or_topics: str | Iterable[tuple[str, int | None]], qos: QOS | None = None): + """ + Subscribe to a MQTT topic. Note that subscriptions made this way are volatile and will only remain until + the next restart. + + :param topic_or_topics: MQTT topic or multiple topic qos pairs to subscribe to + :param qos: QoS, can be 0, 1 or 2. If not specified value from configuration file will be used. + """ + run_func_from_async(async_subscribe(topic_or_topics, qos)) + + +def unsubscribe(topic_or_topics: str | Iterable[str]): + """ + Unsubscribe from a MQTT topic + + :param topic_or_topics: MQTT topic + """ + run_func_from_async(async_subscribe(topic_or_topics)) diff --git a/src/HABApp/mqtt/interface.py b/src/HABApp/mqtt/interface.py deleted file mode 100644 index f429476f..00000000 --- a/src/HABApp/mqtt/interface.py +++ /dev/null @@ -1 +0,0 @@ -from .mqtt_interface import subscribe, unsubscribe, publish # noqa: F401 diff --git a/src/HABApp/mqtt/interface_async.py b/src/HABApp/mqtt/interface_async.py new file mode 100644 index 00000000..24c17f28 --- /dev/null +++ b/src/HABApp/mqtt/interface_async.py @@ -0,0 +1,2 @@ +from .connection.publish import async_publish +from .connection.subscribe import async_subscribe, async_unsubscribe diff --git a/src/HABApp/mqtt/interface_sync.py b/src/HABApp/mqtt/interface_sync.py new file mode 100644 index 00000000..368884c4 --- /dev/null +++ b/src/HABApp/mqtt/interface_sync.py @@ -0,0 +1,2 @@ +from .connection.publish import publish +from .connection.subscribe import subscribe, unsubscribe diff --git a/src/HABApp/mqtt/items/mqtt_item.py b/src/HABApp/mqtt/items/mqtt_item.py index ba6f5977..a38c7cd1 100644 --- a/src/HABApp/mqtt/items/mqtt_item.py +++ b/src/HABApp/mqtt/items/mqtt_item.py @@ -1,7 +1,7 @@ from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import uses_get_item, uses_item_registry from HABApp.core.items import BaseValueItem -from HABApp.mqtt.mqtt_interface import publish +from HABApp.mqtt.interface_sync import publish get_item = uses_get_item() item_registry = uses_item_registry() @@ -40,7 +40,6 @@ def publish(self, payload, qos: int = None, retain: bool = None): :param payload: MQTT Payload :param qos: QoS, can be ``0``, ``1`` or ``2``. If not specified value from configuration file will be used. :param retain: retain message. If not specified value from configuration file will be used. - :return: 0 if successful """ return publish(self.name, payload, qos=qos, retain=retain) diff --git a/src/HABApp/mqtt/items/mqtt_pair_item.py b/src/HABApp/mqtt/items/mqtt_pair_item.py index c3c87540..b7de2760 100644 --- a/src/HABApp/mqtt/items/mqtt_pair_item.py +++ b/src/HABApp/mqtt/items/mqtt_pair_item.py @@ -2,7 +2,7 @@ from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import uses_item_registry -from HABApp.mqtt.mqtt_interface import publish +from HABApp.mqtt.interface_sync import publish from . import MqttBaseItem Items = uses_item_registry() diff --git a/src/HABApp/mqtt/mqtt_connection.py b/src/HABApp/mqtt/mqtt_connection.py deleted file mode 100644 index d22b64b4..00000000 --- a/src/HABApp/mqtt/mqtt_connection.py +++ /dev/null @@ -1,193 +0,0 @@ -import asyncio -import logging -import typing - -import paho.mqtt.client as mqtt - -import HABApp -from HABApp.core.asyncio import async_context -from HABApp.core.const.log import TOPIC_EVENTS -from HABApp.core.errors import ItemNotFoundException -from HABApp.core.internals import uses_post_event, uses_get_item, uses_item_registry -from HABApp.core.wrapper import log_exception -from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueUpdateEvent -from HABApp.mqtt.mqtt_payload import get_msg_payload -from HABApp.runtime import shutdown - -log = logging.getLogger('HABApp.mqtt.connection') -log_msg = logging.getLogger(f'{TOPIC_EVENTS}.mqtt') - - -post_event = uses_post_event() -get_item = uses_get_item() -Items = uses_item_registry() - - -class MqttStatus: - def __init__(self): - self.loop_started = False - self.connected = False - self.client: typing.Optional[mqtt.Client] = None - self.subscriptions: typing.List[typing.Tuple[str, int]] = [] - - -STATUS = MqttStatus() - - -def setup(): - config = HABApp.config.CONFIG.mqtt - - # config changes - config.subscribe.subscribe_for_changes(subscription_changed) - config.connection.subscribe_for_changes(connect) - - # shutdown - shutdown.register_func(disconnect, msg='Disconnecting MQTT') - - -def connect(): - config = HABApp.config.CONFIG.mqtt - - if not config.connection.host: - log.info('MQTT disabled') - disconnect() - return None - - if STATUS.connected: - log.info('disconnecting') - STATUS.client.disconnect() - STATUS.connected = False - - STATUS.client = mqtt_client = mqtt.Client( - client_id=config.connection.client_id, - clean_session=False - ) - - if config.connection.tls.enabled: - log.debug("TLS enabled") - # add option to specify tls certificate - ca_cert = config.connection.tls.ca_cert - if ca_cert is not None and ca_cert.name: - if not ca_cert.is_file(): - log.error(f'Ca cert file does not exist: {ca_cert}') - # don't connect without the properly set certificate - disconnect() - return None - else: - log.debug(f"CA cert path: {ca_cert}") - mqtt_client.tls_set(ca_cert) - else: - mqtt_client.tls_set() - - # we can only set tls_insecure if we have a tls connection - if config.connection.tls.insecure: - log.warning('Verification of server hostname in server certificate disabled!') - log.warning('Use this only for testing, not for a real system!') - mqtt_client.tls_insecure_set(True) - - # set user/pw if required - user = config.connection.user - pw = config.connection.password - if user: - mqtt_client.username_pw_set(user, pw if pw else None) - - # setup callbacks - mqtt_client.on_connect = on_connect - mqtt_client.on_disconnect = on_disconnect - mqtt_client.on_message = process_msg - - mqtt_client.connect_async( - config.connection.host, port=config.connection.port, keepalive=60 - ) - - log.info(f'Connecting to {config.connection.host}:{config.connection.port}') - - if not STATUS.loop_started: - mqtt_client.loop_start() - STATUS.loop_started = True - - -@log_exception -def disconnect(): - if STATUS.connected: - STATUS.client.disconnect() - STATUS.connected = False - if STATUS.loop_started: - STATUS.client.loop_stop() - STATUS.loop_started = False - - STATUS.client = None - - -@log_exception -def on_connect(client, userdata, flags, rc): - log.log(logging.INFO if not rc else logging.ERROR, mqtt.connack_string(rc)) - if rc: - return None - STATUS.connected = True - - STATUS.subscriptions.clear() - subscription_changed() - - -@log_exception -def on_disconnect(client, userdata, rc): - log.log(logging.INFO if not rc else logging.ERROR, f'Disconnect: {mqtt.error_string(rc)} ({rc})') - STATUS.connected = False - - -@log_exception -def subscription_changed(): - if not STATUS.connected: - return None - - if STATUS.subscriptions: - unsubscribe = [k[0] for k in STATUS.subscriptions] - log.debug('Unsubscribing from:') - for t in unsubscribe: - log.debug(f' - "{t}"') - STATUS.client.unsubscribe(unsubscribe) - - topics = HABApp.config.CONFIG.mqtt.subscribe.topics - default_qos = HABApp.config.CONFIG.mqtt.subscribe.qos - STATUS.subscriptions = [(topic, qos if qos is not None else default_qos) for topic, qos in topics] - log.debug('Subscribing to:') - for topic, qos in STATUS.subscriptions: - log.debug(f' - "{topic}" (QoS {qos:d})') - STATUS.client.subscribe(STATUS.subscriptions) - - -@log_exception -def process_msg(client, userdata, message: mqtt.MQTTMessage): - - topic, payload = get_msg_payload(message) - if topic is None: - return None - - # Post events - asyncio.run_coroutine_threadsafe(send_event_async(topic, payload, message.retain), HABApp.core.const.loop) - - -async def send_event_async(topic, payload, retain: bool): - async_context.set('MQTT') - - _item = None # type: typing.Optional[HABApp.mqtt.items.MqttBaseItem] - try: - _item = get_item(topic) # type: HABApp.mqtt.items.MqttBaseItem - except ItemNotFoundException: - # only create items for if the message has the retain flag - if retain: - _item = Items.add_item(HABApp.mqtt.items.MqttItem(topic)) - - # we don't have an item -> we process only the event - if _item is None: - post_event(topic, MqttValueUpdateEvent(topic, payload)) - return None - - # Remember state and update item before doing callbacks - _old_state = _item.value - _item.set_value(payload) - - post_event(topic, MqttValueUpdateEvent(topic, payload)) - if payload != _old_state: - post_event(topic, MqttValueChangeEvent(topic, payload, _old_state)) diff --git a/src/HABApp/mqtt/mqtt_interface.py b/src/HABApp/mqtt/mqtt_interface.py index 98e413aa..c7a40100 100644 --- a/src/HABApp/mqtt/mqtt_interface.py +++ b/src/HABApp/mqtt/mqtt_interface.py @@ -3,7 +3,7 @@ import paho.mqtt.client as mqtt import HABApp -from .mqtt_connection import STATUS, log +from HABApp.mqtt.connection.mqtt_connection import STATUS, log from ..core.const.json import dump_json diff --git a/src/HABApp/mqtt/mqtt_payload.py b/src/HABApp/mqtt/mqtt_payload.py index 72bc36a6..6c6d724d 100644 --- a/src/HABApp/mqtt/mqtt_payload.py +++ b/src/HABApp/mqtt/mqtt_payload.py @@ -1,7 +1,7 @@ import logging from typing import Tuple, Any, Optional -from paho.mqtt.client import MQTTMessage +from aiomqtt import Message from HABApp.core.const.json import load_json from HABApp.core.const.log import TOPIC_EVENTS @@ -10,9 +10,9 @@ log = logging.getLogger(f'{TOPIC_EVENTS}.mqtt') -def get_msg_payload(msg: MQTTMessage) -> Tuple[Optional[str], Any]: +def get_msg_payload(msg: Message) -> Tuple[Optional[str], Any]: try: - topic = msg._topic.decode('utf-8') + topic = msg.topic.value raw = msg.payload try: @@ -53,5 +53,5 @@ def get_msg_payload(msg: MQTTMessage) -> Tuple[Optional[str], Any]: except ValueError: return topic, val except Exception as e: - process_exception('get_msg_payload', e, logger=log) + process_exception(get_msg_payload, e, logger=log) return None, None diff --git a/src/HABApp/openhab/__init__.py b/src/HABApp/openhab/__init__.py index 523134d8..e7d45d0b 100644 --- a/src/HABApp/openhab/__init__.py +++ b/src/HABApp/openhab/__init__.py @@ -5,7 +5,7 @@ # isort: split import HABApp.openhab.interface_async -import HABApp.openhab.interface +import HABApp.openhab.interface_sync # isort: split diff --git a/src/HABApp/openhab/connection/__init__.py b/src/HABApp/openhab/connection/__init__.py new file mode 100644 index 00000000..76b1f05b --- /dev/null +++ b/src/HABApp/openhab/connection/__init__.py @@ -0,0 +1,2 @@ +from .connection import setup +from . import plugins diff --git a/src/HABApp/openhab/connection/connection.py b/src/HABApp/openhab/connection/connection.py new file mode 100644 index 00000000..2973911f --- /dev/null +++ b/src/HABApp/openhab/connection/connection.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Any, TYPE_CHECKING + +import aiohttp + +import HABApp +from HABApp.core.connections import BaseConnection, Connections, ConnectionStateToEventBusPlugin, AutoReconnectPlugin +from HABApp.core.const.const import PYTHON_310 +from HABApp.core.items.base_valueitem import datetime + +if PYTHON_310: + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +if TYPE_CHECKING: + from HABApp.openhab.items import OpenhabItem, Thing + + +@dataclass +class OpenhabContext: + version: tuple[int, int, int] + is_oh3: bool + + # true when we waited during connect + waited_for_openhab: bool + + created_items: dict[str, tuple[OpenhabItem, datetime]] + created_things: dict[str, tuple[Thing, datetime]] + + session: aiohttp.ClientSession + session_options: dict[str, Any] + + +CONTEXT_TYPE: TypeAlias = Optional[OpenhabContext] + + +def setup(): + config = HABApp.config.CONFIG.openhab + + from HABApp.openhab.connection.handler import HANDLER as CONNECTION_HANDLER + from HABApp.openhab.connection.plugins import (WaitForStartlevelPlugin, LoadOpenhabItemsPlugin, + SseEventListenerPlugin, OUTGOING_PLUGIN, LoadTransformationsPlugin, + WaitForPersistenceRestore, PingPlugin, ThingOverviewPlugin, + TextualThingConfigPlugin) + + connection = Connections.add(OpenhabConnection()) + connection.register_plugin(CONNECTION_HANDLER) + + connection.register_plugin(WaitForStartlevelPlugin(), 0) + connection.register_plugin(OUTGOING_PLUGIN, 10) + connection.register_plugin(LoadOpenhabItemsPlugin('LoadItemsAndThings'), 20) + connection.register_plugin(SseEventListenerPlugin(), 30) + connection.register_plugin(LoadOpenhabItemsPlugin('SyncItemsAndThings'), 40) + connection.register_plugin(LoadTransformationsPlugin(), 50) + connection.register_plugin(PingPlugin(), 100) + connection.register_plugin(WaitForPersistenceRestore(), 110) + connection.register_plugin(TextualThingConfigPlugin(), 120) + connection.register_plugin(ThingOverviewPlugin(), 500_000) + + connection.register_plugin(ConnectionStateToEventBusPlugin()) + connection.register_plugin(AutoReconnectPlugin()) + + # config changes + config.general.subscribe_for_changes(CONNECTION_HANDLER.update_cfg_general) + + +class OpenhabConnection(BaseConnection): + def __init__(self): + super().__init__('openhab') + self.context: CONTEXT_TYPE = None + + def is_silent_exception(self, e: Exception): + return isinstance(e, ( + # aiohttp Exceptions + aiohttp.ClientPayloadError, aiohttp.ClientConnectorError, aiohttp.ClientOSError, + + # aiohttp_sse_client Exceptions + ConnectionRefusedError, ConnectionError, ConnectionAbortedError) + ) diff --git a/src/HABApp/openhab/connection/handler/__init__.py b/src/HABApp/openhab/connection/handler/__init__.py new file mode 100644 index 00000000..03c91ca5 --- /dev/null +++ b/src/HABApp/openhab/connection/handler/__init__.py @@ -0,0 +1,7 @@ +from .helper import map_null_str, convert_to_oh_type + +# isort: split + +from .handler import get, put, post, delete, HANDLER + +# isort: split diff --git a/src/HABApp/openhab/connection/handler/func_async.py b/src/HABApp/openhab/connection/handler/func_async.py new file mode 100644 index 00000000..b178383c --- /dev/null +++ b/src/HABApp/openhab/connection/handler/func_async.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import warnings +from datetime import datetime +from typing import Any, List +from urllib.parse import quote as quote_url + +from HABApp.core.const.json import decode_struct +from HABApp.core.internals import ItemRegistryItem +from HABApp.openhab.definitions.rest import PersistenceServiceResp +from HABApp.openhab.definitions.rest import RootResp, SystemInfoRootResp, ItemResp, ShortItemResp, ItemHistoryResp, \ + ItemChannelLinkResp +from HABApp.openhab.definitions.rest import ThingResp +from HABApp.openhab.definitions.rest import TransformationResp +from HABApp.openhab.errors import (ThingNotFoundError, ItemNotFoundError, ItemNotEditableError, + MetadataNotEditableError, ThingNotEditableError, TransformationsRequestError, + PersistenceRequestError, LinkRequestError, LinkNotFoundError, LinkNotEditableError) +from . import convert_to_oh_type +from .handler import get, put, delete, post +from ...definitions.rest.habapp_data import get_api_vals, load_habapp_meta + + +# ---------------------------------------------------------------------------------------------------------------------- +# root +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_root() -> RootResp | None: + resp = await get('/rest/', log_404=False) + if resp.status == 404 or resp.status == 500: + return None + + # during startup, we sometimes get an empty response + if not (b := await resp.read()): + return None + + return decode_struct(b, type=RootResp) + + +# ---------------------------------------------------------------------------------------------------------------------- +# uuid +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_uuid() -> str: + resp = await get('/rest/uuid', log_404=False) + return await resp.text(encoding='utf-8') + + +# ---------------------------------------------------------------------------------------------------------------------- +# /systeminfo +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_system_info(): + resp = await get('/rest/systeminfo', log_404=False) + if resp.status == 404 or resp.status == 500: + return None + + # during startup, we sometimes get an empty response + if not (b := await resp.read()): + return None + + return decode_struct(b, type=SystemInfoRootResp).system_info + + +# ---------------------------------------------------------------------------------------------------------------------- +# /items +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_items() -> list[ItemResp]: + + resp = await get('/rest/items', params={'metadata': '.+'}) + body = await resp.read() + + return decode_struct(body, type=List[ItemResp]) + + +async def async_get_item(item: str | ItemRegistryItem) -> ItemResp | None: + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + resp = await get(f'/rest/items/{item:s}', log_404=False, params={'metadata': '.+'}) + if resp.status == 404: + return None + + body = await resp.read() + + return decode_struct(body, type=ItemResp) + + +async def async_get_all_items_state() -> list[ShortItemResp]: + resp = await get('/rest/items', params={'fields': 'name,state,type'}) + body = await resp.read() + + return decode_struct(body, type=List[ShortItemResp]) + + +async def async_item_exists(item: str | ItemRegistryItem) -> bool: + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + ret = await get(f'/rest/items/{item:s}', log_404=False) + return ret.status == 200 + + +async def async_remove_item(item: str | ItemRegistryItem): + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + if (ret := await delete(f'/rest/items/{item:s}')) is None: + return None + + if ret.status == 404: + raise ItemNotFoundError.from_name(item) + elif ret.status == 405: + raise ItemNotEditableError.from_name(item) + return ret.status < 300 + + +async def async_create_item(item_type: str, name: str, + label: str | None = None, category: str | None = None, + tags: list[str] | None = None, groups: list[str] | None = None, + group_type: str | None = None, + group_function: str | None = None, + group_function_params: list[str] | None = None) -> bool: + + payload = {'type': item_type, 'name': name} + if label: + payload['label'] = label + if category: + payload['category'] = category + if tags: + payload['tags'] = tags + if groups: + payload['groupNames'] = groups # CamelCase! + + # we create a group + if group_type: + payload['groupType'] = group_type # CamelCase! + if group_function: + payload['function'] = {} + payload['function']['name'] = group_function + if group_function_params: + payload['function']['params'] = group_function_params + + if (ret := await put(f'/rest/items/{name:s}', json=payload)) is None: + return False + + if ret.status == 404: + raise ItemNotFoundError.from_name(name) + elif ret.status == 405: + raise ItemNotEditableError.from_name(name) + return ret.status < 300 + + +async def async_remove_metadata(item: str | ItemRegistryItem, namespace: str): + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + if (ret := await delete(f'/rest/items/{item:s}/metadata/{namespace:s}')) is None: + return False + + if ret.status == 404: + raise ItemNotFoundError.from_name(item) + elif ret.status == 405: + raise MetadataNotEditableError.create_text(item, namespace) + return ret.status < 300 + + +async def async_set_metadata(item: str | ItemRegistryItem, namespace: str, value: str, config: dict): + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + payload = { + 'value': value, + 'config': config + } + ret = await put(f'/rest/items/{item:s}/metadata/{namespace:s}', json=payload) + if ret is None: + return False + + if ret.status == 404: + raise ItemNotFoundError.from_name(item) + elif ret.status == 405: + raise MetadataNotEditableError.create_text(item, namespace) + return ret.status < 300 + + +# ---------------------------------------------------------------------------------------------------------------------- +# /things +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_things() -> list[ThingResp]: + resp = await get('/rest/things') + body = await resp.read() + + return decode_struct(body, type=List[ThingResp]) + + +async def async_get_thing(thing: str | ItemRegistryItem) -> ThingResp: + # noinspection PyProtectedMember + thing = thing if isinstance(thing, str) else thing._name + + resp = await get(f'/rest/things/{thing:s}') + if resp.status >= 300: + raise ThingNotFoundError.from_uid(thing) + + body = await resp.read() + return decode_struct(body, type=ThingResp) + + +async def async_set_thing_cfg(thing: str | ItemRegistryItem, cfg: dict[str, Any]): + # noinspection PyProtectedMember + thing = thing if isinstance(thing, str) else thing._name + + if (ret := await put(f'/rest/things/{thing:s}/config', json=cfg)) is None: + return None + + if ret.status == 404: + raise ThingNotFoundError.from_uid(thing) + elif ret.status == 409: + raise ThingNotEditableError.from_uid(thing) + elif ret.status >= 300: + raise ValueError('Something went wrong') + + return ret.status + + +async def async_set_thing_enabled(thing: str | ItemRegistryItem, enabled: bool): + # noinspection PyProtectedMember + thing = thing if isinstance(thing, str) else thing._name + + if (ret := await put(f'/rest/things/{thing:s}/enable', data='true' if enabled else 'false')) is None: + return None + + if ret.status == 404: + raise ThingNotFoundError.from_uid(thing) + elif ret.status == 409: + raise ThingNotEditableError.from_uid(thing) + elif ret.status >= 300: + raise ValueError('Something went wrong') + + return ret.status + + +# ---------------------------------------------------------------------------------------------------------------------- +# /links +# ---------------------------------------------------------------------------------------------------------------------- +async def async_purge_links(): + resp = await post('/rest/purge') + if resp.status != 200: + raise LinkRequestError('Unexpected error') + + +async def async_remove_obj_links(name: str | ItemRegistryItem) -> bool: + """Remove links from an item or a thing + + :param name: name of thing or item + """ + # noinspection PyProtectedMember + name = name if isinstance(name, str) else name._name + + resp = await delete(f'/rest/links/{name:s}') + if resp.status >= 300: + raise LinkRequestError() + + return True + + +async def async_get_links() -> list[ItemChannelLinkResp]: + + resp = await get('/rest/links') + if resp.status != 200: + raise LinkRequestError('Unexpected error') + + body = await resp.read() + return decode_struct(body, type=List[ItemChannelLinkResp]) + + +def __get_item_link_url(item: str | ItemRegistryItem, channel: str) -> str: + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + # rest/links/ endpoint needs the channel to be url encoded + # (AAAA:BBBB:CCCC:0#NAME -> AAAA%3ABBBB%3ACCCC%3A0%23NAME) + # otherwise the REST-api returns HTTP-Status 500 InternalServerError + return '/rest/links/' + quote_url(f"{item:s}/{channel:s}") + + +async def async_get_link(item: str | ItemRegistryItem, channel: str) -> ItemChannelLinkResp: + + resp = await get(__get_item_link_url(item, channel), log_404=False) + if resp.status == 200: + body = await resp.read() + return decode_struct(body, type=ItemChannelLinkResp) + + if resp.status == 404: + raise LinkNotFoundError.from_names(item, channel) + raise LinkRequestError('Unexpected error') + + +async def async_create_link( + item: str | ItemRegistryItem, channel: str, configuration: dict[str, Any] | None = None) -> bool: + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + # if the passed item doesn't exist OpenHAB creates a new empty item + # this is undesired and why we raise an Exception + if not await async_item_exists(item): + raise ItemNotFoundError.from_name(item) + + json = {'itemName': item, 'channelUID': channel} + if configuration is not None: + json['configuration'] = configuration + + if (resp := await put(__get_item_link_url(item, channel), json=json)) is None: + return False + + if resp.status == 200: + return True + + if resp.status == 405: + LinkNotEditableError.from_names(item, channel) + raise LinkRequestError('Unexpected error') + + +async def async_remove_link(item: str | ItemRegistryItem, channel: str): + + if (resp := await delete(__get_item_link_url(item, channel), log_404=False)) is None: + return None + if resp.status == 200: + return None + + if resp.status == 404: + raise LinkNotFoundError.from_names(item, channel) + if resp.status == 405: + LinkNotEditableError.from_names(item, channel) + raise LinkRequestError('Unexpected error') + + +# ---------------------------------------------------------------------------------------------------------------------- +# /transformations +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_transformations() -> list[TransformationResp]: + resp = await get('/rest/transformations') + if resp.status >= 300: + raise TransformationsRequestError() + + body = await resp.read() + return decode_struct(body, type=List[TransformationResp]) + + +# ---------------------------------------------------------------------------------------------------------------------- +# /persistence +# ---------------------------------------------------------------------------------------------------------------------- +async def async_get_persistence_services() -> list[PersistenceServiceResp]: + resp = await get('/rest/persistence') + if resp.status >= 300: + raise PersistenceRequestError() + + body = await resp.read() + return decode_struct(body, type=List[PersistenceServiceResp]) + + +async def async_get_persistence_data(item: str | ItemRegistryItem, persistence: str | None, + start_time: datetime | None, + end_time: datetime | None) -> ItemHistoryResp: + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + params = {} + if persistence: + params['serviceId'] = persistence + if start_time is not None: + params['starttime'] = convert_to_oh_type(start_time) + if end_time is not None: + params['endtime'] = convert_to_oh_type(end_time) + if not params: + params = None + + resp = await get(f'/rest/persistence/items/{item:s}', params=params) + if resp.status >= 300: + raise PersistenceRequestError() + + body = await resp.read() + return decode_struct(body, type=ItemHistoryResp) + + +async def async_set_persistence_data(item: str | ItemRegistryItem, persistence: str | None, + time: datetime, state: Any): + # noinspection PyProtectedMember + item = item if isinstance(item, str) else item._name + + # This does only work for some persistence services (as of OH 3.4) + warnings.warn(f'{async_set_persistence_data.__name__} calls a part of the openHAB API which is buggy!', + category=ResourceWarning) + + params = { + 'time': convert_to_oh_type(time), + 'state': convert_to_oh_type(state), + } + if persistence is not None: + params['serviceId'] = persistence + + ret = await put(f'/rest/persistence/items/{item:s}', params=params) + if ret.status >= 300: + return None + else: + # I would have expected the endpoint to return a valid json, but instead it returns nothing + # return await ret.json(loads=load_json, encoding='utf-8') + return None + + +# --------------------------------------------------------------------------------------------------------------------- +# Funcs for handling HABApp Metadata +# --------------------------------------------------------------------------------------------------------------------- +async def async_remove_habapp_metadata(item: str): + return await async_remove_metadata(item, 'HABApp') + + +async def async_set_habapp_metadata(item: str, obj): + val, cfg = get_api_vals(obj) + return await async_set_metadata(item, 'HABApp', val, cfg) + + +async def async_get_item_with_habapp_meta(item: str) -> ItemResp: + if (data := await async_get_item(item)) is None: + raise ItemNotFoundError.from_name(item) + return load_habapp_meta(data) diff --git a/src/HABApp/openhab/connection/handler/func_sync.py b/src/HABApp/openhab/connection/handler/func_sync.py new file mode 100644 index 00000000..8ae1b0ee --- /dev/null +++ b/src/HABApp/openhab/connection/handler/func_sync.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import datetime +from typing import Any + +from HABApp.core.asyncio import run_coro_from_thread +from HABApp.core.internals import ItemRegistryItem +from .func_async import async_create_item, async_get_item, \ + async_get_thing, async_set_thing_enabled, \ + async_set_metadata, async_remove_metadata, \ + async_remove_item, async_item_exists, async_get_persistence_data, async_set_persistence_data, \ + async_get_persistence_services, async_get_link, async_create_link, async_remove_link +from HABApp.openhab import definitions +from HABApp.openhab.definitions.helpers import OpenhabPersistenceData +from HABApp.openhab.definitions.rest import ItemResp, ItemChannelLinkResp + + +# ---------------------------------------------------------------------------------------------------------------------- +# /items +# ---------------------------------------------------------------------------------------------------------------------- +# def get_items(): +# """Request all items from the openHAB Rest API. Don't use this +# """ +# return run_coro_from_thread(async_get_items()) + + +def get_item(item: str | ItemRegistryItem) -> ItemResp | None: + """Return the complete openHAB item definition + + :param item: name of the item or item + :return: openHAB item + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + + return run_coro_from_thread(async_get_item(item), calling=get_item) + + +def item_exists(item: str | ItemRegistryItem): + """ + Check if an item exists in the openHAB item registry + + :param item: name of the item or item + :return: True if item was found + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + + return run_coro_from_thread(async_item_exists(item), calling=item_exists) + + +def remove_item(item: str | ItemRegistryItem): + """ + Removes an item from the openHAB item registry + + :param item: name + :return: True if item was found and removed + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + + return run_coro_from_thread(async_remove_item(item), calling=remove_item) + + +def create_item(item_type: str, name: str, + label: str | None = None, category: str | None = None, + tags: list[str] | None = None, groups: list[str] | None = None, + group_type: str | None = None, + group_function: str | None = None, + group_function_params: list[str] | None = None) -> bool: + """Creates a new item in the openHAB item registry or updates an existing one + + :param item_type: item type + :param name: item name + :param label: item label + :param category: item category + :param tags: item tags + :param groups: in which groups is the item + :param group_type: what kind of group is it + :param group_function: group state aggregation function + :param group_function_params: params for group state aggregation + :return: True if item was created/updated + """ + + def validate(_in): + assert isinstance(_in, str), type(_in) + + # limit values to special entries and validate parameters + if ':' in item_type: + __type, __unit = item_type.split(':') + assert __unit in definitions.ITEM_DIMENSIONS, \ + f'{__unit} is not a valid openHAB unit: {", ".join(definitions.ITEM_DIMENSIONS)}' + assert __type in definitions.ITEM_TYPES, \ + f'{__type} is not a valid openHAB type: {", ".join(definitions.ITEM_TYPES)}' + else: + assert item_type in definitions.ITEM_TYPES, \ + f'{item_type} is not an openHAB type: {", ".join(definitions.ITEM_TYPES)}' + assert isinstance(name, str), type(name) + assert isinstance(label, str) or label is None, type(label) + assert isinstance(category, str) or category is None, type(category) + if tags: + map(validate, tags) + if groups: + map(validate, groups) + if group_type: + assert isinstance(group_type, str), type(group_type) + if group_function: + assert isinstance(group_function, str), type(group_function) + if group_function_params: + map(validate, group_function_params) + + if group_type or group_function or group_function_params: + assert item_type == 'Group', f'Item type must be "Group"! Is: {item_type}' + + if group_function: + assert group_function in definitions.GROUP_ITEM_FUNCTIONS, \ + f'{item_type} is not a group function: {", ".join(definitions.GROUP_ITEM_FUNCTIONS)}' + + return run_coro_from_thread( + async_create_item( + item_type, name, + label=label, category=category, tags=tags, groups=groups, + group_type=group_type, group_function=group_function, group_function_params=group_function_params + ), + calling=create_item + ) + + +def set_metadata(item: str | ItemRegistryItem, namespace: str, value: str, config: dict): + """ + Add/set metadata to an item + + :param item: name of the item or item + :param namespace: namespace, e.g. ``stateDescription`` + :param value: value + :param config: configuration e.g. ``{"options": "A,B,C"}`` + :return: True if metadata was successfully created/updated + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(namespace, str), type(namespace) + assert isinstance(value, str), type(value) + assert isinstance(config, dict), type(config) + + return run_coro_from_thread( + async_set_metadata(item=item, namespace=namespace, value=value, config=config), calling=set_metadata + ) + + +def remove_metadata(item: str | ItemRegistryItem, namespace: str): + """ + Remove metadata from an item + + :param item: name of the item or item + :param namespace: namespace + :return: True if metadata was successfully removed + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(namespace, str), type(namespace) + + return run_coro_from_thread(async_remove_metadata(item=item, namespace=namespace), calling=remove_metadata) + + +# ---------------------------------------------------------------------------------------------------------------------- +# /things +# ---------------------------------------------------------------------------------------------------------------------- +def get_thing(thing: str | ItemRegistryItem): + """ Return the complete openHAB thing definition + + :param thing: name of the thing or the item + :return: openHAB thing + """ + assert isinstance(thing, (str, ItemRegistryItem)), type(thing) + + return run_coro_from_thread(async_get_thing(thing), calling=get_thing) + + +def set_thing_enabled(thing: str | ItemRegistryItem, enabled: bool = True): + """ + Enable/disable a thing + + :param thing: name of the thing or the thing object + :param enabled: True to enable thing, False to disable thing + """ + assert isinstance(thing, (str, ItemRegistryItem)), type(thing) + + return run_coro_from_thread(async_set_thing_enabled(thing, enabled), calling=set_thing_enabled) + + +# ---------------------------------------------------------------------------------------------------------------------- +# /persistence +# ---------------------------------------------------------------------------------------------------------------------- +def get_persistence_services(): + """ Return all available persistence services + """ + return run_coro_from_thread(async_get_persistence_services(), calling=get_persistence_services) + + +def get_persistence_data(item: str | ItemRegistryItem, persistence: str | None, + start_time: datetime.datetime | None, + end_time: datetime.datetime | None) -> OpenhabPersistenceData: + """Query historical data from the openHAB persistence service + + :param item: name of the persistent item + :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used + :param start_time: return only items which are newer than this + :param end_time: return only items which are older than this + :return: last stored data from persistency service + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(persistence, str) or persistence is None, persistence + assert isinstance(start_time, datetime.datetime) or start_time is None, start_time + assert isinstance(end_time, datetime.datetime) or end_time is None, end_time + + ret = run_coro_from_thread( + async_get_persistence_data(item=item, persistence=persistence, start_time=start_time, end_time=end_time), + calling=get_persistence_data + ) + return OpenhabPersistenceData.from_resp(ret) + + +def set_persistence_data(item: str | ItemRegistryItem, persistence: str | None, time: datetime.datetime, state: Any): + """Set a measurement for a item in the persistence serivce + + :param item_name: name of the persistent item + :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used + :param time: time of measurement + :param state: state which will be set + :return: True if data was stored in persistency service + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(persistence, str) or persistence is None, persistence + assert isinstance(time, datetime.datetime), time + + return run_coro_from_thread( + async_set_persistence_data(item=item, persistence=persistence, time=time, state=state), + calling=set_persistence_data + ) + + +# --------------------------------------------------------------------------------------------------------------------- +# Link handling is experimental +# --------------------------------------------------------------------------------------------------------------------- +def get_link(item: str | ItemRegistryItem, channel: str) -> ItemChannelLinkResp: + """ returns the link between an item and a (things) channel + + :param item: name of the item or item + :param channel: uid of the (things) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(channel, str), type(channel) + + return run_coro_from_thread(async_get_link(item, channel), calling=get_link) + + +def create_link( + item: str | ItemRegistryItem, channel: str, configuration: dict[str, Any] | None = None) -> bool: + """creates a link between an item and a (things) channel + + :param item: name of the item or item + :param channel: uid of the (things) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) + :param configuration: optional configuration for the channel + :return: True on successful creation, otherwise False + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(channel, str), type(channel) + assert isinstance(configuration, dict), type(configuration) + + return run_coro_from_thread(async_create_link(item, channel, configuration), calling=create_link) + + +def remove_link(item: str | ItemRegistryItem, channel: str) -> bool: + """ removes a link between a (things) channel and an item + + :param item: name of the item or item + :param channel: uid of the (things) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) + :return: True on successful removal, otherwise False + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + assert isinstance(channel, str), type(channel) + + return run_coro_from_thread(async_remove_link(item, channel), calling=remove_link) diff --git a/src/HABApp/openhab/connection/handler/handler.py b/src/HABApp/openhab/connection/handler/handler.py new file mode 100644 index 00000000..13d3b2bd --- /dev/null +++ b/src/HABApp/openhab/connection/handler/handler.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from typing import Any + +import aiohttp +from aiohttp.client import ClientResponse, _RequestContextManager +from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT, METH_DELETE + +from HABApp.config import CONFIG +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.connections._definitions import CONNECTION_HANDLER_NAME +from HABApp.core.connections.base_connection import AlreadyHandledException +from HABApp.core.const.json import dump_json +from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext +from HABApp.openhab.errors import OpenhabDisconnectedError, OpenhabCredentialsInvalidError + + +# noinspection PyProtectedMember +class ConnectionHandler(BaseConnectionPlugin[OpenhabConnection]): + request: aiohttp.ClientSession._request + + def __init__(self): + super().__init__(name=CONNECTION_HANDLER_NAME) + self.options: dict[str, Any] = {} + self.read_only: bool = False + self.online = False + self.session: aiohttp.ClientSession | None = None + + def update_cfg_general(self): + self.read_only = CONFIG.openhab.general.listen_only + + async def on_setup(self, connection: OpenhabConnection): + log = self.plugin_connection.log + config = CONFIG.openhab.connection + url: str = config.url + user: str = config.user + password: str = config.password + + # do not run without an url + if not url: + log.info('Connection disabled (url missing)!') + connection.status_from_setup_to_disabled() + return None + + # do not run without user/pw - since OH3 mandatory + is_token = user.startswith('oh.') or password.startswith('oh.') + if not is_token and (not user or not password): + log.info('Connection disabled (user/password missing)!') + connection.status_from_setup_to_disabled() + return None + + if not config.verify_ssl: + self.options['ssl'] = False + log.info('Verify ssl set to False!') + else: + self.options.pop('ssl', None) + + self.update_cfg_general() + + # remove existing session + if (s := self.session) is not None: + self.session = None + await s.close() + + self.session = aiohttp.ClientSession( + base_url=url, + timeout=aiohttp.ClientTimeout(total=None), + json_serialize=dump_json, + auth=aiohttp.BasicAuth(user, password), + read_bufsize=int(config.buffer) + ) + self.request = self.session._request + + async def on_connected(self): + self.online = True + + async def on_disconnected(self, connection: OpenhabConnection): + self.online = False + connection.context = None + + async def on_shutdown(self): + if self.session is None: + return None + + await self.session.close() + self.session = None + + async def get(self, url: str, log_404=True, **kwargs: Any) -> ClientResponse: + mgr = _RequestContextManager(self.request(METH_GET, url, **self.options, **kwargs)) + return await self.check_response(mgr, log_404=log_404) + + async def post(self, url: str, log_404=True, json=None, data=None, **kwargs: Any) -> ClientResponse | None: + if self.read_only or not self.online: + return None + + mgr = _RequestContextManager( + self.request(METH_POST, url, data=data, json=json, **kwargs, **self.options, **kwargs) + ) + if data is None: + data = json + return await self.check_response(mgr, log_404=log_404, sent_data=data) + + async def put(self, url: str, log_404=True, json=None, data=None, **kwargs: Any) -> ClientResponse | None: + if self.read_only or not self.online: + return None + + mgr = _RequestContextManager( + self.request(METH_PUT, url, data=data, json=json, **kwargs, **self.options, **kwargs) + ) + if data is None: + data = json + return await self.check_response(mgr, log_404=log_404, sent_data=data) + + async def delete(self, url: str, log_404=True, json=None, data=None, **kwargs: Any) -> ClientResponse | None: + if self.read_only or not self.online: + return None + + mgr = _RequestContextManager( + self.request(METH_DELETE, url, data=data, json=json, **kwargs, **self.options, **kwargs) + ) + if data is None: + data = json + return await self.check_response(mgr, log_404=log_404, sent_data=data) + + async def check_response(self, future: aiohttp.client._RequestContextManager, sent_data=None, + log_404=True) -> ClientResponse: + try: + resp = await future + except Exception as e: + self.plugin_connection.process_exception(e, None) + raise OpenhabDisconnectedError() from None + + if (status := resp.status) < 300: + return resp + + if status == 404 and not log_404: + return resp + + # Log Error Message + log = self.plugin_connection.log + sent = '' if sent_data is None else f' {sent_data}' + log.warning(f'Status {status} for {resp.request_info.method} {resp.request_info.url}{sent}') + for line in str(resp).splitlines(): + log.debug(line) + + if resp.status == 401: + raise OpenhabCredentialsInvalidError() + + return resp + + async def on_connecting(self, connection: OpenhabConnection): + from HABApp.openhab.connection.handler.func_async import async_get_root + + log = self.plugin_connection.log + log.debug('Trying to connect to OpenHAB ...') + + try: + if (root := await async_get_root()) is None: + connection.set_error() + log.info('... offline!') + return None + + info = root.runtime_info + log.info(f'Connected {"read only " if self.read_only else ""}to OpenHAB ' + f'version {info.version:s} ({info.build_string:s})') + + vers = tuple(map(int, info.version.split('.')[:3])) + if vers < (3, 3): + log.warning('HABApp requires at least openHAB version 3.3!') + + connection.context = OpenhabContext( + version=vers, is_oh3=vers < (4, 0), + waited_for_openhab=False, + created_items={}, created_things={}, + session=self.session, session_options=self.options + ) + + # during startup we get OpenhabCredentialsInvalidError even though credentials are correct + except (OpenhabDisconnectedError, OpenhabCredentialsInvalidError): + connection.set_error() + raise AlreadyHandledException() + + +HANDLER = ConnectionHandler() +get = HANDLER.get +post = HANDLER.post +put = HANDLER.put +delete = HANDLER.delete diff --git a/src/HABApp/openhab/connection/handler/helper.py b/src/HABApp/openhab/connection/handler/helper.py new file mode 100644 index 00000000..8f20e472 --- /dev/null +++ b/src/HABApp/openhab/connection/handler/helper.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from HABApp.core.items import BaseValueItem +from HABApp.core.types import RGB, HSB + + +def convert_to_oh_type(obj: Any) -> str: + if isinstance(obj, (str, int, float, bool)): + return str(obj) + + if isinstance(obj, datetime): + # Add timezone (if not yet defined) to string, then remote anything below ms. + # 2018-11-19T09:47:38.284000+0100 -> 2018-11-19T09:47:38.284+0100 + out = obj.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S.%f%z') + return out + + if isinstance(obj, (set, list, tuple, frozenset)): + return ','.join(map(str, obj)) + + if obj is None: + return 'NULL' + + if isinstance(obj, RGB): + obj = obj.to_hsb() + + if isinstance(obj, HSB): + # noinspection PyProtectedMember + return f'{obj._hue:.2f},{obj._saturation:.2f},{obj._brightness:.2f}' + + if isinstance(obj, BaseValueItem): + raise ValueError() + + return str(obj) + + +def map_null_str(value: str) -> str | None: + if value == 'NULL' or value == 'UNDEF': + return None + return value diff --git a/src/HABApp/openhab/connection/plugins/__init__.py b/src/HABApp/openhab/connection/plugins/__init__.py new file mode 100644 index 00000000..2273b80b --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/__init__.py @@ -0,0 +1,9 @@ +from .wait_for_startlevel import WaitForStartlevelPlugin +from .load_items import LoadOpenhabItemsPlugin +from .events_sse import SseEventListenerPlugin +from .out import OUTGOING_PLUGIN, async_send_command, async_post_update, send_command, post_update +from .load_transformations import LoadTransformationsPlugin +from .ping import PingPlugin +from .wait_for_restore import WaitForPersistenceRestore +from .overview_things import ThingOverviewPlugin +from .plugin_things import TextualThingConfigPlugin diff --git a/src/HABApp/openhab/connection/plugins/events_sse.py b/src/HABApp/openhab/connection/plugins/events_sse.py new file mode 100644 index 00000000..c02bfb53 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/events_sse.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +from typing import Final + +from aiohttp_sse_client import client as sse_client + +import HABApp +import HABApp.core +import HABApp.openhab.events +from HABApp.core.asyncio import AsyncContext +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.const.json import load_json +from HABApp.core.const.log import TOPIC_EVENTS +from HABApp.core.internals import uses_item_registry +from HABApp.core.lib import SingleTask +from HABApp.openhab.connection.connection import OpenhabConnection +from HABApp.openhab.process_events import on_sse_event + +Items = uses_item_registry() + + +class SseEventListenerPlugin(BaseConnectionPlugin[OpenhabConnection]): + + def __init__(self, name: str | None = None): + super().__init__(name) + self.task: Final = SingleTask(self.sse_task, name='SSE Task') + + async def on_connected(self): + self.task.start() + + async def on_disconnected(self): + await self.task.cancel_wait() + + async def sse_task(self): + try: + with AsyncContext('SSE'): + # cache so we don't have to look up every event + _load_json = load_json + _see_handler = on_sse_event + context = self.plugin_connection.context + oh3 = context.is_oh3 + + log_events = logging.getLogger(f'{TOPIC_EVENTS}.openhab') + DEBUG = logging.DEBUG + + async with sse_client.EventSource( + url=f'/rest/events?topics={HABApp.CONFIG.openhab.connection.topic_filter}', + session=context.session, **context.session_options) as event_source: + async for event in event_source: + + e_str = event.data + + try: + e_json = _load_json(e_str) + except (ValueError, TypeError): + log_events.warning(f'Invalid json: {e_str}') + continue + + # Alive event from openhab to detect dropped connections + # -> Can be ignored on the HABApp side + e_type = e_json.get('type') + if e_type == 'ALIVE': + continue + + # Log raw sse event + if log_events.isEnabledFor(logging.DEBUG): + log_events._log(DEBUG, e_str, []) + + # With OH4 we have the ItemStateUpdatedEvent, so we can ignore the ItemStateEvent + if not oh3 and e_type == 'ItemStateEvent': + continue + + # process + _see_handler(e_json, oh3) + except Exception as e: + self.plugin_connection.process_exception(e, self.sse_task) diff --git a/src/HABApp/openhab/connection/plugins/load_items.py b/src/HABApp/openhab/connection/plugins/load_items.py new file mode 100644 index 00000000..261bf4b5 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/load_items.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import logging +from asyncio import sleep +from datetime import datetime + +from immutables import Map + +import HABApp.openhab.events +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import uses_item_registry +from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext +from HABApp.openhab.connection.handler import map_null_str +from HABApp.openhab.connection.handler.func_async import async_get_items, \ + async_get_all_items_state, async_get_things +from HABApp.openhab.definitions import QuantityValue +from HABApp.openhab.definitions.rest import ThingResp +from HABApp.openhab.item_to_reg import fresh_item_sync, add_to_registry, remove_from_registry, add_thing_to_registry, \ + remove_thing_from_registry + +log = logging.getLogger('HABApp.openhab.items') +Items = uses_item_registry() + + +class LoadOpenhabItemsPlugin(BaseConnectionPlugin[OpenhabConnection]): + + async def on_connected(self, context: OpenhabContext): + if not context.created_items and not context.created_things: + await self.load_items(context) + await self.load_things(context) + else: + # Sleep so the event handler is running + await sleep(0.1) + + if context.created_items: + while await self.sync_items(context): + pass + if context.created_things: + while await self.sync_things(context): + pass + + async def load_items(self, context: OpenhabContext): + from HABApp.openhab.map_items import map_item + OpenhabItem = HABApp.openhab.items.OpenhabItem + + log.debug('Requesting items') + items = await async_get_items() + items_len = len(items) + log.debug(f'Got response with {items_len} items') + + fresh_item_sync() + + # add all items + for item in items: + new_item = map_item( + item.name, item.type, map_null_str(item.state), item.label, + frozenset(item.tags), frozenset(item.groups), item.metadata + ) + + # error + if new_item is None: + continue + add_to_registry(new_item, True) + + # remove items which are no longer available + ist = set(Items.get_item_names()) + soll = {item.name for item in items} + for k in ist - soll: + if isinstance(Items.get_item(k), OpenhabItem): + remove_from_registry(k) + + log.info(f'Updated {items_len:d} Items') + + created_items: dict[str, tuple[OpenhabItem, datetime]] = { + i.name: (i, i.last_update) for i in Items.get_items() if isinstance(i, OpenhabItem) + } + context.created_items.update(created_items) + + async def sync_items(self, context: OpenhabContext): + log.debug('Starting item state sync') + created_items = context.created_items + + items = await async_get_all_items_state() + + synced = 0 + for item in items: + # if the item is still None it was not initialized during the start of the item event listener + if (new_state := map_null_str(item.state)) is None: + continue + + # UoM item handling + if ':' in item.type: + new_state, _ = QuantityValue.split_unit(new_state) + + existing_item, existing_item_update = created_items[item.name] + + # noinspection PyProtectedMember + new_value = existing_item._state_from_oh_str(new_state) + + if existing_item.value != new_value and existing_item.last_update == existing_item_update: + existing_item.value = new_value + log.debug(f'Re-synced value of {item.name:s}') + synced += 1 + + log.debug('Item state sync complete') + return synced + + async def load_things(self, context: OpenhabContext): + Thing = HABApp.openhab.items.Thing + + # try to update things, too + log.debug('Requesting things') + + things = await async_get_things() + thing_count = len(things) + log.debug(f'Got response with {thing_count:d} things') + + created_things = {} + for thing in things: + t = add_thing_to_registry(thing) + created_things[t.name] = (t, t.last_update) + + context.created_items.update(created_things) + + # remove things which were deleted + ist = set(Items.get_item_names()) + soll = {thing.uid for thing in things} + for k in ist - soll: + if isinstance(Items.get_item(k), Thing): + remove_thing_from_registry(k) + log.info(f'Updated {thing_count:d} Things') + + async def sync_things(self, context: OpenhabContext): + log.debug('Starting Thing sync') + created_things = context.created_things + + synced = 0 + + for thing in await async_get_things(): + existing_thing, existing_datetime = created_things[thing.uid] + + if thing_changed(existing_thing, thing) and existing_thing.last_update != existing_datetime: + existing_thing.status = thing.status.status + existing_thing.status_description = thing.status.description + existing_thing.status_detail = thing.status.detail if thing.status.detail else '' + existing_thing.label = thing.label + existing_thing.location = thing.location + existing_thing.configuration = Map(thing.configuration) + existing_thing.properties = Map(thing.properties) + log.debug(f'Re-synced {existing_thing.name:s}') + synced += 1 + + return synced + + +def thing_changed(old: HABApp.openhab.items.Thing, new: ThingResp) -> bool: + return old.status != new.status.status or \ + old.status_detail != new.status.detail or \ + old.status_description != ('' if not new.status.description else new.status.description) or \ + old.label != new.label or \ + old.location != new.configuration or \ + old.configuration != new.configuration or \ + old.properties != new.properties diff --git a/src/HABApp/openhab/connection/plugins/load_transformations.py b/src/HABApp/openhab/connection/plugins/load_transformations.py new file mode 100644 index 00000000..0b55eeb3 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/load_transformations.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import uses_item_registry +from HABApp.core.wrapper import ExceptionToHABApp +from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext +from HABApp.openhab.connection.handler.func_async import async_get_transformations +from HABApp.openhab.transformations._map import MAP_REGISTRY +from HABApp.openhab.transformations.base import log, TransformationRegistryBase + +Items = uses_item_registry() + + +class LoadTransformationsPlugin(BaseConnectionPlugin[OpenhabConnection]): + + async def on_connected(self, context: OpenhabContext): + if context.is_oh3: + log.info('Transformations are not supported on openHAB 3') + return None + + exception_handler = ExceptionToHABApp(logger=log) + + log.debug('Requesting transformations') + objs = await async_get_transformations() + transformation_count = len(objs) + log.debug(f'Got response with {transformation_count} transformation{"" if transformation_count == 1 else ""}') + + registries: dict[str, TransformationRegistryBase] = { + MAP_REGISTRY.name: MAP_REGISTRY + } + + for reg in registries.values(): + reg.clear() + + for obj in objs: + with exception_handler: + if reg := registries.get(obj.type): + reg.set(obj.uid, obj.configuration) + + if not any(r.objs for r in registries.values()): + log.info('No transformations available') + else: + log.info('Transformations:') + for name, reg in registries.items(): + log.info(f' {name.title()}: {", ".join(reg.available())}') diff --git a/src/HABApp/openhab/connection/plugins/out.py b/src/HABApp/openhab/connection/plugins/out.py new file mode 100644 index 00000000..eaa46fb4 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/out.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from asyncio import Queue, QueueEmpty +from asyncio import sleep +from typing import Any +from typing import Final + +from HABApp.core.asyncio import run_func_from_async +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import ItemRegistryItem +from HABApp.core.lib import SingleTask +from HABApp.core.logger import log_info, log_warning +from HABApp.openhab.connection.connection import OpenhabConnection +from HABApp.openhab.connection.handler import convert_to_oh_type, post, put + + +class OutgoingCommandsPlugin(BaseConnectionPlugin[OpenhabConnection]): + + def __init__(self, name: str | None = None): + super().__init__(name) + + self.add: bool = False + + self.queue: Queue[tuple[str | ItemRegistryItem, Any, bool]] = Queue() + self.task_worker: Final = SingleTask(self.queue_worker, 'OhQueueWorker') + self.task_watcher: Final = SingleTask(self.queue_watcher, 'OhQueueWatcher') + + async def _clear_queue(self): + try: + while True: + self.queue.get_nowait() + except QueueEmpty: + pass + + async def on_connected(self): + self.add = True + self.task_worker.start() + self.task_watcher.start() + + async def on_disconnected(self): + self.add = False + await self.task_worker.cancel_wait() + await self.task_watcher.cancel_wait() + await self._clear_queue() + + async def queue_watcher(self): + log = self.plugin_connection.log + first_msg_at = 150 + + upper = first_msg_at + lower = -1 + last_info_at = first_msg_at // 2 + + while True: + await sleep(10) + size = self.queue.qsize() + + # small log msg + if size > upper: + upper = size * 2 + lower = size // 2 + log_warning(log, f'{size} messages in queue') + elif size < lower: + upper = max(size / 2, first_msg_at) + lower = size // 2 + if lower <= last_info_at: + lower = -1 + log_info(log, 'queue OK') + else: + log_info(log, f'{size} messages in queue') + + # noinspection PyProtectedMember + async def queue_worker(self): + + queue: Final = self.queue + to_str: Final = convert_to_oh_type + + while True: + try: + while True: + item, state, is_cmd = await queue.get() + + if not isinstance(item, str): + item = item._name + + if not isinstance(state, str): + state = to_str(state) + + if is_cmd: + await post(f'/rest/items/{item:s}', data=state) + else: + await put(f'/rest/items/{item:s}/state', data=state) + except Exception as e: + self.plugin_connection.process_exception(e, 'Outgoing queue worker') + + def async_post_update(self, item: str | ItemRegistryItem, state: Any): + if not self.add: + return None + self.queue.put_nowait((item, state, False)) + + def async_send_command(self, item: str | ItemRegistryItem, state: Any): + if not self.add: + return None + self.queue.put_nowait((item, state, True)) + + +OUTGOING_PLUGIN: Final = OutgoingCommandsPlugin() +async_post_update: Final = OUTGOING_PLUGIN.async_post_update +async_send_command: Final = OUTGOING_PLUGIN.async_send_command + + +def post_update(item: str | ItemRegistryItem, state: Any): + """ + Post an update to the item + + :param item: item name or item + :param state: new item state + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + + run_func_from_async(async_post_update, item, state) + + +def send_command(item: str | ItemRegistryItem, command: Any): + """ + Send the specified command to the item + + :param item: item name or item + :param command: command + """ + assert isinstance(item, (str, ItemRegistryItem)), type(item) + + run_func_from_async(async_send_command, item, command) diff --git a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py b/src/HABApp/openhab/connection/plugins/overview_things.py similarity index 80% rename from src/HABApp/openhab/connection_logic/plugin_thing_overview.py rename to src/HABApp/openhab/connection/plugins/overview_things.py index 39796366..f24c5704 100644 --- a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py +++ b/src/HABApp/openhab/connection/plugins/overview_things.py @@ -1,30 +1,38 @@ +from __future__ import annotations + import asyncio import logging +from typing import Final import HABApp +from HABApp.config import CONFIG +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import uses_item_registry +from HABApp.openhab.connection.connection import OpenhabConnection from HABApp.openhab.definitions.helpers.log_table import Table -from ._plugin import OnConnectPlugin -from ..interface_async import async_get_things +PING_CONFIG: Final = CONFIG.openhab.ping -class ThingOverview(OnConnectPlugin): +Items = uses_item_registry() - def __init__(self): - super().__init__() - self.run = False +class ThingOverviewPlugin(BaseConnectionPlugin[OpenhabConnection]): - @HABApp.core.wrapper.log_exception - async def on_connect_function(self): - # don't run this after the connect, let the rules load etc. - await asyncio.sleep(60) + def __init__(self, name: str | None = None): + super().__init__(name) - # show this overview only once! - if self.run: + self.do_run = True + + @HABApp.core.wrapper.log_exception + async def on_online(self): + if not self.do_run: return None - self.run = True - thing_data = await async_get_things() + # don't run this after the connect, let the rules load etc. + await asyncio.sleep(90) + self.do_run = False + + thing_data = await HABApp.openhab.interface_async.async_get_things() thing_table = Table('Things') thing_stat = thing_table.add_column('Status', align='^') @@ -91,6 +99,3 @@ async def on_connect_function(self): log = logging.getLogger('HABApp.openhab.zwave') for line in zw_table.get_lines(sort_columns=[zw_type, 'Node']): log.info(line) - - -PLUGIN_THING_OVERVIEW = ThingOverview.create_plugin() diff --git a/src/HABApp/openhab/connection/plugins/ping.py b/src/HABApp/openhab/connection/plugins/ping.py new file mode 100644 index 00000000..1e5113f6 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/ping.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import logging +from asyncio import sleep +from time import monotonic +from typing import Final + +import HABApp.openhab.events +from HABApp.config import CONFIG +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import uses_item_registry, uses_event_bus +from HABApp.core.lib import SingleTask +from HABApp.openhab.connection.connection import OpenhabConnection + +PING_CONFIG: Final = CONFIG.openhab.ping + +log = logging.getLogger('HABApp.openhab.items') +Items = uses_item_registry() +EventBus = uses_event_bus() + + +class PingPlugin(BaseConnectionPlugin[OpenhabConnection]): + + def __init__(self, name: str | None = None): + super().__init__(name) + self.task: Final = SingleTask(self.ping_worker, 'OhQueueWorker') + + self.sent_value: float | None = None + self.next_value: float | None = None + self.timestamp_sent: float | None = None + + self.listener: HABApp.core.internals.EventBusListener | None = None + + async def on_connected(self): + if not PING_CONFIG.enabled: + return None + + self.sent_value = None + self.next_value = None + self.timestamp_sent = None + + self.listener = HABApp.core.internals.EventBusListener( + HABApp.config.CONFIG.openhab.ping.item, + HABApp.core.internals.wrap_func(self.ping_received), + HABApp.core.events.EventFilter(HABApp.openhab.events.ItemStateUpdatedEvent) + ) + EventBus.add_listener(self.listener) + + self.task.start() + + async def on_disconnected(self): + await self.task.cancel_wait() + + if self.listener is not None: + self.listener.cancel() + self.listener = None + + async def ping_received(self, event: HABApp.openhab.events.ItemStateEvent): + value = event.value + if value != self.sent_value: + return None + + # If we are queued up it's possible that we receive multiple pings + # Then we only take the first one + if self.next_value is None: + self.next_value = round((monotonic() - self.timestamp_sent) * 1000, 1) + + async def ping_worker(self): + try: + log.debug('Ping started') + + item_name = PING_CONFIG.item + + if not (send_ping := Items.item_exists(item_name)): + log.warning(f'Number item "{item_name:s}" does not exist!') + + while True: + self.sent_value = self.next_value + self.next_value = None + self.timestamp_sent = monotonic() + + if send_ping: + HABApp.openhab.interface_async.async_post_update( + item_name, + f'{self.sent_value:.1f}' if self.sent_value is not None else None + ) + else: + send_ping = Items.item_exists(item_name) + + await sleep(PING_CONFIG.interval) + + except Exception as e: + self.plugin_connection.process_exception(e, self.ping_worker) + finally: + log.debug('Ping stopped') diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/__init__.py b/src/HABApp/openhab/connection/plugins/plugin_things/__init__.py new file mode 100644 index 00000000..e94d39c2 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/plugin_things/__init__.py @@ -0,0 +1 @@ +from .plugin_things import TextualThingConfigPlugin diff --git a/src/HABApp/openhab/connection_logic/plugin_things/_log.py b/src/HABApp/openhab/connection/plugins/plugin_things/_log.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/_log.py rename to src/HABApp/openhab/connection/plugins/plugin_things/_log.py diff --git a/src/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py b/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py similarity index 73% rename from src/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py rename to src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py index 8600a8e7..66defc10 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py @@ -1,17 +1,24 @@ import re import typing from dataclasses import dataclass -from typing import Dict, List -from typing import Iterator, Optional, Union +from typing import Iterator, Optional, Union, Dict, List -from pydantic import BaseModel, Field, ValidationError, parse_obj_as, validator +from pydantic import BaseModel as _BaseModel, Field, ValidationError, field_validator, \ + ConfigDict, TypeAdapter, AfterValidator +from HABApp.core.const.const import PYTHON_310 from HABApp.core.logger import HABAppError -from HABApp.openhab.connection_logic.plugin_things.filters import ChannelFilter, ThingFilter -from HABApp.openhab.connection_logic.plugin_things.str_builder import StrBuilder +from HABApp.openhab.connection.plugins.plugin_things.filters import ChannelFilter, ThingFilter +from HABApp.openhab.connection.plugins.plugin_things.str_builder import StrBuilder from HABApp.openhab.definitions import ITEM_TYPES from ._log import log +if PYTHON_310: + from typing import Annotated +else: + from typing_extensions import Annotated + + RE_VALID_NAME = re.compile(r'\w+') @@ -43,21 +50,32 @@ class InvalidItemNameError(Exception): pass +class BaseModel(_BaseModel): + model_config = ConfigDict(validate_assignment=True, extra='forbid', validate_default=True, strict=True) + + class MetadataCfg(BaseModel): value: str config: Dict[str, typing.Any] = {} +def mk_str_builder(v: str) -> StrBuilder: + return StrBuilder(v) + + +TypeStrBuilder = Annotated[str, AfterValidator(mk_str_builder)] + + class UserItemCfg(BaseModel): type: str - name: str - label: str = '' - icon: str = '' - groups: List[str] = [] - tags: List[str] = [] + name: TypeStrBuilder + label: TypeStrBuilder = '' + icon: TypeStrBuilder = '' + groups: List[TypeStrBuilder] = [] + tags: List[TypeStrBuilder] = [] metadata: Optional[Dict[str, MetadataCfg]] = None - @validator('type', always=True) + @field_validator('type') def validate_item_type(cls, v): if v in ITEM_TYPES: return v @@ -66,11 +84,7 @@ def validate_item_type(cls, v): except KeyError: raise ValueError(f'Must be one of {", ".join(ITEM_TYPES)}') - @validator('name', 'label', 'icon', 'groups', 'tags', each_item=True, always=True) - def validate_make_str_builder(cls, v): - return StrBuilder(v) - - @validator('metadata', pre=True) + @field_validator('metadata', mode='before') def make_meta_cfg(cls, v): if not isinstance(v, dict): return v @@ -82,7 +96,7 @@ def make_meta_cfg(cls, v): def get_item(self, context: dict) -> UserItem: v = {'link': None} - for k in self.__fields__: + for k in self.model_fields: val = self.__dict__[k] # type is const @@ -113,11 +127,9 @@ class UserChannelCfg(BaseModel): filter: List[ChannelFilter] link_items: List[UserItemCfg] = Field(default_factory=list, alias='link items') - class Config: - allow_population_by_field_name = True - arbitrary_types_allowed = True + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) - @validator('filter', pre=True, always=True) + @field_validator('filter', mode='before') def validate_filter(cls, v): return create_filters(ChannelFilter, v) @@ -134,11 +146,9 @@ class UserThingCfg(BaseModel): create_items: List[UserItemCfg] = Field(alias='create items', default_factory=list) channels: List[UserChannelCfg] = Field(default_factory=list) - class Config: - allow_population_by_field_name = True - arbitrary_types_allowed = True + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) - @validator('filter', pre=True, always=True) + @field_validator('filter', mode='before') def validate_filter(cls, v): return create_filters(ThingFilter, v) @@ -161,9 +171,10 @@ def create_filters(cls, v: Union[List[Dict[str, str]], Dict[str, str]]): def validate_cfg(_in, filename: Optional[str] = None) -> Optional[List[UserThingCfg]]: try: if isinstance(_in, list): - return parse_obj_as(List[UserThingCfg], _in, type_name=filename) + return TypeAdapter(List[UserThingCfg]).validate_python(_in) else: - return [parse_obj_as(UserThingCfg, _in, type_name=filename)] + return [UserThingCfg.model_validate(_in)] except ValidationError as e: + log.error(f'Error while parsing "{filename}"') HABAppError(log).add_exception(e).dump() return None diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/__init__.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py rename to src/HABApp/openhab/connection/plugins/plugin_things/file_writer/__init__.py diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py rename to src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py similarity index 97% rename from src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py rename to src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py index a932dc73..d0018a82 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py @@ -1,6 +1,6 @@ from typing import Final, Optional, Callable, Any -from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem +from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import UserItem from .formatter import TYPE_FORMATTER, EmptyFormatter, ValueFormatter diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py similarity index 97% rename from src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py rename to src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py index d65f1878..e6ff7ac1 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py @@ -3,7 +3,7 @@ from typing import Iterable, Optional, List, Dict from HABApp.core.const.const import PYTHON_311 -from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem +from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import UserItem from .formatter import FormatterScope from .formatter_builder import ValueFormatterBuilder, MultipleValueFormatterBuilder, ConstValueFormatterBuilder, \ MetadataFormatter, LinkFormatter diff --git a/src/HABApp/openhab/connection_logic/plugin_things/filters.py b/src/HABApp/openhab/connection/plugins/plugin_things/filters.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/filters.py rename to src/HABApp/openhab/connection/plugins/plugin_things/filters.py diff --git a/src/HABApp/openhab/connection_logic/plugin_things/item_worker.py b/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py similarity index 83% rename from src/HABApp/openhab/connection_logic/plugin_things/item_worker.py rename to src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py index c0f799ec..faf32650 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/item_worker.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py @@ -1,33 +1,34 @@ from typing import Set, Dict import HABApp -from HABApp.openhab.connection_handler.func_async import async_set_habapp_metadata, async_create_item, \ - async_remove_item, async_create_channel_link, async_get_items, \ - async_remove_channel_link, async_remove_metadata, async_set_metadata, async_get_item_with_habapp_meta +from HABApp.openhab.connection.handler.func_async import async_set_habapp_metadata, async_create_item, \ + async_remove_item, async_create_link, async_get_items, \ + async_remove_link, async_remove_metadata, async_set_metadata, async_get_item_with_habapp_meta +from HABApp.openhab.definitions.rest import ItemResp from HABApp.openhab.definitions.rest.habapp_data import HABAppThingPluginData, load_habapp_meta from ._log import log_item as log from .cfg_validator import UserItem -def _filter_items(i: dict): - if not i.get('editable', False): +def _filter_items(i: ItemResp): + if not i.editable: return False - if 'HABApp' not in i.setdefault('metadata', {}): + if 'HABApp' not in i.metadata: return False load_habapp_meta(i) - if not isinstance(i['metadata']['HABApp'], HABAppThingPluginData): + if not isinstance(i.metadata['HABApp'], HABAppThingPluginData): return False return True async def cleanup_items(keep_items: Set[str]): - all_items = await async_get_items(include_habapp_meta=True) + all_items = await async_get_items() to_delete: Dict[str, HABAppThingPluginData] = {} for cfg in filter(_filter_items, all_items): - name = cfg['name'] + name = cfg.name if name not in keep_items: to_delete[name] = cfg['metadata']['HABApp'] @@ -43,7 +44,7 @@ async def _remove_item(item: str, data: HABAppThingPluginData): # remove created links if data.created_link is not None: log.debug(f'Removing link from {data.created_link} to {item}') - await async_remove_channel_link(data.created_link, item) + await async_remove_link(item, data.created_link) # remove created metadata for ns in data.created_ns: @@ -69,7 +70,7 @@ async def create_item(item: UserItem, test: bool) -> bool: try: existing_ok = True existing_item = await async_get_item_with_habapp_meta(name) - habapp_data = existing_item['metadata']['HABApp'] + habapp_data = existing_item.metadata['HABApp'] # we only modify items we created if not isinstance(habapp_data, HABAppThingPluginData): @@ -78,7 +79,7 @@ async def create_item(item: UserItem, test: bool) -> bool: # check if the item properties are already correct for k, v in item.get_oh_cfg().items(): - if v != existing_item.get(k, ''): + if v != getattr(existing_item, k, ''): existing_ok = False except HABApp.openhab.errors.ItemNotFoundError: @@ -95,6 +96,7 @@ async def create_item(item: UserItem, test: bool) -> bool: log.error(f'Item operation failed for {tmp}!') return False await async_set_habapp_metadata(name, habapp_data) + existing_item = await async_get_item_with_habapp_meta(name) else: log.debug(f'Item {name} is already correct!') @@ -103,11 +105,11 @@ async def create_item(item: UserItem, test: bool) -> bool: # remove existing if habapp_data.created_link: log.debug(f'Removing link from {habapp_data.created_link} to {name}') - await async_remove_channel_link(habapp_data.created_link, name) + await async_remove_link(name, habapp_data.created_link) # create new link log.debug(f'Creating link from {item.link} to {item.name}') - if not await async_create_channel_link(item.link, name): + if not await async_create_link(name, item.link): log.error(f'Creating link from {item.link} to {name} failed!') await _remove_item(name, habapp_data) return False @@ -127,7 +129,7 @@ async def create_item(item: UserItem, test: bool) -> bool: # create new for ns, meta_cfg in item.metadata.items(): - existing_cfg = existing_item.get('metadata', {}).get(ns, {}) + existing_cfg = existing_item.metadata.get(ns, {}) if 'config' not in existing_cfg: existing_cfg['config'] = {} if existing_cfg == meta_cfg: diff --git a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py similarity index 86% rename from src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py rename to src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py index dcfa3efb..111167b2 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py @@ -1,46 +1,53 @@ -import asyncio +from __future__ import annotations + import time from pathlib import Path -from typing import Dict, Set, Optional, List, Any +from typing import Any + +import msgspec import HABApp +import HABApp.openhab.events +from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.files.file import HABAppFile from HABApp.core.files.folders import add_folder as add_habapp_folder from HABApp.core.files.watcher import AggregatingAsyncEventHandler from HABApp.core.lib import PendingFuture from HABApp.core.logger import log_warning, HABAppError -from HABApp.openhab.connection_handler.func_async import async_get_things -from HABApp.openhab.connection_logic.plugin_things.cfg_validator import validate_cfg, InvalidItemNameError -from HABApp.openhab.connection_logic.plugin_things.filters import THING_ALIAS, CHANNEL_ALIAS -from HABApp.openhab.connection_logic.plugin_things.filters import apply_filters, log_overview +from HABApp.openhab.connection.connection import OpenhabConnection +from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import validate_cfg, InvalidItemNameError +from HABApp.openhab.connection.plugins.plugin_things.filters import THING_ALIAS, CHANNEL_ALIAS +from HABApp.openhab.connection.plugins.plugin_things.filters import apply_filters, log_overview from ._log import log -from .item_worker import create_item, cleanup_items from .file_writer import ItemsFileWriter +from .item_worker import create_item, cleanup_items from .thing_worker import update_thing_cfg -from .._plugin import OnConnectPlugin class DuplicateItemError(Exception): pass -class ManualThingConfig(OnConnectPlugin): +class TextualThingConfigPlugin(BaseConnectionPlugin[OpenhabConnection]): def __init__(self): super().__init__() - self.created_items: Dict[str, Set[str]] = {} + self.created_items: dict[str, set[str]] = {} self.do_cleanup = PendingFuture(self.clean_items, 120) - self.watcher: Optional[AggregatingAsyncEventHandler] = None + self.watcher: AggregatingAsyncEventHandler | None = None self.cache_ts: float = 0.0 - self.cache_cfg: List[Dict[str, Any]] = [] + self.cache_cfg: list[dict[str, Any]] = [] - def setup(self): + async def on_setup(self): path = HABApp.CONFIG.directories.config if path is None: return None + if self.watcher is not None: + return None + class HABAppThingConfigFile(HABAppFile): LOGGER = log LOAD_FUNC = self.file_load @@ -53,11 +60,10 @@ class HABAppThingConfigFile(HABAppFile): async def file_unload(self, prefix: str, path: Path): return None - async def on_connect_function(self): + async def on_connected(self): if self.watcher is None: return None - await asyncio.sleep(0.3) await self.load_thing_data(always=True) await self.watcher.trigger_all() @@ -68,9 +74,9 @@ async def clean_items(self): items.update(s) await cleanup_items(items) - async def load_thing_data(self, always: bool) -> List[Dict[str, Any]]: + async def load_thing_data(self, always: bool) -> list[dict[str, Any]]: if always or not self.cache_cfg or time.time() - self.cache_ts > 20: - self.cache_cfg = [k.dict(by_alias=True) for k in await async_get_things()] + self.cache_cfg = [msgspec.to_builtins(k) for k in await HABApp.openhab.interface_async.async_get_things()] self.cache_ts = time.time() return self.cache_cfg @@ -191,6 +197,3 @@ async def file_load(self, name: str, path: Path): self.cache_cfg = [] items_file_writer.create_file(items_file_path) - - -PLUGIN_MANUAL_THING_CFG = ManualThingConfig.create_plugin() diff --git a/src/HABApp/openhab/connection_logic/plugin_things/str_builder.py b/src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/str_builder.py rename to src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py diff --git a/src/HABApp/openhab/connection_logic/plugin_things/thing_config.py b/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py similarity index 96% rename from src/HABApp/openhab/connection_logic/plugin_things/thing_config.py rename to src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py index 8fffb780..cbc730fb 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/thing_config.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py @@ -3,8 +3,8 @@ import bidict +import HABApp from HABApp.core.logger import log_error -from HABApp.openhab.connection_handler.func_async import async_set_thing_cfg from ._log import log_cfg as log @@ -131,7 +131,7 @@ async def update_thing_cfg(self): try: # we write only the changed configuration - ret = await async_set_thing_cfg(self.uid, cfg=self.new) + ret = await HABApp.openhab.interface_async.async_set_thing_cfg(self.uid, cfg=self.new) if ret is None: return None except Exception as e: diff --git a/src/HABApp/openhab/connection_logic/plugin_things/thing_worker.py b/src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py similarity index 100% rename from src/HABApp/openhab/connection_logic/plugin_things/thing_worker.py rename to src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py diff --git a/src/HABApp/openhab/connection/plugins/wait_for_restore.py b/src/HABApp/openhab/connection/plugins/wait_for_restore.py new file mode 100644 index 00000000..20e8512c --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/wait_for_restore.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import logging +from asyncio import sleep +from time import monotonic + +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.internals import uses_item_registry +from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext +from HABApp.openhab.items import OpenhabItem +from HABApp.runtime import shutdown + +log = logging.getLogger('HABApp.openhab.startup') + +item_registry = uses_item_registry() + + +def count_none_items() -> int: + found = 0 + for item in item_registry.get_items(): + if isinstance(item, OpenhabItem) and item.value is None: + found += 1 + return found + + +class WaitForPersistenceRestore(BaseConnectionPlugin[OpenhabConnection]): + + async def on_connected(self, context: OpenhabContext): + if context.waited_for_openhab: + log.debug('Openhab has already been running -> complete') + return None + + # if we find None items check if they are still getting initialized (e.g. from persistence) + if this_count := count_none_items(): + log.debug('Some items are still None - waiting for initialisation') + + last_count = -1 + start = monotonic() + + while not shutdown.requested and last_count != this_count: + await sleep(2) + + # timeout so we start eventually + if monotonic() - start >= 180: + log.debug('Timeout while waiting for initialisation') + break + + last_count = this_count + this_count = count_none_items() + + log.debug('complete') diff --git a/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py new file mode 100644 index 00000000..16722be2 --- /dev/null +++ b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +from time import monotonic + +import HABApp +import HABApp.core +import HABApp.openhab.events +from HABApp.core.connections import BaseConnectionPlugin +from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext +from HABApp.openhab.connection.handler.func_async import async_get_system_info + + +async def _start_level_reached() -> tuple[bool, None | int]: + start_level_min = HABApp.CONFIG.openhab.general.min_start_level + + if (system_info := await async_get_system_info()) is None: + return False, None + + start_level_is = system_info.start_level + + return start_level_is >= start_level_min, start_level_is + + +class WaitForStartlevelPlugin(BaseConnectionPlugin[OpenhabConnection]): + + async def on_connected(self, context: OpenhabContext, connection: OpenhabConnection): + level_reached, level = await _start_level_reached() + + if level_reached: + context.waited_for_openhab = False + return None + + context.waited_for_openhab = False + log = connection.log + + log.info('Waiting for openHAB startup to be complete') + + last_level: int = -100 + + timeout_duration = 10 * 60 + timeout_start_at_level = 70 + timeout_timestamp = 0 + + while not level_reached: + await asyncio.sleep(1) + + level_reached, level = await _start_level_reached() + + # show start level change + if last_level != level: + if level is None: + log.debug('Start level: not received!') + level = -10 + else: + log.debug(f'Start level: {level}') + + # Wait but start eventually because sometimes we have a bad configured thing or an offline gateway + # that prevents the start level from advancing + # This is a safety net, so we properly start e.g. after a power outage + # When starting manually one should fix the blocking thing + if level >= timeout_start_at_level: + timeout_timestamp = monotonic() + log.debug('Starting start level timeout') + + # timeout is running + if timeout_timestamp and monotonic() - timeout_timestamp > timeout_duration: + log.warning(f'Starting even though openHAB is not ready yet (start level: {level})') + break + + # update last level! + last_level = level + + log.info('openHAB startup complete') diff --git a/src/HABApp/openhab/connection_handler/__init__.py b/src/HABApp/openhab/connection_handler/__init__.py deleted file mode 100644 index 618a0c6e..00000000 --- a/src/HABApp/openhab/connection_handler/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import http_connection, func_async, func_sync diff --git a/src/HABApp/openhab/connection_handler/func_async.py b/src/HABApp/openhab/connection_handler/func_async.py deleted file mode 100644 index 8643d431..00000000 --- a/src/HABApp/openhab/connection_handler/func_async.py +++ /dev/null @@ -1,332 +0,0 @@ -import datetime -import typing -import warnings -from typing import Any, Optional, Dict, List -from urllib.parse import quote as quote_url - -from pydantic import parse_obj_as - -from HABApp.core.const.json import load_json -from HABApp.core.items import BaseValueItem -from HABApp.openhab.definitions.rest import ItemChannelLinkDefinition, LinkNotFoundError, OpenhabThingDefinition -from HABApp.openhab.definitions.rest.habapp_data import get_api_vals, load_habapp_meta -from HABApp.openhab.errors import ThingNotEditableError, \ - ThingNotFoundError, ItemNotEditableError, ItemNotFoundError, MetadataNotEditableError -from .http_connection import delete, get, put, post, async_get_root, async_get_uuid, async_send_command, \ - async_post_update -from HABApp.core.types import HSB, RGB - -if typing.TYPE_CHECKING: - post = post - async_get_root = async_get_root - async_get_uuid = async_get_uuid - async_send_command = async_send_command - async_post_update = async_post_update - - -def convert_to_oh_type(_in: Any) -> str: - if isinstance(_in, (str, int, float, bool)): - return str(_in) - - if isinstance(_in, datetime.datetime): - # Add timezone (if not yet defined) to string, then remote anything below ms. - # 2018-11-19T09:47:38.284000+0100 -> 2018-11-19T09:47:38.284+0100 - out = _in.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S.%f%z') - return out - - if isinstance(_in, (set, list, tuple, frozenset)): - return ','.join(map(str, _in)) - - if _in is None: - return 'NULL' - - if isinstance(_in, RGB): - _in = _in.to_hsb() - - if isinstance(_in, HSB): - return f'{_in._hue:.2f},{_in._saturation:.2f},{_in._brightness:.2f}' - - if isinstance(_in, BaseValueItem): - raise ValueError() - - return str(_in) - - -async def async_item_exists(item) -> bool: - ret = await get(f'/rest/items/{item:s}', log_404=False) - return ret.status == 200 - - -async def async_get_items(include_habapp_meta=False, metadata: Optional[str] = None, - all_metadata=False, - only_item_state=False) -> Optional[List[Dict[str, Any]]]: - params = None - if include_habapp_meta: - params = {'metadata': 'HABApp'} - if metadata is not None: - if params is not None: - raise ValueError('Use include_habapp_meta or metadata') - params = {'metadata': metadata} - if all_metadata: - params = {'metadata': '.+'} - - if only_item_state: - if params is None: - params = {} - params['fields'] = 'name,state,type' - - resp = await get('/rest/items', params=params) - return await resp.json(loads=load_json, encoding='utf-8') - - -async def async_get_item(item: str, metadata: Optional[str] = None, all_metadata=False) -> dict: - params = None if metadata is None else {'metadata': metadata} - if all_metadata: - params = {'metadata': '.+'} - - ret = await get(f'/rest/items/{item:s}', params=params, log_404=False) - if ret.status == 404: - raise ItemNotFoundError.from_name(item) - if ret.status >= 300: - return {} - else: - data = await ret.json(loads=load_json, encoding='utf-8') - return data - - -async def async_get_things() -> List[OpenhabThingDefinition]: - resp = await get('/rest/things') - data = await resp.json(loads=load_json, encoding='utf-8') - - return parse_obj_as(List[OpenhabThingDefinition], data) - - -async def async_get_thing(uid: str) -> OpenhabThingDefinition: - ret = await get(f'/rest/things/{uid:s}') - if ret.status >= 300: - raise ThingNotFoundError.from_uid(uid) - - return OpenhabThingDefinition.parse_obj(await ret.json(loads=load_json, encoding='utf-8')) - - -async def async_get_persistence_data(item_name: str, persistence: typing.Optional[str], - start_time: typing.Optional[datetime.datetime], - end_time: typing.Optional[datetime.datetime]) -> dict: - - params = {} - if persistence: - params['serviceId'] = persistence - if start_time is not None: - params['starttime'] = convert_to_oh_type(start_time) - if end_time is not None: - params['endtime'] = convert_to_oh_type(end_time) - if not params: - params = None - - ret = await get(f'/rest/persistence/items/{item_name:s}', params=params) - if ret.status >= 300: - return {} - else: - return await ret.json(loads=load_json, encoding='utf-8') - - -async def async_set_persistence_data(item_name: str, persistence: typing.Optional[str], - time: datetime.datetime, state: typing.Any): - - # This does only work for some persistence services (as of OH 3.4) - warnings.warn(f'{async_set_persistence_data.__name__} calls a part of the openHAB API which is buggy!', - category=ResourceWarning) - - params = { - 'time': convert_to_oh_type(time), - 'state': convert_to_oh_type(state), - } - if persistence is not None: - params['serviceId'] = persistence - - ret = await put(f'/rest/persistence/items/{item_name:s}', params=params) - if ret.status >= 300: - return None - else: - # I would have expected the endpoint to return a valid json but instead it returns nothing - # return await ret.json(loads=load_json, encoding='utf-8') - return None - - -async def async_create_item(item_type, name, label="", category="", tags=[], groups=[], - group_type=None, group_function=None, group_function_params=[]) -> bool: - - payload = {'type': item_type, 'name': name} - if label: - payload['label'] = label - if category: - payload['category'] = category - if tags: - payload['tags'] = tags - if groups: - payload['groupNames'] = groups # CamelCase! - - # we create a group - if group_type: - payload['groupType'] = group_type # CamelCase! - if group_function: - payload['function'] = {} - payload['function']['name'] = group_function - if group_function_params: - payload['function']['params'] = group_function_params - - ret = await put(f'/rest/items/{name:s}', json=payload) - if ret is None: - return False - - if ret.status == 404: - raise ItemNotFoundError.from_name(name) - elif ret.status == 405: - raise ItemNotEditableError.from_name(name) - return ret.status < 300 - - -async def async_remove_item(item): - await delete(f'/rest/items/{item:s}') - - -async def async_remove_metadata(item: str, namespace: str): - ret = await delete(f'/rest/items/{item:s}/metadata/{namespace:s}') - if ret is None: - return False - - if ret.status == 404: - raise ItemNotFoundError.from_name(item) - elif ret.status == 405: - raise MetadataNotEditableError.create_text(item, namespace) - return ret.status < 300 - - -async def async_set_metadata(item: str, namespace: str, value: str, config: dict): - payload = { - 'value': value, - 'config': config - } - ret = await put(f'/rest/items/{item:s}/metadata/{namespace:s}', json=payload) - if ret is None: - return False - - if ret.status == 404: - raise ItemNotFoundError.from_name(item) - elif ret.status == 405: - raise MetadataNotEditableError.create_text(item, namespace) - return ret.status < 300 - - -async def async_set_thing_cfg(uid: str, cfg: typing.Dict[str, typing.Any]): - ret = await put(f'/rest/things/{uid:s}/config', json=cfg) - if ret is None: - return None - - if ret.status == 404: - raise ThingNotFoundError.from_uid(uid) - elif ret.status == 409: - raise ThingNotEditableError.from_uid(uid) - elif ret.status >= 300: - raise ValueError('Something went wrong') - - return ret.status - - -async def async_set_thing_enabled(uid: str, enabled: bool): - ret = await put(f'/rest/things/{uid:s}/enable', data='true' if enabled else 'false') - if ret is None: - return None - - if ret.status == 404: - raise ThingNotFoundError.from_uid(uid) - elif ret.status == 409: - raise ThingNotEditableError.from_uid(uid) - elif ret.status >= 300: - raise ValueError('Something went wrong') - - return ret.status - - -# --------------------------------------------------------------------------------------------------------------------- -# Link handling is experimental -# --------------------------------------------------------------------------------------------------------------------- - -def __get_link_url(channel_uid: str, item_name: str) -> str: - # rest/links/ endpoint needs the channel to be url encoded - # (AAAA:BBBB:CCCC:0#NAME -> AAAA%3ABBBB%3ACCCC%3A0%23NAME) - # otherwise the REST-api returns HTTP-Status 500 InternalServerError - return '/rest/links/' + quote_url(f"{item_name}/{channel_uid}") - - -async def async_remove_channel_link(channel_uid: str, item_name: str) -> bool: - ret = await delete(__get_link_url(channel_uid, item_name)) - if ret is None: - return False - return ret.status == 200 - - -async def async_get_channel_links() -> List[Dict[str, str]]: - ret = await get('/rest/links') - if ret.status >= 300: - return None - else: - return await ret.json(loads=load_json, encoding='utf-8') - - -async def async_get_channel_link_mode_auto() -> bool: - ret = await get('/rest/links/auto') - if ret.status >= 300: - return False - else: - return await ret.json(loads=load_json, encoding='utf-8') - - -async def async_get_channel_link(channel_uid: str, item_name: str) -> ItemChannelLinkDefinition: - ret = await get(__get_link_url(channel_uid, item_name), log_404=False) - if ret.status == 404: - raise LinkNotFoundError(f'Link {item_name} -> {channel_uid} not found!') - if ret.status >= 300: - return None - else: - return ItemChannelLinkDefinition(**await ret.json(loads=load_json, encoding='utf-8')) - - -async def async_channel_link_exists(channel_uid: str, item_name: str) -> bool: - ret = await get(__get_link_url(channel_uid, item_name), log_404=False) - return ret.status == 200 - - -async def async_create_channel_link( - channel_uid: str, item_name: str, configuration: Optional[Dict[str, Any]] = None) -> bool: - - # if the passed item doesn't exist OpenHAB creates a new empty item item - # this is undesired and why we raise an Exception - if not await async_item_exists(item_name): - raise ItemNotFoundError.from_name(item_name) - - ret = await put( - __get_link_url(channel_uid, item_name), - json={'configuration': configuration} if configuration is not None else {} - ) - if ret is None: - return False - return ret.status == 200 - - -# --------------------------------------------------------------------------------------------------------------------- -# Funcs for handling HABApp Metadata -# --------------------------------------------------------------------------------------------------------------------- -async def async_remove_habapp_metadata(item: str): - return await async_remove_metadata(item, 'HABApp') - - -async def async_set_habapp_metadata(item: str, obj): - val, cfg = get_api_vals(obj) - return await async_set_metadata(item, 'HABApp', val, cfg) - - -async def async_get_item_with_habapp_meta(item: str) -> dict: - data = await async_get_item(item, all_metadata=True) - data['groups'] = data.pop('groupNames') - return load_habapp_meta(data) diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py deleted file mode 100644 index cd9165ee..00000000 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ /dev/null @@ -1,313 +0,0 @@ -import datetime -from typing import Any, Optional, List, Dict - -import HABApp -import HABApp.core -import HABApp.openhab.events -from HABApp.core.asyncio import run_coro_from_thread, run_func_from_async -from HABApp.core.items import BaseValueItem -from HABApp.openhab.definitions.rest import OpenhabItemDefinition, OpenhabThingDefinition, ItemChannelLinkDefinition -from .func_async import async_post_update, async_send_command, async_create_item, async_get_item, \ - async_get_thing, async_set_thing_enabled, \ - async_set_metadata, async_remove_metadata, async_get_channel_link, async_create_channel_link, \ - async_remove_channel_link, async_channel_link_exists, \ - async_remove_item, async_item_exists, async_get_persistence_data, async_set_persistence_data -from .. import definitions -from ..definitions.helpers import OpenhabPersistenceData - - -def post_update(item_name: str, state: Any): - """ - Post an update to the item - - :param item_name: item name or item - :param state: new item state - """ - assert isinstance(item_name, (str, BaseValueItem)), type(item_name) - - if isinstance(item_name, BaseValueItem): - item_name = item_name.name - - run_func_from_async(async_post_update, item_name, state) - - -def send_command(item_name: str, command): - """ - Send the specified command to the item - - :param item_name: item name or item - :param command: command - """ - assert isinstance(item_name, (str, BaseValueItem)), type(item_name) - - if isinstance(item_name, BaseValueItem): - item_name = item_name.name - - run_func_from_async(async_send_command, item_name, command) - - -def create_item(item_type: str, name: str, label="", category="", - tags: List[str] = [], groups: List[str] = [], - group_type: str = '', group_function: str = '', group_function_params: List[str] = []): - """Creates a new item in the openHAB item registry or updates an existing one - - :param item_type: item type - :param name: item name - :param label: item label - :param category: item category - :param tags: item tags - :param groups: in which groups is the item - :param group_type: what kind of group is it - :param group_function: group state aggregation function - :param group_function_params: params for group state aggregation - :return: True if item was created/updated - """ - - def validate(_in): - assert isinstance(_in, str), type(_in) - - # limit values to special entries and validate parameters - if ':' in item_type: - __type, __unit = item_type.split(':') - assert __unit in definitions.ITEM_DIMENSIONS, \ - f'{__unit} is not a valid openHAB unit: {", ".join(definitions.ITEM_DIMENSIONS)}' - assert __type in definitions.ITEM_TYPES, \ - f'{__type} is not a valid openHAB type: {", ".join(definitions.ITEM_TYPES)}' - else: - assert item_type in definitions.ITEM_TYPES, \ - f'{item_type} is not an openHAB type: {", ".join(definitions.ITEM_TYPES)}' - assert isinstance(name, str), type(name) - assert isinstance(label, str), type(label) - assert isinstance(category, str), type(category) - map(validate, tags) - map(validate, groups) - assert isinstance(group_type, str), type(group_type) - assert isinstance(group_function, str), type(group_function) - map(validate, group_function_params) - - if group_type or group_function or group_function_params: - assert item_type == 'Group', f'Item type must be "Group"! Is: {item_type}' - - if group_function: - assert group_function in definitions.GROUP_ITEM_FUNCTIONS, \ - f'{item_type} is not a group function: {", ".join(definitions.GROUP_ITEM_FUNCTIONS)}' - - return run_coro_from_thread( - async_create_item( - item_type, name, - label=label, category=category, tags=tags, groups=groups, - group_type=group_type, group_function=group_function, group_function_params=group_function_params - ), - calling=create_item - ) - - -def get_item(item_name: str, metadata: Optional[str] = None, all_metadata=False) -> OpenhabItemDefinition: - """Return the complete openHAB item definition - - :param item_name: name of the item or item - :param metadata: metadata to include (optional, comma separated or search expression) - :param all_metadata: if true the result will include all item metadata - :return: openHAB item - """ - if isinstance(item_name, BaseValueItem): - item_name = item_name.name - assert isinstance(item_name, str), type(item_name) - assert metadata is None or isinstance(metadata, str), type(metadata) - - data = run_coro_from_thread( - async_get_item(item_name, metadata=metadata, all_metadata=all_metadata), calling=get_item) - return OpenhabItemDefinition.parse_obj(data) - - -def get_thing(thing_name: str) -> OpenhabThingDefinition: - """ Return the complete openHAB thing definition - - :param thing_name: name of the thing or the item - :return: openHAB thing - """ - if isinstance(thing_name, HABApp.core.items.BaseItem): - thing_name = thing_name.name - assert isinstance(thing_name, str), type(thing_name) - - return run_coro_from_thread(async_get_thing(thing_name), calling=get_thing) - - -def remove_item(item_name: str): - """ - Removes an item from the openHAB item registry - - :param item_name: name - :return: True if item was found and removed - """ - assert isinstance(item_name, str), type(item_name) - return run_coro_from_thread(async_remove_item(item_name), calling=remove_item) - - -def item_exists(item_name: str): - """ - Check if an item exists in the openHAB item registry - - :param item_name: name - :return: True if item was found - """ - assert isinstance(item_name, str), type(item_name) - - return run_coro_from_thread(async_item_exists(item_name), calling=item_exists) - - -def set_metadata(item_name: str, namespace: str, value: str, config: dict): - """ - Add/set metadata to an item - - :param item_name: name of the item or item - :param namespace: namespace, e.g. ``stateDescription`` - :param value: value - :param config: configuration e.g. ``{"options": "A,B,C"}`` - :return: True if metadata was successfully created/updated - """ - if isinstance(item_name, BaseValueItem): - item_name = item_name.name - assert isinstance(item_name, str), type(item_name) - assert isinstance(namespace, str), type(namespace) - assert isinstance(value, str), type(value) - assert isinstance(config, dict), type(config) - - return run_coro_from_thread( - async_set_metadata(item=item_name, namespace=namespace, value=value, config=config), calling=set_metadata - ) - - -def remove_metadata(item_name: str, namespace: str): - """ - Remove metadata from an item - - :param item_name: name of the item or item - :param namespace: namespace - :return: True if metadata was successfully removed - """ - if isinstance(item_name, BaseValueItem): - item_name = item_name.name - assert isinstance(item_name, str), type(item_name) - assert isinstance(namespace, str), type(namespace) - - return run_coro_from_thread(async_remove_metadata(item=item_name, namespace=namespace), calling=remove_metadata) - - -def set_thing_enabled(thing_name: str, enabled: bool = True): - """ - Enable/disable a thing - - :param thing_name: name of the thing or the thing object - :param enabled: True to enable thing, False to disable thing - """ - if isinstance(thing_name, BaseValueItem): - thing_name = thing_name.name - - return run_coro_from_thread(async_set_thing_enabled(uid=thing_name, enabled=enabled), calling=set_thing_enabled) - - -def get_persistence_data(item_name: str, persistence: Optional[str], - start_time: Optional[datetime.datetime], - end_time: Optional[datetime.datetime]) -> OpenhabPersistenceData: - """Query historical data from the openHAB persistence service - - :param item_name: name of the persistent item - :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used - :param start_time: return only items which are newer than this - :param end_time: return only items which are older than this - :return: last stored data from persistency service - """ - assert isinstance(item_name, str) and item_name, item_name - assert isinstance(persistence, str) or persistence is None, persistence - assert isinstance(start_time, datetime.datetime) or start_time is None, start_time - assert isinstance(end_time, datetime.datetime) or end_time is None, end_time - - ret = run_coro_from_thread( - async_get_persistence_data( - item_name=item_name, persistence=persistence, start_time=start_time, end_time=end_time - ), - calling=get_persistence_data - ) - return OpenhabPersistenceData.from_dict(ret) - - -def set_persistence_data(item_name: str, persistence: Optional[str], time: datetime.datetime, state: Any): - """Set a measurement for a item in the persistence serivce - - :param item_name: name of the persistent item - :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used - :param time: time of measurement - :param state: state which will be set - :return: True if data was stored in persistency service - """ - assert isinstance(item_name, str) and item_name, item_name - assert isinstance(persistence, str) or persistence is None, persistence - assert isinstance(time, datetime.datetime), time - - return run_coro_from_thread( - async_set_persistence_data(item_name=item_name, persistence=persistence, time=time, state=state), - calling=set_persistence_data - ) - -# --------------------------------------------------------------------------------------------------------------------- -# Link handling is experimental -# --------------------------------------------------------------------------------------------------------------------- - - -def get_channel_link(channel_uid: str, item_name: str) -> ItemChannelLinkDefinition: - """ returns the ItemChannelLinkDefinition for a link between a (things) channel and an item - - :param channel_uid: uid of the (things) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) - :param item_name: name of the item - :return: an instance of ItemChannelLinkDefinition or None on error - """ - - assert isinstance(channel_uid, str), type(channel_uid) - assert isinstance(item_name, str), type(item_name) - - return run_coro_from_thread(async_get_channel_link(channel_uid, item_name), calling=get_channel_link) - - -def create_channel_link(channel_uid: str, item_name: str, configuration: Optional[Dict[str, Any]] = None) -> bool: - """creates a link between a (things) channel and an item - - :param channel_uid: uid of the (thing) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) - :param item_name: name of the item - :param configuration: optional configuration for the channel - :return: True on successful creation, otherwise False - """ - assert isinstance(channel_uid, str), type(channel_uid) - assert isinstance(item_name, str), type(item_name) - assert isinstance(configuration, dict), type(configuration) - - return run_coro_from_thread( - async_create_channel_link(item_name=item_name, channel_uid=channel_uid, configuration=configuration), - calling=create_channel_link - ) - - -def remove_channel_link(channel_uid: str, item_name: str) -> bool: - """ removes a link between a (things) channel and an item - - :param channel_uid: uid of the (thing) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) - :param item_name: name of the item - :return: True on successful removal, otherwise False - """ - assert isinstance(channel_uid, str), type(channel_uid) - assert isinstance(item_name, str), type(item_name) - - return run_coro_from_thread(async_remove_channel_link(channel_uid, item_name), calling=remove_channel_link) - - -def channel_link_exists(channel_uid: str, item_name: str) -> bool: - """ check if a things channel is linked to an item - - :param channel_uid: uid of the linked channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) - :param item_name: name of the linked item - :return: True when the link exists, otherwise False - """ - assert isinstance(channel_uid, str), type(channel_uid) - assert isinstance(item_name, str), type(item_name) - - return run_coro_from_thread(async_channel_link_exists(channel_uid, item_name), calling=channel_link_exists) diff --git a/src/HABApp/openhab/connection_handler/http_connection.py b/src/HABApp/openhab/connection_handler/http_connection.py deleted file mode 100644 index 27aa0974..00000000 --- a/src/HABApp/openhab/connection_handler/http_connection.py +++ /dev/null @@ -1,501 +0,0 @@ -import asyncio -import logging -import traceback -from asyncio import Queue, sleep, QueueEmpty -from time import monotonic -from typing import Any, Optional, Final, Tuple, Callable, Union - -import aiohttp -from aiohttp import ContentTypeError -from aiohttp.client import ClientResponse, _RequestContextManager -from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT, METH_DELETE -from aiohttp_sse_client import client as sse_client - -import HABApp -import HABApp.core -import HABApp.openhab.events -from HABApp.core.asyncio import async_context -from HABApp.core.const.json import dump_json, load_json -from HABApp.core.logger import log_info, log_warning -from HABApp.core.wrapper import process_exception, ignore_exception -from HABApp.openhab.errors import OpenhabDisconnectedError, ExpectedSuccessFromOpenhab -from .http_connection_waiter import WaitBetweenConnects -from ...core.const.log import TOPIC_EVENTS -from ...core.lib import SingleTask - -log = logging.getLogger('HABApp.openhab.connection') -log_events = logging.getLogger(f'{TOPIC_EVENTS}.openhab') - - -IS_ONLINE: bool = False -IS_READ_ONLY: bool = False - - -# HTTP options -HTTP_ALLOW_REDIRECTS: bool = True -HTTP_VERIFY_SSL: Optional[bool] = None -HTTP_SESSION: aiohttp.ClientSession = None - -CONNECT_WAIT: WaitBetweenConnects = WaitBetweenConnects() - -ON_CONNECTED: Callable = None -ON_DISCONNECTED: Callable = None - - -OH_3: bool = False - - -async def get(url: str, log_404=True, **kwargs: Any) -> ClientResponse: - - mgr = _RequestContextManager( - HTTP_SESSION._request(METH_GET, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, **kwargs) - ) - return await check_response(mgr, log_404=log_404) - - -async def post(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]: - - if IS_READ_ONLY or not IS_ONLINE: - return None - - mgr = _RequestContextManager( - HTTP_SESSION._request( - METH_POST, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, - data=data, json=json, **kwargs - ) - ) - - if data is None: - data = json - return await check_response(mgr, log_404=log_404, sent_data=data) - - -async def put(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]: - - if IS_READ_ONLY or not IS_ONLINE: - return None - - mgr = _RequestContextManager( - HTTP_SESSION._request( - METH_PUT, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, - data=data, json=json, **kwargs - ) - ) - - if data is None: - data = json - return await check_response(mgr, log_404=log_404, sent_data=data) - - -async def delete(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]: - - if IS_READ_ONLY or not IS_ONLINE: - return None - - mgr = _RequestContextManager( - HTTP_SESSION._request(METH_DELETE, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, - data=data, json=json, **kwargs) - ) - - if data is None: - data = json - return await check_response(mgr, log_404=log_404, sent_data=data) - - -def set_offline(log_msg=''): - global IS_ONLINE - - if not IS_ONLINE: - return None - IS_ONLINE = False - - log.warning(f'Disconnected! {log_msg}') - - # cancel SSE listener - TASK_SSE_LISTENER.cancel() - TASK_TRY_CONNECT.cancel() - - ON_DISCONNECTED() - - TASK_TRY_CONNECT.start() - - -def is_disconnect_exception(e) -> bool: - if not isinstance(e, ( - # aiohttp Exceptions - aiohttp.ClientPayloadError, aiohttp.ClientConnectorError, aiohttp.ClientOSError, - - # aiohttp_sse_client Exceptions - ConnectionRefusedError, ConnectionError, ConnectionAbortedError)): - return False - - set_offline(str(e)) - return True - - -async def check_response(future: aiohttp.client._RequestContextManager, sent_data=None, - log_404=True, disconnect_on_error=False) -> ClientResponse: - try: - resp = await future - except Exception as e: - is_disconnect = is_disconnect_exception(e) - log.log(logging.WARNING if is_disconnect else logging.ERROR, f'"{e}" ({type(e)})') - if is_disconnect: - raise OpenhabDisconnectedError() - raise - - status = resp.status - - # Sometimes openHAB issues 404 instead of 500 during startup - if disconnect_on_error and status >= 400: - set_offline(f'Expected success but got status {status} for ' - f'{str(resp.request_info.url).replace(HABApp.CONFIG.openhab.connection.url, "")}') - raise ExpectedSuccessFromOpenhab() - - # Something went wrong - log error message - log_msg = False - if status >= 300: - log_msg = True - - # possibility skip logging of 404 - if status == 404 and not log_404: - log_msg = False - - if log_msg: - # Log Error Message - sent = '' if sent_data is None else f' {sent_data}' - log.warning(f'Status {status} for {resp.request_info.method} {resp.request_info.url}{sent}') - for line in str(resp).splitlines(): - log.debug(line) - - return resp - - -async def shutdown_connection(): - global HTTP_SESSION - - TASK_TRY_CONNECT.cancel() - TASK_SSE_LISTENER.cancel() - - TASK_QUEUE_WORKER.cancel() - TASK_QUEUE_WATCHER.cancel() - - await asyncio.sleep(0) - - # If we are already connected properly disconnect - if HTTP_SESSION is not None: - await HTTP_SESSION.close() - HTTP_SESSION = None - - -async def setup_connection(): - global HTTP_SESSION, HTTP_VERIFY_SSL - - await shutdown_connection() - - config = HABApp.CONFIG.openhab - url: str = config.connection.url - user: str = config.connection.user - password: str = config.connection.password - - # do not run without an url - if not url: - log_info(log, 'openHAB connection disabled (url missing)!') - return None - - # do not run without user/pw - since OH3 mandatory - is_token = user.startswith('oh.') or password.startswith('oh.') - if not is_token and (not user or not password): - log_info(log, 'openHAB connection disabled (user/password missing)!') - return None - - if not config.connection.verify_ssl: - HTTP_VERIFY_SSL = False - log.info('Verify ssl set to False!') - else: - HTTP_VERIFY_SSL = None - - HTTP_SESSION = aiohttp.ClientSession( - base_url=url, - timeout=aiohttp.ClientTimeout(total=None), - json_serialize=dump_json, - auth=aiohttp.BasicAuth(user, password), - read_bufsize=int(config.connection.buffer), - ) - - TASK_TRY_CONNECT.start() - - -async def start_sse_event_listener(): - - async_context.set('SSE') - - try: - # cache so we don't have to look up every event - _load_json = load_json - _see_handler = on_sse_event - oh3 = OH_3 - - async with sse_client.EventSource(url=f'/rest/events?topics={HABApp.CONFIG.openhab.connection.topic_filter}', - session=HTTP_SESSION, ssl=HTTP_VERIFY_SSL) as event_source: - async for event in event_source: - - e_str = event.data - - try: - e_json = _load_json(e_str) - except (ValueError, TypeError): - log_events.warning(f'Invalid json: {e_str}') - continue - - # Alive event from openhab to detect dropped connections - # -> Can be ignored on the HABApp side - e_type = e_json.get('type') - if e_type == 'ALIVE': - continue - - # Log raw sse event - if log_events.isEnabledFor(logging.DEBUG): - log_events._log(logging.DEBUG, e_str, []) - - # With OH4 we have the ItemStateUpdatedEvent, so we can ignore the ItemStateEvent - if not oh3 and e_type == 'ItemStateEvent': - continue - - # process - _see_handler(e_json, oh3) - except Exception as e: - disconnect = is_disconnect_exception(e) - lvl = logging.WARNING if disconnect else logging.ERROR - log.log(lvl, f'SSE request Error: {e}') - for line in traceback.format_exc().splitlines(): - log.log(lvl, line) - - # reconnect even if we have an unexpected error - if not disconnect: - set_offline(f'Uncaught error in process_sse_events: {e}') - - -QUEUE = Queue() - - -async def output_queue_listener(): - # clear Queue - try: - while True: - await QUEUE.get_nowait() - except QueueEmpty: - pass - - while True: - try: - while True: - item, state, is_cmd = await QUEUE.get() - - if not isinstance(state, str): - state = convert_to_oh_type(state) - - if is_cmd: - await post(f'/rest/items/{item:s}', data=state) - else: - await put(f'/rest/items/{item:s}/state', data=state) - except Exception as e: - process_exception(output_queue_listener, e, logger=log) - - -@ignore_exception -async def output_queue_check_size(): - - first_msg_at = 150 - - upper = first_msg_at - lower = -1 - last_info_at = first_msg_at // 2 - - while True: - await sleep(5) - size = QUEUE.qsize() - - # small log msg - if size > upper: - upper = size * 2 - lower = size // 2 - log_warning(log, f'{size} messages in queue') - elif size < lower: - upper = max(size / 2, first_msg_at) - lower = size // 2 - if lower <= last_info_at: - lower = -1 - log_info(log, 'queue OK') - else: - log_info(log, f'{size} messages in queue') - - -def async_post_update(item, state: Any): - QUEUE.put_nowait((item, state, False)) - - -def async_send_command(item, state: Any): - QUEUE.put_nowait((item, state, True)) - - -async def async_get_uuid() -> str: - resp = await get('/rest/uuid', log_404=False) - return await resp.text(encoding='utf-8') - - -async def async_get_root() -> Optional[dict]: - resp = await get('/rest/', log_404=False) - if resp.status == 404: - return None - - try: - return await resp.json(loads=load_json, encoding='utf-8') - except ContentTypeError: - # during start up openHAB sends an empty response with a wrong - # content type which causes the above error - return None - - -async def async_get_system_info() -> dict: - resp = await get('/rest/systeminfo', log_404=False) - if resp.status == 404: - return {} - return await resp.json(loads=load_json, encoding='utf-8') - - -async def _start_level_reached() -> Tuple[bool, Union[None, int]]: - start_level_min = HABApp.CONFIG.openhab.general.min_start_level - - system_info = await async_get_system_info() - start_level_is = system_info.get('systemInfo', {}).get('startLevel') # type: Optional[int] - - if start_level_is is None: - return False, None - - return start_level_is >= start_level_min, start_level_is - - -WAITED_FOR_OPENHAB: bool = False - - -async def wait_for_min_start_level(): - global WAITED_FOR_OPENHAB - - level_reached, level = await _start_level_reached() - if level_reached: - return None - - WAITED_FOR_OPENHAB = True - log.info('Waiting for openHAB startup to be complete') - - last_level: int = -100 - - timeout_duration = 10 * 60 - timeout_start_at_level = 70 - timeout_timestamp = 0 - - while not level_reached: - await asyncio.sleep(1) - - level_reached, level = await _start_level_reached() - - # show start level change - if last_level != level: - if level is None: - log.debug('Start level: not received!') - level = -10 - else: - log.debug(f'Start level: {level}') - - # Wait but start eventually because sometimes we have a thing that prevents the start level from advancing - # This is a safety net, so we properly start e.g. after a power outage - # When starting manually one should fix the blocking thing - if level >= timeout_start_at_level: - timeout_timestamp = monotonic() - log.debug('Starting start level timeout') - - # timeout is running - if timeout_timestamp and monotonic() - timeout_timestamp > timeout_duration: - log.warning(f'Starting even though openHAB is not ready yet (start level: {level})') - break - - # update last level! - last_level = level - - log.info('openHAB startup complete') - - -async def try_connect(): - global IS_ONLINE, OH_3 - - while True: - try: - # sleep before reconnect - await CONNECT_WAIT.wait() - - log.debug('Trying to connect to OpenHAB ...') - if (root := await async_get_root()) is None: - root = {} - - # It's possible that we get status 4XX during startup and then the response is empty - runtime_info = root.get('runtimeInfo') - if not runtime_info: - log.info('... offline!') - continue - - log.info(f'Connected {"read only " if IS_READ_ONLY else ""}to OpenHAB ' - f'version {runtime_info["version"]} ({runtime_info["buildString"]})') - - # todo: remove this 2023 - # Show warning (convenience) - vers = tuple(map(int, runtime_info["version"].split('.')[:2])) - if vers < (3, 3): - log.warning('HABApp requires at least openHAB version 3.3!') - - if vers < (4, 0): - OH_3 = True - - # wait for openhab startup to be complete - await wait_for_min_start_level() - break - except Exception as e: - if isinstance(e, (OpenhabDisconnectedError, ExpectedSuccessFromOpenhab)): - log.info('... offline!') - else: - for line in traceback.format_exc().splitlines(): - log.error(line) - - IS_ONLINE = True - - # start sse processing - TASK_SSE_LISTENER.start() - - # output messages - TASK_QUEUE_WORKER.start() - TASK_QUEUE_WATCHER.start() - - ON_CONNECTED() - return None - - -TASK_SSE_LISTENER: Final = SingleTask(start_sse_event_listener, 'SSE event listener') -TASK_TRY_CONNECT: Final = SingleTask(try_connect, 'Try OH connect') - -TASK_QUEUE_WORKER: Final = SingleTask(output_queue_listener, 'OhQueue') -TASK_QUEUE_WATCHER: Final = SingleTask(output_queue_check_size, 'OhQueueSize') - - -def __load_cfg(): - global IS_READ_ONLY - IS_READ_ONLY = HABApp.config.CONFIG.openhab.general.listen_only - - -# setup config -__load_cfg() -HABApp.config.CONFIG.subscribe_for_changes(__load_cfg) - - -# import it here otherwise we get cyclic imports -from HABApp.openhab.connection_handler.sse_handler import on_sse_event # noqa: E402 -from HABApp.openhab.connection_handler.func_async import convert_to_oh_type # noqa: E402 diff --git a/src/HABApp/openhab/connection_handler/http_connection_waiter.py b/src/HABApp/openhab/connection_handler/http_connection_waiter.py deleted file mode 100644 index e121e776..00000000 --- a/src/HABApp/openhab/connection_handler/http_connection_waiter.py +++ /dev/null @@ -1,27 +0,0 @@ -import asyncio -import time - - -MAX_WAIT: int = 180 - - -class WaitBetweenConnects: - def __init__(self): - self.last_try: float = 0 - self.wait_time = 0 - - async def wait(self): - - # wenn wir lang connected sind oder beim ersten Mal versuchen wir den reconnect gleich - if time.time() - self.last_try > MAX_WAIT: - wait = 0 - else: - wait = self.wait_time - wait = wait * 2 if wait <= 16 else wait + 8 - wait = max(wait, 1) - wait = min(wait, MAX_WAIT) - - self.wait_time = wait - await asyncio.sleep(self.wait_time) - - self.last_try = time.time() diff --git a/src/HABApp/openhab/connection_logic/__init__.py b/src/HABApp/openhab/connection_logic/__init__.py deleted file mode 100644 index 4e7afe52..00000000 --- a/src/HABApp/openhab/connection_logic/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .connection import setup, start - -# Order matters! -from .plugin_load_items import PLUGIN_LOAD_ITEMS -from .plugin_ping import PLUGIN_PING -from .plugin_thing_overview import PLUGIN_THING_OVERVIEW -from .plugin_things import PLUGIN_MANUAL_THING_CFG diff --git a/src/HABApp/openhab/connection_logic/_plugin.py b/src/HABApp/openhab/connection_logic/_plugin.py deleted file mode 100644 index fd04de55..00000000 --- a/src/HABApp/openhab/connection_logic/_plugin.py +++ /dev/null @@ -1,77 +0,0 @@ -import asyncio -import logging -from typing import List, Optional - -from HABApp.core.wrapper import ExceptionToHABApp - -log = logging.getLogger('HABApp.openhab.plugin') - - -class PluginBase: - IS_CONNECTED: bool = False - - @classmethod - def create_plugin(cls): - c = cls() - assert c not in PLUGINS - PLUGINS.append(c) - return c - - def setup(self): - raise NotImplementedError() - - def on_connect(self): - raise NotImplementedError() - - def on_disconnect(self): - raise NotImplementedError() - - -class OnConnectPlugin(PluginBase): - """Plugin that runs a function on connect""" - def __init__(self): - super().__init__() - self.fut: Optional[asyncio.Future] = None - - def setup(self): - pass - - def on_connect(self): - self.fut = asyncio.create_task(self.on_connect_function()) - self.fut.add_done_callback(self._connect_function_complete) - - def _connect_function_complete(self, fut: asyncio.Future): - if self.fut is fut: - self.fut = None - - def on_disconnect(self): - if self.fut is not None and not self.fut.done(): - self.fut.cancel() - - async def on_connect_function(self): - raise NotImplementedError() - - -PLUGINS: List[PluginBase] = [] - - -def on_connect(): - PluginBase.IS_CONNECTED = True - for p in PLUGINS: - with ExceptionToHABApp(log, ignore_exception=True): - p.on_connect() - - -def on_disconnect(): - PluginBase.IS_CONNECTED = False - for p in PLUGINS: - with ExceptionToHABApp(log, ignore_exception=True): - p.on_disconnect() - - -def setup_plugins(): - log.debug('Starting setup') - for p in PLUGINS: - with ExceptionToHABApp(log, ignore_exception=True): - p.setup() - log.debug(f'Setup {p.__class__.__name__} complete') diff --git a/src/HABApp/openhab/connection_logic/connection.py b/src/HABApp/openhab/connection_logic/connection.py deleted file mode 100644 index 8c2dfe28..00000000 --- a/src/HABApp/openhab/connection_logic/connection.py +++ /dev/null @@ -1,26 +0,0 @@ -from HABApp.openhab.connection_handler import http_connection -from ._plugin import on_connect, on_disconnect, setup_plugins - -log = http_connection.log - - -def setup(): - from HABApp.runtime import shutdown - - # initialize callbacks - http_connection.ON_CONNECTED = on_connect - http_connection.ON_DISCONNECTED = on_disconnect - - # shutdown handler for connection - shutdown.register_func(http_connection.shutdown_connection, msg='Stopping openHAB connection') - - # shutdown handler for plugins - shutdown.register_func(on_disconnect, msg='Stopping openHAB plugins') - - # initialize all plugins - setup_plugins() - return None - - -async def start(): - await http_connection.setup_connection() diff --git a/src/HABApp/openhab/connection_logic/plugin_load_items.py b/src/HABApp/openhab/connection_logic/plugin_load_items.py deleted file mode 100644 index 5642a05c..00000000 --- a/src/HABApp/openhab/connection_logic/plugin_load_items.py +++ /dev/null @@ -1,153 +0,0 @@ -import logging -from datetime import datetime -from typing import Dict, Tuple, Optional - -from immutables import Map - -import HABApp -from HABApp.core.wrapper import ignore_exception -from HABApp.openhab.item_to_reg import add_to_registry, fresh_item_sync, remove_from_registry, \ - remove_thing_from_registry, add_thing_to_registry -from HABApp.openhab.map_items import map_item -from ._plugin import OnConnectPlugin -from ..definitions import QuantityValue -from ..definitions.rest import OpenhabThingDefinition -from ..interface_async import async_get_items, async_get_things -from ..items import OpenhabItem -from ...core.internals import uses_item_registry - -log = logging.getLogger('HABApp.openhab.items') - -Items = uses_item_registry() - - -def map_null(value: str) -> Optional[str]: - if value == 'NULL' or value == 'UNDEF': - return None - return value - - -def thing_changed(old: HABApp.openhab.items.Thing, new: OpenhabThingDefinition) -> bool: - return old.status != new.status.status or \ - old.status_detail != new.status.detail or \ - old.status_description != ('' if not new.status.description else new.status.description) or \ - old.label != new.label or \ - old.location != new.configuration or \ - old.configuration != new.configuration or \ - old.properties != new.properties - - -class LoadAllOpenhabItems(OnConnectPlugin): - - @ignore_exception - async def on_connect_function(self): - if await self.load_items(): - return True - if await self.load_things(): - return True - - async def load_items(self) -> bool: - log.debug('Requesting items') - data = await async_get_items(all_metadata=True) - if data is None: - return True - - found_items = len(data) - log.debug(f'Got response with {found_items} items') - - fresh_item_sync() - - for _dict in data: - item_name = _dict['name'] - new_item = map_item(item_name, _dict['type'], map_null(_dict['state']), _dict.get('label'), - frozenset(_dict['tags']), frozenset(_dict['groupNames']), - _dict.get('metadata', {})) # type: HABApp.openhab.items.OpenhabItem - if new_item is None: - continue - add_to_registry(new_item, True) - - # remove items which are no longer available - ist = set(Items.get_item_names()) - soll = {k['name'] for k in data} - for k in ist - soll: - if isinstance(Items.get_item(k), HABApp.openhab.items.OpenhabItem): - remove_from_registry(k) - - log.info(f'Updated {found_items:d} Items') - - # Sync missed updates (e.g. where we got a value updated/changed event during the item request) - # Some bindings poll the item states directly after persistence and we might miss those during startup - log.debug('Starting item state sync') - created_items: Dict[str, Tuple[OpenhabItem, datetime]] = { - i.name: (i, i.last_update) for i in Items.get_items() if isinstance(i, OpenhabItem) - } - if (data := await async_get_items(only_item_state=True)) is None: - return True - - for _dict in data: - item_name = _dict['name'] - - # if the item is still None it was not initialized during the item request - if (_new_value := map_null(_dict['state'])) is None: - continue - - # UoM item handling - if ':' in _dict['type']: - _new_value, _ = QuantityValue.split_unit(_new_value) - - existing_item, existing_item_update = created_items[item_name] - # noinspection PyProtectedMember - _new_value = existing_item._state_from_oh_str(_new_value) - - if existing_item.value != _new_value and existing_item.last_update == existing_item_update: - existing_item.value = _new_value - log.debug(f'Re-synced value of {item_name:s}') - - log.debug('Item state sync complete') - return False - - async def load_things(self): - # try to update things, too - log.debug('Requesting things') - - data = await async_get_things() - - found_things = len(data) - log.debug(f'Got response with {found_things} things') - - Thing = HABApp.openhab.items.Thing - - created_things = {} - for thing_cfg in data: - t = add_thing_to_registry(thing_cfg) - created_things[t.name] = (t, t.last_update) - - # remove things which were deleted - ist = set(Items.get_item_names()) - soll = {k.uid for k in data} - for k in ist - soll: - if isinstance(Items.get_item(k), Thing): - remove_thing_from_registry(k) - log.info(f'Updated {found_things:d} Things') - - # Sync missed updates (e.g. where we got a value updated/changed event during the item request) - log.debug('Starting Thing sync') - data = await async_get_things() - - for thing_cfg in data: - existing_thing, existing_datetime = created_things[thing_cfg.uid] - if thing_changed(existing_thing, thing_cfg) and existing_thing.last_update != existing_datetime: - existing_thing.status = thing_cfg.status.status - existing_thing.status_description = thing_cfg.status.description - existing_thing.status_detail = thing_cfg.status.detail if thing_cfg.status.detail else '' - existing_thing.label = thing_cfg.label - existing_thing.location = thing_cfg.location - existing_thing.configuration = Map(thing_cfg.configuration) - existing_thing.properties = Map(thing_cfg.properties) - log.debug(f'Re-synced {existing_thing.name:s}') - - log.debug('Thing sync complete') - return False - - -PLUGIN_LOAD_ITEMS = LoadAllOpenhabItems.create_plugin() diff --git a/src/HABApp/openhab/connection_logic/plugin_ping.py b/src/HABApp/openhab/connection_logic/plugin_ping.py deleted file mode 100644 index f81da3bf..00000000 --- a/src/HABApp/openhab/connection_logic/plugin_ping.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio -import logging -import time -from typing import Optional - -import HABApp -from HABApp.core.internals import uses_event_bus, uses_item_registry -from HABApp.core.wrapper import log_exception -from HABApp.openhab.errors import OpenhabDisconnectedError -from ._plugin import PluginBase - -log = logging.getLogger('HABApp.openhab.ping') - - -EVENT_BUS = uses_event_bus() -ITEM_REGISTRY = uses_item_registry() - - -class PingOpenhab(PluginBase): - def __init__(self): - self.ping_value: Optional[float] = None - self.ping_sent: Optional[float] = None - self.ping_new: Optional[float] = None - - self.listener: Optional[HABApp.core.internals.EventBusListener] = None - - self.fut_ping: Optional[asyncio.Future] = None - - def setup(self): - HABApp.config.CONFIG.openhab.ping.subscribe_for_changes(self.cfg_changed) - self.cfg_changed() - - def on_connect(self): - if not self.IS_CONNECTED: - return None - - if not HABApp.config.CONFIG.openhab.ping.enabled: - return None - - # initialize - self.ping_value = None - self.ping_sent = None - self.ping_new = None - - self.fut_ping = asyncio.create_task(self.async_ping()) - - def on_disconnect(self): - if self.fut_ping is not None: - self.fut_ping.cancel() - self.fut_ping = None - - log.debug('Ping stopped') - - def cfg_changed(self): - self.on_disconnect() - - if self.listener is not None: - self.listener.cancel() - self.listener = None - - if not HABApp.config.CONFIG.openhab.ping.enabled: - return None - - self.listener = HABApp.core.internals.EventBusListener( - HABApp.config.CONFIG.openhab.ping.item, - HABApp.core.internals.wrap_func(self.ping_received), - HABApp.core.events.EventFilter(HABApp.openhab.events.ItemStateUpdatedEvent) - ) - EVENT_BUS.add_listener(self.listener) - - self.on_connect() - - async def ping_received(self, event: HABApp.openhab.events.ItemStateEvent): - value = event.value - if value != self.ping_value: - return None - - # We only save take the first ping we get - if self.ping_new is not None: - return None - - self.ping_new = round((time.time() - self.ping_sent) * 1000, 1) - - @log_exception - async def async_ping(self): - await asyncio.sleep(5) - - log.debug('Ping started') - - item_name = HABApp.config.CONFIG.openhab.ping.item - - send_ping = True - if not ITEM_REGISTRY.item_exists(item_name): - log.warning(f'Number item "{item_name:s}" does not exist!') - send_ping = False - - try: - while True: - - self.ping_value = self.ping_new - self.ping_new = None - self.ping_sent = time.time() - - if send_ping: - HABApp.openhab.interface_async.async_post_update( - item_name, - f'{self.ping_value:.1f}' if self.ping_value is not None else None - ) - else: - send_ping = ITEM_REGISTRY.item_exists(item_name) - - await asyncio.sleep(HABApp.config.CONFIG.openhab.ping.interval) - - except OpenhabDisconnectedError: - pass - - -PLUGIN_PING = PingOpenhab.create_plugin() diff --git a/src/HABApp/openhab/connection_logic/plugin_things/__init__.py b/src/HABApp/openhab/connection_logic/plugin_things/__init__.py deleted file mode 100644 index 350eb4ee..00000000 --- a/src/HABApp/openhab/connection_logic/plugin_things/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .plugin_things import PLUGIN_MANUAL_THING_CFG diff --git a/src/HABApp/openhab/connection_logic/wait_startup.py b/src/HABApp/openhab/connection_logic/wait_startup.py deleted file mode 100644 index b1e8eced..00000000 --- a/src/HABApp/openhab/connection_logic/wait_startup.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging -from asyncio import sleep -from time import monotonic -from typing import TypeVar, Type, Optional - -from HABApp.core.internals import uses_item_registry -from HABApp.core.items import BaseItem -from HABApp.openhab.connection_handler import http_connection -from HABApp.openhab.items import Thing, OpenhabItem -from HABApp.runtime import shutdown - -log = logging.getLogger('HABApp.openhab.startup') - -item_registry = uses_item_registry() - -T = TypeVar('T', bound=BaseItem) - - -def count_none_items() -> int: - found = 0 - for item in item_registry.get_items(): - if isinstance(item, OpenhabItem) and item.value is None: - found += 1 - return found - - -def find_in_registry(type: Type[T]): - for item in item_registry.get_items(): - if isinstance(item, type): - return True - return False - - -async def wait_for_obj(obj_class: Type[T], timeout: Optional[float] = None): - start = monotonic() - while not find_in_registry(obj_class) and not shutdown.requested: - if timeout is not None and monotonic() - start >= timeout: - log.warning(f'Timeout while waiting for {obj_class.__class__.__name__}s') - return None - await sleep(1) - - -async def wait_for_openhab(): - # wait until we find the items from openhab - await wait_for_obj(OpenhabItem) - - # normally we also have things - await wait_for_obj(Thing, 10) - - # quick return since everything seems to be already started - if not http_connection.WAITED_FOR_OPENHAB: - log.debug('Openhab has already been running -> complete') - return None - - # if we find None items check if they are still getting initialized (e.g. from persistence) - if this_count := count_none_items(): - log.debug('Some items are still None - waiting for initialisation') - - last_count = -1 - start = monotonic() - - while not shutdown.requested and last_count != this_count: - await sleep(1.5) - - # timeout so we start eventually - if monotonic() - start >= 120: - log.debug('Timeout while waiting for initialisation') - break - - last_count = this_count - this_count = count_none_items() - - log.debug('complete') diff --git a/src/HABApp/openhab/definitions/helpers/persistence_data.py b/src/HABApp/openhab/definitions/helpers/persistence_data.py index 75774677..03e78163 100644 --- a/src/HABApp/openhab/definitions/helpers/persistence_data.py +++ b/src/HABApp/openhab/definitions/helpers/persistence_data.py @@ -1,21 +1,23 @@ -import datetime -import typing +from datetime import datetime +from typing import Optional, Union -OPTIONAL_DT = typing.Optional[datetime.datetime] +from HABApp.openhab.definitions.rest import ItemHistoryResp + +OPTIONAL_DT = Optional[datetime] class OpenhabPersistenceData: def __init__(self): - self.data: typing.Dict[float, typing.Union[int, float, str]] = {} + self.data: dict[float, Union[int, float, str]] = {} @classmethod - def from_dict(cls, data) -> 'OpenhabPersistenceData': + def from_resp(cls, data: ItemHistoryResp) -> 'OpenhabPersistenceData': c = cls() - for entry in data['data']: + for entry in data.data: # calc as timestamp - time = entry['time'] / 1000 - state = entry['state'] + time = entry.time / 1000 + state = entry.state if '.' in state: try: state = float(state) @@ -46,13 +48,13 @@ def get_data(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) ret[ts] = val return ret - def min(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> typing.Optional[float]: + def min(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: return min(self.get_data(start_date, end_date).values(), default=None) - def max(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> typing.Optional[float]: + def max(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: return max(self.get_data(start_date, end_date).values(), default=None) - def average(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> typing.Optional[float]: + def average(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: values = list(self.get_data(start_date, end_date).values()) ct = len(values) if ct == 0: diff --git a/src/HABApp/openhab/definitions/items.py b/src/HABApp/openhab/definitions/items.py index aecfcaec..a43c4b72 100644 --- a/src/HABApp/openhab/definitions/items.py +++ b/src/HABApp/openhab/definitions/items.py @@ -74,6 +74,7 @@ class ItemDimensions(StrEnum): class GroupItemFunctions(StrEnum): AND = 'AND' AVG = 'AVG' + COUNT = 'COUNT' MAX = 'MAX' MIN = 'MIN' NAND = 'NAND' diff --git a/src/HABApp/openhab/definitions/rest/__init__.py b/src/HABApp/openhab/definitions/rest/__init__.py index 309a4d3c..e60351bb 100644 --- a/src/HABApp/openhab/definitions/rest/__init__.py +++ b/src/HABApp/openhab/definitions/rest/__init__.py @@ -1,3 +1,8 @@ -from .items import OpenhabItemDefinition -from .things import OpenhabThingChannelDefinition, OpenhabThingDefinition -from .links import ItemChannelLinkDefinition, LinkNotFoundError +from .items import ShortItemResp, ItemResp +from .things import ThingResp, ChannelResp +from .links import ItemChannelLinkResp + +from .root import RootResp +from .systeminfo import SystemInfoRootResp +from .transformations import TransformationResp +from .persistence import ItemHistoryResp, PersistenceServiceResp diff --git a/src/HABApp/openhab/definitions/rest/habapp_data.py b/src/HABApp/openhab/definitions/rest/habapp_data.py index 346c01f3..9256c41f 100644 --- a/src/HABApp/openhab/definitions/rest/habapp_data.py +++ b/src/HABApp/openhab/definitions/rest/habapp_data.py @@ -1,29 +1,31 @@ import typing -from typing import List, Optional +from typing import List, Optional, ClassVar from pydantic import BaseModel +from HABApp.openhab.definitions.rest import ItemResp + class HABAppThingPluginData(BaseModel): - _val_name = 'ThingPlugin' + obj_name: ClassVar[str] = 'ThingPlugin' - created_link: Optional[str] + created_link: Optional[str] = None created_ns: List[str] = [] # keep this up to date -cls_names = {k._val_name: k for k in (HABAppThingPluginData, )} +cls_names = {k.obj_name: k for k in (HABAppThingPluginData, )} -def load_habapp_meta(data: dict) -> dict: - meta = data.setdefault('metadata', {}) +def load_habapp_meta(data: ItemResp) -> ItemResp: + meta = data.metadata if meta.setdefault('HABApp', None) is None: return data cls = cls_names.get(meta['HABApp']['value']) # type: typing.Union[HABAppThingPluginData] - meta['HABApp'] = cls.parse_obj(meta['HABApp'].get('config', {})) + meta['HABApp'] = cls.model_validate(meta['HABApp'].get('config', {})) return data def get_api_vals(obj: typing.Union[HABAppThingPluginData]) -> typing.Tuple[str, dict]: - return obj._val_name, obj.dict(exclude_defaults=True) + return obj.obj_name, obj.model_dump(exclude_defaults=True) diff --git a/src/HABApp/openhab/definitions/rest/items.py b/src/HABApp/openhab/definitions/rest/items.py index 9b8ee973..b9218d58 100644 --- a/src/HABApp/openhab/definitions/rest/items.py +++ b/src/HABApp/openhab/definitions/rest/items.py @@ -1,55 +1,69 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Optional, Union, List, Any, Dict -from pydantic import BaseModel, Field +from msgspec import Struct, field -class StateOptionDefinition(BaseModel): +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/StateOption.java +class StateOptionResp(Struct): value: str label: Optional[str] = None -class CommandOptionDefinition(BaseModel): - command: str - label: Optional[str] = None +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/StateDescription.java +class StateDescriptionResp(Struct, kw_only=True): + minimum: Union[int, float, None] = None + maximum: Union[int, float, None] = None + step: Union[int, float, None] = None + pattern: Optional[str] = None + read_only: bool = field(name='readOnly') + options: List[StateOptionResp] -class CommandDescriptionDefinition(BaseModel): - command_options: Optional[List[CommandOptionDefinition]] = Field(alias='commandOptions') +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/CommandOption.java +class CommandOptionResp(Struct): + command: str + label: Optional[str] = None -class StateDescriptionDefinition(BaseModel): - minimum: Optional[Union[int, float]] - maximum: Optional[Union[int, float]] - step: Optional[Union[int, float]] - pattern: Optional[str] - read_only: Optional[bool] = Field(alias='readOnly') - options: Optional[List[StateOptionDefinition]] +class CommandDescriptionResp(Struct): + command_options: List[CommandOptionResp] = field(name='commandOptions') -class GroupFunctionDefinition(BaseModel): +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/items/dto/GroupFunctionDTO.java +class GroupFunctionResp(Struct): name: str - params: Optional[List[str]] + params: List[str] = [] -class OpenhabItemDefinition(BaseModel): +class ItemResp(Struct, kw_only=True): + # ItemDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/items/dto/ItemDTO.java type: str name: str - label: Optional[str] - category: Optional[str] + label: Optional[str] = None + category: Optional[str] = None tags: List[str] - link: str - state: Any - groups: List[str] = Field(alias='groupNames') - members: List['OpenhabItemDefinition'] = [] - transformed_state: Optional[str] = Field(alias='transformedState') - state_description: Optional[StateDescriptionDefinition] = Field(alias='stateDescription') - command_description: Optional[CommandDescriptionDefinition] = Field(alias='commandDescription') + groups: List[str] = field(name='groupNames') + + # EnrichedItemDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/item/EnrichedItemDTO.java + link: Optional[str] = None + state: str + transformed_state: Optional[str] = field(default=None, name='transformedState') + state_description: Optional[StateDescriptionResp] = field(default=None, name='stateDescription') + unit: Optional[str] = field(default=None, name='unitSymbol') + command_description: Optional[CommandDescriptionResp] = field(default=None, name='commandDescription') metadata: Dict[str, Any] = {} editable: bool = True - # Group only fields - group_type: Optional[str] = Field(alias='groupType') - group_function: Optional[GroupFunctionDefinition] = Field(alias='function') + # EnrichedGroupItemDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/item/EnrichedGroupItemDTO.java + members: List['ItemResp'] = [] + group_type: Optional[str] = field(default=None, name='groupType') + group_function: Optional[GroupFunctionResp] = field(default=None, name='function') -OpenhabItemDefinition.update_forward_refs() +class ShortItemResp(Struct): + type: str + name: str + state: str diff --git a/src/HABApp/openhab/definitions/rest/links.py b/src/HABApp/openhab/definitions/rest/links.py index 3f277231..978d8eab 100644 --- a/src/HABApp/openhab/definitions/rest/links.py +++ b/src/HABApp/openhab/definitions/rest/links.py @@ -1,16 +1,18 @@ from typing import Any, Dict -from pydantic import Field +from msgspec import Struct, field -from .base import RestBase +class ItemChannelLinkResp(Struct, kw_only=True): + # AbstractLinkDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/dto/AbstractLinkDTO.java + item: str = field(name='itemName') -class ItemChannelLinkDefinition(RestBase): - editable: bool - channel_uid: str = Field(alias='channelUID') + # ItemChannelLinkDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/dto/ItemChannelLinkDTO.java + channel: str = field(name='channelUID') configuration: Dict[str, Any] = {} - item_name: str = Field(alias='itemName') - -class LinkNotFoundError(Exception): - pass + # EnrichedItemChannelLinkDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/link/EnrichedItemChannelLinkDTO.java + editable: bool diff --git a/src/HABApp/openhab/definitions/rest/persistence.py b/src/HABApp/openhab/definitions/rest/persistence.py new file mode 100644 index 00000000..a4459d69 --- /dev/null +++ b/src/HABApp/openhab/definitions/rest/persistence.py @@ -0,0 +1,25 @@ +from typing import Optional, List + +from msgspec import Struct, field + + +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceDTO.java +class PersistenceServiceResp(Struct): + id: str + label: Optional[str] = None + type: Optional[str] = None + + +class DataPoint(Struct): + time: int + state: str + + +# ItemHistoryDTO +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/ItemHistoryDTO.java + +class ItemHistoryResp(Struct): + name: str + total_records: Optional[str] = field(default=None, name='totalrecords') + data_points: Optional[str] = field(default=None, name='datapoints') + data: List[DataPoint] = [] diff --git a/src/HABApp/openhab/definitions/rest/root.py b/src/HABApp/openhab/definitions/rest/root.py new file mode 100644 index 00000000..72e8496b --- /dev/null +++ b/src/HABApp/openhab/definitions/rest/root.py @@ -0,0 +1,24 @@ +from typing import List + +from msgspec import Struct + + +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest/src/main/java/org/openhab/core/io/rest/internal/resources/beans/RootBean.java + + +class RuntimeResp(Struct, rename='camel'): + version: str + build_string: str + + +class LinkResp(Struct): + type: str + url: str + + +class RootResp(Struct, rename='camel'): + version: str + locale: str + measurement_system: str + runtime_info: RuntimeResp + links: List[LinkResp] diff --git a/src/HABApp/openhab/definitions/rest/systeminfo.py b/src/HABApp/openhab/definitions/rest/systeminfo.py new file mode 100644 index 00000000..4aeec7f1 --- /dev/null +++ b/src/HABApp/openhab/definitions/rest/systeminfo.py @@ -0,0 +1,26 @@ +from typing import Optional + +from msgspec import Struct + + +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest/src/main/java/org/openhab/core/io/rest/internal/resources/beans/SystemInfoBean.java + + +class SystemInfoResp(Struct, rename='camel', kw_only=True): + config_folder: str + userdata_folder: str + log_folder: Optional[str] = None + java_version: Optional[str] = None + java_vendor: Optional[str] = None + java_vendor_version: Optional[str] = None + os_name: Optional[str] = None + os_version: Optional[str] = None + os_architecture: Optional[str] = None + available_processors: int + free_memory: int + total_memory: int + start_level: int + + +class SystemInfoRootResp(Struct, rename='camel'): + system_info: SystemInfoResp diff --git a/src/HABApp/openhab/definitions/rest/things.py b/src/HABApp/openhab/definitions/rest/things.py index 1bc262a2..8d317ea9 100644 --- a/src/HABApp/openhab/definitions/rest/things.py +++ b/src/HABApp/openhab/definitions/rest/things.py @@ -1,48 +1,59 @@ -from typing import Any, Dict, Optional, Tuple +from typing import Dict, List +from typing import Optional, Any -from pydantic import BaseModel, Field +from msgspec import Struct, field from HABApp.openhab.definitions import ThingStatusEnum, ThingStatusDetailEnum -class OpenhabThingChannelDefinition(BaseModel): +class ChannelResp(Struct, kw_only=True): + # ChannelDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/dto/ChannelDTO.java + uid: str id: str - channel_type: str = Field(..., alias='channelTypeUID') + channel_type: str = field(name='channelTypeUID') + item_type: Optional[str] = field(default=None, name='itemType') kind: str - label: str = '' description: str = '' - item_type: Optional[str] = Field(None, alias='itemType') - - linked_items: Tuple[str, ...] = Field(default_factory=tuple, alias='linkedItems') - default_tags: Tuple[str, ...] = Field(default_factory=tuple, alias='defaultTags') + default_tags: List[str] = field(default=list, name='defaultTags') + properties: Dict[str, Any] = {} + configuration: Dict[str, Any] = {} + auto_update_policy: str = field(default='', name='autoUpdatePolicy') - properties: Dict[str, Any] = Field(default_factory=dict) - configuration: Dict[str, Any] = Field(default_factory=dict) + # EnrichedChannelDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedChannelDTO.java + linked_items: List[str] = field(name='linkedItems') -class OpenhabThingStatus(BaseModel): +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/ThingStatusInfo.java +class ThingStatusResp(Struct): status: ThingStatusEnum - detail: ThingStatusDetailEnum = Field(..., alias='statusDetail') + detail: ThingStatusDetailEnum = field(name='statusDetail') description: Optional[str] = None -class OpenhabThingDefinition(BaseModel): - # These are mandatory fields - editable: bool - status: OpenhabThingStatus = Field(..., alias='statusInfo') - thing_type: str = Field(..., alias='thingTypeUID') - uid: str = Field(..., alias='UID') +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/firmware/dto/FirmwareStatusDTO.java +class FirmwareStatusResp(Struct): + status: str + updatable_version: Optional[str] = field(default=None, name='updatableVersion') + - # These fields are optional, but we want to have a value set - # because it simplifies the thing handling a lot - bridge_uid: Optional[str] = Field(None, alias='bridgeUID') +class ThingResp(Struct, kw_only=True): + # AbstractThingDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/dto/AbstractThingDTO.java label: str = '' + bridge_uid: Optional[str] = field(default=None, name='bridgeUID') + configuration: Dict[str, Any] = {} + properties: Dict[str, str] = {} + uid: str = field(name='UID') + thing_type: str = field(name='thingTypeUID') location: str = '' - # Containers should always have a default, so it's easy to iterate over them - channels: Tuple[OpenhabThingChannelDefinition, ...] = Field(default_factory=tuple) - configuration: Dict[str, Any] = Field(default_factory=dict) - firmwareStatus: Dict[str, str] = Field(default_factory=dict) - properties: Dict[str, Any] = Field(default_factory=dict) + # EnrichedThingDTO + # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java + channels: List[ChannelResp] = [] + status: ThingStatusResp = field(name='statusInfo') + firmware_status: Optional[FirmwareStatusResp] = None + editable: bool diff --git a/src/HABApp/openhab/definitions/rest/transformations.py b/src/HABApp/openhab/definitions/rest/transformations.py new file mode 100644 index 00000000..dec164cd --- /dev/null +++ b/src/HABApp/openhab/definitions/rest/transformations.py @@ -0,0 +1,14 @@ +from typing import Dict + +from msgspec import Struct + + +# Documentation of TransformationDTO +# https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/TransformationDTO.java + +class TransformationResp(Struct): + uid: str + label: str + type: str + configuration: Dict[str, str] + editable: bool diff --git a/src/HABApp/openhab/errors.py b/src/HABApp/openhab/errors.py index 961068a0..68bc123d 100644 --- a/src/HABApp/openhab/errors.py +++ b/src/HABApp/openhab/errors.py @@ -16,7 +16,7 @@ class OpenhabDisconnectedError(HABAppOpenhabError): pass -class ExpectedSuccessFromOpenhab(HABAppOpenhabError): +class OpenhabCredentialsInvalidError(HABAppOpenhabError): pass @@ -63,3 +63,44 @@ class MetadataNotEditableError(HABAppOpenhabError): @classmethod def create_text(cls, item: str, namespace: str): return cls(f'Metadata {namespace} for {item} is not editable!') + + +# ---------------------------------------------------------------------------------------------------------------------- +# Transformation errors +# ---------------------------------------------------------------------------------------------------------------------- +class TransformationsRequestError(HABAppOpenhabError): + pass + + +class MapTransformationError(HABAppOpenhabError): + pass + + +class MapTransformationNotFound(HABAppOpenhabError): + pass + + +# ---------------------------------------------------------------------------------------------------------------------- +# Persistence errors +# ---------------------------------------------------------------------------------------------------------------------- +class PersistenceRequestError(HABAppOpenhabError): + pass + + +# ---------------------------------------------------------------------------------------------------------------------- +# Link errors +# ---------------------------------------------------------------------------------------------------------------------- +class LinkRequestError(HABAppOpenhabError): + pass + + +class LinkNotFoundError(HABAppOpenhabError): + @classmethod + def from_names(cls, item: str, channel: str): + return cls(f'Link {item:s} <-> {channel:s} not found!') + + +class LinkNotEditableError(HABAppOpenhabError): + @classmethod + def from_names(cls, item: str, channel: str): + return cls(f'Link {item:s} <-> {channel:s} is not editable!') diff --git a/src/HABApp/openhab/interface.py b/src/HABApp/openhab/interface.py deleted file mode 100644 index 0a4e8f95..00000000 --- a/src/HABApp/openhab/interface.py +++ /dev/null @@ -1,7 +0,0 @@ -from HABApp.openhab.connection_handler.func_sync import \ - post_update, send_command, \ - get_item, item_exists, remove_item, create_item, \ - get_thing, set_thing_enabled, \ - get_persistence_data, \ - remove_metadata, set_metadata, \ - get_channel_link, remove_channel_link, channel_link_exists, create_channel_link diff --git a/src/HABApp/openhab/interface_async.py b/src/HABApp/openhab/interface_async.py index a69d65c6..1dd6915b 100644 --- a/src/HABApp/openhab/interface_async.py +++ b/src/HABApp/openhab/interface_async.py @@ -1,7 +1,8 @@ -from HABApp.openhab.connection_handler.func_async import \ - async_post_update, async_send_command, \ - async_get_items, async_get_item, async_item_exists, async_remove_item, async_create_item, \ - async_get_things, async_get_thing, async_set_thing_cfg, async_set_thing_enabled, \ - async_remove_metadata, async_set_metadata, \ - async_get_persistence_data, \ - async_get_channel_link, async_remove_channel_link, async_channel_link_exists, async_create_channel_link +from HABApp.openhab.connection.handler.func_async import \ + async_get_root, async_get_system_info, async_get_uuid, \ + async_get_things, async_get_thing, async_set_thing_cfg, async_set_thing_enabled,\ + async_get_items, async_item_exists, async_get_item, async_remove_item, async_create_item, \ + async_set_metadata, async_remove_metadata + + +from HABApp.openhab.connection.plugins import async_send_command, async_post_update diff --git a/src/HABApp/openhab/interface_sync.py b/src/HABApp/openhab/interface_sync.py new file mode 100644 index 00000000..3b2fd95d --- /dev/null +++ b/src/HABApp/openhab/interface_sync.py @@ -0,0 +1,9 @@ +from HABApp.openhab.connection.handler.func_sync import \ + get_thing, set_thing_enabled,\ + item_exists, get_item, remove_item, create_item, \ + set_metadata, remove_metadata, \ + get_persistence_services, get_persistence_data, set_persistence_data, \ + get_link, remove_link, create_link + + +from HABApp.openhab.connection.plugins import send_command, post_update diff --git a/src/HABApp/openhab/item_to_reg.py b/src/HABApp/openhab/item_to_reg.py index 03f5f5b3..58c4ac7b 100644 --- a/src/HABApp/openhab/item_to_reg.py +++ b/src/HABApp/openhab/item_to_reg.py @@ -83,7 +83,7 @@ def get_members(group_name: str) -> Tuple['HABApp.openhab.items.OpenhabItem', .. # ---------------------------------------------------------------------------------------------------------------------- # Thing handling # ---------------------------------------------------------------------------------------------------------------------- -def add_thing_to_registry(data: Union['HABApp.openhab.definitions.rest.OpenhabThingDefinition', +def add_thing_to_registry(data: Union['HABApp.openhab.definitions.rest.ThingResp', 'HABApp.openhab.events.thing_events.ThingAddedEvent']): if isinstance(data, HABApp.openhab.events.thing_events.ThingAddedEvent): @@ -91,7 +91,7 @@ def add_thing_to_registry(data: Union['HABApp.openhab.definitions.rest.OpenhabTh status: ThingStatusEnum = THING_STATUS_DEFAULT status_detail: ThingStatusDetailEnum = THING_STATUS_DETAIL_DEFAULT status_description: str = '' - elif isinstance(data, HABApp.openhab.definitions.rest.OpenhabThingDefinition): + elif isinstance(data, HABApp.openhab.definitions.rest.ThingResp): name = data.uid status = data.status.status status_detail = data.status.detail diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index 97ba5b5e..af772e69 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -6,7 +6,7 @@ from HABApp.core.const import MISSING from HABApp.core.items import BaseValueItem from HABApp.core.lib.funcs import compare as _compare -from HABApp.openhab.interface import get_persistence_data, post_update, send_command +from HABApp.openhab.interface_sync import get_persistence_data, post_update, send_command class MetaData(NamedTuple): diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index 81db8fbe..52b92a61 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -14,16 +14,16 @@ class ColorItem(OpenhabItem, OnOffCommand, PercentCommand): """ColorItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Tuple[float, float, float] value: - :ivar float hue: - :ivar float saturation: - :ivar float brightness: - - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar str name: |oh_item_desc_name| + :ivar Tuple[float, float, float] value: |oh_item_desc_value| + :ivar float hue: Hue part of the value + :ivar float saturation: Saturation part of the value + :ivar float brightness: Brightness part of the value + + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ def __init__(self, name: str, h: float = 0.0, s: float = 0.0, b: float = 0.0, diff --git a/src/HABApp/openhab/items/commands.py b/src/HABApp/openhab/items/commands.py index 7f353f97..38fa68a7 100644 --- a/src/HABApp/openhab/items/commands.py +++ b/src/HABApp/openhab/items/commands.py @@ -1,5 +1,5 @@ from HABApp.openhab.definitions import OnOffValue, UpDownValue -from HABApp.openhab.interface import send_command +from HABApp.openhab.interface_sync import send_command class OnOffCommand: diff --git a/src/HABApp/openhab/items/contact_item.py b/src/HABApp/openhab/items/contact_item.py index 26303019..29fd0dca 100644 --- a/src/HABApp/openhab/items/contact_item.py +++ b/src/HABApp/openhab/items/contact_item.py @@ -4,7 +4,7 @@ from ..definitions import OpenClosedValue from ...core.const import MISSING from ..errors import SendCommandNotSupported -from HABApp.openhab.interface import post_update +from HABApp.openhab.interface_sync import post_update if TYPE_CHECKING: Optional = Optional @@ -20,13 +20,13 @@ class ContactItem(OpenhabItem): """ContactItem - :ivar str name: - :ivar str value: + :ivar str name: |oh_item_desc_name| + :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/items/datetime_item.py b/src/HABApp/openhab/items/datetime_item.py index 63957174..890cf0fa 100644 --- a/src/HABApp/openhab/items/datetime_item.py +++ b/src/HABApp/openhab/items/datetime_item.py @@ -14,13 +14,13 @@ class DatetimeItem(OpenhabItem): """DateTimeItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar datetime value: + :ivar str name: |oh_item_desc_name| + :ivar datetime value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod @@ -30,7 +30,8 @@ def _state_from_oh_str(state: str): dt = datetime.fromisoformat(state) else: pos_dot = state.find('.') - pos_plus = state.find('+') + if (pos_plus := state.rfind('+')) == -1: + pos_plus = state.rfind('-') if pos_plus - pos_dot > 6: state = state[:pos_dot + 7] + state[pos_plus:] dt = datetime.strptime(state, '%Y-%m-%dT%H:%M:%S.%f%z') diff --git a/src/HABApp/openhab/items/dimmer_item.py b/src/HABApp/openhab/items/dimmer_item.py index afc78978..260a3e34 100644 --- a/src/HABApp/openhab/items/dimmer_item.py +++ b/src/HABApp/openhab/items/dimmer_item.py @@ -15,13 +15,13 @@ class DimmerItem(OpenhabItem, OnOffCommand, PercentCommand): """DimmerItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Union[int, float] value: + :ivar str name: |oh_item_desc_name| + :ivar Union[int, float] value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py index 2d53a2a2..e61ec185 100644 --- a/src/HABApp/openhab/items/group_item.py +++ b/src/HABApp/openhab/items/group_item.py @@ -15,13 +15,13 @@ class GroupItem(OpenhabItem): """GroupItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Any value: + :ivar str name: |oh_item_desc_name| + :ivar Any value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/items/image_item.py b/src/HABApp/openhab/items/image_item.py index ce2389d8..dc3b882e 100644 --- a/src/HABApp/openhab/items/image_item.py +++ b/src/HABApp/openhab/items/image_item.py @@ -30,14 +30,14 @@ def _convert_bytes(data: bytes, img_type: Optional[str]) -> str: class ImageItem(OpenhabItem): """ImageItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar bytes value: - :ivar Optional[str] image_type: - - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar str name: |oh_item_desc_name| + :ivar bytes value: |oh_item_desc_value| + :ivar Optional[str] image_type: image type (e.g. jpg or png) + + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ def __init__(self, name: str, initial_value: Any = None, label: Optional[str] = None, diff --git a/src/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py index 4e997500..a3f37b92 100644 --- a/src/HABApp/openhab/items/number_item.py +++ b/src/HABApp/openhab/items/number_item.py @@ -13,13 +13,13 @@ class NumberItem(OpenhabItem): """NumberItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Union[int, float] value: + :ivar str name: |oh_item_desc_name| + :ivar Union[int, float] value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @property diff --git a/src/HABApp/openhab/items/rollershutter_item.py b/src/HABApp/openhab/items/rollershutter_item.py index d7810ebd..0d892a6f 100644 --- a/src/HABApp/openhab/items/rollershutter_item.py +++ b/src/HABApp/openhab/items/rollershutter_item.py @@ -15,13 +15,13 @@ class RollershutterItem(OpenhabItem, UpDownCommand, PercentCommand): """RollershutterItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Union[int, float] value: + :ivar str name: |oh_item_desc_name| + :ivar Union[int, float] value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/items/string_item.py b/src/HABApp/openhab/items/string_item.py index d6ba63d5..9a6ffbb0 100644 --- a/src/HABApp/openhab/items/string_item.py +++ b/src/HABApp/openhab/items/string_item.py @@ -12,13 +12,13 @@ class StringItem(OpenhabItem): """StringItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar str value: + :ivar str name: |oh_item_desc_name| + :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod @@ -29,13 +29,13 @@ def _state_from_oh_str(state: str): class PlayerItem(OpenhabItem): """PlayerItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar str value: + :ivar str name: |oh_item_desc_name| + :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py index 42996c42..a5687f9f 100644 --- a/src/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -19,13 +19,13 @@ class SwitchItem(OpenhabItem, OnOffCommand): """SwitchItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar str value: + :ivar str name: |oh_item_desc_name| + :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py index d30c9ddc..20a87a07 100644 --- a/src/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -8,7 +8,7 @@ from HABApp.openhab.definitions import ThingStatusEnum, ThingStatusDetailEnum from HABApp.openhab.definitions.things import THING_STATUS_DEFAULT, THING_STATUS_DETAIL_DEFAULT from HABApp.openhab.events import ThingConfigStatusInfoEvent, ThingStatusInfoEvent, ThingUpdatedEvent -from HABApp.openhab.interface import set_thing_enabled +from HABApp.openhab.interface_sync import set_thing_enabled class Thing(BaseItem): diff --git a/src/HABApp/openhab/items/tuple_items.py b/src/HABApp/openhab/items/tuple_items.py index 2d82e37c..afdc0ec8 100644 --- a/src/HABApp/openhab/items/tuple_items.py +++ b/src/HABApp/openhab/items/tuple_items.py @@ -14,13 +14,13 @@ class CallItem(OpenhabItem): """CallItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Tuple[str, ...] value: + :ivar str name: |oh_item_desc_name| + :ivar Tuple[str, ...] value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod @@ -41,13 +41,13 @@ def set_value(self, new_value) -> bool: class LocationItem(OpenhabItem): """LocationItem which accepts and converts the data types from OpenHAB - :ivar str name: - :ivar Optional[Tuple[float, float, Optional[float]]] value: + :ivar str name: |oh_item_desc_name| + :ivar Optional[Tuple[float, float, Optional[float]]] value: |oh_item_desc_value| - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: - :ivar Mapping[str, MetaData] metadata: + :ivar Optional[str] label: |oh_item_desc_label| + :ivar FrozenSet[str] tags: |oh_item_desc_tags| + :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @staticmethod diff --git a/src/HABApp/openhab/connection_handler/sse_handler.py b/src/HABApp/openhab/process_events.py similarity index 84% rename from src/HABApp/openhab/connection_handler/sse_handler.py rename to src/HABApp/openhab/process_events.py index 90a94fa2..56ff51de 100644 --- a/src/HABApp/openhab/connection_handler/sse_handler.py +++ b/src/HABApp/openhab/process_events.py @@ -1,3 +1,4 @@ +import logging from asyncio import create_task from typing import Union @@ -9,7 +10,6 @@ from HABApp.core.internals import uses_post_event, uses_get_item from HABApp.core.logger import log_warning from HABApp.core.wrapper import process_exception -from HABApp.openhab.connection_handler import http_connection from HABApp.openhab.definitions.topics import TOPIC_THINGS, TOPIC_ITEMS from HABApp.openhab.events import GroupStateChangedEvent, GroupStateUpdatedEvent, \ ItemAddedEvent, ItemRemovedEvent, ItemUpdatedEvent, \ @@ -17,10 +17,8 @@ from HABApp.openhab.item_to_reg import add_to_registry, remove_from_registry, remove_thing_from_registry, \ add_thing_to_registry from HABApp.openhab.map_events import get_event -from HABApp.openhab.map_items import map_item - -log = http_connection.log +log = logging.getLogger('HABApp.openhab.items') post_event = uses_post_event() get_item = uses_get_item() @@ -95,16 +93,23 @@ def on_sse_event(event_dict: dict, oh_3: bool): async def item_event(event: Union[ItemAddedEvent, ItemUpdatedEvent]): - name = event.name + try: + from HABApp.openhab.map_items import map_item - # Since metadata is not part of the event we have to request it - cfg = await HABApp.openhab.interface_async.async_get_item(name, metadata='.+') + name = event.name - new_item = map_item(name, event.type, None, event.label, event.tags, event.groups, metadata=cfg.get('metadata')) - if new_item is None: - return None + # Since metadata is not part of the event we have to request it through the item + if (cfg := await HABApp.openhab.interface_async.async_get_item(name)) is None: + return None - add_to_registry(new_item) - # Send Event to Event Bus - post_event(TOPIC_ITEMS, event) - return None + new_item = map_item(name, event.type, None, event.label, event.tags, event.groups, metadata=cfg.metadata) + if new_item is None: + return None + + add_to_registry(new_item) + # Send Event to Event Bus + post_event(TOPIC_ITEMS, event) + return None + except Exception as e: + process_exception(func=item_event, e=e) + return None diff --git a/src/HABApp/openhab/transformations/__init__.py b/src/HABApp/openhab/transformations/__init__.py new file mode 100644 index 00000000..9b42a706 --- /dev/null +++ b/src/HABApp/openhab/transformations/__init__.py @@ -0,0 +1,7 @@ +import typing as _typing + +# noinspection PyPep8Naming, PyShadowingBuiltins +from ._map import MAP_FACTORY as map + +if _typing.TYPE_CHECKING: + map = map diff --git a/src/HABApp/openhab/transformations/_map/__init__.py b/src/HABApp/openhab/transformations/_map/__init__.py new file mode 100644 index 00000000..6c9bd270 --- /dev/null +++ b/src/HABApp/openhab/transformations/_map/__init__.py @@ -0,0 +1,2 @@ +from .classes import MapTransformation, MapTransformationWithDefault +from .registry import MAP_REGISTRY, MAP_FACTORY diff --git a/src/HABApp/openhab/transformations/_map/classes.py b/src/HABApp/openhab/transformations/_map/classes.py new file mode 100644 index 00000000..237c32a4 --- /dev/null +++ b/src/HABApp/openhab/transformations/_map/classes.py @@ -0,0 +1,27 @@ +from typing import NoReturn + +from HABApp.openhab.errors import MapTransformationError + + +class MapTransformation(dict): + def __init__(self, *args, name: str) -> None: + super().__init__(*args) + self._name = name + + def __repr__(self, additional: str = ''): + return f'<{self.__class__.__name__} name={self._name} items={super().__repr__()}{additional:s}>' + + +class MapTransformationWithDefault(MapTransformation): + def __init__(self, *args, default, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._default = default + + def __missing__(self, key): + return self._default + + def __repr__(self, additional: str = ''): + return super().__repr__(f', default={self._default}{additional}') + + def get(self, key, default=None) -> NoReturn: + raise MapTransformationError(f'Mapping is already defined with a default: "{self._default}"') diff --git a/src/HABApp/openhab/transformations/_map/registry.py b/src/HABApp/openhab/transformations/_map/registry.py new file mode 100644 index 00000000..61cf7622 --- /dev/null +++ b/src/HABApp/openhab/transformations/_map/registry.py @@ -0,0 +1,55 @@ +from typing import Dict, Union, Tuple, Any, Final + +from javaproperties import loads as load_map_file + +from HABApp.openhab.errors import MapTransformationNotFound +from HABApp.openhab.transformations._map.classes import MapTransformation, MapTransformationWithDefault +from HABApp.openhab.transformations.base import TransformationRegistryBase, TransformationFactoryBase, log + + +class MapTransformationRegistry(TransformationRegistryBase): + + def __init__(self, name: str): + super().__init__(name) + self.objs: Dict[str, Tuple[dict, Any]] = {} + + def get(self, name: str) -> Union[MapTransformation, MapTransformationWithDefault]: + try: + data, default = self.objs[name] + except KeyError: + raise MapTransformationNotFound(f'Map transformation "{name:s}" not found!') from None + + if default: + return MapTransformationWithDefault(data, name=name, default=default) + else: + return MapTransformation(data, name=name) + + def set(self, name: str, configuration: Dict[str, str]): + data = load_map_file(configuration['function']) + if not data: + log.warning(f'Map transformation "{name:s}" is empty -> skipped!') + return None + + default = data.pop('', None) + map_keys = all(k.isdecimal() for k in data) + map_values = all(v.isdecimal() for v in data.values()) + if map_values and default is not None and default.isdecimal(): + default = int(default) + + obj = {} + for k, v in data.items(): + key = k if not map_keys else int(k) + value = v if not map_values else int(v) + obj[key] = value + + self.objs[name] = obj, default + + +MAP_REGISTRY: Final = MapTransformationRegistry('map') + + +class MapTransformationFactory(TransformationFactoryBase[Dict[Union[str, int], Union[str, int]]]): + pass + + +MAP_FACTORY = MapTransformationFactory(MAP_REGISTRY) diff --git a/src/HABApp/openhab/transformations/base.py b/src/HABApp/openhab/transformations/base.py new file mode 100644 index 00000000..8ccab0f9 --- /dev/null +++ b/src/HABApp/openhab/transformations/base.py @@ -0,0 +1,50 @@ +import logging +from typing import Dict, Any, Final, TypeVar, Tuple, Generic + + +T = TypeVar('T') + +log = logging.getLogger('HABApp.openhab.transform') + + +class TransformationFactoryBase(Generic[T]): + def __init__(self, registry: 'TransformationRegistryBase'): + self._registry: Final = registry + + def __repr__(self): + return f'<{self._registry.name.title()}{self.__class__.__name__}>' + + def __getitem__(self, key: str) -> T: + return self._registry.get(key) + + +def sort_order(uid: str): + # created through file + if '.' in uid: + name, ext = uid.rsplit('.', 1) + return 0, ext, name + + # UI created + return 1, uid.split(':') + + +class TransformationRegistryBase: + objs: Dict[str, Any] + + def __init__(self, name: str): + self.name: Final = name + + def __repr__(self): + return f'<{self.__class__.__name__} {" ".join(self.available())}' + + def available(self) -> Tuple[str, ...]: + return tuple(sorted(self.objs.keys(), key=sort_order)) + + def get(self, name: str): + raise NotImplementedError() + + def set(self, name: str, configuration: dict): + raise NotImplementedError() + + def clear(self): + self.objs.clear() diff --git a/src/HABApp/rule/__init__.py b/src/HABApp/rule/__init__.py index e7f9a4e2..daba1f9b 100644 --- a/src/HABApp/rule/__init__.py +++ b/src/HABApp/rule/__init__.py @@ -2,4 +2,4 @@ # isort: split -from .rule import Rule +from .rule import Rule, create_rule diff --git a/src/HABApp/rule/rule.py b/src/HABApp/rule/rule.py index 289ead2d..9e9a39c5 100644 --- a/src/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -3,7 +3,7 @@ import sys import warnings from pathlib import Path -from typing import Iterable, Union, Any, Optional, Tuple, Pattern, List, overload, Literal +from typing import Iterable, Union, Any, Optional, Tuple, Pattern, List, overload, Literal, TypeVar, Callable, Final import HABApp import HABApp.core @@ -11,6 +11,7 @@ import HABApp.rule_manager import HABApp.util from HABApp.core.asyncio import create_task +from HABApp.core.const.const import PYTHON_310 from HABApp.core.const.hints import HINT_EVENT_CALLBACK from HABApp.core.internals import HINT_EVENT_FILTER_OBJ, HINT_EVENT_BUS_LISTENER, ContextProvidingObj, \ uses_post_event, EventFilterBase, uses_item_registry, ContextBoundEventBusListener @@ -23,6 +24,12 @@ HINT_PROCESS_CB_FULL from .rule_hook import get_rule_hook as _get_rule_hook +if PYTHON_310: + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + + log = logging.getLogger('HABApp.Rule') @@ -60,9 +67,9 @@ def __init__(self): # interfaces self.async_http = interfaces.http - self.mqtt: HABApp.mqtt.interface = HABApp.mqtt.interface - self.oh: HABApp.openhab.interface = HABApp.openhab.interface - self.openhab: HABApp.openhab.interface = self.oh + self.mqtt: Final = HABApp.mqtt.interface_sync + self.oh: Final = HABApp.openhab.interface_sync + self.openhab: Final = self.oh def on_rule_loaded(self): """Override this to implement logic that will be called when the rule and the file has been successfully loaded @@ -303,3 +310,16 @@ def get_items(type: Union[Tuple[HINT_TYPE_ITEM_OBJ, ...], HINT_TYPE_ITEM_OBJ] = ret.append(item) return ret + + +PSPEC_RULE = ParamSpec('PSPEC_RULE') +TYPE_RULE = TypeVar('TYPE_RULE', bound=Rule) + + +def create_rule(f: Callable[PSPEC_RULE, TYPE_RULE], *args: PSPEC_RULE.args, **kwargs: PSPEC_RULE.kwargs) -> TYPE_RULE: + try: + _get_rule_hook() + except RuntimeError: + return None + + return f(*args, **kwargs) diff --git a/src/HABApp/rule/scheduler/habappschedulerview.py b/src/HABApp/rule/scheduler/habappschedulerview.py index 93f17cfb..6ca26eaa 100644 --- a/src/HABApp/rule/scheduler/habappschedulerview.py +++ b/src/HABApp/rule/scheduler/habappschedulerview.py @@ -1,15 +1,18 @@ import random -from datetime import datetime as dt_datetime, time as dt_time, timedelta as dt_timedelta -from typing import Iterable, Union, Callable, Any +from datetime import datetime as dt_datetime +from datetime import time as dt_time +from datetime import timedelta as dt_timedelta +from typing import Callable, Any +from typing import Iterable, Union import HABApp.rule_ctx from HABApp.core.const.const import PYTHON_310 -from HABApp.core.internals import ContextProvidingObj, HINT_CONTEXT_OBJ +from HABApp.core.internals import ContextProvidingObj, Context from HABApp.core.internals import wrap_func from HABApp.rule.scheduler.executor import WrappedFunctionExecutor from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler from eascheduler import SchedulerView -from eascheduler.jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ +from .jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ SunsetJob if PYTHON_310: @@ -22,10 +25,11 @@ HINT_CB: TypeAlias = Callable[HINT_CB_P, Any] + class HABAppSchedulerView(SchedulerView, ContextProvidingObj): def __init__(self, context: 'HABApp.rule_ctx.HABAppRuleContext'): super().__init__(_HABAppScheduler(), WrappedFunctionExecutor) - self._habapp_rule_ctx: HINT_CONTEXT_OBJ = context + self._habapp_rule_ctx: Context = context def at(self, time: Union[None, dt_datetime, dt_timedelta, dt_time, int], callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> OneTimeJob: diff --git a/src/HABApp/rule/scheduler/jobs.py b/src/HABApp/rule/scheduler/jobs.py new file mode 100644 index 00000000..cdbdabf9 --- /dev/null +++ b/src/HABApp/rule/scheduler/jobs.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import inspect + +import eascheduler.scheduler_view +from HABApp.core.internals import uses_item_registry +from HABApp.core.items import BaseValueItem +from HABApp.core.wrapper import ignore_exception +from HABApp.openhab.items import OpenhabItem +from eascheduler.const import FAR_FUTURE +from eascheduler.jobs import ( + CountdownJob as CountdownJobBase, DawnJob as DawnJobBase, DayOfWeekJob as DayOfWeekJobBase, + DuskJob as DuskJobBase, OneTimeJob as OneTimeJobBase, ReoccurringJob as ReoccurringJobBase, + SunriseJob as SunriseJobBase, SunsetJob as SunsetJobBase +) +from eascheduler.jobs.job_base import ScheduledJobBase + +Items = uses_item_registry() + + +class ItemBoundJobMixin: + def __init__(self): + super().__init__() + self._item: BaseValueItem | None = None + + @ignore_exception + def _timestamp_to_item(self, job: ScheduledJobBase): + if self._item is None: + return None + + if self._next_run >= FAR_FUTURE: + next_run = None + else: + next_run = self.get_next_run() + + if isinstance(self._item, OpenhabItem): + self._item.oh_post_update(next_run) + else: + self._item.post_value(next_run) + + # if the job has been canceled we clean this up, too + if self._parent is None: + self._item = None + self._next_run_callback = None + + def to_item(self, item: str | BaseValueItem | None): + """Sends the next execution (date)time to an item. Sends none if the job is not scheduled. + + :param item: item name or item, None to disable + """ + if item is None: + self._item = None + self._next_run_callback = None + else: + self._item = Items.get_item(item) if not isinstance(item, BaseValueItem) else item + self._next_run_callback = self._timestamp_to_item + # Update the item with the current timestamp + self._timestamp_to_item(self) + + # I am not sure about this operator - it seems like a nice idea but doesn't work well + # with the code inspections from the IDE + # + # def __gt__(self, other): + # if isinstance(other, (str, BaseValueItem, None)): + # self.to_item(other) + # return self + # return NotImplemented + + +class CountdownJob(CountdownJobBase, ItemBoundJobMixin): + pass + + +class DawnJob(DawnJobBase, ItemBoundJobMixin): + pass + + +class DayOfWeekJob(DayOfWeekJobBase, ItemBoundJobMixin): + pass + + +class DuskJob(DuskJobBase, ItemBoundJobMixin): + pass + + +class OneTimeJob(OneTimeJobBase, ItemBoundJobMixin): + pass + + +class ReoccurringJob(ReoccurringJobBase, ItemBoundJobMixin): + pass + + +class SunriseJob(SunriseJobBase, ItemBoundJobMixin): + pass + + +class SunsetJob(SunsetJobBase, ItemBoundJobMixin): + pass + + +# This is a very dirty hack - I really should come up with something different +def replace_jobs(): + g = globals() + module = eascheduler.scheduler_view + for name, obj in inspect.getmembers(module): + if not name.endswith('Job'): + continue + + assert obj is g[name + 'Base'] + setattr(module, name, g[name]) + + +replace_jobs() diff --git a/src/HABApp/rule_manager/benchmark/bench_mqtt.py b/src/HABApp/rule_manager/benchmark/bench_mqtt.py index fd3c78b2..302439ae 100644 --- a/src/HABApp/rule_manager/benchmark/bench_mqtt.py +++ b/src/HABApp/rule_manager/benchmark/bench_mqtt.py @@ -7,7 +7,7 @@ from HABApp.core.events import ValueUpdateEvent, ValueUpdateEventFilter from .bench_base import BenchBaseRule from .bench_times import BenchContainer, BenchTime -from HABApp.mqtt.interface import publish +from HABApp.mqtt.interface_sync import publish LOCK = Lock() diff --git a/src/HABApp/rule_manager/rule_manager.py b/src/HABApp/rule_manager/rule_manager.py index 40649a28..aaa1a649 100644 --- a/src/HABApp/rule_manager/rule_manager.py +++ b/src/HABApp/rule_manager/rule_manager.py @@ -16,7 +16,7 @@ from .rule_file import RuleFile from ..core.internals import uses_item_registry from HABApp.core.internals.wrapped_function import run_function -from ..openhab.connection_logic.wait_startup import wait_for_openhab +from HABApp.core.connections import Connections log = logging.getLogger('HABApp.Rules') @@ -71,8 +71,10 @@ class HABAppRuleFile(HABAppFile): async def load_rules_on_startup(self): - if HABApp.CONFIG.openhab.connection.url and HABApp.CONFIG.openhab.general.wait_for_openhab: - await wait_for_openhab() + if HABApp.CONFIG.openhab.general.wait_for_openhab: + c = Connections.get('openhab') + while not (c.is_shutdown or c.is_disabled or c.is_online): + await sleep(1) else: await sleep(1) @@ -130,6 +132,11 @@ async def request_file_unload(self, name: str, path: Path, request_lock=True): async def request_file_load(self, name: str, path: Path): path_str = str(path) + # if we want to shut down we don't load the rules + if HABApp.runtime.shutdown.requested: + log.debug(f'Skip load of {name:s} because of shutdown') + return None + # Only load existing files if not path.is_file(): log_warning(log, f'Rule file {name} ({path}) does not exist and can not be loaded!') diff --git a/src/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py index 5e3b3192..8a7d9457 100644 --- a/src/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -4,20 +4,22 @@ import HABApp import HABApp.config import HABApp.core -import HABApp.mqtt.mqtt_connection import HABApp.parameters.parameter_files import HABApp.rule.interfaces._http import HABApp.rule_manager +from HABApp.core import Connections import HABApp.util import eascheduler from HABApp.core.asyncio import async_context from HABApp.core.internals import setup_internals from HABApp.core.internals.proxy import ConstProxyObj from HABApp.core.wrapper import process_exception -from HABApp.openhab import connection_logic as openhab_connection +import HABApp.mqtt.connection as mqtt_connection +from HABApp.openhab import connection as openhab_connection from HABApp.runtime import shutdown + class Runtime: def __init__(self): @@ -30,6 +32,9 @@ async def start(self, config_folder: Path): try: token = async_context.set('HABApp startup') + # shutdown setup + shutdown.register_func(Connections.on_application_shutdown, msg='Shutting down connections') + # setup exception handler for the scheduler eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) @@ -53,9 +58,11 @@ async def start(self, config_folder: Path): # generic HTTP await HABApp.rule.interfaces._http.create_client() - # openhab + # Connection setup openhab_connection.setup() + mqtt_connection.setup() + # File loader setup # Parameter Files await HABApp.parameters.parameter_files.setup_param_files() @@ -63,11 +70,7 @@ async def start(self, config_folder: Path): self.rule_manager = HABApp.rule_manager.RuleManager(self) await self.rule_manager.setup() - # MQTT - HABApp.mqtt.mqtt_connection.setup() - HABApp.mqtt.mqtt_connection.connect() - - await openhab_connection.start() + Connections.application_startup_complete() async_context.reset(token) diff --git a/tests/test_core/test_connections.py b/tests/test_core/test_connections.py new file mode 100644 index 00000000..48eaedab --- /dev/null +++ b/tests/test_core/test_connections.py @@ -0,0 +1,118 @@ +from typing import List +from unittest.mock import Mock + +import pytest + +from HABApp.core.connections import BaseConnectionPlugin, BaseConnection, PluginCallbackHandler +from HABApp.core.connections.status_transitions import StatusTransitions, ConnectionStatus + + +def test_transitions(): + status = StatusTransitions() + + def get_flow() -> List[str]: + ret = [] + while add := status.advance_status(): + ret.append(add.value) + return ret + + status.setup = True + assert get_flow() == ['SETUP', 'CONNECTING', 'CONNECTED', 'ONLINE'] + + # No error + for initial_state in (ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTED, ConnectionStatus.ONLINE): + status = StatusTransitions() + status.status = initial_state + status.setup = True + + assert get_flow() == ['DISCONNECTED', 'OFFLINE', 'SETUP', 'CONNECTING', 'CONNECTED', 'ONLINE'] + + # Error Handling + for initial_state in (ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTED, ConnectionStatus.ONLINE): + status = StatusTransitions() + status.status = initial_state + status.error = True + + assert get_flow() == ['DISCONNECTED', 'OFFLINE'] + + # Shutdown + for initial_state in (ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTED, ConnectionStatus.ONLINE): + status = StatusTransitions() + status.status = initial_state + status.shutdown = True + + assert get_flow() == ['DISCONNECTED', 'OFFLINE', 'SHUTDOWN'] + + +async def test_plugin_callback(): + + sentinel = object() + mock_connected = Mock() + mock_setup = Mock() + + class TestPlugin(BaseConnectionPlugin): + def __init__(self): + super().__init__('asdf') + + async def on_connected(self, context): + assert context is sentinel + mock_connected() + + async def on_disconnected(self): + pass + + async def on_setup(self, context, connection): + assert context is sentinel + assert connection is b + mock_setup() + + b = BaseConnection('test') + b.register_plugin(TestPlugin()) + + b.context = sentinel + mock_connected.assert_not_called() + mock_setup.assert_not_called() + + b.status.status = ConnectionStatus.CONNECTED + b.plugin_task.start() + await b.plugin_task.wait() + + mock_connected.assert_called_once() + mock_setup.assert_not_called() + + b.status.status = ConnectionStatus.SETUP + b.plugin_task.start() + await b.plugin_task.wait() + + mock_connected.assert_called_once() + mock_setup.assert_called_once() + + +def test_coro_inspection(): + p = BaseConnectionPlugin('test') + + # ------------------------------------------------------------------------- + # Check args + # + async def coro(): + pass + + assert PluginCallbackHandler._get_coro_kwargs(p, coro) == () + + async def coro(connection): + pass + + assert PluginCallbackHandler._get_coro_kwargs(p, coro) == ('connection', ) + + async def coro(connection, context): + pass + + assert PluginCallbackHandler._get_coro_kwargs(p, coro) == ('connection', 'context') + + # typo in definition + async def coro(connection, contrxt): + pass + + with pytest.raises(ValueError) as e: + PluginCallbackHandler._get_coro_kwargs(p, coro) + assert str(e.value) == 'Invalid parameter name "contrxt" for test.coro' diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index 1ea17b75..c35cbc87 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -10,7 +10,7 @@ from HABApp.core.events import NoEventFilter from HABApp.core.items.base_item import ChangedTime, UpdatedTime from tests.helpers import TestEventBus, LogCollector -from HABApp.core.internals import wrap_func, HINT_ITEM_REGISTRY, HINT_EVENT_BUS +from HABApp.core.internals import wrap_func, ItemRegistry, EventBus from HABApp.core.items import Item @@ -84,7 +84,7 @@ async def test_cancel_running(parent_rule, u: UpdatedTime): assert w2 not in u.tasks -async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: HINT_EVENT_BUS): +async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: EventBus): m = MagicMock() u.set(pd_now(UTC)) list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) @@ -113,7 +113,7 @@ async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: HINT_E list.cancel() -async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: HINT_EVENT_BUS): +async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: EventBus): m = MagicMock() c.set(pd_now(UTC)) list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) @@ -143,7 +143,7 @@ async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: HINT_E await asyncio.sleep(0.01) -async def test_watcher_change_restore(parent_rule, ir: HINT_ITEM_REGISTRY): +async def test_watcher_change_restore(parent_rule, ir: ItemRegistry): name = 'test_save_restore' item_a = Item(name) @@ -162,7 +162,7 @@ async def test_watcher_change_restore(parent_rule, ir: HINT_ITEM_REGISTRY): ir.pop_item(name) -async def test_watcher_update_restore(parent_rule, ir: HINT_ITEM_REGISTRY): +async def test_watcher_update_restore(parent_rule, ir: ItemRegistry): name = 'test_save_restore' item_a = Item(name) @@ -183,7 +183,7 @@ async def test_watcher_update_restore(parent_rule, ir: HINT_ITEM_REGISTRY): @pytest.mark.ignore_log_warnings async def test_watcher_update_cleanup(monkeypatch, parent_rule, c: ChangedTime, - sync_worker, eb: TestEventBus, ir: HINT_ITEM_REGISTRY): + sync_worker, eb: TestEventBus, ir: ItemRegistry): monkeypatch.setattr(HABApp.core.items.tmp_data.CLEANUP, 'secs', 0.7) text_warning = '' diff --git a/tests/test_core/test_items/tests_all_items.py b/tests/test_core/test_items/tests_all_items.py index e351a1a6..00ed37f3 100644 --- a/tests/test_core/test_items/tests_all_items.py +++ b/tests/test_core/test_items/tests_all_items.py @@ -4,7 +4,7 @@ from pendulum import UTC from pendulum import now as pd_now -from HABApp.core.internals import HINT_ITEM_REGISTRY +from HABApp.core.internals import ItemRegistry from HABApp.core.items import Item @@ -17,7 +17,7 @@ def test_test_params(self): assert self.CLS is not None assert self.TEST_VALUES, type(self) - def test_factories(self, ir: HINT_ITEM_REGISTRY): + def test_factories(self, ir: ItemRegistry): cls = self.CLS ITEM_NAME = 'testitem' diff --git a/tests/test_core/test_wrapped_func.py b/tests/test_core/test_wrapped_func.py index 67d0f13f..b65d9754 100644 --- a/tests/test_core/test_wrapped_func.py +++ b/tests/test_core/test_wrapped_func.py @@ -1,4 +1,5 @@ import asyncio +from datetime import date from unittest.mock import AsyncMock from unittest.mock import Mock @@ -12,6 +13,20 @@ from tests.helpers import TestEventBus +def test_error(): + with pytest.raises(ValueError) as e: + wrap_func(None) + assert str(e.value) == 'Callable or coroutine function expected! Got "None" (type NoneType)' + + with pytest.raises(ValueError) as e: + wrap_func(6) + assert str(e.value) == 'Callable or coroutine function expected! Got "6" (type int)' + + with pytest.raises(ValueError) as e: + wrap_func(date(2023, 12, 24)) + assert str(e.value) == 'Callable or coroutine function expected! Got "2023-12-24" (type date)' + + def test_sync_run(sync_worker): func = Mock() f = wrap_func(func, name='mock') diff --git a/tests/test_mqtt/test_interface.py b/tests/test_mqtt/test_interface.py new file mode 100644 index 00000000..1be9af7f --- /dev/null +++ b/tests/test_mqtt/test_interface.py @@ -0,0 +1,9 @@ +from HABApp.mqtt.connection.publish import async_publish, publish +from HABApp.mqtt.connection.subscribe import async_subscribe, subscribe, async_unsubscribe, unsubscribe +from tests.helpers.inspect import assert_same_signature + + +def test_sync_async_signature(): + assert_same_signature(async_publish, publish) + assert_same_signature(async_subscribe, subscribe) + assert_same_signature(async_unsubscribe, unsubscribe) diff --git a/tests/test_mqtt/test_mqtt_connect.py b/tests/test_mqtt/test_mqtt_connect.py deleted file mode 100644 index 832ad7d7..00000000 --- a/tests/test_mqtt/test_mqtt_connect.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging - -import HABApp -from HABApp.mqtt.mqtt_connection import connect, STATUS -from pathlib import Path - -from tests.helpers import LogCollector - - -def test_connect(test_logs: LogCollector): - HABApp.CONFIG.mqtt.connection.host = 'localhost' - HABApp.CONFIG.mqtt.connection.tls.ca_cert = Path('invalid_file_path') - - connect() - - assert STATUS.client is None - assert STATUS.connected is False - assert STATUS.loop_started is False - - test_logs.add_expected('HABApp.mqtt.connection', logging.ERROR, 'Ca cert file does not exist: invalid_file_path') diff --git a/tests/test_mqtt/test_retain.py b/tests/test_mqtt/test_retain.py index 4da039fb..189e67e2 100644 --- a/tests/test_mqtt/test_retain.py +++ b/tests/test_mqtt/test_retain.py @@ -1,5 +1,5 @@ -from HABApp.core.internals import HINT_ITEM_REGISTRY -from HABApp.mqtt.mqtt_connection import send_event_async +from HABApp.core.internals import ItemRegistry +from HABApp.mqtt.connection.subscribe import msg_to_event class MqttDummyMsg: @@ -11,14 +11,14 @@ def __init__(self, topic='', payload='', retain=False): self.qos = 0 -async def test_retain_create(ir: HINT_ITEM_REGISTRY): +async def test_retain_create(ir: ItemRegistry): topic = '/test/creation' assert not ir.item_exists(topic) - await send_event_async(topic, 'aaa', retain=False) + msg_to_event(topic, 'aaa', retain=False) assert not ir.item_exists(topic) # Retain True will create the item - await send_event_async(topic, 'adsf123', retain=True) + msg_to_event(topic, 'adsf123', retain=True) assert ir.item_exists(topic) assert ir.get_item(topic).value == 'adsf123' diff --git a/tests/test_mqtt/test_values.py b/tests/test_mqtt/test_values.py index bcd26e84..155827bb 100644 --- a/tests/test_mqtt/test_values.py +++ b/tests/test_mqtt/test_values.py @@ -1,5 +1,5 @@ import pytest -from paho.mqtt.client import MQTTMessage +from aiomqtt import Message from HABApp.mqtt.mqtt_payload import get_msg_payload @@ -22,6 +22,6 @@ ) ) def test_value_cast(payload, expected): - msg = MQTTMessage(topic=b'test_topic') - msg.payload = payload.encode('utf-8') if not isinstance(payload, bytes) else payload + payload = payload.encode('utf-8') if not isinstance(payload, bytes) else payload + msg = Message('test_topic', payload, None, None, None, None) assert get_msg_payload(msg) == ('test_topic', expected) diff --git a/tests/test_openhab/test_conections.py b/tests/test_openhab/test_conections.py deleted file mode 100644 index 3ade62e9..00000000 --- a/tests/test_openhab/test_conections.py +++ /dev/null @@ -1,10 +0,0 @@ -from HABApp.openhab.connection_handler.http_connection import is_disconnect_exception - - -def test_aiohttp_sse_client_exceptions(): - list = [ConnectionError, ConnectionRefusedError, ConnectionAbortedError] - for k in list: - try: - raise k() - except Exception as e: - assert is_disconnect_exception(e) diff --git a/tests/test_openhab/test_connection/test_connection_waiter.py b/tests/test_openhab/test_connection/test_connection_waiter.py deleted file mode 100644 index 361c70b5..00000000 --- a/tests/test_openhab/test_connection/test_connection_waiter.py +++ /dev/null @@ -1,43 +0,0 @@ -import asyncio - -from HABApp.openhab.connection_handler.http_connection_waiter import WaitBetweenConnects - -waited = -1 - - -async def sleep(time): - global waited - waited = time - - -async def test_aggregation_item(monkeypatch): - monkeypatch.setattr(asyncio, "sleep", sleep) - - c = WaitBetweenConnects() - - await c.wait() - assert waited == 0 - - await c.wait() - assert waited == 1 - - await c.wait() - assert waited == 2 - - await c.wait() - assert waited == 4 - - await c.wait() - assert waited == 8 - - await c.wait() - assert waited == 16 - - await c.wait() - assert waited == 32 - - await c.wait() - assert waited == 40 - - await c.wait() - assert waited == 48 diff --git a/tests/test_openhab/test_interface_sync.py b/tests/test_openhab/test_interface_sync.py index 0b7efda8..9488ee1a 100644 --- a/tests/test_openhab/test_interface_sync.py +++ b/tests/test_openhab/test_interface_sync.py @@ -1,19 +1,20 @@ +from datetime import datetime from typing import Callable import pytest -import HABApp.openhab.interface +import HABApp.openhab.interface_sync from HABApp.core.asyncio import async_context, AsyncContextError -from HABApp.openhab.interface import \ +from HABApp.openhab.interface_sync import \ post_update, send_command, \ get_item, item_exists, remove_item, create_item, \ - get_thing, get_persistence_data, set_thing_enabled, \ + get_thing, get_persistence_data, get_persistence_services, set_persistence_data, set_thing_enabled, \ remove_metadata, set_metadata, \ - get_channel_link, remove_channel_link, channel_link_exists, create_channel_link + get_link, remove_link, create_link @pytest.mark.parametrize('func', [ - getattr(HABApp.openhab.interface, i) for i in dir(HABApp.openhab.interface) if i[0] != '_' + getattr(HABApp.openhab.interface_sync, i) for i in dir(HABApp.openhab.interface_sync) if i[0] != '_' ]) def test_all_imported(func: Callable): assert func.__name__ in globals(), f'"{func.__name__}" not imported!' @@ -28,13 +29,14 @@ def test_all_imported(func: Callable): (item_exists, ('name', )), (remove_item, ('name', )), (create_item, ('String', 'name')), + (get_persistence_services, ()), (get_persistence_data, ('name', None, None, None)), + (set_persistence_data, ('name', 'asdf', datetime.now(), None)), (remove_metadata, ('name', 'ns')), (set_metadata, ('name', 'ns', 'val', {})), - (get_channel_link, ('channel', 'item')), - (channel_link_exists, ('channel', 'item')), - (remove_channel_link, ('channel', 'item')), - (create_channel_link, ('channel', 'item', {})), + (get_link, ('item', 'channel')), + (remove_link, ('item', 'channel')), + (create_link, ('item', 'channel', {})), )) async def test_item_has_name(func, args): async_context.set('Test') diff --git a/tests/test_openhab/test_items/test_mapping.py b/tests/test_openhab/test_items/test_mapping.py index 83eb7ecd..9709a28c 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -4,9 +4,10 @@ import pytest from immutables import Map -from HABApp.openhab.items import DatetimeItem, NumberItem +from HABApp.openhab.items import NumberItem, DatetimeItem from HABApp.openhab.items.base_item import MetaData from HABApp.openhab.map_items import map_item +from eascheduler.const import local_tz from tests.helpers import TestEventBus @@ -50,13 +51,15 @@ def test_number_unit_of_measurement(): def test_datetime(): - # Todo: remove this test once we go >= OH3.1 - # Old format - assert map_item('test1', 'DateTime', '2018-11-19T09:47:38.284+0000', '', frozenset(), frozenset(), {}) == \ - DatetimeItem('test', datetime(2018, 11, 19, 9, 47, 38, 284000)) or \ - DatetimeItem('test', datetime(2018, 11, 19, 10, 47, 38, 284000)) - - # From >= OH3.1 - assert map_item('test1', 'DateTime', '2021-04-10T21:00:43.043996+0000', '', frozenset(), frozenset(), {}) == \ - DatetimeItem('test', datetime(2021, 4, 10, 21, 0, 43, 43996)) or \ - DatetimeItem('test', datetime(2021, 4, 10, 23, 0, 43, 43996)) + offset_str = datetime(2022, 6, 15, tzinfo=local_tz).isoformat()[-6:].replace(':', '') + + def get_dt(value: str): + return map_item( + 'test1', 'DateTime', f'{value}{offset_str}', label='', tags=frozenset(), groups=frozenset(), metadata={}) + + assert get_dt('2022-06-15T09:47:38.284') == datetime(2022, 6, 15, 9, 47, 38, 284000) + assert get_dt('2022-06-15T09:21:43.043996') == datetime(2022, 6, 15, 9, 21, 43, 43996) + assert get_dt('2022-06-15T09:21:43.754673068') == datetime(2022, 6, 15, 9, 21, 43, 754673) + + offset_str = '-0400' + assert isinstance(get_dt('2022-06-15T09:21:43.754673068'), DatetimeItem) diff --git a/tests/test_openhab/test_items/test_thing.py b/tests/test_openhab/test_items/test_thing.py index 291ed905..62176c9b 100644 --- a/tests/test_openhab/test_items/test_thing.py +++ b/tests/test_openhab/test_items/test_thing.py @@ -6,15 +6,15 @@ from pendulum import set_test_now, DateTime, UTC import HABApp -from HABApp.core.internals import HINT_ITEM_REGISTRY -from HABApp.openhab.connection_handler import sse_handler +from HABApp.core.internals import ItemRegistry +from HABApp.openhab import process_events as process_events_module from HABApp.openhab.events import ThingStatusInfoEvent, ThingUpdatedEvent, ThingAddedEvent from HABApp.openhab.items import Thing from HABApp.openhab.map_events import get_event @pytest.fixture(scope="function") -def test_thing(ir: HINT_ITEM_REGISTRY): +def test_thing(ir: ItemRegistry): set_test_now(DateTime(2000, 1, 1, tzinfo=UTC)) thing = HABApp.openhab.items.Thing('test_thing') @@ -156,8 +156,8 @@ def __init__(self, name: str = '', thing_type: str = '', label: str = '', locati assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) -def test_thing_called_status_event(monkeypatch, ir: HINT_ITEM_REGISTRY, test_thing: Thing): - monkeypatch.setattr(sse_handler, 'get_event', lambda x: x) +def test_thing_called_status_event(monkeypatch, ir: ItemRegistry, test_thing: Thing): + monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) ir.add_item(test_thing) test_thing.process_event = Mock() @@ -165,12 +165,12 @@ def test_thing_called_status_event(monkeypatch, ir: HINT_ITEM_REGISTRY, test_thi event = get_status_event('REMOVING') assert test_thing.name == event.name - sse_handler.on_sse_event(event, oh_3=False) + process_events_module.on_sse_event(event, oh_3=False) test_thing.process_event.assert_called_once_with(event) -def test_thing_called_updated_event(monkeypatch, ir: HINT_ITEM_REGISTRY, test_thing: Thing): - monkeypatch.setattr(sse_handler, 'get_event', lambda x: x) +def test_thing_called_updated_event(monkeypatch, ir: ItemRegistry, test_thing: Thing): + monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) ir.add_item(test_thing) test_thing.process_event = Mock() @@ -178,12 +178,12 @@ def test_thing_called_updated_event(monkeypatch, ir: HINT_ITEM_REGISTRY, test_th event = ThingUpdatedEvent('test_thing', 'new_type', 'new_label', '', channels=[], configuration={}, properties={}) assert test_thing.name == event.name - sse_handler.on_sse_event(event, oh_3=False) + process_events_module.on_sse_event(event, oh_3=False) test_thing.process_event.assert_called_once_with(event) -def test_thing_handler_add_event(monkeypatch, ir: HINT_ITEM_REGISTRY): - monkeypatch.setattr(sse_handler, 'get_event', lambda x: x) +def test_thing_handler_add_event(monkeypatch, ir: ItemRegistry): + monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) name = 'AddedThing' type = 'thing:type' @@ -195,7 +195,7 @@ def test_thing_handler_add_event(monkeypatch, ir: HINT_ITEM_REGISTRY): event = ThingAddedEvent(name=name, thing_type=type, label=label, location=location, channels=channels, configuration=configuration, properties=properties) - sse_handler.on_sse_event(event, oh_3=False) + process_events_module.on_sse_event(event, oh_3=False) thing = ir.get_item(name) assert isinstance(thing, Thing) @@ -217,7 +217,7 @@ def test_thing_handler_add_event(monkeypatch, ir: HINT_ITEM_REGISTRY): event = ThingAddedEvent(name=name, thing_type=type, label=label, location=location, channels=channels, configuration=configuration, properties=properties) - sse_handler.on_sse_event(event, oh_3=False) + process_events_module.on_sse_event(event, oh_3=False) thing = ir.get_item(name) assert isinstance(thing, Thing) diff --git a/tests/test_openhab/test_plugins/test_load_items.py b/tests/test_openhab/test_plugins/test_load_items.py index c05dc8dd..8a124adf 100644 --- a/tests/test_openhab/test_plugins/test_load_items.py +++ b/tests/test_openhab/test_plugins/test_load_items.py @@ -1,68 +1,90 @@ import logging +from json import dumps +from typing import List + +import msgspec.json from HABApp.core.internals import ItemRegistry -from HABApp.openhab.connection_logic.plugin_load_items import LoadAllOpenhabItems -import HABApp.openhab.connection_logic.plugin_load_items as plugin_load_items - - -async def _mock_get_items(all_metadata=False, only_item_state=False): - - if all_metadata: - return [ - { - "link": "link length", - "state": "NULL", - "stateDescription": { - "pattern": "%d", - "readOnly": True, - "options": [] - }, - "metadata": { - "autoupdate": { - "value": "false" - } - }, - "editable": False, - "type": "Number:Length", - "name": "ItemLength", - "label": "Label length", - "tags": [], - "groupNames": ["grp1"] - }, - { - "link": "link plain", - "state": "NULL", - "editable": False, - "type": "Number", - "name": "ItemPlain", - "label": "Label plain", - "tags": [], - "groupNames": [] +from HABApp.openhab.connection.plugins import LoadOpenhabItemsPlugin +from HABApp.openhab.connection.connection import OpenhabContext +import HABApp.openhab.connection.plugins.load_items as load_items_module +from HABApp.openhab.definitions.rest import ShortItemResp, ItemResp + + +async def _mock_get_all_items(): + resp = [ + { + "link": "link length", + "state": "NULL", + "stateDescription": { + "pattern": "%d", + "readOnly": True, + "options": [] }, - { - "link": "link no update", - "state": "NULL", - "editable": False, - "type": "Number", - "name": "ItemNoUpdate", - "tags": [], - "groupNames": [] + "metadata": { + "autoupdate": { + "value": "false" + } }, - ] + "editable": False, + "type": "Number:Length", + "name": "ItemLength", + "label": "Label length", + "tags": [], + "groupNames": ["grp1"] + }, + { + "link": "link plain", + "state": "NULL", + "editable": False, + "type": "Number", + "name": "ItemPlain", + "label": "Label plain", + "tags": [], + "groupNames": [] + }, + { + "link": "link no update", + "state": "NULL", + "editable": False, + "type": "Number", + "name": "ItemNoUpdate", + "tags": [], + "groupNames": [] + }, + ] + + return msgspec.json.decode(dumps(resp), type=List[ItemResp]) - if only_item_state: - return [ - {"type": "Number:Length", "name": "ItemLength", "state": "5 m"}, - {"type": "Number", "name": "ItemPlain", "state": "3.14"} - ] - raise ValueError() +async def _mock_get_all_items_state(): + return [ + ShortItemResp("Number:Length", "ItemLength", "5 m"), + ShortItemResp("Number", "ItemPlain", "3.14") + ] + + +async def _mock_get_things(): + return [] async def test_item_sync(monkeypatch, ir: ItemRegistry, test_logs): - monkeypatch.setattr(plugin_load_items, 'async_get_items', _mock_get_items) + monkeypatch.setattr(load_items_module, 'async_get_items', _mock_get_all_items) + monkeypatch.setattr(load_items_module, 'async_get_all_items_state', _mock_get_all_items_state) + monkeypatch.setattr(load_items_module, 'async_get_things', _mock_get_things) + + context = OpenhabContext( + version=(1, 0, 0), is_oh3=False, + waited_for_openhab=False, + created_items={}, created_things={}, + + session=None, session_options=None + ) + # initial item create + await LoadOpenhabItemsPlugin().on_connected(context) - await LoadAllOpenhabItems().load_items() + # sync state + await LoadOpenhabItemsPlugin().on_connected(context) assert [(i.name, i.value) for i in ir.get_items()] == [ ('ItemLength', 5), ('ItemPlain', 3.14), ('ItemNoUpdate', None)] diff --git a/tests/test_openhab/test_plugins/test_thing/test_errors.py b/tests/test_openhab/test_plugins/test_thing/test_errors.py index 68b25cec..8d7921a0 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_errors.py +++ b/tests/test_openhab/test_plugins/test_thing/test_errors.py @@ -1,13 +1,13 @@ import time -from HABApp.openhab.connection_logic.plugin_things.plugin_things import ManualThingConfig +from HABApp.openhab.connection.plugins.plugin_things.plugin_things import TextualThingConfigPlugin from tests.helpers import MockFile, TestEventBus, LogCollector async def test_errors(test_logs: LogCollector, eb: TestEventBus): eb.allow_errors = True - cfg = ManualThingConfig() + cfg = TextualThingConfigPlugin() data = [{"statusInfo": {"status": "ONLINE", "statusDetail": "NONE"}, "editable": True, "label": "Astronomische Sonnendaten", diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_format.py b/tests/test_openhab/test_plugins/test_thing/test_file_format.py index 0fa83916..b7d63a22 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_format.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_format.py @@ -1,6 +1,6 @@ import pytest -from HABApp.openhab.connection_logic.plugin_things.cfg_validator import validate_cfg, UserItemCfg +from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import validate_cfg, UserItemCfg from tests.helpers import TestEventBus @@ -75,7 +75,7 @@ def test_cfg_item_builder(): def test_item_cfg(): - c = UserItemCfg.parse_obj({ + c = UserItemCfg.model_validate({ 'type': 'Switch', 'name': 'asdf', 'metadata': {'a': 'b'} @@ -84,7 +84,7 @@ def test_item_cfg(): i = c.get_item({}) assert i.metadata == {'a': {'value': 'b', 'config': {}}} - c = UserItemCfg.parse_obj({ + c = UserItemCfg.model_validate({ 'type': 'Switch', 'name': 'asdf', 'metadata': {'k': {'value': 'b', 'config': {'d': 'e'}}}, diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py index 9850d1bf..9e84b96f 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py @@ -1,4 +1,4 @@ -from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter_builder import ValueFormatterBuilder, \ +from HABApp.openhab.connection.plugins.plugin_things.file_writer.formatter_builder import ValueFormatterBuilder, \ EmptyFormatter, \ MultipleValueFormatterBuilder, LinkFormatter, MetadataFormatter diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py index d4cfb421..e88cb90c 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py @@ -1,5 +1,5 @@ -from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter import FormatterScope -from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter_builder import ValueFormatter +from HABApp.openhab.connection.plugins.plugin_things.file_writer.formatter import FormatterScope +from HABApp.openhab.connection.plugins.plugin_things.file_writer.formatter_builder import ValueFormatter def test_scope(): diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py index e8790a02..ce2226c6 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py @@ -2,8 +2,8 @@ import io -from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem -from HABApp.openhab.connection_logic.plugin_things.file_writer.writer import ItemsFileWriter +from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import UserItem +from HABApp.openhab.connection.plugins.plugin_things.file_writer import ItemsFileWriter class MyStringIO(io.StringIO): diff --git a/tests/test_openhab/test_plugins/test_thing/test_filter.py b/tests/test_openhab/test_plugins/test_thing/test_filter.py index 669fcc7a..3adb54a3 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_filter.py +++ b/tests/test_openhab/test_plugins/test_thing/test_filter.py @@ -1,4 +1,4 @@ -from HABApp.openhab.connection_logic.plugin_things.filters import ThingFilter, apply_filters +from HABApp.openhab.connection.plugins.plugin_things.filters import ThingFilter, apply_filters def test_thing_filter(): diff --git a/tests/test_openhab/test_plugins/test_thing/test_str_builder.py b/tests/test_openhab/test_plugins/test_thing/test_str_builder.py index 33aa68c1..d2b2cb51 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_str_builder.py +++ b/tests/test_openhab/test_plugins/test_thing/test_str_builder.py @@ -1,4 +1,4 @@ -from HABApp.openhab.connection_logic.plugin_things.str_builder import StrBuilder +from HABApp.openhab.connection.plugins.plugin_things.str_builder import StrBuilder def test_accessor(): diff --git a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py index 8db5b5df..70c777d2 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py +++ b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py @@ -1,6 +1,6 @@ from pytest import fixture, raises -from HABApp.openhab.connection_logic.plugin_things.thing_config import ThingConfigChanger +from HABApp.openhab.connection.plugins.plugin_things.thing_config import ThingConfigChanger @fixture diff --git a/tests/test_openhab/test_rest/test_grp_func.py b/tests/test_openhab/test_rest/test_grp_func.py index ba702b13..654d148f 100644 --- a/tests/test_openhab/test_rest/test_grp_func.py +++ b/tests/test_openhab/test_rest/test_grp_func.py @@ -1,4 +1,8 @@ -from HABApp.openhab.definitions.rest.items import GroupFunctionDefinition +from json import dumps + +from msgspec.json import decode + +from HABApp.openhab.definitions.rest.items import GroupFunctionResp def test_or(): @@ -9,13 +13,13 @@ def test_or(): "OFF" ] } - o = GroupFunctionDefinition.parse_obj(_in) # type: GroupFunctionDefinition + o = decode(dumps(_in), type=GroupFunctionResp) assert o.name == 'OR' assert o.params == ['ON', 'OFF'] def test_eq(): _in = {"name": "EQUALITY"} - o = GroupFunctionDefinition.parse_obj(_in) # type: GroupFunctionDefinition + o = decode(dumps(_in), type=GroupFunctionResp) assert o.name == 'EQUALITY' - assert o.params is None + assert o.params == [] diff --git a/tests/test_openhab/test_rest/test_items.py b/tests/test_openhab/test_rest/test_items.py index 162124a6..6fccc503 100644 --- a/tests/test_openhab/test_rest/test_items.py +++ b/tests/test_openhab/test_rest/test_items.py @@ -1,4 +1,6 @@ -from HABApp.openhab.definitions.rest.items import OpenhabItemDefinition +from msgspec import convert + +from HABApp.openhab.definitions.rest.items import ItemResp, StateOptionResp, CommandOptionResp def test_item_1(): @@ -26,7 +28,7 @@ def test_item_1(): "tags": ["Tag1"], "groupNames": ["Group1", "Group2"] } - item = OpenhabItemDefinition.parse_obj(_in) # type: OpenhabItemDefinition + item = convert(_in, type=ItemResp) assert item.name == 'Item1Name' assert item.label == 'Item1Label' @@ -36,6 +38,42 @@ def test_item_1(): assert item.groups == ["Group1", "Group2"] +def test_item_2(): + d1 = 'DASDING 98.9 (Euro-Hits)' + d2 = 'SWR3 95.5 (Top 40/Pop)' + + _in = {"link": "http://openhabian:8080/rest/items/iSbPlayer_Favorit", + "state": "6", + "stateDescription": { + "pattern": "%s", + "readOnly": False, + "options": [{"value": "0", "label": d1}, {"value": "1", "label": d2}] + }, + "commandDescription": { + "commandOptions": [{"command": "0", "label": d1}, {"command": "1", "label": d2}] + }, + "editable": False, + "type": "String", + "name": "iSbPlayer_Favorit", + "label": "Senderliste", + "category": None, + "tags": [], "groupNames": []} + item = convert(_in, type=ItemResp) + + assert item.name == 'iSbPlayer_Favorit' + assert item.label == 'Senderliste' + assert item.state == '6' + assert item.transformed_state is None + + desc = item.state_description + assert desc.pattern == '%s' + assert desc.read_only is False + assert desc.options == [StateOptionResp('0', d1), StateOptionResp('1', d2)] + + desc = item.command_description + assert desc.command_options == [CommandOptionResp('0', d1), CommandOptionResp('1', d2)] + + def test_group_item(): _in = { "members": [ @@ -84,8 +122,10 @@ def test_group_item(): "ALL_TOPICS" ] } - item = OpenhabItemDefinition.parse_obj(_in) # type: OpenhabItemDefinition + item = convert(_in, type=ItemResp) assert item.name == 'SwitchGroup' - assert isinstance(item.members[0], OpenhabItemDefinition) + assert isinstance(item.members[0], ItemResp) assert item.members[0].name == 'christmasTree' + assert item.group_function.name == 'OR' + assert item.group_function.params == ['ON', 'OFF'] diff --git a/tests/test_openhab/test_rest/test_links.py b/tests/test_openhab/test_rest/test_links.py index 5161893a..c44b8bbb 100644 --- a/tests/test_openhab/test_rest/test_links.py +++ b/tests/test_openhab/test_rest/test_links.py @@ -1,4 +1,5 @@ -from HABApp.openhab.definitions.rest import ItemChannelLinkDefinition +from HABApp.openhab.definitions.rest import ItemChannelLinkResp +from msgspec import convert def test_simple(): @@ -8,9 +9,9 @@ def test_simple(): "itemName": "ZWaveItem1", 'editable': False, } - o = ItemChannelLinkDefinition(**_in) - assert o.channel_uid == 'zwave:device:controller:node15:sensor_luminance' - assert o.item_name == 'ZWaveItem1' + o = convert(_in, type=ItemChannelLinkResp) + assert o.channel == 'zwave:device:controller:node15:sensor_luminance' + assert o.item == 'ZWaveItem1' def test_configuration(): @@ -23,7 +24,7 @@ def test_configuration(): "itemName": "ZWaveItem1", 'editable': False, } - o = ItemChannelLinkDefinition(**_in) - assert o.channel_uid == 'zwave:device:controller:node15:sensor_luminance' - assert o.item_name == 'ZWaveItem1' + o = convert(_in, type=ItemChannelLinkResp) + assert o.channel == 'zwave:device:controller:node15:sensor_luminance' + assert o.item == 'ZWaveItem1' assert o.configuration == {'profile': 'follow', 'offset': 1} diff --git a/tests/test_openhab/test_rest/test_things.py b/tests/test_openhab/test_rest/test_things.py index d07029e3..95306268 100644 --- a/tests/test_openhab/test_rest/test_things.py +++ b/tests/test_openhab/test_rest/test_things.py @@ -1,4 +1,6 @@ -from HABApp.openhab.definitions.rest.things import OpenhabThingDefinition +from HABApp.openhab.definitions.rest.things import ThingResp +from msgspec.json import decode +from json import dumps def test_thing_summary(): @@ -13,7 +15,7 @@ def test_thing_summary(): "thingTypeUID": "astro:sun" } - thing = OpenhabThingDefinition.parse_obj(_in) + thing = decode(dumps(_in), type=ThingResp) assert thing.editable is True assert thing.uid == 'astro:sun:d522ba4b56' @@ -89,14 +91,14 @@ def test_thing_full(): "thingTypeUID": "astro:sun" } - thing = OpenhabThingDefinition.parse_obj(_in) + thing = decode(dumps(_in), type=ThingResp) c0, c1, c2 = thing.channels - assert c0.linked_items == ("LinkedItem1", "LinkedItem2") + assert c0.linked_items == ["LinkedItem1", "LinkedItem2"] assert c0.configuration == {"offset": 0} - assert c1.linked_items == () + assert c1.linked_items == [] assert c1.configuration == {} assert thing.status.status == 'UNINITIALIZED' diff --git a/tests/test_openhab/test_transformations/__init__.py b/tests/test_openhab/test_transformations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_openhab/test_transformations/test_base.py b/tests/test_openhab/test_transformations/test_base.py new file mode 100644 index 00000000..f3b9bf42 --- /dev/null +++ b/tests/test_openhab/test_transformations/test_base.py @@ -0,0 +1,13 @@ +# noinspection PyProtectedMember +from HABApp.openhab.transformations._map.registry import MapTransformationRegistry + + +def test_sort(): + m = MapTransformationRegistry(name='map') + m.objs['test.map'] = ({}, None) + m.objs['aa.map'] = ({}, None) + + # UI generated transformation + m.objs['config:map:test:map'] = ({}, None) + + assert m.available() == ('aa.map', 'test.map', 'config:map:test:map') diff --git a/tests/test_openhab/test_transformations/test_map.py b/tests/test_openhab/test_transformations/test_map.py new file mode 100644 index 00000000..12e74d77 --- /dev/null +++ b/tests/test_openhab/test_transformations/test_map.py @@ -0,0 +1,69 @@ +import pytest + +from HABApp.openhab.errors import MapTransformationError +# noinspection PyProtectedMember +from HABApp.openhab.transformations._map import MapTransformation, MapTransformationWithDefault +# noinspection PyProtectedMember +from HABApp.openhab.transformations._map.registry import MapTransformationRegistry + + +def test_classes(): + a = MapTransformation({1: 2}, name='myname') + assert str(a) == '' + assert a[1] == 2 + with pytest.raises(KeyError): + _ = a[5] + + a = MapTransformationWithDefault({1: 2}, name='myname', default='asdf') + assert str(a) == '' + assert a[1] == 2 + assert a[5] == 'asdf' + + with pytest.raises(MapTransformationError) as e: + a.get(5) + assert str(e.value) == 'Mapping is already defined with a default: "asdf"' + + +def test_parse_file_default(): + file = ''' +ON=1 +OFF=0 +white\\ space=using escape +=default +''' + + m = MapTransformationRegistry('map') + m.set('testobj', {'function': file}) + assert m.objs['testobj'] == ({'OFF': '0', 'ON': '1', 'white space': 'using escape'}, 'default') + + +def test_parse_file_int(): + file = ''' +ON=1 +OFF=0 +=2 +''' + + m = MapTransformationRegistry('map') + m.set('testobj', {'function': file}) + assert m.objs['testobj'] == ({'OFF': 0, 'ON': 1}, 2) + + +def test_parse_file_int_keys(): + file = ''' +1=asdf +2=qwer +''' + m = MapTransformationRegistry('map') + m.set('testobj', {'function': file}) + assert m.objs['testobj'] == ({1: 'asdf', 2: 'qwer'}, None) + + +def test_parse_file_int_values(): + file = ''' +1=6 +2=7 +''' + m = MapTransformationRegistry('map') + m.set('testobj', {'function': file}) + assert m.objs['testobj'] == ({1: 6, 2: 7}, None) diff --git a/tests/test_rule/test_item_search.py b/tests/test_rule/test_item_search.py index c82ef52a..533e3482 100644 --- a/tests/test_rule/test_item_search.py +++ b/tests/test_rule/test_item_search.py @@ -1,13 +1,13 @@ import pytest from HABApp import Rule -from HABApp.core.internals import HINT_ITEM_REGISTRY +from HABApp.core.internals import ItemRegistry from HABApp.core.items import Item, BaseValueItem from HABApp.openhab.items import OpenhabItem, SwitchItem from HABApp.openhab.items.base_item import MetaData -def test_search_type(ir: HINT_ITEM_REGISTRY): +def test_search_type(ir: ItemRegistry): item1 = BaseValueItem('item_1') item2 = Item('item_2') @@ -23,7 +23,7 @@ def test_search_type(ir: HINT_ITEM_REGISTRY): assert Rule.get_items(type=Item) == [item2] -def test_search_oh(ir: HINT_ITEM_REGISTRY): +def test_search_oh(ir: ItemRegistry): item1 = OpenhabItem('oh_item_1', tags=frozenset(['tag1', 'tag2', 'tag3']), groups=frozenset(['grp1', 'grp2']), metadata={'meta1': MetaData('meta_v1')}) item2 = SwitchItem('oh_item_2', tags=frozenset(['tag1', 'tag2', 'tag4']), @@ -61,7 +61,7 @@ def test_classcheck(): Rule.get_items(Item, tags='asdf') -def test_search_name(ir: HINT_ITEM_REGISTRY): +def test_search_name(ir: ItemRegistry): item1 = BaseValueItem('item_1a') item2 = Item('item_2a') diff --git a/tests/test_rule/test_process.py b/tests/test_rule/test_process.py index 916fe4dd..9d4a5845 100644 --- a/tests/test_rule/test_process.py +++ b/tests/test_rule/test_process.py @@ -1,5 +1,6 @@ import asyncio import sys +from asyncio import get_event_loop_policy from json import loads from pathlib import Path from unittest.mock import Mock @@ -12,6 +13,12 @@ from ..helpers import LogCollector from ..rule_runner import SimpleRuleRunner +# It's either subprocesses or async-mqtt but never both +pytestmark = pytest.mark.skipif( + get_event_loop_policy().__class__.__name__ == 'WindowsSelectorEventLoopPolicy', + reason='Subprocesses not supported with the WindowsSelectorEventLoopPolicy' +) + class ProcRule(Rule): def __init__(self): diff --git a/tests/test_rule/test_rule_factory.py b/tests/test_rule/test_rule_factory.py new file mode 100644 index 00000000..c12cd2ea --- /dev/null +++ b/tests/test_rule/test_rule_factory.py @@ -0,0 +1,22 @@ +import pytest + +from HABApp.rule import create_rule, Rule +from tests import SimpleRuleRunner + + +def test_rule_no_create(): + class MyRule(Rule): + pass + + assert create_rule(MyRule) is None + + +@pytest.mark.no_internals +def test_rule_create(): + class MyRule(Rule): + pass + + with SimpleRuleRunner(): + r = create_rule(MyRule) + + assert isinstance(r, MyRule)