From 005fd47ca24577230309fa8b9cb06c40217ff276 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Mon, 25 Jul 2022 13:28:38 +0200 Subject: [PATCH] 1.0.0 (#302) --- .github/workflows/publish-pypi.yml | 4 +- .github/workflows/run_tox.yml | 2 +- .gitignore | 43 ++- .pre-commit-config.yaml | 2 +- .pyup.yml | 2 +- .readthedocs.yml | 12 +- Dockerfile | 54 ++- MANIFEST.in | 1 + _doc/_static/theme_overrides.css | 25 -- _doc/images/folders.drawio | 1 - _doc/images/folders.png | Bin 207726 -> 0 bytes _doc/requirements.txt | 5 - container/entrypoint.sh | 25 ++ {_doc => docs}/__style_guide__ | 0 docs/_static/theme_changes.css | 28 ++ {_doc => docs}/about_habapp.rst | 10 +- {_doc => docs}/advanced_usage.rst | 55 ++- {_doc => docs}/asyncio.rst | 2 +- {_doc => docs}/class_reference.rst | 0 {_doc => docs}/conf.py | 98 ++++- {_doc => docs}/configuration.rst | 131 ++++++- {_doc => docs}/getting_started.rst | 30 +- {_doc => docs}/gifs/mqtt.gif | Bin {_doc => docs}/gifs/openhab.gif | Bin {_doc => docs}/images/architecture.drawio | 0 {_doc => docs}/images/architecture.png | Bin docs/images/folders.drawio | 1 + docs/images/folders.png | Bin 0 -> 221172 bytes {_doc => docs}/images/openhab_api_config.png | Bin docs/images/pycharm_run.png | Bin 0 -> 4725 bytes docs/images/pycharm_run_settings.png | Bin 0 -> 25485 bytes docs/images/pycharm_settings.png | Bin 0 -> 12541 bytes docs/images/pycharm_settings_install.png | Bin 0 -> 29235 bytes {_doc => docs}/index.rst | 1 + {_doc => docs}/installation.rst | 190 +++++++++- {_doc => docs}/interface_habapp.rst | 5 + {_doc => docs}/interface_mqtt.rst | 9 +- {_doc => docs}/interface_openhab.rst | 79 +++- {_doc => docs}/logging.rst | 49 ++- {_doc => docs}/parameters.rst | 12 +- docs/requirements.txt | 9 + {_doc => docs}/rule.rst | 77 +++- {_doc => docs}/rule_examples.rst | 33 +- {_doc => docs}/tips.rst | 4 +- docs/troubleshooting.rst | 15 + {_doc => docs}/util.rst | 121 ++++-- readme.md | 135 +++++-- requirements.txt | 29 +- requirements_setup.txt | 30 +- requirements_tests.txt | 10 + run/conf/config.yml | 46 +++ run/conf/logging.yml | 53 +++ {conf => run/conf}/rules/async_rule.py | 0 {conf => run/conf}/rules/logging_rule.py | 0 {conf => run/conf}/rules/mqtt_rule.py | 4 +- {conf => run/conf}/rules/openhab_rule.py | 4 +- {conf => run/conf}/rules/openhab_things.py | 3 +- .../conf}/rules/openhab_to_mqtt_rule.py | 4 +- {conf => run/conf}/rules/time_rule.py | 0 {conf_testing => run/conf_listen}/config.yml | 18 +- run/conf_listen/logging.yml | 44 +++ run/conf_testing/config.yml | 46 +++ run/conf_testing/config/thing_test.yml | 28 ++ .../conf_testing}/lib/HABAppTests/__init__.py | 0 .../lib/HABAppTests/compare_values.py | 0 .../conf_testing}/lib/HABAppTests/errors.py | 0 .../lib/HABAppTests/event_waiter.py | 27 +- .../lib/HABAppTests/item_waiter.py | 5 +- .../lib/HABAppTests/openhab_tmp_item.py | 6 +- .../lib/HABAppTests/test_data.py | 0 .../lib/HABAppTests/test_rule/__init__.py | 0 .../HABAppTests/test_rule/_rest_patcher.py | 7 +- .../lib/HABAppTests/test_rule/_rule_ids.py | 2 - .../lib/HABAppTests/test_rule/_rule_status.py | 0 .../test_rule/test_case/__init__.py | 0 .../test_rule/test_case/test_case.py | 0 .../test_rule/test_case/test_result.py | 0 .../lib/HABAppTests/test_rule/test_rule.py | 15 +- .../conf_testing}/lib/HABAppTests/utils.py | 2 +- .../conf_testing}/logging.yml | 2 +- .../conf_testing}/parameters/param_file.yml | 0 .../rules/habapp/test_event_listener.py | 45 +++ .../rules/habapp/test_group_listener.py | 99 +++++ .../conf_testing}/rules/habapp/test_habapp.py | 53 ++- .../rules/habapp/test_parameter_files.py | 0 .../rules/habapp/test_scheduler.py | 0 .../rules/habapp/test_util_fade.py | 37 ++ .../conf_testing}/rules/habapp/test_utils.py | 0 .../rules/openhab/test_event_types.py | 6 +- .../rules/openhab/test_groups.py | 4 +- .../rules/openhab/test_habapp_internals.py | 0 .../rules/openhab/test_interface.py | 11 + .../rules/openhab/test_interface_links.py | 6 +- .../rules/openhab/test_item_change.py | 6 +- .../rules/openhab/test_item_funcs.py | 0 .../conf_testing}/rules/openhab/test_items.py | 4 - .../rules/openhab/test_max_sse_msg_size.py | 12 +- .../rules/openhab/test_persistence.py | 3 +- .../rules/openhab/test_things.py | 0 .../conf_testing}/rules/openhab_bugs.py | 0 .../conf_testing}/rules/test_mqtt.py | 10 +- setup.py | 15 +- src/HABApp/__check_dependency_packages__.py | 56 +++ src/HABApp/__cmd_args__.py | 12 +- src/HABApp/__debug_info__.py | 36 ++ src/HABApp/__init__.py | 9 +- src/HABApp/__main__.py | 84 +--- ...{__do_setup__.py => __setup_packages__.py} | 16 +- src/HABApp/__splash_screen__.py | 14 +- src/HABApp/__version__.py | 2 +- src/HABApp/config/__init__.py | 10 +- src/HABApp/config/_conf_location.py | 20 - src/HABApp/config/_conf_mqtt.py | 66 ---- src/HABApp/config/_conf_openhab.py | 32 -- src/HABApp/config/config.py | 62 +-- src/HABApp/config/config_loader.py | 191 ---------- src/HABApp/config/errors.py | 10 + src/HABApp/config/loader.py | 130 +++++++ src/HABApp/config/logging/__init__.py | 7 + src/HABApp/config/logging/buffered_logger.py | 31 ++ src/HABApp/config/logging/config.py | 157 ++++++++ .../config/{ => logging}/default_logfile.py | 36 +- .../{core/lib => config/logging}/handler.py | 0 src/HABApp/config/logging/queue_handler.py | 110 ++++++ src/HABApp/config/models/__init__.py | 1 + src/HABApp/config/models/application.py | 17 + src/HABApp/config/models/directories.py | 66 ++++ src/HABApp/config/models/habapp.py | 28 ++ src/HABApp/config/models/location.py | 15 + src/HABApp/config/models/mqtt.py | 67 ++++ src/HABApp/config/models/openhab.py | 74 ++++ src/HABApp/config/platform_defaults.py | 2 +- src/HABApp/core/EventBus.py | 87 ----- src/HABApp/core/Items.py | 70 ---- src/HABApp/core/__init__.py | 25 +- src/HABApp/core/asyncio.py | 40 ++ src/HABApp/core/const/__init__.py | 1 + src/HABApp/core/const/const.py | 13 +- src/HABApp/core/const/hints.py | 19 + src/HABApp/core/const/loop.py | 2 +- src/HABApp/core/const/topics.py | 24 +- src/HABApp/core/context.py | 14 - src/HABApp/core/errors.py | 35 ++ src/HABApp/core/event_bus_listener.py | 82 ---- src/HABApp/core/events/__init__.py | 7 +- src/HABApp/core/events/event_filters.py | 53 --- src/HABApp/core/events/events.py | 22 +- src/HABApp/core/events/filter/__init__.py | 4 + src/HABApp/core/events/filter/event.py | 73 ++++ src/HABApp/core/events/filter/groups.py | 42 ++ .../core/events/filter/habapp_events.py | 15 + src/HABApp/core/events/filter/no_filter.py | 11 + src/HABApp/core/files/folders/folders.py | 10 +- .../core/files/manager/listen_events.py | 17 +- src/HABApp/core/internals/__init__.py | 14 + src/HABApp/core/internals/context/__init__.py | 5 + src/HABApp/core/internals/context/context.py | 60 +++ .../core/internals/context/get_context.py | 38 ++ .../core/internals/event_bus/__init__.py | 2 + .../core/internals/event_bus/base_listener.py | 11 + .../core/internals/event_bus/event_bus.py | 89 +++++ .../core/internals/event_bus_listener.py | 57 +++ src/HABApp/core/internals/event_filter.py | 16 + .../core/internals/item_registry/__init__.py | 5 + .../internals/item_registry/item_registry.py | 72 ++++ .../item_registry/item_registry_item.py | 25 ++ src/HABApp/core/internals/proxy/__init__.py | 5 + src/HABApp/core/internals/proxy/proxies.py | 31 ++ src/HABApp/core/internals/proxy/proxy_obj.py | 91 +++++ .../internals/wrapped_function/__init__.py | 5 + .../core/internals/wrapped_function/base.py | 55 +++ .../wrapped_function/wrapped_async.py | 34 ++ .../wrapped_function/wrapped_sync.py | 35 ++ .../wrapped_function/wrapped_thread.py | 98 +++++ .../internals/wrapped_function/wrapper.py | 56 +++ src/HABApp/core/items/__init__.py | 4 + src/HABApp/core/items/base_item.py | 86 ++--- src/HABApp/core/items/base_item_times.py | 24 +- src/HABApp/core/items/base_item_watch.py | 33 +- src/HABApp/core/items/base_valueitem.py | 26 +- src/HABApp/core/items/item.py | 15 +- src/HABApp/core/items/item_aggregation.py | 35 +- src/HABApp/core/items/item_color.py | 24 +- src/HABApp/core/items/tmp_data.py | 7 +- src/HABApp/core/lib/__init__.py | 3 + src/HABApp/core/lib/exceptions/__init__.py | 1 + src/HABApp/core/lib/exceptions/const.py | 3 + src/HABApp/core/lib/exceptions/format.py | 59 +++ .../core/lib/exceptions/format_frame.py | 61 +++ .../core/lib/exceptions/format_frame_vars.py | 109 ++++++ src/HABApp/core/lib/parameters/__init__.py | 1 + .../core/lib/parameters/positive_time_diff.py | 21 + src/HABApp/core/lib/pending_future.py | 13 +- src/HABApp/core/lib/single_task.py | 31 ++ src/HABApp/core/logger.py | 22 +- src/HABApp/core/wrappedfunction.py | 111 ------ src/HABApp/core/wrapper.py | 79 +--- src/HABApp/mqtt/events/mqtt_events.py | 6 +- src/HABApp/mqtt/events/mqtt_filters.py | 17 +- src/HABApp/mqtt/items/mqtt_item.py | 12 +- src/HABApp/mqtt/items/mqtt_pair_item.py | 10 +- src/HABApp/mqtt/mqtt_connection.py | 48 ++- src/HABApp/mqtt/mqtt_payload.py | 3 +- .../openhab/connection_handler/func_async.py | 84 ++-- .../openhab/connection_handler/func_sync.py | 170 +++------ .../connection_handler/http_connection.py | 358 ++++++++++-------- .../openhab/connection_handler/sse_handler.py | 74 +++- .../openhab/connection_logic/connection.py | 4 +- .../connection_logic/plugin_load_items.py | 39 +- .../openhab/connection_logic/plugin_ping.py | 24 +- .../connection_logic/plugin_thing_overview.py | 12 +- .../plugin_things/plugin_things.py | 33 +- src/HABApp/openhab/definitions/definitions.py | 2 +- src/HABApp/openhab/definitions/rest/things.py | 25 +- src/HABApp/openhab/definitions/topics.py | 8 + src/HABApp/openhab/errors.py | 29 +- src/HABApp/openhab/events/__init__.py | 6 +- src/HABApp/openhab/events/channel_events.py | 38 +- src/HABApp/openhab/events/event_filters.py | 22 +- src/HABApp/openhab/events/item_events.py | 82 ++-- src/HABApp/openhab/events/thing_events.py | 112 ++++-- src/HABApp/openhab/item_to_reg.py | 62 ++- src/HABApp/openhab/items/__init__.py | 1 + src/HABApp/openhab/items/base_item.py | 29 +- src/HABApp/openhab/items/call_item.py | 30 ++ src/HABApp/openhab/items/color_item.py | 25 +- src/HABApp/openhab/items/contact_item.py | 18 + src/HABApp/openhab/items/datetime_item.py | 29 +- src/HABApp/openhab/items/dimmer_item.py | 23 +- src/HABApp/openhab/items/group_item.py | 11 +- src/HABApp/openhab/items/image_item.py | 29 +- src/HABApp/openhab/items/number_item.py | 30 +- .../openhab/items/rollershutter_item.py | 22 +- src/HABApp/openhab/items/string_item.py | 40 +- src/HABApp/openhab/items/switch_item.py | 10 + src/HABApp/openhab/items/thing_item.py | 36 +- src/HABApp/openhab/map_events.py | 18 +- src/HABApp/openhab/map_items.py | 98 ++--- src/HABApp/openhab/map_values.py | 9 +- src/HABApp/parameters/parameter_files.py | 12 +- src/HABApp/rule/__init__.py | 6 +- src/HABApp/rule/interfaces/_http.py | 4 +- src/HABApp/rule/rule.py | 322 ++++------------ src/HABApp/rule/rule_hook.py | 60 +++ src/HABApp/rule/scheduler/executor.py | 5 +- .../rule/scheduler/habappschedulerview.py | 57 +-- src/HABApp/rule/scheduler/scheduler.py | 2 +- src/HABApp/rule_ctx/__init__.py | 1 + src/HABApp/rule_ctx/rule_ctx.py | 77 ++++ .../rule_manager/benchmark/bench_base.py | 4 +- .../rule_manager/benchmark/bench_file.py | 14 +- .../rule_manager/benchmark/bench_habapp.py | 4 +- .../rule_manager/benchmark/bench_mqtt.py | 6 +- src/HABApp/rule_manager/benchmark/bench_oh.py | 8 +- src/HABApp/rule_manager/rule_file.py | 27 +- src/HABApp/rule_manager/rule_manager.py | 19 +- src/HABApp/runtime/runtime.py | 30 +- src/HABApp/runtime/shutdown.py | 22 +- src/HABApp/util/__init__.py | 9 +- src/HABApp/util/counter_item.py | 58 --- src/HABApp/util/fade/__init__.py | 1 + src/HABApp/util/fade/fade.py | 146 +++++++ src/HABApp/util/listener_groups/__init__.py | 1 + .../util/listener_groups/listener_creator.py | 59 +++ .../{ => listener_groups}/listener_groups.py | 79 +--- src/HABApp/util/multimode/item.py | 20 +- src/HABApp/util/multimode/mode_base.py | 6 +- src/HABApp/util/multimode/mode_switch.py | 24 +- src/HABApp/util/multimode/mode_value.py | 12 +- src/HABApp/util/period_counter.py | 63 --- src/HABApp/util/statistics.py | 14 +- tests/conftest.py | 63 ++- tests/helpers/__init__.py | 2 +- tests/helpers/docs.py | 17 + tests/helpers/event_bus.py | 59 +-- tests/helpers/habapp_config.py | 53 +-- tests/helpers/module_helpers.py | 36 +- tests/helpers/parent_rule.py | 26 +- tests/helpers/sync_worker.py | 8 +- tests/helpers/traceback.py | 15 + tests/rule_runner/rule_runner.py | 113 +++--- tests/rule_runner/test_rule_runner.py | 36 ++ tests/test_all/__init__.py | 0 tests/test_all/test_items.py | 51 +++ tests/test_config/test_platform.py | 10 +- tests/test_core/test_all_items.py | 34 -- tests/test_core/test_context.py | 3 +- tests/test_core/test_event_bus.py | 135 ++----- .../test_events/test_core_filters.py | 75 +++- .../test_files/test_file_dependencies.py | 8 +- tests/test_core/test_files/test_watcher.py | 9 +- tests/test_core/test_item_registry.py | 19 + tests/test_core/test_item_watch.py | 41 +- .../test_items/test_item_aggregation.py | 14 +- tests/test_core/test_items/test_item_color.py | 8 +- .../test_items/test_item_interface.py | 40 +- tests/test_core/test_items/test_item_times.py | 89 +++-- tests/test_core/test_items/tests_all_items.py | 9 +- tests/test_core/test_lib/__init__.py | 0 .../test_lib/test_format_traceback.py | 170 +++++++++ tests/test_core/test_lib/test_single_task.py | 33 ++ tests/test_core/test_logger.py | 13 +- tests/test_core/test_utilities.py | 3 +- tests/test_core/test_wrapped_func.py | 130 ++----- tests/test_core/test_wrapper.py | 18 +- tests/test_debug_info.py | 2 +- tests/test_docs.py | 86 +++++ tests/test_mqtt/test_mqtt_connect.py | 3 +- tests/test_mqtt/test_mqtt_filters.py | 31 +- tests/test_mqtt/test_retain.py | 20 +- .../test_connection/test_connection_waiter.py | 3 - .../test_events/test_from_dict.py | 91 ++++- .../test_events/test_from_dict_oh2.py | 189 --------- .../test_events/test_oh_filters.py | 38 +- tests/test_openhab/test_interface_sync.py | 3 +- tests/test_openhab/test_item_to_reg.py | 13 + tests/test_openhab/test_items/test_all.py | 53 +++ tests/test_openhab/test_items/test_call.py | 37 ++ .../test_openhab/test_items/test_commands.py | 16 +- tests/test_openhab/test_items/test_contact.py | 13 + .../test_items/test_group_handling.py | 16 + tests/test_openhab/test_items/test_image.py | 1 + tests/test_openhab/test_items/test_items.py | 14 - tests/test_openhab/test_items/test_mapping.py | 14 +- tests/test_openhab/test_items/test_thing.py | 118 +++++- .../test_plugins/test_load_items.py | 98 ----- .../test_plugins/test_thing/test_errors.py | 12 +- .../test_thing/test_file_format.py | 4 +- tests/test_openhab/test_rest/test_items.py | 2 +- tests/test_packages.py | 21 + tests/test_rule/test_item_search.py | 22 +- tests/test_rule/test_process.py | 6 +- tests/test_rule/test_rule_funcs.py | 40 +- tests/test_utils/test_counter.py | 17 - tests/test_utils/test_fade.py | 46 +++ tests/test_utils/test_listener_groups.py | 7 +- tox.ini | 27 +- 337 files changed, 7068 insertions(+), 3810 deletions(-) delete mode 100644 _doc/_static/theme_overrides.css delete mode 100644 _doc/images/folders.drawio delete mode 100644 _doc/images/folders.png delete mode 100644 _doc/requirements.txt create mode 100644 container/entrypoint.sh rename {_doc => docs}/__style_guide__ (100%) create mode 100644 docs/_static/theme_changes.css rename {_doc => docs}/about_habapp.rst (74%) rename {_doc => docs}/advanced_usage.rst (79%) rename {_doc => docs}/asyncio.rst (91%) rename {_doc => docs}/class_reference.rst (100%) rename {_doc => docs}/conf.py (70%) rename {_doc => docs}/configuration.rst (50%) rename {_doc => docs}/getting_started.rst (91%) rename {_doc => docs}/gifs/mqtt.gif (100%) rename {_doc => docs}/gifs/openhab.gif (100%) rename {_doc => docs}/images/architecture.drawio (100%) rename {_doc => docs}/images/architecture.png (100%) create mode 100644 docs/images/folders.drawio create mode 100644 docs/images/folders.png rename {_doc => docs}/images/openhab_api_config.png (100%) create mode 100644 docs/images/pycharm_run.png create mode 100644 docs/images/pycharm_run_settings.png create mode 100644 docs/images/pycharm_settings.png create mode 100644 docs/images/pycharm_settings_install.png rename {_doc => docs}/index.rst (96%) rename {_doc => docs}/installation.rst (51%) rename {_doc => docs}/interface_habapp.rst (95%) rename {_doc => docs}/interface_mqtt.rst (95%) rename {_doc => docs}/interface_openhab.rst (95%) rename {_doc => docs}/logging.rst (76%) rename {_doc => docs}/parameters.rst (94%) create mode 100644 docs/requirements.txt rename {_doc => docs}/rule.rst (82%) rename {_doc => docs}/rule_examples.rst (81%) rename {_doc => docs}/tips.rst (93%) create mode 100644 docs/troubleshooting.rst rename {_doc => docs}/util.rst (81%) create mode 100644 requirements_tests.txt create mode 100644 run/conf/config.yml create mode 100644 run/conf/logging.yml rename {conf => run/conf}/rules/async_rule.py (100%) rename {conf => run/conf}/rules/logging_rule.py (100%) rename {conf => run/conf}/rules/mqtt_rule.py (89%) rename {conf => run/conf}/rules/openhab_rule.py (97%) rename {conf => run/conf}/rules/openhab_things.py (76%) rename {conf => run/conf}/rules/openhab_to_mqtt_rule.py (87%) rename {conf => run/conf}/rules/time_rule.py (100%) rename {conf_testing => run/conf_listen}/config.yml (68%) create mode 100644 run/conf_listen/logging.yml create mode 100644 run/conf_testing/config.yml create mode 100644 run/conf_testing/config/thing_test.yml rename {conf_testing => run/conf_testing}/lib/HABAppTests/__init__.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/compare_values.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/errors.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/event_waiter.py (72%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/item_waiter.py (88%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/openhab_tmp_item.py (95%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_data.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/__init__.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/_rest_patcher.py (95%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/_rule_ids.py (97%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/_rule_status.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/test_case/__init__.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/test_case/test_case.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/test_case/test_result.py (100%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/test_rule/test_rule.py (95%) rename {conf_testing => run/conf_testing}/lib/HABAppTests/utils.py (97%) rename {conf_testing => run/conf_testing}/logging.yml (98%) rename {conf_testing => run/conf_testing}/parameters/param_file.yml (100%) create mode 100644 run/conf_testing/rules/habapp/test_event_listener.py create mode 100644 run/conf_testing/rules/habapp/test_group_listener.py rename {conf_testing => run/conf_testing}/rules/habapp/test_habapp.py (60%) rename {conf_testing => run/conf_testing}/rules/habapp/test_parameter_files.py (100%) rename {conf_testing => run/conf_testing}/rules/habapp/test_scheduler.py (100%) create mode 100644 run/conf_testing/rules/habapp/test_util_fade.py rename {conf_testing => run/conf_testing}/rules/habapp/test_utils.py (100%) rename {conf_testing => run/conf_testing}/rules/openhab/test_event_types.py (91%) rename {conf_testing => run/conf_testing}/rules/openhab/test_groups.py (93%) rename {conf_testing => run/conf_testing}/rules/openhab/test_habapp_internals.py (100%) rename {conf_testing => run/conf_testing}/rules/openhab/test_interface.py (89%) rename {conf_testing => run/conf_testing}/rules/openhab/test_interface_links.py (94%) rename {conf_testing => run/conf_testing}/rules/openhab/test_item_change.py (76%) rename {conf_testing => run/conf_testing}/rules/openhab/test_item_funcs.py (100%) rename {conf_testing => run/conf_testing}/rules/openhab/test_items.py (91%) rename {conf_testing => run/conf_testing}/rules/openhab/test_max_sse_msg_size.py (81%) rename {conf_testing => run/conf_testing}/rules/openhab/test_persistence.py (95%) rename {conf_testing => run/conf_testing}/rules/openhab/test_things.py (100%) rename {conf_testing => run/conf_testing}/rules/openhab_bugs.py (100%) rename {conf_testing => run/conf_testing}/rules/test_mqtt.py (91%) create mode 100644 src/HABApp/__check_dependency_packages__.py create mode 100644 src/HABApp/__debug_info__.py rename src/HABApp/{__do_setup__.py => __setup_packages__.py} (63%) delete mode 100644 src/HABApp/config/_conf_location.py delete mode 100644 src/HABApp/config/_conf_mqtt.py delete mode 100644 src/HABApp/config/_conf_openhab.py delete mode 100644 src/HABApp/config/config_loader.py create mode 100644 src/HABApp/config/errors.py create mode 100644 src/HABApp/config/loader.py create mode 100644 src/HABApp/config/logging/__init__.py create mode 100644 src/HABApp/config/logging/buffered_logger.py create mode 100644 src/HABApp/config/logging/config.py rename src/HABApp/config/{ => logging}/default_logfile.py (75%) rename src/HABApp/{core/lib => config/logging}/handler.py (100%) create mode 100644 src/HABApp/config/logging/queue_handler.py create mode 100644 src/HABApp/config/models/__init__.py create mode 100644 src/HABApp/config/models/application.py create mode 100644 src/HABApp/config/models/directories.py create mode 100644 src/HABApp/config/models/habapp.py create mode 100644 src/HABApp/config/models/location.py create mode 100644 src/HABApp/config/models/mqtt.py create mode 100644 src/HABApp/config/models/openhab.py delete mode 100644 src/HABApp/core/EventBus.py delete mode 100644 src/HABApp/core/Items.py create mode 100644 src/HABApp/core/asyncio.py create mode 100644 src/HABApp/core/const/hints.py delete mode 100644 src/HABApp/core/context.py create mode 100644 src/HABApp/core/errors.py delete mode 100644 src/HABApp/core/event_bus_listener.py delete mode 100644 src/HABApp/core/events/event_filters.py create mode 100644 src/HABApp/core/events/filter/__init__.py create mode 100644 src/HABApp/core/events/filter/event.py create mode 100644 src/HABApp/core/events/filter/groups.py create mode 100644 src/HABApp/core/events/filter/habapp_events.py create mode 100644 src/HABApp/core/events/filter/no_filter.py create mode 100644 src/HABApp/core/internals/__init__.py create mode 100644 src/HABApp/core/internals/context/__init__.py create mode 100644 src/HABApp/core/internals/context/context.py create mode 100644 src/HABApp/core/internals/context/get_context.py create mode 100644 src/HABApp/core/internals/event_bus/__init__.py create mode 100644 src/HABApp/core/internals/event_bus/base_listener.py create mode 100644 src/HABApp/core/internals/event_bus/event_bus.py create mode 100644 src/HABApp/core/internals/event_bus_listener.py create mode 100644 src/HABApp/core/internals/event_filter.py create mode 100644 src/HABApp/core/internals/item_registry/__init__.py create mode 100644 src/HABApp/core/internals/item_registry/item_registry.py create mode 100644 src/HABApp/core/internals/item_registry/item_registry_item.py create mode 100644 src/HABApp/core/internals/proxy/__init__.py create mode 100644 src/HABApp/core/internals/proxy/proxies.py create mode 100644 src/HABApp/core/internals/proxy/proxy_obj.py create mode 100644 src/HABApp/core/internals/wrapped_function/__init__.py create mode 100644 src/HABApp/core/internals/wrapped_function/base.py create mode 100644 src/HABApp/core/internals/wrapped_function/wrapped_async.py create mode 100644 src/HABApp/core/internals/wrapped_function/wrapped_sync.py create mode 100644 src/HABApp/core/internals/wrapped_function/wrapped_thread.py create mode 100644 src/HABApp/core/internals/wrapped_function/wrapper.py create mode 100644 src/HABApp/core/lib/exceptions/__init__.py create mode 100644 src/HABApp/core/lib/exceptions/const.py create mode 100644 src/HABApp/core/lib/exceptions/format.py create mode 100644 src/HABApp/core/lib/exceptions/format_frame.py create mode 100644 src/HABApp/core/lib/exceptions/format_frame_vars.py create mode 100644 src/HABApp/core/lib/parameters/__init__.py create mode 100644 src/HABApp/core/lib/parameters/positive_time_diff.py create mode 100644 src/HABApp/core/lib/single_task.py delete mode 100644 src/HABApp/core/wrappedfunction.py create mode 100644 src/HABApp/openhab/definitions/topics.py create mode 100644 src/HABApp/openhab/items/call_item.py create mode 100644 src/HABApp/rule/rule_hook.py create mode 100644 src/HABApp/rule_ctx/__init__.py create mode 100644 src/HABApp/rule_ctx/rule_ctx.py delete mode 100644 src/HABApp/util/counter_item.py create mode 100644 src/HABApp/util/fade/__init__.py create mode 100644 src/HABApp/util/fade/fade.py create mode 100644 src/HABApp/util/listener_groups/__init__.py create mode 100644 src/HABApp/util/listener_groups/listener_creator.py rename src/HABApp/util/{ => listener_groups}/listener_groups.py (63%) delete mode 100644 src/HABApp/util/period_counter.py create mode 100644 tests/helpers/docs.py create mode 100644 tests/helpers/traceback.py create mode 100644 tests/test_all/__init__.py create mode 100644 tests/test_all/test_items.py delete mode 100644 tests/test_core/test_all_items.py create mode 100644 tests/test_core/test_item_registry.py create mode 100644 tests/test_core/test_lib/__init__.py create mode 100644 tests/test_core/test_lib/test_format_traceback.py create mode 100644 tests/test_core/test_lib/test_single_task.py create mode 100644 tests/test_docs.py delete mode 100644 tests/test_openhab/test_events/test_from_dict_oh2.py create mode 100644 tests/test_openhab/test_item_to_reg.py create mode 100644 tests/test_openhab/test_items/test_all.py create mode 100644 tests/test_openhab/test_items/test_call.py create mode 100644 tests/test_openhab/test_items/test_contact.py create mode 100644 tests/test_openhab/test_items/test_group_handling.py delete mode 100644 tests/test_openhab/test_items/test_items.py delete mode 100644 tests/test_openhab/test_plugins/test_load_items.py create mode 100644 tests/test_packages.py delete mode 100644 tests/test_utils/test_counter.py create mode 100644 tests/test_utils/test_fade.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index c7e3a30f..085baee2 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -12,10 +12,10 @@ jobs: - uses: actions/checkout@v2 with: ref: master - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install setuptools run: | diff --git a/.github/workflows/run_tox.yml b/.github/workflows/run_tox.yml index 7829f8f1..ad69a070 100644 --- a/.github/workflows/run_tox.yml +++ b/.github/workflows/run_tox.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.7, 3.8, 3.9] + python-version: ['3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v1 diff --git a/.gitignore b/.gitignore index a370a20a..80d247d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,41 @@ .mypy_cache .idea -__pycache__ -/conf -/conf_testing -/build + +# HABApp configurations +run/*/log/ +run/*/config/*.items + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +/lib/ +/lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6a38699..ffac85d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: check-yaml - id: end-of-file-fixer - exclude: ^_doc/(?:images|gifs) + exclude: ^docs/(?:images|gifs) - id: trailing-whitespace # - repo: https://github.com/pycqa/isort diff --git a/.pyup.yml b/.pyup.yml index f39a4f50..c9291225 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -10,7 +10,7 @@ pr_prefix: "PyUp" # default: empty # allowed: list requirements: - - _doc/requirements.txt: + - docs/requirements.txt: update: False - requirements.txt: update: True diff --git a/.readthedocs.yml b/.readthedocs.yml index a5f92def..67505ffc 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,7 +7,7 @@ version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: - configuration: _doc/conf.py + configuration: docs/conf.py # Build documentation with MkDocs #mkdocs: @@ -16,10 +16,16 @@ sphinx: # Optionally build your docs in additional formats such as PDF and ePub formats: all +build: + os: ubuntu-20.04 + tools: + python: "3.9" + apt_packages: + - graphviz + # Optionally set the version of Python and requirements required to build your docs python: - version: 3.8 install: - - requirements: _doc/requirements.txt + - requirements: docs/requirements.txt - method: setuptools path: . diff --git a/Dockerfile b/Dockerfile index 0ee2646e..63518771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,44 @@ -FROM python:3.8-alpine +FROM python:3.9-slim as buildimage -VOLUME [ "/config"] +COPY . /tmp/app_install -# Install required dependencies -RUN apk add --no-cache \ -# Support for Timezones - tzdata \ -# ujson won't compile without these libs - g++ +RUN set -eux; \ +# wheel all packages for habapp + cd /tmp/app_install; \ + pip wheel --wheel-dir=/root/wheels --use-feature=in-tree-build . + +FROM python:3.9-slim + +COPY --from=buildimage /root/wheels /root/wheels +COPY container/entrypoint.sh /entrypoint.sh -# Always use latest versions -RUN mkdir -p /usr/src/app -WORKDIR /usr/src/app -COPY . . +ENV HABAPP_HOME=/habapp \ + USER_ID=9001 \ + GROUP_ID=${USER_ID} + +RUN set -eux; \ +# Install required dependencies + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + gosu \ + tini; \ + ln -s -f $(which gosu) /usr/local/bin/gosu; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; \ + mkdir -p ${HABAPP_HOME}; \ + mkdir -p ${HABAPP_HOME}/config; \ +# install HABApp + pip3 install \ + --no-index \ + --find-links=/root/wheels \ + habapp; \ +# prepare entrypoint script + chmod +x /entrypoint.sh; \ +# clean up + rm -rf /root/wheels -# Install -RUN pip3 install . +WORKDIR ${HABAPP_HOME} +VOLUME ["${HABAPP_HOME}/config"] +ENTRYPOINT ["/entrypoint.sh"] -CMD [ "python", "-m", "HABApp", "--config", "/config" ] +CMD ["gosu", "habapp", "tini", "--", "python", "-m", "HABApp", "--config", "./config"] diff --git a/MANIFEST.in b/MANIFEST.in index 1aba38f6..7aad70d0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include LICENSE +include requirements_setup.txt diff --git a/_doc/_static/theme_overrides.css b/_doc/_static/theme_overrides.css deleted file mode 100644 index 67bec8f1..00000000 --- a/_doc/_static/theme_overrides.css +++ /dev/null @@ -1,25 +0,0 @@ -/* override table width restrictions */ -@media screen and (min-width: 767px) { - - .wy-nav-content { - max-width: 1100px !important; - } - - .wy-table-responsive table td { - /* !important prevents the common CSS stylesheets from overriding - this as on RTD they are loaded after this stylesheet */ - white-space: normal !important; - } - - .wy-table-responsive { - overflow: visible !important; - } -} - - -h1 { font-size: 200% } -h2 { font-size: 180% } -h3 { font-size: 160% } -h4 { font-size: 140% } -h5 { font-size: 120% } -h6 { font-size: 100% } diff --git a/_doc/images/folders.drawio b/_doc/images/folders.drawio deleted file mode 100644 index 6d2208ab..00000000 --- a/_doc/images/folders.drawio +++ /dev/null @@ -1 +0,0 @@ -7VrbcpswEP0aP9ZjLpbtx8RN0pleptNM2+mjDAuolREj5NrO11dCAgzCKU2xM+PkJUEraZHOWcTZxSNvud7dcZwlH1kIdOROwt3IeztyXWcyR/Kfsuy1ZYFm2hBzEppBteGePEA501g3JIS8MVAwRgXJmsaApSkEomHDnLNtc1jEaPOuGY7BMtwHmNrW7yQUibbO3VltfwckTso7O2ihe9a4HGx2kic4ZNsDk3cz8pacMaGv1rslUAVeiYued3ukt1oYh1T0mfDe//Hl64flXb77+ZGkH1bfcLx9Y7z8xnRjNvzu6voqy8ySxb7EgbNNGoJyNRl519uECLjPcKB6t5J5aUvEmsqWIy8jQumSUcaLuV6IYR4Fys5SYSh2lZuEcfIgbbiaqAaYm3ZssFwtcAG7A5PZ8B2wNQi+l0PK3hJ8E30OMu1tzWU1Jjng0ZsYIzbxE1e+a4jlhUH5HxD3LMT5hsoIHxLwKIrcQAGeC85+wSEVaIWmqEmF48v2AGB7TwUbnQrrqYW13CBeXyDWrv/cWCMLa3kkRyS+QLCr9rOBPbPApiyOSXqJaPuT50Z7bqFtwSxf7pm6jCjsrpTskFhAGprLtwHFeU6Cx9GeB9CN9mo+9aeTx9CFsKFibGwPsJt2QFfaOFAsyO+m9unC09zhMyNyJRV1lZ+SOncxnjad5GzDAzDzDtVKy5XntFx5LXYF5jEIy5FEHO8PhmVqQP7IkufHllxHjPZZx0+F69NDyrGF12tM9QoEz39yTLXDs8PVkagajHb3lfan0e6iwWjvcHVq2m3R/0p7L9qd+WC0d7g6Ne12/vF5LxKWqkIEKZK+CVENkZDcFCeAF1gXzlcg/1CGlX6TLcVCmZ2rLUiwZeZY+2oFlJRbohkuzbBIWQqtGDImTEmcyiaFSHlQ0o0EmF4Zs2DZESnZFJtHY62/FvRRi8OOlNI9qxZ07DznB17ToRgt8lMQxZyXQ2tX9npmWu2MakhadSq84fJobDz9EVNeFKUbTLV/lcO1x7+AEOjKqc8cAnaaV5FoeA44YKG5jksKqxeeKkzbIfICqOtK0M9M3cKi7vZU+AcSOOnYZmBNwlDdpgcJ7arJ0GVvqxLbt2DVTrIHY8i18119xo336pQdsGgFTjiFWZcIXqCZh9Ewz0D7w8K0b/3VPxnCrzlGzxzDmVWJQMleO+775hi2K3/Wr041VI7h2jnGRT1YqG/193QP1qPSsHjjY6E1m8AkzQsLdIm+QuyVmmKpHohxPB6pzSCq3kor2Y9iUbwQjMBMZTagPOI0LJCVEBQzzedu7TgHIaRuzHXXeDy+SNnhor8fuVX54DyRYSvG/4kM3VdpTvVFB3g+tqPjE2zr/mq6+Yw8CbByiWnOysxEy9awncQUa3wBgdJ1hFSa9T8DRTbrX3DoM73+HYx38wc= \ No newline at end of file diff --git a/_doc/images/folders.png b/_doc/images/folders.png deleted file mode 100644 index e18c694841abd17d41f3865868434228b082d3c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207726 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

jg@l(4@du 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 diff --git a/_doc/requirements.txt b/_doc/requirements.txt deleted file mode 100644 index f2c6c717..00000000 --- a/_doc/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Packages required to build the documentation -sphinx -sphinx-autodoc-typehints -sphinx_rtd_theme -sphinx-exec-code diff --git a/container/entrypoint.sh b/container/entrypoint.sh new file mode 100644 index 00000000..9b0963ec --- /dev/null +++ b/container/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash -x + +set -euo pipefail + +NEW_USER_ID=${USER_ID} +NEW_GROUP_ID=${GROUP_ID:-$NEW_USER_ID} + +echo "Starting with habapp user id: $NEW_USER_ID and group id: $NEW_GROUP_ID" +if ! id -u habapp >/dev/null 2>&1; then + if [ -z "$(getent group $NEW_GROUP_ID)" ]; then + echo "Create group habapp with id ${NEW_GROUP_ID}" + groupadd -g $NEW_GROUP_ID habapp + else + group_name=$(getent group $NEW_GROUP_ID | cut -d: -f1) + echo "Rename group $group_name to habapp" + groupmod --new-name habapp $group_name + fi + echo "Create user habapp with id ${NEW_USER_ID}" + adduser -u $NEW_USER_ID --disabled-password --gecos '' --home "${HABAPP_HOME}" --gid ${NEW_GROUP_ID} habapp +fi + +chown -R habapp:habapp "${HABAPP_HOME}/config" +sync + +exec "$@" diff --git a/_doc/__style_guide__ b/docs/__style_guide__ similarity index 100% rename from _doc/__style_guide__ rename to docs/__style_guide__ diff --git a/docs/_static/theme_changes.css b/docs/_static/theme_changes.css new file mode 100644 index 00000000..07eacb29 --- /dev/null +++ b/docs/_static/theme_changes.css @@ -0,0 +1,28 @@ +/* https://stackoverflow.com/questions/23211695/modifying-content-width-of-the-sphinx-theme-read-the-docs + +and https://github.com/readthedocs/sphinx_rtd_theme/issues/295 + +*/ +@media screen and (min-width: 767px) { + .wy-nav-content { + max-width: 1100px !important; + } + + /* and fix wrap bug per https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html */ + .wy-table-responsive table td { + /* !important prevents the common CSS stylesheets from overriding + this as on RTD they are loaded after this stylesheet */ + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + } +} + +h1 { font-size: 200% } +h2 { font-size: 180% } +h3 { font-size: 160% } +h4 { font-size: 140% } +h5 { font-size: 120% } +h6 { font-size: 100% } diff --git a/_doc/about_habapp.rst b/docs/about_habapp.rst similarity index 74% rename from _doc/about_habapp.rst rename to docs/about_habapp.rst index 3a1c0ad7..9e6aa161 100644 --- a/_doc/about_habapp.rst +++ b/docs/about_habapp.rst @@ -23,15 +23,15 @@ HABApp folder structure Integration with openHAB ------------------------------ -HABApp connects to the openhab event stream and automatically updates the local openhab items when an item in openhab changes. +HABApp connects to the openHAB event stream and automatically updates the local openHAB items when an item in openHAB changes. These item values are cached, so accessing and working with items in rules is very fast. -The events from openhab are also mirrored to the internal event bus which means that triggering on these +The events from openHAB are also mirrored to the internal event bus which means that triggering on these events is also possible. -When HABApp connects to openhab for the first time it will load all items/things from the openhab instance and create local items. -The name of the local openhab items is equal to the name in openhab. +When HABApp connects to openHAB for the first time it will load all items/things from the openHAB instance and create local items. +The name of the local openHAB items is equal to the name in openHAB. -Posting updates, sending commands or any other openhab interface call will issue a corresponding REST-API call to change openhab. +Posting updates, sending commands or any other openHAB interface call will issue a corresponding REST-API call to change openHAB. Integration with MQTT ------------------------------ diff --git a/_doc/advanced_usage.rst b/docs/advanced_usage.rst similarity index 79% rename from _doc/advanced_usage.rst rename to docs/advanced_usage.rst index 393dc52c..b521ea42 100644 --- a/_doc/advanced_usage.rst +++ b/docs/advanced_usage.rst @@ -94,19 +94,29 @@ Example ... +.. _ref_run_code_on_startup: -Invoking OpenHAB actions +Running Python code on startup +------------------------------ + +It's possible to run arbitrary code during the startup of HABApp. This can be achieved by creating a module/package +called ``HABAppUser``. HABApp will try to import it before loading the configuration and thus execute the code. +The module/package must be importable so it has to be in one of the ``PATH``/``PYTHONPATH`` folders or in the current +working directory. + + +Invoking openHAB actions ------------------------ -The openhab REST interface does not expose `actions `_, -and thus there is no way to trigger them from HABApp. If it is not possible to create and OpenHAB item that -directly triggers the action there is a way to work around it with additional items within openhab. -An additional OpenHAB (note not HABapp) rule listens to changes on those items and invokes the appropriate -openhab actions. +The openHAB REST interface does not expose `actions `_, +and thus there is no way to trigger them from HABApp. Even if it is not possible to create an openHAB item that +directly triggers the action, there is a way to work around it with additional items within openHAB. +An additional openHAB (note not HABapp) rule listens to changes on those items and invokes the appropriate +openHAB actions. On the HABApp side these actions are indirectly executed by setting the values for those items. -Below is an example how to invoke the openhab Audio and Voice actions. +Below is an example how to invoke the openHAB Audio and Voice actions. -First, define couple items to accept values from HABApp, and place them in /etc/openhab2/items/habapp-bridge.items: +First, define a couple of items to accept values from HABApp, and place them in /etc/openhab2/items/habapp-bridge.items: .. code-block:: text @@ -141,7 +151,7 @@ Second, create the JSR223 script to invoke the actions upon changes in the value @rule("Play audio stream URL") @when("Item AudioStreamUrl changed") - def onTextToSpeechMessageChanged(event): + def onAudioStreamURLChanged(event): stream_url = scope.items[event.itemName].toString() if stream_url is not None and stream_url != '': Audio.playStream(scope.items[SINK_ITEM_NAME].toString(), stream_url) @@ -151,7 +161,7 @@ Second, create the JSR223 script to invoke the actions upon changes in the value @rule("Play local audio file") @when("Item AudioFileLocation changed") - def onTextToSpeechMessageChanged(event): + def onAudioFileLocationChanged(event): file_location = scope.items[event.itemName].toString() if file_location is not None and file_location != '': Audio.playSound(scope.items[SINK_ITEM_NAME].toString(), file_location) @@ -181,19 +191,24 @@ Finally, define the HABApp functions to indirectly invoke the actions: HABApp.openhab.interface.send_command(ACTION_TEXT_TO_SPEECH_MESSAGE_ITEM_NAME, tts) -Mocking OpenHAB items and events for tests +Mocking openHAB items and events for tests -------------------------------------------- -It is possible to create mock items in HABApp which do not exist in Openhab to create unit tests for rules and libraries. -Ensure that this mechanism is only used for testing because since the items will not exist in openhab they will not get +It is possible to create mock items in HABApp which do not exist in openHAB to create unit tests for rules and libraries. +Ensure that this mechanism is only used for testing because since the items will not exist in openHAB they will not get updated which can lead to hard to track down errors. Examples: -Add an openhab mock item to the item registry +Add an openHAB mock item to the item registry .. exec_code:: :hide_output: + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + # ------------ hide: stop ------------- + import HABApp from HABApp.openhab.items import SwitchItem @@ -205,7 +220,10 @@ Remove the mock item from the registry .. exec_code:: :hide_output: - # ------------ hide: start ------------ + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + import HABApp from HABApp.openhab.items import SwitchItem HABApp.core.Items.add_item(SwitchItem('my_switch', 'ON')) @@ -214,13 +232,18 @@ Remove the mock item from the registry HABApp.core.Items.pop_item('my_switch') Note that there are some item methods that encapsulate communication with openhab -(e.g.: ``SwitchItem.on(), SwithItem.off(), and DimmerItem.percentage()``) +(e.g.: ``SwitchItem.on(), SwitchItem.off(), and DimmerItem.percentage()``) These currently do not work with the mock items. The state has to be changed like any internal item. .. exec_code:: :hide_output: + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + # ------------ hide: stop ------------- + import HABApp from HABApp.openhab.items import SwitchItem from HABApp.openhab.definitions import OnOffValue diff --git a/_doc/asyncio.rst b/docs/asyncio.rst similarity index 91% rename from _doc/asyncio.rst rename to docs/asyncio.rst index ef905da5..1803f0bd 100644 --- a/_doc/asyncio.rst +++ b/docs/asyncio.rst @@ -24,4 +24,4 @@ Functions Examples ^^^^^^^^^^^^^^^^^^^^^^^^ -.. literalinclude:: ../conf/rules/async_rule.py +.. literalinclude:: ../run/conf/rules/async_rule.py diff --git a/_doc/class_reference.rst b/docs/class_reference.rst similarity index 100% rename from _doc/class_reference.rst rename to docs/class_reference.rst diff --git a/_doc/conf.py b/docs/conf.py similarity index 70% rename from _doc/conf.py rename to docs/conf.py index 51e3e72e..a6cbdfca 100644 --- a/_doc/conf.py +++ b/docs/conf.py @@ -14,15 +14,18 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os +import re import sys +from docutils.nodes import Text, Node + # required for autodoc sys.path.insert(0, os.path.join(os.path.abspath('..'), 'src')) # -- Project information ----------------------------------------------------- project = 'HABApp' -copyright = '2021, spacemanspiff2007' +copyright = '2022, spacemanspiff2007' author = 'spacemanspiff2007' # The short X.Y version @@ -51,6 +54,7 @@ 'sphinx_autodoc_typehints', 'sphinx_exec_code', 'sphinx.ext.inheritance_diagram', + 'sphinxcontrib.autodoc_pydantic', ] # Add any paths that contain templates here, relative to this directory. @@ -70,7 +74,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -113,11 +117,8 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -html_context = { - 'css_files': [ - '_static/theme_overrides.css', # override wide tables in RTD theme - ], -} +html_css_files = ['theme_changes.css'] + # Custom sidebar templates, must be a dictionary that maps document names # to template names. # @@ -199,9 +200,88 @@ # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] +add_module_names = False +python_use_unqualified_type_names = True + # -- Extension configuration ------------------------------------------------- exec_code_working_dir = '../src' -exec_code_folders = ['../src', '../tests'] +exec_code_source_folders = ['../src', '../tests'] autodoc_member_order = 'bysource' -autoclass_content = 'both' +autoclass_content = 'class' + + +# No config on member +autodoc_pydantic_model_show_config_member = False +autodoc_pydantic_model_show_config_summary = False + +# No validators +autodoc_pydantic_model_show_validator_summary = False +autodoc_pydantic_model_show_validator_members = False + +autodoc_pydantic_model_signature_prefix = 'settings' +autodoc_pydantic_model_show_json = False +autodoc_pydantic_model_show_field_summary = False + +# Field config +autodoc_pydantic_field_show_alias = False +autodoc_pydantic_field_list_validators = False +autodoc_pydantic_field_swap_name_and_alias = True + + +# ---------------------------------------------------------------------------------------------------------------------- +# Post processing of default value + +regex_path = re.compile(r"^\w+Path\('([^']+)'\)") +assert regex_path.search('WindowsPath(\'lib\')').group(1) == 'lib' + + +def replace_node_contents(node: Node): + """Find nodes with given `tag_matches` and `text_matches`. Recursively + iterate children nodes. + + """ + + matched_nodes = [] + + # iterate children + for child in node.children: + child_matches = replace_node_contents(node=child) + matched_nodes.extend(child_matches) + + # handle node itself + parent: Node = node.parent + node_text: str = node.astext() + + replacement = None + + # Replace default value + # WindowsPath('config') -> 'config' + if node_text.endswith(')') and (m := regex_path.search(node_text)) is not None: + replacement = Text(f"'{m.group(1)}'") + + # # Type hints + # tag_matches = {"pending_xref", "pending_xref_condition"} + # text_matches = {'Path', 'pathlib.Path'} + # is_pending_xref = node.tagname in tag_matches + # if is_pending_xref and child_text and node_text in text_matches: + # matched_nodes.append(node) + + # put replacement in place + if replacement is not None: + replacement.parent = parent + pos = parent.children.index(node) + parent.children[pos] = replacement + + return matched_nodes + + +def transform_desc(app, domain, objtype: str, contentnode): + if objtype != 'pydantic_field': + return None + + replace_node_contents(node=contentnode.parent) + + +def setup(app): + app.connect('object-description-transform', transform_desc) diff --git a/_doc/configuration.rst b/docs/configuration.rst similarity index 50% rename from _doc/configuration.rst rename to docs/configuration.rst index db18876c..00af68fb 100644 --- a/_doc/configuration.rst +++ b/docs/configuration.rst @@ -1,7 +1,10 @@ +************************************** +Configuration +************************************** +Description +====================================== -Configuration -================================== Configuration is done through ``config.yml`` The parent folder of the file can be specified with ``-c PATH`` or ``--config PATH``. If nothing is specified the file ``config.yml`` is searched in the subdirectory `HABApp` in @@ -12,15 +15,15 @@ If nothing is specified the file ``config.yml`` is searched in the subdirectory If the config does not yet exist in the folder a blank configuration will be created -Configuration contents ------------------------------- +Example +====================================== .. code-block:: yaml directories: logging: log # If the filename for the logfile in logging.yml is not absolute it will be placed in this directory rules: rules # All *.py files in this folder (and subfolders) will be loaded. Load order will be alphabetical by path. param: param # Optional, this is the folder where the parameter files will be created and loaded from - config: config # Folder from which configuration files for openhab will be loaded + config: config # Folder from which configuration files for openHAB will be loaded lib: lib # Custom modules, libraries and files can be placed there. # (!) Attention (!): # Don't create rule instances in files inside the lib folder! It will lead to strange behaviour. @@ -34,17 +37,16 @@ Configuration contents ping: enabled: true # If enabled the configured item will show how long it takes to send an update from HABApp # and get the updated value back in milliseconds - item: 'HABApp_Ping' # Name of the Numberitem that will show the ping + item: 'HABApp_Ping' # Name of the NumberItem that will show the ping interval: 10 # Seconds between two pings connection: - host: localhost - port: 8080 + url: http://localhost:8080 user: '' password: '' general: - listen_only: False # If True HABApp will not change any value on the openhab instance. + listen_only: False # If True HABApp will not change any value on the openHAB instance. # Useful for testing rules from another machine. wait_for_openhab: True # If True HABApp will wait for items from the openHAB instance # before loading any rules on startup @@ -57,16 +59,17 @@ Configuration contents port: 8883 user: '' password: '' - tls: true - tls_insecure: false # do not check certificate - tls_ca_cert: '' # Path to a CA certificate that will be treated as trusted - # (e.g. when using a self signed certificate) + tls: + enabled: false # Enable TLS for the connection + insecure: false # Validate server hostname in server certificate + ca cert: '' # Path to a CA certificate that will be treated as trusted + # (e.g. when using a self signed certificate) subscribe: # Changes to Subscribe get picked up without restarting HABApp qos: 0 # Default QoS for subscribing topics: - - '#' # Subscribe to this topic - - 0 # QoS for previous topic, can be omitted + - '#' # Subscribe to this topic, qos is default QoS + - ['my/topic', 1] # Subscribe to this topic with explicit QoS publish: qos: 0 # Default QoS when publishing values @@ -75,3 +78,101 @@ Configuration contents general: listen_only: False # If True HABApp will not publish any value to the broker. # Useful for testing rules from another machine. + + + +Configuration Reference +====================================== + +All possible configuration options are described here. Not all entries are created by default in the config file +and one should take extra care when changing those entries. + + +.. autopydantic_model:: HABApp.config.models.application.ApplicationConfig + +Directories +-------------------------------------- + +.. autopydantic_model:: HABApp.config.models.directories.DirectoriesConfig + :exclude-members: create_folders + +Location +-------------------------------------- + +.. autopydantic_model:: HABApp.config.models.location.LocationConfig + + + + +MQTT +-------------------------------------- +.. py:currentmodule:: HABApp.config.models.mqtt + +.. autopydantic_model:: MqttConfig + +Connection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: Connection + +TLS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: TLSSettings + +Subscribe +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: Subscribe + +Publish +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: Publish + +General +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: General + + + + +Openhab +-------------------------------------- +.. py:currentmodule:: HABApp.config.models.openhab + +.. autopydantic_model:: OpenhabConfig + + +.. _CONFIG_OPENHAB_CONNECTION: + +Connection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autopydantic_model:: Connection + +Ping +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autopydantic_model:: Ping + +General +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autopydantic_model:: General + + + + + +HABApp +-------------------------------------- +.. py:currentmodule:: HABApp.config.models.habapp + +.. autopydantic_model:: HABAppConfig + +ThreadPool +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autopydantic_model:: ThreadPoolConfig + +Logging +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autopydantic_model:: LoggingConfig diff --git a/_doc/getting_started.rst b/docs/getting_started.rst similarity index 91% rename from _doc/getting_started.rst rename to docs/getting_started.rst index f295ad19..d9651887 100644 --- a/_doc/getting_started.rst +++ b/docs/getting_started.rst @@ -4,7 +4,7 @@ Getting Started It is really recommended to use a python IDE, for example PyCharm. The IDE can provide auto complete and static checks -which will help you write error free rules and vastly speed up your developement. +which will help you write error free rules and vastly speed up your development. First start HABApp and keep it running. It will automatically load and update all rules which are created or changed in the configured ``rules`` directory. @@ -83,9 +83,9 @@ This often comes in handy if there is some logic that shall be applied to differ Interacting with items ------------------------------ -HABApp uses an internal item registry to store both openhab items and locally +HABApp uses an internal item registry to store both openHAB items and locally created items (only visible within HABApp). Upon start-up HABApp retrieves -a list of openhab items and adds them to the internal registry. +a list of openHAB items and adds them to the internal registry. Rules and HABApp derived libraries may add additional local items which can be used to share states across rules and/or files. @@ -94,6 +94,11 @@ An item is created and added to the item registry through the corresponding clas .. exec_code:: :hide_output: + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + # ------------ hide: stop ------------- + from HABApp.core.items import Item # This will create an item in the local (HABApp) item registry @@ -101,7 +106,7 @@ An item is created and added to the item registry through the corresponding clas Posting values from the item will automatically create the events on the event bus. This example will create an item in HABApp (locally) and post some updates to it. -To access items from openhab use the correct openhab item type (see :ref:`the openhab item description `). +To access items from openHAB use the correct openHAB item type (see :ref:`the openHAB item description `). .. exec_code:: :caption: Output @@ -161,16 +166,16 @@ It is possible to watch items for changes or updates. .. exec_code:: # ------------ hide: start ------------ - from HABApp.core.items import Item - Item.get_create_item('Item_Name', initial_value='Some value') - from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() + + from HABApp.core.items import Item + Item.get_create_item('Item_Name', initial_value='Some value') # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item - from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent + from HABApp.core.events import ValueUpdateEventFilter, ValueChangeEventFilter, ValueChangeEvent, ValueUpdateEvent class MyFirstRule(HABApp.Rule): def __init__(self): @@ -179,13 +184,14 @@ It is possible to watch items for changes or updates. self.my_item = Item.get_create_item('Item_Name') # Run this function whenever the item receives an ValueUpdateEvent - self.listen_event(self.my_item, self.item_updated, ValueUpdateEvent) + self.listen_event(self.my_item, self.item_updated, ValueUpdateEventFilter()) # Run this function whenever the item receives an ValueChangeEvent - self.listen_event(self.my_item, self.item_changed, ValueChangeEvent) + self.listen_event(self.my_item, self.item_changed, ValueChangeEventFilter()) # If you already have an item you can use the more convenient method of the item - self.my_item.listen_event(self.item_changed, ValueChangeEvent) + # This is the recommended way to use event listener + self.my_item.listen_event(self.item_changed, ValueChangeEventFilter()) # the function has 1 argument which is the event def item_changed(self, event: ValueChangeEvent): @@ -214,7 +220,7 @@ Trigger an event when an item is constant from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - HABApp.core.Items.create_item('test_watch', HABApp.core.items.Item) + HABApp.core.Items.add_item(HABApp.core.items.Item('test_watch')) # ------------ hide: stop ------------- import HABApp diff --git a/_doc/gifs/mqtt.gif b/docs/gifs/mqtt.gif similarity index 100% rename from _doc/gifs/mqtt.gif rename to docs/gifs/mqtt.gif diff --git a/_doc/gifs/openhab.gif b/docs/gifs/openhab.gif similarity index 100% rename from _doc/gifs/openhab.gif rename to docs/gifs/openhab.gif diff --git a/_doc/images/architecture.drawio b/docs/images/architecture.drawio similarity index 100% rename from _doc/images/architecture.drawio rename to docs/images/architecture.drawio diff --git a/_doc/images/architecture.png b/docs/images/architecture.png similarity index 100% rename from _doc/images/architecture.png rename to docs/images/architecture.png diff --git a/docs/images/folders.drawio b/docs/images/folders.drawio new file mode 100644 index 00000000..b175f5af --- /dev/null +++ b/docs/images/folders.drawio @@ -0,0 +1 @@ +7VrbcpswEP0aP9ZjrrYfE+c206STaabt5FGGBdTKiBFybOfrK4EAg3BKE+zM2H5J0EpapHMWcXbxwJot1rcMJdED9YEMzJG/HlhXA9M0RmNT/JOWTW6ZOsoQMuyrQZXhCb9CMVNZl9iHtDaQU0o4TupGj8YxeLxmQ4zRVX1YQEn9rgkKQTM8eYjo1l/Y51FunZjjyn4HOIyKOxvuNO9ZoGKw2kkaIZ+utkzW9cCaMUp5frVYz4BI8Apc8nk3O3rLhTGIeZcJX+3n7z/uZ7fp+vcDju/nP1G4+qK8vCCyVBu+u7i8SBK1ZL4pcGB0GfsgXY0G1uUqwhyeEuTJ3pVgXtgiviCiZYjLABMyo4SybK7lI5gEnrTTmCuKTekmogy/ChsqJ8oB6qYtGyxWC4zDesukNnwLdAGcbcSQorcAX0Wf4ar2quKyHBNt8WiNlBGp+AlL3xXE4kKh/B+IWxribElEhPcJeBAEpicBTzmjf2CbCnfuOm6dCsMW7R7Att4LtrsvrB0Na7FBtDhCrE37s7F2NazFkRzg8AjBLtufBvZYA5vQMMTxMaJtjz4b7YmGtgazeLkn8jIgsL6QskNgAbGvLq88gtIUe2+jPfGgHe35xLGd0Vvogl9TMTq2W9g5LdAVNgYEcfxS1z5teKo7PFIsVlJSV/opqDOnQ6fuJKVL5oGat61WGq4so+HKarDLEQuBa44E4mizNSyRA9I3ljzZteQqYnKfVfyUuL4/pAxdeJ1jqlMgWPa7Y6oZni2udkRVb7SbZ9rfR7vp9kZ7i6t9066L/jPtnWg3Jr3R3uJq37Tr+cfjhkc0loUInCV9IywbPMKpKk4Ay7DOnM9B/CEUSf0mWpKFIjuXWxBgi8yx8tUIKCG3eD1c6mER0xgaMaRMiOAwFk0CgfQgpRv2ELlQZk6THVKyLjZ3xlp3LWi7DQ5bUkrzoFrQ0POcZ7QgfTGa5afAszmnQ2tb9npgWvWMqk9a81R4ycTRWHv6Ayq9SEqXiOT+ZQ7XHH8CIdCWUx84BPQ0ryRR8ewxQDznOiwoLF94sjCth8gJUNeWoB+YuqlG3c2+8PcEcMKxzsAC+768TQcSmlWTvsveWiW2a8GqmWT3xpCp57v5GTfcyFO2x6IVGL4D4zYRPHXHFnL7eQaaHxacrvVXe28In3OMjjmGMS4TgYK9Ztx3zTF0V/a4W52qrxzD1HMMVQo+kifL7Vr+3d+T9aY2zF75iOeijSMcp5kF2lRfpvYKUTGTT8QwHA7kZlwiX0tz0e+GPHsjKIUZi3RAekSxnyErIMhmqu/dueMUOBeMp3nXcDg8St1huv8+c8v6wWEiQ5eMH4mMvK8UnfI5BpYO9ej4Bquqv5yuviOPPCRdIpLSIjXJdavfzGKyNZ5AoLQdIaVo/WCgiGb1E478UK9+CGNd/wU= \ No newline at end of file diff --git a/docs/images/folders.png b/docs/images/folders.png new file mode 100644 index 0000000000000000000000000000000000000000..c6f2c1b4a3ef9de98e17de82e9e8e9ead5871c8f GIT binary patch literal 221172 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

-G5QZV(7O z(C>hKW60DW`pU}Nrh`njiy|u+L&a>Owxlfu!=e;_vZR~63NSNX>(cB(`b*ENO zPMnqSf?JOWV1ZqjgkMA(;6Gw{y@{?)C*=WwWBz&n-!ov~9-i%Jd-DDNoS#tPf9P;V zh5yTRFzs=50$vPP@oZ*56;Gy|HR5kw8{nUZEdN#F_94}OKUWjPUWhQu_MWfPvo|bU zBMB^MFWRWjhCWYelsEvC!P&| zkEiVZLek>&1(MP6DXa@GAi(#5@8K$$-~Lmqy8OQtuKr)n;mM2Bz{P>_jvFCO_BB9B zxJoYuzb)#nhD*TL#d9#gF#bIR|63vK|2aP+%Ks4^o_0UnNvOFb+5S*>ej$KV#5La%TgrMD(Wiu8U zl=qeVWqTYB6S*dOwvyu}kMGxM$BK&7Wi%$e>a&>o){(q`=RhAgyz%Anei2)yg|@}| z`%89E#DVNvCU$n!9EtnULYgMu_JnI)dKaG;(GZFxz zgCm;{r{*;0qQ_NZ%W0rE;hv|^AkmQ%w_0DHQubI1)L{_2S{;h;lj!KQtlYBZ zw0<{9U}&vy2a4C*#C*(A**G@&VWVqMQJpqg+`~?w?w=03IKwhH^408m(n5zqf=R-{ zoX+EzwOBv74r?F@{r|JV5lFFC=QYq1r zs>l+Pb}P>BZik%J(TcRTa(cq7VBLt2z37;lr{;EX`x>>f@uvm3xXE$>pa%{tH#4%z z-C|`96A13H64|*Wq$FI|03RDTMw#6Wya<-D{w!T4pB7fROjvgI*oV9w2kT<}{?Ydk zOq{|yX2dSM?PlASl%^Yfxn&r?P`o((q2OnN^U<_q!~B+2T)n8gtbm+36P&Wl_Fqz1 zxR}LO0fm1BJGx>gT_TV*=4i>D1S5>rBTm{Oegro5=w*$>ImR(=<$%UX7E+S@Q`h~3 z338ISI8hpe2o1wAPyL8oqUSwKaKtIR1-zDp2t6*}o(y!o=3! z=^*z9`tT~_T6&XaEHJvhCb8P)!9YM`+J9C=m&AT*Bxl@Sd@XH(TiIju=Z?o8kmW{u zsk`#=z09iBTJzY}xcZyGP#eZmUUVlmrUFtCz}sQ;#IJ|T+XsbV9j$hn;A)!ko}$8! z>?Lzy`zDDVjS5xHWDX)Mb1f4bnw!ww7{vNkdivuIR>wvhp1+ zPZc(nDR-#oo+R~P6EP*DCwIsNm&}!+Nxqwm$Iz6$Mohaf5k50xKi6o-=44_0Y}3PO zI{#$!$w#BaVemN@SYNK>1^-(e6xwF(zOQBx^M;4f(L(!E#ConKwGF(K<6C4J=4d4f zjL4eaN$v@t$V^gMYm(F0?(bfoSB*RJwFOOkTtikF#lDF>(q4fu?;lHu_tFd!U${pX z!MN}|-kO#uf7HY=dOsRGA7SB8qPim8{UV7? zTSzTU0%19bfShjrn^U z`7~`ruS6+p*w*)Y#(_*pi$q=GYCoKsvSJZ{?`1&4@AEK8%Cbod1g896(fEZs{?Vuy zN}M66MaolCy;z37W0!~B^C5a;EDZ_u%{|v2e=9!|r4t+CQMq+91%l+4#z@}n7*50M zX7O+tr!;r;u*I>C3`%!%2U0!Y(7Dl)y zvlF?z11=yAr?ilPRu(R@Xoon~G$~@pyJv?e;!--kN_l%yM>q#JU5nUoiv!*4Kq9<) z3}RCUtL&S4F@2uK8>z3N8!fP^?&^2{g=Twc*T)1XXpG}@a+)=;^;~zQAT4ab7 zZ;gOEn~zm2OS>V0tI$2J*dZ3^Z}?^6^rvq2jXuq_GN{U0*>f^fW+89VlvA{-e}IYl z_I8BC`vR411K5toXf69N4j(=Q*><|sxNhj7=`X?RL9J<7B-*FpkM?tDM?pJYA;x=3 zU>kGN*->v0IkKn<*v=eu$*W)^E7)p^gH>*6Ne??~8+PH}fMS?*~Dj?1a%2;Jcb6K-kBmcNmyzhXD_ef6^sjh*N?Hv9YjSc&*YrQZF$kp24> zvF2+_e~y(*KW)qnDyI*6uPUIux~Y|A`>R&rI^?A;mCG}`>s4hU0l#fur&@v+ z);}FS4sd(vUAr^c_u3e$Rj95${dz)VQm$j;{yGt|lGvvnci6T)4I8_J$kd$cXcQi`s3QkKqxt>XH`Ep6JRDf2(tupLoA zA-=W^_PL4QK`1xIkGVlnLslOBJn5qG%%X>DDTk91dy@`Y4%@YNHH`3;iqLqerP{-2 z#JUx77jXY}O1p{P8gu#*mps+UQxE%K-wTDTvI=3Xy|C{sm7dDr3ZRu${<*p1qK64i zN);i}yk*FB>8$p3vO{+1v_skIyKhh~$s=6eX14X1s=IsX_g^`u`uW@XvF3OI(tPni zi0SgQ;U}r4+M94QWD??gi&kI95i&_Rsv-sRgUeINiKI~4V>&hWp@$cWfX_ZAwi)8* zCWp{SebYzq2 zN@9OcN5qa+oK;5e=A{t_Z9(Q^o`$$VO*JsEQy&Pk>SJMUV3I~DYJ{wY@^!q?rfux$ zh?U3+AeEgO8%fOOp-C3xj8c8aaeT9kR?0VLyw#64E+xN?6YT#5v%E}g0r1r;VH;0% z|9~FCxP#`_`fk7#W%X%^th*{Cm+>uy~$`!9zySA zceCr&eUHMpzhG)w2ymHiiq@<+J_ztZb>-@~CoSQXY+d)8q@q!VIiAO{J#yI|%SYVe zW4IMAj$YG;&e$O|VW_>dRT{d%ZtmZ@8nX~RD1D`Yf0%t?`(`w%u#C;Adv#&HG0g&+ z)2B)tMi!kTJE2a_j8*c#(1R_zDb7xCv*hilTe3CSv4Y(@>KQu<9K8Z_ zhV1xF#sIl>pPJnQ5{I^q(Xc)*)`^@JmY#?5RXy`^7Mr4JDy~IC2ZNwS7-w2<#;toCa+$A^6Nt*IIuALe@S=z zGaLjXY}I#9IqrQJYIHcn4?PgG2Wjwq*a;i*94s`_a=?#Rtiy3m?&-+a9=XiF^#<_` zj_T+wC#6WNP_GwzQnRgz$yz>9l=pl)rEq<+|VfOrx&#lxafMUAjtp@`Ack9r>sB zE}e&s#a0y6rf9FCY-z@8#Nm#P95eb{jpO%^q497#zT>P8*3p zI$p`LXl=}^Go>DVR#tDR|LTM@(%Nu0vei0>TTZ*Y;y4ez?7gm_O-pZ3HVjLE^jvD~ z(gIr@-+PmxpoPcU!3c!H_U$PxTHn_F<-4%?JUgRY=H50Y**kUj=#0Gse0XVT)WH|@ zm5tMVP^hjM-x~2f+4RI|HxJ^jZUMw*B#WnCE@n2aG%;IVKWASMs&v1pZHRr@fo#OV zQv~^an;J&R1|6w7>W5K+x@;zftWc@n>jmh1e8CqKWR=-Ot+IH$QAke|mB?|mRCn_- zwX(fZnq5)XrrYs07c^KNrVz?2moV zMNAqi)J9>1|dj~8=Ya`QJfGvgc7+WW~C-Vb08j>v=g3H%< z7o3b}vq@@_S`PbLWc%SDQhWHH)HQR0UO#8OBHm%N1h4dIt!=nf;T$N5<;q%l) zTbIFd)ur?!#-#>WE>%iyv75$<9$cM8i&iDYHlz4n2VziK(!EM2Jh)&?MigQ*zX&ra z@6%iUvoa)9EjI}9c;%B9^Lj1zRYv=olVv%*rWLPb$%Z>SsAY#|@uyB(B914mx5M`LbFqmWp6nFb94BRP=W>Rc)A;9wf@Nw@w-w~s zjQg#8epu)pRB7X$hjVVv{EuE0u$Mttu0vuLP!ADrSuAcaI1O`?g?)LrVQ-i=RN)AtmWD=af*&Lo4NSEU?X3v< zcSZMXnq#Mz9a<~y?lm}n+V_^o>fH%v>019()B2o7ZKKBjQLa?KU;J&rlz(lm*$o*- zX0?ywM&gIC8%-)s){7mmeJuiyPzBFXrLa9C*Bpmy`yGLgeq zbAm9BYwFUxHESRGP%#U)lH`P4)N`|*4URDtyld|pnBcR)->Y;|-7{EDQH zhvne*hR*N5w*BUw(ugR~*m#8`s#HvW?o)?_R=8=!sTBR{OkR)_-wAmZ>%)OsHn`u5 zyQgerxl4HorR>D$oYP&|r~l)4{$<_JmS{#s&be16O4}Q-N=~`SZig|$Xk~Ixt%e3T zkqLd|V}~kL&~osiUqNaK`qti60z3XcRJ~+Hp7yLWMl ze0o_Jw?VDx>ONr)F%VTTHi9H@MC)L3PhW!FKuh~=sVfB>ioRwOxk?V^LAkgpHO5Kg!;~1W$OB6#{JQ7O8(8fPlRD&)xgMRD%pu!qzf@D;QKDo#!)h`;Md!5 zK=xM37SE~k*Ys$IlRohE~AIfjQ&lTzc4`-}vOV48(O@dv~=7wvL)>#MD;?Xio;`YqmD<}5Mhq=mYP=ICp zPS?mi_Ni!!su0y#w8_FCND!wFG-lLKi|Z=r-GDmpOL^OD#+iwAEW2r+H?}&)e_{jg zeRi=ZP81yQWk^pyecI_xu|IZj;RJ2t^xFyQH&~~HF~^?LojrV+wXD~*tf%LytfG*g zH2s^QWyxS9v**NH3~KwKj9*XiOQWIfe({}io%!v8JRpwX31cNUW_l>wr z)JGXys(kEJn2J+Ri<7t0Y=#CV%R)xvZsBVpr2?Q&_##EK5Xb;jGk1t=OIKgvCO&Za zC^*48RY)^JH>pDqF_iB(e>1DT)jBXH;xmN(-CBLXrOf!~{GK!KPYwPbUE(G+b!Ec& zb(JIMaWgp>(YWSu;K<^Fx}_;&2536|2`Q>U@nLv&o(AOQE;_e!%*e)Zu;5~!17Y>2 zaI=w@ZkqA;EPmX_0zPEleBF4$9yHn^d z#Q5V*>fAVIQUP~h*J0IxVKg`}oNf6<#!6^N-{62hVK*hi_PqvkQquzS^j%Z!bNLqb zVFj_kXE_m(_RBA_=6WCEZI5>Y>hyhLa*yVX#H8Kkqzu+e=9Nd8?n?8R9v$vDGhptW zcm^u>{N&@;+B2z(cS~C#4C(7sfW|Gd*WmM|;-$a?`yAlb_?0H>)=u0gp2b=Sr-?Z} z`6(bEJpm%!iJhxW_~)(aAl5sDZruxq>x2vzrPyx=q&3vN3yMzD5@TnqHQ?9L`eRv#mb8GR79;NaRun{5mX`FvQA0P zUxuJW$}H^`vvUO-8h=0N}@BFYXmcN*l?%KP#Om_tfKAQR(xlj@{Qq-$wQ1>Oe^=)(AtfO}&Ix!&Jbv zd~a&ay1pH`xpuRbe@4P*?(jV;y2`bv#bmUe&KLftNKecDdu8uW@laF+%0{GKRK0#_ zNg#xzjvr!eP{*ZTH?4b#o+;U?P^1%@{Bm9Vb<93KUop+@iwzKUb{#_kizi$h2m2fZ z!&5_3e%uYg?KCO$IL|_+B@G%X9J6260wIMOu6{R zTURIYm72RAC9v9F#x(+v1kqxaz7mCVT~(GEd}k zQ>C$|-H?NRwoO9{FJaQ&f@}jdUEPLlaDXU*z7zW6mJjqb#y%Z4D_BMgR~u7R>`pL7xrt9 z0IAW3kcSetq<60ELK<8HiFuhIgy9h{{_dZ;CjG!_LDgQ}$&t604Igv8up!)Jw3|F0 z!6YF><{%s8b^x+i?`n7#h-*MgrdfG5>8d2N86>Ky&YmKAZ*eq_ZiHk7-u8hgeUrq*KC0lMy!^XsyDO zbP6gbmV>5kMz5c{{PWfq<0(0RLDAzjJJAM~ng+ZZ@1)pLi$W*Z-cC!lJZb){H&0=z zx(RgR6P!%xV&0w9ob_t_0`PF(J7%~fc=1z6*f1Re`W3|6QyeER`k8(Y?DWm8f&b(8 zYRP;)EtZ6p;(d8v*`8YnUo8-r#g95hCa(1NA^74!r|f&BSsaAxdEH8S^a5G3w{ zt22pI^sIRE0Bszu=AagfkJ7I%aDIPa$l3U?>PP&(n5mD0EZsL(;Bzm-rzh%(ip|yO zZeTCqYaG9+GwPSD`23=gep@X*ntgY~h7Cw(FGHUO=L*iuiiik)VSIN_;jBnM%=1oC ze%Ssk@_vVYLvm#5rFRe4dm@g(=_DSmZ7~p)&-e=6;xUE+*Gns$B!xXuLnh z9r>h;BF(+b&`%GcuE0~!Ifk~Y{^D21mqjz4=$@ue()8RcYb7V7k4oKLS}diMaoWew zFjQ_L`=6D6%m!KmyQlXeQKv~5--Vw^*4{vl1-B{S#Hj79=_f=sq^GU4!OsHV;d*hY zK|BFCi2HqwjeexPObs~{lVxwtq}Mil(W1|kKvuw4AM8dul%id8aTY9M_)N{PBX2R zcBb{EI_~_L#d-$M8;|;3nyTcu1JssV%k$noF|8ako@_p@->%{gD0Tk)vUDdOvb+NZ zv$lOiTVz}hY!fwz*ltCf_6l zecM-1X^T)#(%tXo{2hs@k^qm)mvM;XnLA7bT3>H!+67)zIF6-COfA<@4+!D6lllfP zmb_e9fyF*FiCYOk+?Ceaj61W34;6&&hG^X#HwjNbCnC#t3gqfA>8WO|P8}#u@1mmL z6IUF|wqAl@bT9UNr+0?UgUyxjQIU;E9p@TK+0 zCF8!&giW<40(8H@yNGch%DTk)&OcA6a2K0?;hJ8)G!p~)k#R0p$Ck*5Zyd|=b)QMn zA4YfGm@K1v0szxtg1@?=13L(rpfwu@7hi^s{N{M^%=q}N%K6a$6l4OltI2$29jA;4 zU8^*Y1TXhW)#|Y^93$y=83=C@8m)a~p0-j2wn#2|w>uGn!(^>U@w7ZIkrqdYt{>>9 zm9@Z|i!q|_b31ESu~i4}{v3hb$KF1lmFx)tSV55+Y00gk5~B03%k`gq@{AmLdr{Qt zqs4PlBRVm9cV5f=_eZxIWpaKOO`G3tzM~%;a`Cs8ty;_^vp@20kf*Di9TDWb>DzSE zkoLp^kX~RZ(*z~>qQHIX{O+*{4vg`C{VXtC2QYOKEibD+Ll#j51ttL@pft8UuzkxW zQ=Ol-JHF|;rhWAn^~(xR3{tdu@}HRv5?YYKVVcf=>JK{#Nfi_wNGQlxZ8uS=8BlD? z3Wks6n<)RQQ^fA(#Q1M)7!-pt)C(`6a&u_KDRhs{AaI&hxlATd!ys~tS_cCvfpYiE z+a6pe(qkxp4eG=69+2M3*JDrh!;)Dl;S;s$(e66c8(x`O)NZcVw) z9yNnY%-*K88^^Xt#>#h2VI3__=%b!lmEfHOutmm?4Zc1)p$6F=D9*L7)mz?I&sVdi z!}0Wps1ERR04;f>Rr&K?d4+l4Pk!$P#g@fSxkQ1e=!&wFSo=@^tp&(^%f`cb@){11 zV9!azb^5jo)+zb6;))V0HSEIe01INKf|8$Ug`xyna18~t8L8C-yffHxz(w`Ub<+gW$J0or7Kg6@tE-u4jfvr zKN-^bqN|1>ea5D*+7%Ac*lXle`4&BUQiu;wQjRuaL(y$$+8mr}Klyw8zxoh&C#15`K%+>m4PWQb6(;`hqWV zvZS|4(O~`Cyu|vsJPvHGLG2(QC%5e^UZYEv+d-U@tnTFysN9RT^8o2=0GPFr_;bdl;EsPPgxWyso<^Pa`nw-ca^5`OL+r{%5_f$mmbNaF~qV}d{{5; zbi76b&XZ>XjdFTF=zXMNUI19?J{%?u998pq{RcyN(a<>r-@5r0iO(FOLkzz#^6PK5 z5A13cw*O9NS$u^Gim1?@2xP*pesagmrQ^N?$F{8N)f$_ZP@>;AhVRd9a|Ym8Dx*An-Y1 zq-x%Dk5B0kmSXc_cQ+J)eJn-uon2x|LA(9iK(uy*kxGS8k*npX>ZoZ7cghTa%?fFH z*)1PQqun7GT*^9SdWnYnW&>w~R-mcLY+03yeiQde+$}b_I7a?7{GKC2$-_rU_`KHe z=q2tCUZ4Yrkng{QRuK8>WOt8lxVFCzO@awLGxE}qn+>@L5 zewlG;i}L;@L<WP6n4F4v;G*w8))iEPYP5ZMcp7& zrlex4iotcn6JZ8lu@33GknGVDyTniN_RQxJQJ5#?O~06}WP#C1Xb0j_mQ2^RPYwsZS4Bs=rv;Lw(6 zn+BX-Y6l-kYD(bRlOK0x3&#)b*PH91RmH#W#ojcG-AKK%IY7b{e-Irzq-l_lBK)|H zn=XA5^d6M{!`FU-`@|CflQxoBS5sS@i&ocuDDR|g2EMj{Qy-LK#!}i?Cdx9pq8LA< zfW4dohsI_hrB14j=RTL5mvSwOKgGrF#jA760q__|)5}FN zEFo=opFA?|lhV#@?@BS{4C||^7KfA=w)0%&0z1PHS@D*(#|`JRm{>d;er2nA!opkO zst0jHFjot$DxUkK3khnbPWxh!kc#zphvRsrJiD!TV*i*5f0uVt-z!I09{2%Xu#VHc z8+(^Z53#&NjQFlSDkmln^L`GG!zZ>p=y4v!J7e=*_2}s5Msdk8Px;FmT>dtlo2CRm z3mZ5bJ7DD>1<%1#%`%E_cN_s_K`YxKeL6iW{BDmZ<=T8T_XhRVUKX$q87DYRczMC? z4`y-EtrI4N?!Feq>jvNpuAUub);8sh;E*5{=a^B6I7Xh+p>rdy0Kbh%X5Wx0g_@XC zew#Mo9%JhgD7;L=JuUM*ZtKqph7#etWdJ-~RMV|01G)@gWQMsrD}hy%oz}8YbneBE z^P<7M7Knl2(N<8DIHWCdhxCSFSBx=gVTltOn=0*6s}T4cjqyhCQ80<&i!aW_|AHWK z`AqL;>O^~*AlojDf@1k{n<1f2x6jfCWep47+TrcLQ;s$wZ5Gz_A5Fo}A4)tyQL1cb zrTXLTic-6O11v{^@vXc~;EIDBG5iT8IrT-b?Z$a9mjT#1jk7If0UV- z8l&_h8g7UkpKPUE$DbMSJw6Br3YJ17@vciXYgM&X(RLEOvyge=xk=**DLsrk0N$5f zU^ti{31MqR4-zzeOU!RV<5s2+7@Bfzk|u4Py=|mD?h8`#=>3hNsgqdB`&;a*^2+k7CXx(vvsKh)UuV_o5-dF_Q4M*nwq15t9;5Vit3bd&lqw}i@tGN0!DmX?d&XlFkunPt`- zuFiUcQP#-d+m5Vd=5-OAk@ChBW$B%f;xsir%Ohv^z0$0$u3&C{?8b}vJRnqo4x_g? zFI&PUOg+!02??ws@RJo?JAl)SmS#8TIJh)z8IYXX$MTS$0GYL`=W{2bt>as4ZR`@B z_^=-zx{3jW_Z;*&cM5g|c9 z6J`Zaxb>0@;ySl!9DiK%eV@#JzbI)zp8;9LWrN!(SHy9droqPmlXq->Kh5H=|CE>6 z&(dSkBTPlx9<)yx=~@301q0y=I1t4Hbg~T_%9A@{i~rO5*&_bBYzzCc(J3nVbSF@^ zvuQAWJYWzdcIx?0&@P%Jexbtq?)JEJf6u#j_rM#Ck|iw5#>Nd}sLpRdRd@S!-_8ov zGds8Qv8GG5ARG%&26E(iCKqpkS%;^CrDw2*U=J_T6hlgslQzx|95}mv-L)V^9+FUT-yHynQop!n>BwMfh z-rS&YP1b&Jjo}+A%;OE-bphN#l4a0<>Gj?8S$M7F{3ScLR&V`TVfC19vz7zPXav@T z`ElXKmlDI+V1+@h>W#pUry-sqCl{yuC=2}gWG(17Y-oZPHZ&RW*imrEkppYl@q1H` zyflo;&h#*H9fn&_Za@1Kd{nknJAUgeu^&yV>APD**<=;l5~;x$`dcP461kXYN|=o0 z9R_~(>eS+XJPhYn9WvqKbs6k>Q=NRC8h1-cfOzWYTqMmSvWun_S_B-=E7ZEV(V?RB zxpWssqs5+WV6>-@FcU*hkx=0^8#D0n5cK4@SvCSWRFVMm7}9_pl~#cjcrdr-MmcWd zVhE|g{do^GCXed=PQlbQdP4ZLx4NV|=Eg;*7nKQlVC{e2ZT460JwMlGSe{2*?S|Z<$v7Hr4bqT{m|+R_iR)U2 zaJmrS{~OsK?J4mF@~rrfQ~#u&)CK6TKTeXbM*RDOD0_&YloVWnV(#|IQMg`|hiR2jQ@cCzMcx;RD)LrKbJg*i0=}iL(y> zbkyi&@0S08a(iaub}@7A7eL_6C3Z%HUS(5sP21WCl!Vg9pP1v~!{uQIJ_^gRRhV9is1 ze8^EAdO^wLPW3=n_5Ys72={vfA}kug#F`y61t$i#InX z$~6CRY}$w4&_rPAV1x(KYm>p6|D7c#I4bPlS}}?_A`5**C1XB!EqAGQ`8hOjD3DQR zFB6ijI6M7M=1$lCBdM@1RZ7sKyO&NT%qA*bH)MVC(O;E(H>M>l_2T|JQwYb)VO2OM zB9%Ks;!I%a&hODqxh>TD%!r6b?s{L60{%Azgs13V4nS8a<5Z*SAF#$)rYX)Hc5_VO zbKm@bG!V}CpS@u~oA$`-xZYo0QNUK@ZjSw;)sghU_uv7I|4lMVKQ373G4SN<6#0z< z1)eD83-&L!_Z%BEZx-7v!|KA4DE5nbE-x&yaF%cuD%-Ik_% zSc*pgM(fkRwG+6~Zv3wQ+0?=AEVV4GHD@UQ(5-3J+T=FP-wjDC2}5h@m^t*gQJvTM~psKYrW z_~KNe>v>pXp!^}hLxemd=YLZRU9v!NDpWD(AgXC0t*9T!AJ3M{dvH&ICnE1ND-~K0 z>k_#T$a3ybb63{{N>J|?b&s_twQIGnIh7T&@3e6mbP1EwIad#8w>Q|l%QPhN4l561 zAunxKEJt0v+JDS4pNQ=-HiIWaB3LN*U9hji*eAuGLoPtSN8T9HUmv4-ah(62_lT=T zqQ`9EUH5Xvo6*}t#k_UIrR0KDe2q2tuen8&T&R#QK0UuD-I(g#({-T%X|3tTUE9dk zeq3H%;T-1H4u&nD!7yKV@rJJxJh##2-@awm$@J;`!IW0<>0el}(<(<6t(X=K&3M2!I0T0%D?mtAi8?u7e<5oQUvsIjJhERNuGz`A|}!+Z`3Q|x$?m~^;c@Y zIvZx#9!hmDK$4^h6C2|=_93Imr@#k)bdvcFxRBF!)9Nd9IC9r23Ywo?sz<|}@8x~dnQH|D=E2Hr0*OgH?|C=-A-43=D9Qk@6 zz2hIq>=)cxkiCsWZk7+=?p(?Ddo`)H4pZ8bLJJYf`iEknvLY#a{7lFQX_1e*t~|y* z+4RF?{#DmEogMJP;37Fw@_9LU?snuq$NB)!gxz2P74_}{aFyMb_(8nNtv$ef!gERO z+?AfC=O$6mxe{1#)P#&#eO0V}Z%!qk&V(xdwCVH#xEY|ucmLTtB5o?SIqSXd#m(hi ze|1EfVbhNKd~D9t`GWG^fBfIH#cy&!MBuYdJA%I3mlj0wYyltcd zYXd}?r4hc3Kl}P~c~3mqu$b6;#db`X(SxA?rehx6djW2e(H~1VsDe56Ufi(KebwTw znz^t33QzVW%bBr>pnynuWX(_Wl#*ugY|Fe{TN0VFs3_|Kr+E#Cl)feM4QE zr-myF%|rV70dtq>8~QMb*li%1omH1+bFDIS4=Bgn%ib7QaFLsv+R^Y{AYTx(Vn%cU z=bqdnJA+{_dXDP#U!SGXOz)p3U{gP|Dqf_%sBIofNrd5?4raJw(B*Gf_0N6iIGTmp zs5S2g3q3<0=~&&F$|z5S?Z=s2)H%k?t8VNzR7Rue52LCZIEhnAX;G<}_biFzpZc4l z8!5Yk&s&p^j-Ry$_JV}gyB^6-q>jU|fo+lfLls8km|3$n@sS#Bpw&F2Ew1SF-LQqHFCZ@ zIWbSX1Xg_xPOEg0|b1gXax8$q#kL_dao~!ccgpS z;%}Z9O?1|h9!jFIxLgIdg5L4$fV8FUs}Lq>d7q-8dxyImO7(vWT!JpU$QQq+z4Hd@_PRv z6;zRqp3psVzEj&$7y9^k@K_KlvKS=5BnTuD<(M5?d5gF#YQS2)_yhP!*NH2a@r+kf zn{EF5i?WQ>frIi@ECi1Sop-yKCWJ1YAO7+m`O}_VBVXRTMY=`X&=#rtjKk<;=CZb( z?M)v-dWDF67)T+W&5avOjuW=GJ-j6HL^`(nFK9ZIS854VTp-#nbx$r->1c@fmkXuI zi55@$rO5Gm7TpB`Xr496`03J}IQ=wq0RvXqijnSpaPJ7!v3T|ajV|UyXC-p2y!XN1 zxZ_STw@Kuc$i4J^-9|G=&{0j;d;HR{H$-CbwCJg|!EeF4qa{NHxc?>Rm>PES|&Eq_jq$-=*p zdvjzg-xfxr)Ra=GtY{0)9D48dLIuuvhaZU4a<|8GV}EvaE$_ZuUk=K6EjAl1&&Q^B zB`i*o0g7#@Z}dx9WQv+WbMHUy=5SaS&P(|s^r;UC15uyrwotQ)ye&zPDl{@zw>z6Ea!0@LGRQ=E3^=*?X+2$>J|Lz ze4YM1{cXO=ev(=mliWj%eueXfE}G~?9o{yjUQgI3+CScAM9u0Bcs=;NP2!vP_wSu$ z>W@tfC2_MO_MSXy1=seOu-G-xrCB=ql z$qg!4IS$W9V=I-J`QN0pJG;nP&XOCvrqT{_lT(sNnu1M0B&pbI0(HT!TE%H`)aw0G zftv4b4gM)^XL}H1yU%*TzBr?3`Xl`n>?;z~E$@DVVmls=E-jgGW5=>BnA)`Z*3k_9 z#(%_j(OOu7afJI1a5$ln)OnyE^jU~w%N@(JV{W8xwhikPYYhOG*t{E_tt9Aoy3;XN8qdYp||6)n5 zttU?YTmf(7&Xq0_fB9GM^;%g$J0f-(?-0KuFnZdVcuR;89r#8x3x7G#-PY#}^`gsG z@7BsSWNCcsL3s2-pzaXg-DZEgLurUGFLpN(_Pr;{rAcijL)Y`Kyw&qWRIduzf_<*V zah9@!>jASZS+WU@(8^^dQeD`d0H=Et;Gj|O!7@9T0Xx#}>-7&c?)qx^YKyV*M!o(s z*UYRC@-B(M=NG&k&JKptl5i8A@c@>T#YD2HyEF*3J!U%Mq}$VETy;Kz!zr%WEadDF z?mV@z`brK8ugTbF5`|R!sV@?BZFvxE?EYA)sT%C5@ya~(!L`B>OST}w%cK|XBbT>{ z>{!V0C(cUFEe>h9#pDLrZnmh+?_t=+kk~y_J{BE2X=m=mW-a!8g^}CRpFLdSQ0qF< z@9ATwm)vNvk5@vyA>oQ~NR#Bb+ITm<&u0^e$z&ryL z*g-O~A)ShJq7}YHL~om-b8HKR*em^x&L2xxmQ1~6;zN1A8t5)hyJxfz!B^9UW@U*&$u`lxyIH#V&2Xlp{wrmL`oN`wFk zYNg)RJ$Xmz>spOiZEQ7FeFnBM@}1`?u|m~kOra(5Ge^`X+9K%FiPj$*Xp~y-zY9-`1rWl(PB^xa4Y}fCR9{U&O0b* zJlC;PcwKe}N^lj#z8!4(zbFO5t%kcg;bUOx@byB0z8;rb`fBx$Ee+^z5oshuq_#fL z4^CDhhiPwL15oVy1mLGn^f`qC%Q*Erd-9?TAR&`ba>6jMzFEKuhY|rhqY|TwshxuHY{~=D0dC^RR?JfGc=z{R(7bM9ZRVUCsF`YDi7rEIk9;veX%mL z*>dP0!A9rT9H<`4n5(W1~1+VftNPY;#b^$!GqI5*p zcnS82|Ig>ZbKbU8b=Nel`pGkQFY1%W=aZM7FkP_Z%Xfa{36bBfQ*1j`dJ1EG?+$6~ zpmSm?E^=$7i=*!?N?WQ<;?`ld<^sSHZ=Ev1!ijwZVhCcGKL{`pzPqBs?tJ5&# zeX}44D%xP}qd=|E_-?ZD>JtU>azqRVuJ6W!nyX;HDQ(Mw2`qfeB#(=dvznlU;v{qa zLC2<3(D29MBSFNqddg$YzSU(I4*DovG8sEdPsg$EU15-j^3S4~3w(!_*(YFc<_AZ; zyOJ65ua>OyvDpVGzwG$y_bh)ydVxc#Up{XA4_eZDhgkc5jck`jdxv_WX|lfsa^1#| zyzmAYG3^`EFjg5MQ_CYzn#YroyJF!gAr!8!`KrQb(z_)!tB8_QC4C=CjKXa>u;kf% z1CFpy;_l<^fqj*awl5|q77Kf)ul=TQO^qgNB{}@O~m67B~xQ07?R&@61$ymp?#xIPl>B@z7bPrWgsypx7xBFT?1~7f+l-io~;B&5%hphH9sxQxT_%T)|{i4}u40 zo{-moAvym55eXBWOodcpI-1|CyBIHn+f&x znqQR-{tDX{iYq+B1}vY|6f9>c3}Sb9s((#!vBmKzTPuJtYe7WDCTd5Eu{Dl35C?3+ zNnHwJDiNY~WDxspB;2tjOwu4j|7j~4CzOKzS|JdHqr$AJr(SF)?go6CJi6j!?X zZM$8YtGcN9uJqefiU4&;c)SHG1W4GPCrf+%lBp2fB^&AT+n$QEh)|vcP_O@L$nPVL z8b~{ll3bokbFzPE*01wHSg$~NT*zT`F6vDd5mTkV8GwUfC$vKJ6B-b zfbFP_FM)Ubw$N*8%^6{Wqx?NzO@oHVXB62cs(ar;>6H$@noaYLEU$jDH}oh zGbaKrdL~5R3wt^VeCibc_Qha3^_y}pGo7SreABJBRtXUd{Bf9*Qce)&Pvdxe|0h`m z@&sQmpU2bZvtf60S-VnThH$iMXCtu@ryD;-R&2~IK50R&YSQ>zm7yze$munAro>tP zz1^(57>wuNmj}%HY9$|^QC>lZ2v#TUBCBRqw^E<`ut(prV+~=S zf7T#mvU|04lpIzGl+sc{Z6;K>&6$sdvzH(N^xZmYGxwQMJmdz@d_w1q)9o1C!ErrV2M8F>@gX|9RY~j@;+r zsNYM1pvbOe!JK8m>AS<;!&cL{IjJjj6rnN$vU`9I#r2lr$g+a(;O0XFn`%)MVIw1u zwYGCbYK6T!Lv7NZrRL1kdZrKWR6G8DLUyzUv!-5*)YD#AAPyQWF&<@18O_(S6!p7! zmER(thPF)qy{DsSf7;(*%BJHMgi&bn*m_PTiv7j$w^3C!bw$@4leg|IU5k~o-SKmD zzc)BFQo96Lz-VRv;_H}3puE4RRx?1#Q$bE z8Ee9rXKb##^hac^fyC;Ig(qI+5IqCW1y3;1xxBfD})S}ANrNhvJ%+l?s_*w-GB1^f2%M>8U7L6uGZS#G4+-v$?JL=j?- z*qcG+L!N2;6b8?ZII2hD^d+*0+8$JQFl&*M8XkpAu^iK~a~^P58MQ; zW|>!ac8`+}vc7L4^Y|5Rkm(I0^eoo6z!38Q_a4==rB7)LxXJj)F7@#;`{}-_l%qYD zvX(_RDv%8*^kmfCYx;||kE^ivzPaR4kyD!aTjltsW;*( zg5_D!0|B3p_vd%)XRP%gtdghgqDG2_FA^Yg*HnmKcKYw~tU*-uumXRksFtklYNc^$LkJ@?t5Kc1@|aNYgv8Zy`!mF9o^2YtNF zwxdbG%k?Zq17k6JGH_|pUARc>EO5XDmiQ{c{sqEBN^8Q|VxqAr+{d_J3uG1M(GPH%6l9kZwK@j4QBBEgNmP4wR9CCQ@qvf)<_ zk89btCkcApbNp|5FfDqAJZI^D4&Q+NXT>Ex{vP^VkQXe=I-Fz?5$;&K9DR}kutGlf zS!1qL!|W_>v+t%~yzN@aNu)eRtTkkg;cPKg!=cUeRH9)oAu(~UJ4f&M5aZSt7_@$+ ztWSQ1wy(AwO9BVRnpV4wRqjQMxXd+2T;jXJ%>uUP`GYOmTJhhFVid$WHO9-()X)q1 zy0hgU*NGKZd5xPgp>w#00PPp<~ivUi2});8x`-%=+Eq0vd{&XcN;kO~k$jYwqBB=;C-DCs^3 zc-{&?ri)8K1}3)*#-^+B=V7ztgt4y=9WR>*rDU*`y;##H)iJg_WBmG&IK@ji{vUGJr@wue`h&m_i zl4s*u5EYzr^=CZ2#qx;dr9@7VcIDWe_HVy6`brwThoG#h`w5mFc+2vy*6(|pAXke4 zo!J*jvOKTji?ALszbpuv3@DI1+ky_}IgW1e<6U@XJD^?KON~sJvV#V6roy9~-{S>Y zrAz3f%ZN+)W1B&5Z6m;}g7EWg!xcZvn7wV<(vz|-kAlojH`~OPYXW54adpt<&x$R$ zT+<2dvkj>_4^)P=_uPe#)SMQsEJMcL#uk*DlxsrIp4S7G-}wXf~n+V>ubo$h+F9c1|N6K6ijp+Id7gZQ>4>2SOQG!S%PkMVXK1{ zdFZqGG$1>F>Aq^KauR2e?JTTxn16aZ7lGM#7HH+vFZ#5hO@6$3o2t|??G0D^{2M62 z`&)`_IomCK&RM)*%763}a^d#}b7O#!6*$XMCs0h0N)k3Iuk}gdMEnU1=w!BQYxqK! zRW>8*doiKCI>MGU6pG(zs1Y;-+!Utul6_*%Lkg#eecCI_Ex#)tv^DpJDHNBZ&c7*- z>*yC#R^0Y=?3+-beEIil&XoIni-YMfBYA1fT_KZ>c*kyv7HT+V)|TH9F{z$><98R@KIu91-Q&5aDvPI8Z)KX4`{Ejk z^`ZF6sNZE~de9Lsv(Hn@qxGo}=Y%w1)+^h7i6Xu;;_`})CY=}b!seakm#jli$adoN ze7@Dq8+ULe&2^_YU|d+_0Ka-Nc=A*F0?2?(hf^t;B*nbPO=m)fI5b0#65?0U{UPt6 zmpISqYPW>jdslAwWV-vuP3aw7*=qN$wE@#)aSY3a&%Cv~?jspn7B1%ral4t(o2>pe zD)-gl8!vb|%CR|_#EOMWr-rxq!k>l)HnF^j=5DF8~Ds$Z*ycVi;9Gv`2I#EaWJj^I0%hDQtaSHl?RY=qjksv+0 zbMG1X-+r#(cM3zbpPtN8VA{gTe`F*9tuF&?8>O?y<|Q5_dl_I1L`=cF)i+j1rNcHD1PInQ0Kq7K@)>B{@XD! z&XZtMVnwi4>Z9z5x!Gg%JoKlda&%uI!D~PpWi~#v23sEtd!cknk6&}u&53ezta#1I z6nOl^glZN;a=-Mj@0GnP5S&$znU<+H%+okOf4QS} zag1VnKAb-`-i5-{-sobUiYafMPX7bnSY_}&Mu)`YN74ado=@8eJAJ74H9?a{Lz8o5 z+rB`@3S84AAS{5nIzCWx!xG92H^2osQch zWnNF^5EtBG{h4*?3re!|cJ8%?p7piWT{O)Kxt@&rfEG7%-r1DoU~@TS){lS61hn5e zJ1~;UOAoit@3$Ifa`y(_r!upNbL=fKxd9li9%hfQNGmM}BgrL|(kAHCl^QcPp)E>$ zD#)R44>kh!)cxHVgz;cDZ}#?!NA;p{TSECqWJetM`{06KGjMfs*UTc+r##-pZ(c|y zn3YzoFDX^Yf$=cWv8V{AXB&(rz85&Ad{N?2mg|$OfAA08;@co3)5_iP4K=5+uhS#3 zNbI;-uUm^z<~{bW!}pq1$zO32!!q8ygo$VdqDuCvwFJC9mL$`M)z^2otwN)E%#_9& zYzoUki*j2+m6rl@8N-l0wOWJYQwH{xRAdAVfo5o(3#Wd1pJekPE4#zwy|=#bcy|2s zv*tuSJ#KA&(ZE?dL5?J^t+42;{RAiCxa(xhGx)1Y6h9JmB=qT`f=P^ z_him&%Gyrf^ZvL;?%J$qVR+$FLT?!mu9_9hZtHRt^Dftbc%@co#~KYZLifVUy{B}9 zy8T@izj3)te!)}Q=&%F}7@hA0G2r3VKdA;FZ#9^t+?Ez8hALbFHlXyaOCuQUIVMs- zd?l_-wQlxg9|P?~gvV$xSk|72&xP892k`{#EBhE!`Ql8K4zC{-uz}EWXzJxiy=dY6 z-M5mZ^{YBby94g&7CW-363)FOmY^sNAw?eBW}EH6xnO6{gf$_sL@vqVD@&vX?6db+ z#XWynSC{*UA4$`XXr%dFOG>aBA+wkJ6!eW%o*qjl;68Ht&MFEDI+~)XE%vji- z^7A18Ha9ZTv+Am@xN8HY%MqDRoZitOK`wz%51Fj#8D~t zfw$6WMY)>zLX(XlRT1U-z6;^jm!Eqf6yC1D_gY1zPea0ypHvfMB9J&WF=VoabN$}G z@QsaFI6o@ny{@AGlNN>RZJo$SI@+SbXD!sttPNQuu*6b;CCPv*k{BlLn}MA?u4-0t zRhED4E*nt`G5Xo1A7_oQnV$8J3Z%+^^mlYr%;yF}N*lI#=tTm5ef28defs;AC}2xC zh0exPc$f7o{G%Ak2l%~H0HeK!^hRXdzdl{PL^jX#tcz;9GSj}wPD^GvetEsd&nYQ_ z8f5Y#iOHM#^eQ+jQBnbGD8A_27tXV=m)$-7DUI98W=MeYjUblG(iIW$we4doP|GbJ1tTjX8fcuFAiuDAQ7ZP2k)^CQULVVHU=PSliZK zBf%H&HtlH%Ny3dIvc=i;V>ug zVs7&(+Bo0Aa)>F@Js0gDInK(D0%;CZznpq%cbI7nAQ^n4*Nz#n0ITDyo*aa{tK@&* z#CZL-l3IQx=)_N2_%BCTsXnT(D4_vqIDM@_ot-h~+UY{G;)hjzbR`V9Q z|N3F}shT8eimg7X-@`mkAgSDasr{M~jVMBHv1(^nEJ@9W080x2(evDz7PaMXVR;pj zHh=Vr1A8B_&44GCf}HrNMCu-HzIR-H2IG7EJd0*pmz$2p1Z_+E!j;nOv0a`FVEGv- z|C#?k3?~1Fz4wl4YWv!S@px>#9 zN4IKcZ%HnHr*j831}fmZBSw^%@LR85)@AgDot^WDtl#2@C}C0joIr1`Brqew5QPQI0Tbo(8Nn5Q8(egVL1@W%rru@le#_u$|niWWO7|`A@@nywc$K z0KNKNmo1d2*)dhk=ETTb$16lG8CZRC78ZEo`y%+_otJ-=Nv4?bXg@JOvJZ_s^-Qd~ zE5#l9Ugb^&rHYa9o8=KU(@tzo0?YH$5gcmu94EgWk4G99EZE-&iQ%LAC%^4u@F(GmPX90$QubH`ve}+G)GSl} zQ<>}{{r3HcqLjD{vJ=kwOt@}$=8J-!%qSY&R(mV-yyl!u-Lap}H=bOvKN`R99sLXT z2p&3h_xk+4;UoUyl7`2WZ)})8)X=L&ISJijR*+cPcDxu84Ck(qKe&0491({Ww`hIU z!`hKu@W$Nl(ONAi0iDZ_)R9keHZLNtxMu;t{pcs~*SP1p&OC|K-iKi4x6l-Xr?#*2 z^~BzLuCv~80Hs%z{!|$aV5pu@`NXP3H0a%k4lZ>Zc$R0bI9+wGBEZk9#aD&8)b*T7 ztcmf)Q{R}lk6OX(*KN;s$tw`Z9#*WTZX2C9P+`fme*cC-lt^Z z`a#Hw@zd}4g?{(nhQxfzF1;`h)b3{(9cg%{@c8$~((w%setTb}ru}p2;+Wjoz`VRRYSO}u#9lVf zueW;AJ%xd-k3GFUfWV2B*o|DB_bjpPNMdhGBuMMRvTg?4s(x}}?Au%gmnkyoq>jno zwi3}ESI1&$o_8`IaKBIP5VTS@gl{rbesbN2j+x|RIV9*jB_vQ{z@_f|KJv(m+l|Lx z9Q|_O5hh(@^XxM81uYBa@MQjJbyLoFH-0O)vX1GIdpAFEyKxLXgYX>TKz9v2+gEkM zAN_crBA2wS`r)c1!zO=WKc}9kK}9IvsjS}@b?l9^SmYI4g>g4-G)`;(#5x|Otp90n z%mn_ms61E@QwUw2C~5GEySTp!xLXurT^&ckx^y2F3WIT;*i z0hPOFuI3pSnonupC=j6pIyPXQ^b5Xdmgs=C&btK;ReFB0`eOf0B4kd-Hu>(`$<6u~ zf25o_w8Y{i;BWmmsBI9r;2#*xhKWu+*w$;#OgkuTyj2elVh@8VuoJ>(wqo*Y9*#xo z7EF>e$q5Pku%1o_qBGc9xO`PG=2G=b_BW^h%oI8=>-gyFx6N`|in=`GTlP7I&juEu z9ndCRRoj1NIRLZpwVo#bL;L)(PEI$1Y>5s)kpBxd_ z8+Bhmz(tVzt{&ar#ykPD7Pn|Jag`qz8YtZSy`g;tp={B zSIIi;ld`FMrSF4Qu!aRTQPT~o<{l#zi~c4(sM@R@M3hzMT3VvL35?y zS1*@5Q+#{!ibCY=jx?UJIMCE!BROPh)03X|;tvtI)24TMRBhQJ9TlV!AJ+(;d1fMf zvBmjf5(BMP|Hl!+yDF%_sKC81OE+vUNk4g<6v-B)aah!5`gC}$X7|0G49D(ivhVZ9 zq0}?rwJJxszhjo`J?^tF^N)Q9SU;C{p=6OW{xke9`9P^pxQShdFlQI0giXyngr+av)+N5?ZN7MCNIVbGXZI6RvQn{>|4O5~&eZL|8 zq2-W6(wN-zeL-m3zyhQU~wE!5K`6hi75@78mOphU_{;R2Mi(&{VPxn0y-0!gmfvu#RSJ1ivDSE zmuHZux9Axa6C=r3+yo^0*%YK5dwz{<1gcbKVD>tS>&43#A#*ZL`yacG8>U#j_=ClB z?9`0|$-&!VuGzvHPK*|Pjl=p?dhT69eTp|W;ttK|&*Y_*gG1n5mC>H)c?bU3HM5a`L9N53Q+l2mSBK(YQ*8Bs*OFq^ zvtxu+L+Vu|S8}A-8y;#eUC@Id+SK`0r{jWV?#jP75kQ%AeFd5h;F^%FI6Qq!EVv%3 zpLSa-h_u*WFxFCa&|X7R(|3v72MBlKBalkSE(r$!B0E~v2E?h%G_h6Aq6oN+k}6-| zdK-Ho&%u*~_cL2Y^Su(t;adM|)nb940OwJADx$%6vV(B$+WJMhFaD##0Z$!y(#b$X zU0rM3s>`U)ES7r&3m0uKc?fo}|Jm%o`1doO||8hSWm7_=PI#caZticfKWSs}U zgY-dG)i&ig+&WaoA%-OMBDB{Nz_}9uE)KpaJQJwm+Pz$-JwALUV*NASr+1z?*6Ke%PRhK}U*`@gRWhJv z^=NNYj^$?iVs^*vg;_Z{0!sL@-_MF?RY+9SN^>^mYFoLYVXj4OoU5+Qi|Crowo_UGt0(A$ zZykUfQtiEf7B!m-D*s~BIF?}9fQ`jB)5_mKMKl?9U>*Ey8{ z(pVTrp*G)g90cCBAjgp&%HzjGwy@lp;`B#6`BfC_`qkKb{<1;5-GYQgF*7=69iIr! zw|veIq{Xz*3UXFCAo|%NUnX<{xl+h?`TFg0H1yZZ9N4LPKdr+@-BMQ#7C3gf9sgk> zEm3Z*{kxzqa?X8c6jpvK(Q?N0CL0BDIjcbjtz?p4bN?N6?pqBfD$_#)C<9kV;j6CA z#&{aSP-t@hHTPbB_G;?&=$PE_I-;1>z-sq|2gqu5lxj94^_4o)UWNX6vnS0)2~&vQ zz!Gh=y$Nc7gI1X3y6@!*iWb|eEsn+;2bld?Roe6Z+WCVqT8O8*c#AI;y3n>w1Ar=| zr(X7bCcb~Yy$wL1v(iRKgYakS=n)~y15T!{t4D&mO6=x{Pr>9_xtk_t31}yr45@QV znOEn_|4ElPBULZAs~c_>aIwzKeedkL1im*0KtU!aC`eE_QKDw7CCqU^FTwTLZf8+y z!Xz>PjPz>lo5Bf(MoyF6m*APL-~%oa`7;+ZAoMG`QrR^p2u;`=SBk$BF_XBm?NwQl2HbN-X!RyN=&ZPTOcysZ?^FRD zi7YgKayMJ^ib*91b*HlCK`kg^)MR^~uu;I@x*&QUt zn6h59+xf$-S`S(M+fF3#;m>?v{E7cb!|Ljj$W zoofetk~n(5>J#H}uagA@F_;a#M@kYuAKq6LbxH&TaT7adopY*tlRh00Ji; zK<5}Y>EpBsR47eF{g^zuK~P)MBo9P?Ox}>&?>1VpZ>WlVB1v(}7%)p3oap86YoS{d zai53aotgaw5|-uKi8bTQ+-{S&%lPt{c1w`oZB9nm+1VNP{FC*uWr>GLoX95;zU|aall%swp#8P2hugDoaZ$7LP`s1#n3}imyxW9+SuVFBq z2?`DnDH|)e>r${Lwsz*!^MC4^8CT^ikWjHLirw~NxP;Jzlua9<^I79!?n(1he+mP}Do8CxF>%0{Rz7 zZNbcHdK0eI5)O^I46wYccf*tjbkpzzS11C+YPKIZ@dE`Xs2KtC^#1#>;sf6UOJ#jz**SF9W*r zAh-9G$oi3QWVljkCxHW`6Bzg86Ee@Z5CGqAzy)67SEgy^T)v{`OkK;L87+11U7H^* zl|oi530AGItM~_Wp9BP%4ne1qNnHrx5+Uwl01q_!Rp0dl$%b=`FWUaZ#7mOj)YzE0 zDPRAEwzD)s1b3$k{9)la>gIsbltRcT?avH*W(gbkqjy)jm3|uh;4AI1@YyJZJjf~N zLci*zbWKE|=0T6fbhz)El$Xy3CVtee%BwPC1SYr5Ou>MuKnL;q2HT`pRg@O#fGPup z&u|wVK0oFChq$i;e3VrZ#x9z$Mk9t#Zs(($HM(e|}a~_(X(AyVR&Q`zd-4htY$0zO+b<4lBx%2aJ3INWX=b+RAgd=0U1a4m+IB6u~Py?^$$^E*@&+-r+sj$dyyvD}-P{>Cr zUtAGi1ca=d!K#wi++e&r16f)?vl_1}`w^0-64zu{f$Kj{^=NMVT`m>NGxr^|Ffe}K zRt~6|f2&@!3ERJR?PIfcHGMFOsv2C6ut<|$LvtsuGN2T0gh@^MMJG>2-0LLjh_HVB zQsuliGc%>!b_-?S`;5coRyh_gWVx+$mwDqVh&3TuPjATs-0?HKD#YSy!5>eE-|IM|aPC^iy?WbRgF}=*B-IY&;D6+E@`E z4`odYKZ*a=C3=6S+wLHbR|60HxDeN7wU06#I~yVD8+g?Px^m8}W-uxOGz9JqCG+VH zxHdTY3Zgv5`dvZk#O02aDfj7XF0JQ!bGM}bed+x>kx6FEF<+nUa~g?B+=37##hNUi zBkm!*<7;&xdyQi|mzMc!$I{z({7~nOFZT`V{B|xhEF1~Yp?ib!clG?X*8fenu2lwf zx!>k_NPR^3S7H~R*?RXQcIcYHe`~_s?E>cEyeCa}8!tQx7;68GyQ5dHKD2B~@YnjU zuGQl^1t+PB(vxGZ-g;boe}{U1oIuyC$ujTH!FQGb+Hh0-md<|+It^blZC(UFOe1z0 zJi*M&y@*6U&6oQ@q-$Wli6GIRKL9jc!|lk>ee!hs}O0OZTiesy$pQ0x7L#U zcskTyTl<%Qz&3Upn*Ans@;#ynkDa;mw2_W0UAo0vvrXi;P{B*oEBV0D#m5j_pZInEz`Go^3nr!YbZ#mm=3~Y<0-VIbT&_8{brGkc-I~%T*6HR zySsz__eN~LE9q+Y8yUskgcZX+Px=>lU&xhm0X@CDNiYK{N9GTJ+!d_~$x6^salM{=8LSYdlJi~8Kzvbt^ zlVd`#w___d`=IpkqvDTd8I__qNa=f??A@&`-zl_$*9#SJ__gH+QlBPFqR&I=%dMP? zcTViwn?;nlr}KUBjRg3}>Qq8^6-37OQ?cR|>8 zuYFsHx#4v{Bb+uDGjGuvLcHZ>gc6d;q*@{Rb~mN|OMz#Qxgp44?*S}jBqDPAbM^f0 znBPfSgUjz8Pps&{A(NEPrvQ$t_MDU`vCJ9c8_;|{A-_JHs)5{ zyT|{%!T+72T}k*qlOhjWB6DYUlE-G-=bCEG-H%Fv7hLWhf64q$qV+Dl`sk;>%xmE> zxxVkK=YVq5y9RmNZr_#(@ZSCGlj>m3|356okN4F_-!0OX`k+mR@@qaA3`y7|qt9b0 zA3f$ri|gi%K@&e#7m*R0HXb!$i? z$RG8H66I=Avp#wZ;%h8y*jr<$vicZ&EyDw`i!o@%6kdsM_%gx#@g1>%hDxbNGt1px z{qADo^L*x!nUPDMY!ijfIOE717D;*Lm1TPWbsJ6ZuP1U$Xt%6&(Z(IJ^SycPlPhIb z^S!yb20bZGU0vthTf_sSH1XGDjjGq$$_cC?dPYWlqgIz;F~ik=7N_6EKy9=W;}EeT z_G#eVxjV-@w`Y)o{hJ~daWB_wjH*UH94KDfqNtm_|1c-%yY1xLP-jCx)*#$5j=p*3 z4g*C~<*g}@%<)B8m1>DKYuzE6=Vp6XS^&oS-P<;tV{@u&nXKKANtb|i$1?@ddv`ns z*VpU)DVD?*Bj+lyMD#$nv!`;E+f8eosqJ=yf`Z+ljo<*%;8l)o+s^BO12 z>etKoQehL$PJQ=VQl{tS0tBFxrM3aex1)=a^iZLRqKD)F?Ln`VZ@%#=mTpIlcC9)K zi}t0N{l~j|daURgko{`izh}FFq@#?Yz*;#>b>bQ+tLoU{N%~j6P=i<@nc3VR} zeSzVWV|nsXGeHC7}?ZExn^c(JG{@z!l!4*O{?Rp6|(8u zw-dt=MAXfA8K;NElsNF+P{e64^sl{n`GI0y=d!oBt=Bbv*mPS;47jG<(_j+sQ9@gD zE|zA3aUK~Im{4JC$7sbrMu3?uG@&{A`B|pxa`st!?)q4kFYSjZUu4ZN%CQ4of~B|M z#p&9u9}c*irsh{Sy-*6=nL+`}T=VLyM+rFlw>8#U|Lwy}lW|{nWx1IxUcvK5$cCKV za4x5NFL~DEHi3PyZ^z^*EyqnY!{qF8=pjUUu z>jZ2HP;}wyk-?sb$$>Up$wRBAX0{eLqobljcZqrA;v%=YqW{NLm&}W4Ny_dQY>XZpDttHe$CQkAS&#laa~EPBp(?W8BW=`(Dk{IjA~67 zYFEw0IR?2D9Usw`vRGr55J31$s^@gIMUU@YJRwaL!NIcVv^aOy#Mmu9~qW}82nnVD(V5Da&o2S{=1d5ffF^Hyu3il}hR2s&CgMgUgf zHUIYM^Mf{xLK#-;@dT@7SjJKhfqnOx{!)DgSy;2i#U+6uMB5|g>?}Js8$U`>Lf2M{ z`2vf(Wr1`zqj~+VHK4cVrlZQ$=Ld5U6`RT>21A2`Co%`}Jq6ATZMUXtad|srVv<>x zPHQ$ar3H)j$Apcnots#b4RoTBj)~UVk-A zJV2J|frds`yvrAo8E=f%RkBPr_7s0%R%E0O;xwUuUJ@ul+MpNYvu1sgZ=k({gOqLK zk7GQtMcrPkDxZ~s$lfwHH+R<<`KYq8Mae;UXlG);0K^WJb{-SiwWnS@l9}Gk%XbtW z{P^jy;PaPSxI*RnIjIco*5Ij4xzg{3g-abW?y(HQ8`X--Fy`b>x2|c5;Im0qS7^Xow;7}#hs)vN^OJ-MH;OzOOlJz`S+(2hB~!*ndxHnIQ*=Cj=hy9FnA zt23Auy8mK8IF=T3&%I}gd1}b|pile0Ope!P>nj(`2KP-aqYGf^1Dl$qP+F*a7cq{WtJi|64TF3h8#k2zFN-1tiqd8Ah+Ml~p``HnhY<%p$u zr57%ftyPEVUzAq~)i$vAOl;weCB59H2IXIsIj@PoM49@aIr=u7ht+n)lsWmu8M;?T z0{a^a#S_D3Do6OzsNr9H>oSU7JVMlW3Or8am{CGrN z3faDHI17cP>sDN1LIl%X26MP;X!@<*e6t~Y%iSTfl*nA?Bf$WSi2~+TbR@95-@SA8 zUAs8KADFWY#*?RM6yw6}n$|dfn?#Y<-CQXH>mDioA-7NI} z{mdEw#gm&RTw30JRn<2$GrLorJN5Z#esfwZR*69GIp3AtH?Mu z_9OzHZ_EDfi1)UbYEVS85`K*?iwLtx_I@x*Z{M_T6uRWrcwt0OUmxrkJCbAt@CcOl zRgQ`1@~-^3-bF-S$~eK7{JypY*Sn|41sRebS*c08W<&-cXR)5Z#vi8?7M6_`mdinW z&^|!G=B@YdfhvtM{P`s6pxopjZX}6U1yQQOI4`UG_JF%hlrz+SvOigjuNP>D0?-uA zO&KP@vDnA}OVfp*&%)Q%@VYo9I%$r3l<*JsMV7si$1VJ&i1x~S`t3khYU@3ZCZ(%$ zfJ>oW=k>pD=MYtu3=m3-HlAWqLwb(4UKq&N?#mm|2am_kj6pd4R|~fLkVs)XxygxX zw>xQhev@O{;|+MbGOOObwKYWn^P04WR->Gey}lev(&te8f;E)XW1Nar9!TKbnHEHB zq$Z)7*1S0_1>g2MvDo8Rd}E~Y(H(2;MB}W*f9}{!rs}ILtdoIGH7>uk)QqSG`X`bE zt@NPw{viJpBQIl-vP5d1Ll^i*r6YLFORf7N!MS}X-`{1HKJ>7u+>*kFp2x1zwtMFB zC=gtc8}xwDcy)p=nn9;T2rCi>-Q#6;NBty*f2I~<(=>8aGm<@(l|jV=z&XJJwiAfuShSSer0hBm81JXHaP=&+wq-b2i@X-PUNPFj9T0v;Zn zLYE{Tc5|jVFmZTgJNw#FHZ;zgPNQmwhx7t1EvF+L*j8#DiOJ6W-Ic0B7ry!?3B=d%*qgW#w5?t zmzBp~@i#H?Nz~X!jlPZqsnn9PhF)lbcmRDcNoFCM+~94Ff{N7$i`Q&D%np@7)K7|7 z$mbgj-Lb?qy?(Ac>Qo8F9ICLk-T5__lq5Rs$pKHOJ%PL#iQ$p-@S)FbxCo5|wzmq2 z(z&Rt=i-jyw|ax0YrqQ8VpTIvX|c-*N?KsE50^?e9%@wZn67Q5Jksvm?- zQ9Uus^u7~^tbtY5*i=DKzB8fpEJ<|i#r5Y>j+1d9GLW{TGR3=5Vk$NXvGsMz*Sf1q zo6EuGRT#HEg?s4jXaL1}reJknrPU^*u*q@{z$_g3J)qylv48n*_SWP(6|~GtY8eiF zOKF3>Al3kPb6+pqudxY5P*NQSR`1P{dKMb+4AVS6LZUm4kG<3wEo>yxVtzSxaSi~1 zS7C}DJ+7pK&vUv@lhYiS-fzjIs=$L9^Z48A=kCVD^tS1>SjfTS?Lc-kO9O1DR&(He zMa8$1x3VJLem+f3sG`xS-mS}bfK9@J7)F`8Ff9DWLa;Pdd~N1@gmK3DU>)VoG!zN7v6C$BlKJ{ zegDB2XDA392LQkhuD1=R@7K9fUDe<2G2eShoo57VFvRpig1m?vlL?t_U?70GdSkum z{{5|b?1}zd%9^UL*P2ut?_Lb|t{T8AT^~AGG+uIy#Ef_|Nvf|-;7+NNvTZ|wRnD_5 z&tPjIwuCl$GXuL1u2}=)FW1~g8t9iDjPmUbp-h)C(ADx1gPM#wm66ePJz1D*qA{@H zQGcUE+K?_t73CzU?C8E zj~@++O&qWt`RK6vePTXQWl!P%Ld^acJy2kC9)!zG3#3uD)P}u)@Bijo>^6J89^->m zD)QRM5)mnH)klQ_U|aX~4L>J{+g=^xN!bZwpq`yPz9V(9=|)5dTSU)U(9vF7HP#k; zw`v50hGflK_8VF1MP6-L+ha=y%GWUXM<7VZ`IN69l`)CHR|)ZLHD+`bPV8grL-FrX zHL?GJ32YIrKpZ50MMwrzCQ$*HC`sGpAKksZhRPOcC-VP%S1eUpS~}aY_M>wV4FE}T zLrz~c;r;Aj?r^71B@@5Qj(!|=@bOxkcJy`DENc9Z6;(nTz?0T6Ri0GYRbF*@E9Pq27{r2NDZkPYIE-bfO|4PY9! zjZG?kyJVei>$oPBC<){!ZHSf6SB=8!>k(PcCH=_uX2Z8<3SDdc6{Xr-*{7@hHen6> zSsmZj`0@G~&tgDWTXM5r3$!UzreZK;lP;Q)msS}{YQM_*&(+Rizh8R#x^<@3!sS=I zG$d{zFK)xi_am6i$%Q#Bzy=85oV!whUv8X{!nH``D~t8fhoDx?FNQRo%0MJP>^twm zL_opt&EP{Z)&P2?4KbF7;O(qf+7yuctn%XY>7~l)>FuN9&ONSvyo18YN=A^0?vzxs zh?D`WWdliUFL-;G71t3|J-Wj<9BA<>Ed>Z~ax7YSurkzo7V$Us-dYDki%AB!NV$-@ zbAX1hh3Oj^#R4kB8n6k`G5yv8VU;pW&TQ0b*JOf3%jxw8phI~_QHOYWjxkLq6Kw*f za5Pylv zqhCQwV3JumJYeVGle5hBZod z&!93VEKyb^SzFh|F}j9HZL+mw+w^{95;0(~rKXH*BUl9m2HNjx1hjYC#FT5OVC=O>oYKcSs*`qCMrg}w+9n3vnwPAf0;q^$iVta^ z&7|1xZYGAQWJE|nqKt(qlk zHLEpFGOy>EnJ}MmLPm=J2a^D0dVfWZv7uO~$K)OL3`9hXImw>fi6{p0WW7=7R<|lr zNsLGvB+VioomlA3G@002{}MqUOd6U?7Pmctu`PEP%^61GP(YGky($#~04Ah;aPs5v z^0BwK_ZpZc6UrU|?KX3Q74%BGkV=@MoxQH&-P>)AJGY(@EnHOc=+^Y7kJJpaGTt_> z{K86()^^LwHWLGH0jn?_5kkB=LXV?2STSfyvcAf{rlJ?*D4xV%tXkYboUJ#z#G&=O5)!SimXyMbR-^q)Yk@dSspHEP0P$?$m)L&D zyUA6s%D!x|H_sv(aPiSgo55IWhW{2GI&_tYXP!M6vF#qXw#K)I1Wm6A65u|rA;2kk zkCGCTB*)jeeeO>ccRP+$$N+9$nCBfA01KVK53@xS3%lEBw=vnKS90*))I~m^O@Mne z&vMEi4Oq>J)N$(k4{rQJic-+$>oXPHnxy#NgZjQ!Ee+WFTti&uB^?|FVsw3FJr9`>CrNi4gq`FQx;3OY`E(O8|^K;ERna@NX^KolD;s020y$;H0aA)_dGT zt{#p#DPXegIx)aZNqcY8`}UA=l5!PnVxgT$kN3*B_I?yP!+_7mR~|gUl~kk7-IMw& zrX`&PgrO%OyMdw+g?lh-U(Ma7%Q;Hs+#nznRjgG{0Z4-z`mw zDQ~qBJ#6x5jZngd&2f;rrc?)070KOiXTdwKDjkbgGLOeYX4^Xh!huUaShkuY3do7p zH}9uZ#hEGvJOf5Ul$nI_#?tX@hpc3w#X?Z>XrBgJ5RfgA^afT(jn|bjR^JxMQxVw6 zT=A;g9bSNzjfHRH$~4QM0So1`z122pQ@{My9l-NhSk=A~EV{oG+PN46(1Q_gpW?n( zemqLNZ;IP1uR*s-v6?Q$;MY~Nomd!%63t3teg);CgEso>Dj(qOzHRd$)UIst+5wzw zB13ltl)LW*P1b zCjzY;wi<#$x9>wJbZ?t)JXqlo1}A}jm%`J9Ew^H(Siw=E1WXMVGbsWzwPxtuU)3NO zdjzPSvL+F}AYLz@c6n-P+K>ylvcM>r$^`$yhCL9SbJFs$_ekx4W&I*Hl)IC*6XzSW zZ<|WvQgU5xVmnmEP0B7p`S;K+fm0OrSz^8gBc^J4!^OGla(>Ln zW`qC2ekA@O&D^i9(pw2}c6SZ_D!ulR6%jTdC13**O=&|BKqV`sfeDp_&FEdPQnUuyTh2&jocYDw6zmqvaDKP5a%Sz3XXorrq z3SCkw0~6K<5Hy?UW)wfuHoQ8nTcUTYl2Rk zkNy}A!6;1KUHDJ50tG0xH9_`*r>Cc=Bz*%;4{TT7e8xVTUC5+|f*>mc!Oyg!xnxL5 z{!5y8ZM>l=`vM>`E*QbrRt%G`{~BU0bBM;}4^5V`?hoWWxYRHS?ojgZrjjgR_jZ^d zDsaftqnAU#JoWaKnnn1pO2M0jwQ!!U6hxXE z`9S-nO>)=yvPJ=)A_cO`YzI`CmuKEa*s#=+X`(vVMDV4vvtjW*1Y$ApthvT_=M{+0 zLRM+;(fF1;e-}kB<#*k(d%5x}D)oE3WqM4%h#=a}^k%ViTY{`a2z#qt;R=E8Y0Ti24>fQ3_0naQac=GYRLNdREdJU~Kb)(IrD-vk-u+16#58ZwFLWTG$J zIk`w4PA7>=&h!Wxo#iswfz5_ZbndrIzC>Tc3Bw!28xyIUEAmKsFRW(Ie&6>Xh@g*V zm_pnZkAwJ3J(B86jV88jB3*5CAR8s@*}0(!2?@t|AXo8Q9gOJ@Wfr58fy}HL&VOa7 ze5u|gRUKy`YJ6c=b-tv0WM&E5esS!xBtWexfvN5ZW?LKtd`|nPLc*i%#j+@SEXbyp zQ}Yx!A8)_Ty*H5O2_6hMT-1IgC+L~ZBZ4PjI8{U%!*##Z&#g1=AaSEN^xiHO^g!+4 z^s9r@Ics7fDrUDevjGuiLNJemZ*M83*+iLEJ+|N_uhoiHKv}_3kCIb1r?!uZH8ZOL zyoV|CJCVN=+x9@J>qo40C47gxPErBH8y0_#74s2w|=xVtCRK2dv*I~{y49NJY zgKHq0F2~ZGKq8{6`R*^_%I}3IrcHIrY+DT^a13Ujcw*?f#*x>e-N#VR#6YPdC1BR< zgK#PsdPi%#ghH2KM9Xv5VRR)iff`HD0o>EUYCl`RC3@(b4=`j#ogv*R{JM}8V`dr# zq&}U?oEnlm1D&_$B06&sxLB3V@epzAsbHYjzW11^eYTb*Tr->O`JfQJkC$3QQBJ^i@Hz<3nD%Rh`~{DKQksnwrH*qxlo1=Wb)?} zz@G^-=gNS!{!vdXQos|a~W}_$N!s>mlG|h_2tCdp!#vqZ9j$q3ke$KebE||64xwTCG zE|kA`)04B*j0f6q-A+5f0E`u_C&vb*wAxAa@J3Axm43kV#N=a96J=c7(UY|>fmC^; zd3fIfKU7;XD{S5uF}Or?@(=Ju2#2ZqMklpcLHW2gO=4qcDL;hjTn9~QMv)Wx3&jB& z#h=Q#ut@Jz`M_**2e`v|r1Mr@|F^w@OVV*qSYRU9wVoGgi4sP2W4@d#h2?3vxqfV5 zC^hA<&!C$#o9vFK%%dJ@p^P%*W#8q{P)X5~Ba7yZ7N1m6SXj}OCITuc za{OMdw<+pqWK};YZ66Eb4^UkSjwb;P>1W?3)`jt66^DZUN|-Bg3XoZE;fID^gPCQ+ zRyT#LS7(btyc|%AIf?1T&6jikXLKz2j%R@xSeG*K`@hgka0c`iAyqC9XVKEtT zr&9G%(Jbqz7z2x=@g=N1!0T9^6J7?+YXa|(C@Xdkgicny?6a^NeDG^ zczC8T5C%y~MI4}c;4#{@mn)~!51%f-UQO`1*3`9V{`>90{jd~A;zgDxDiQB?Qp{g! zGR2y3k=q3w6S4fhn$2zDcOoZ)orb!H;vSOosb=rgOxt_o%#UQfc4uelzwm2G`}ASX zT5j-<;2|b^E@P0Ms7-0V1$7u*&iOX5xFzzxP8{OX6DjWb@rA#SU*QqQ5KmEV+n0k8 zZQ84KQShQ{1dx_{=tp{}2EE^3%pt7>$rdQhNN&R-9KSE(W1x=9^6)-b`%o{D7FJL3 zKp9yP7{u*2PNmz*O{zl}gh&$)j9&PS%WQB3w$!efPe5|1Fftv%-VbR$=(*(4neu$W z*=~(eUerS@QoQNgK}$%~MSfBZzk0@Sj-FBx9A#Oyv4-`vgZHHM6R;zs;V&gi0jLzA z@GwdtQKt~?mAXR3KgKV_j(^_vK-mfX=64*QkJX*$L7;DuEwef;(``^KMKp5v@;&9t zQ=7W_5%?2AzlET073OnUJZBdm+XY{ncxy*l`79L{5y2Ujg49LX6w&4>f4>g&_6qEQ zXG7kO%Xr(8gs)@>)KND_`LQ@n;)*J)20iZQ|JTv*KB1;~n6rkf$l}ZfaZV9?DQxpD zZRKv{qgyEAbXwot>EcH=_L1907(9U}UhdS#k990rM5YeA4*3k#_ho71S@nF-4JKYY zv)U=MK|f1qyF9S5i7qmw7U$)9+>#U#XB^9dj=Jc$1$uY$Z2qbRK(&irYkv)+3)PjD zjUfyfH6t$xoUpGOLs#KkC04^m8rxRX&;LgEYv zjT!|NKX|!ZPP{r+*9vSSZ{?}Gns|^VqAB_L2(6?XLpiyVh`En5z`p34IotBMr}ARc z7C*GT#fFdxTg9j?%B3GBi-=21cL+Hb(6muvmA2l47_@NN2BYV!)X7|72{lU@n3{98 zYrj}MWeej9b}U|T!Y#&*zxwX!OKxl9g;^9Y5(fGCZmpzL|9HW*>6NViLJyKM9^jCJ z$Drj>52I<%9A5kFuYIhk|z`acp%cJ8Yfv}PG?G-{$ybVK9R z2|uLQc8zS6VZhampDrL;jAp&HI(?SF2)}V9432ci z(P)uX!Z)vFj5%aO1M$(58?dFUceq^GG*GZfCmM073ZmTfL@naD#wp(Zf8eXka%Gr$P z&&7lweKu)D4wTohcBf@(E5Gp7{+f}vjvXO*k}Nv55dpI(e@+tQFR$nTuL+mXZ_evZ zJ>04_(7Ty;0}|#5bDfwfvm4a<%|&x1_FHAeYXf4|S!DUBLLFbLMdZW!S0iCVmu~it z^A!;oF9Xe#F4V!bkQbC{R?06KrOF$QMvY+6z(b6Vppt|9y(T?}>bHg#e39HLl64Z5 z#phoDPv}~^rUdgVv%b^jVT^J~-F98BI7{_#Jlb0T59W+?ZfaMBDCr@U>32z0YY5w5 zhlvb*`ZibOc+$45sSPQQl0cWK#P(vv>O#-t+m9Ou36c$i-&hUcf++5cIfK9@V(dkh zUirzcAIWxuZR=?;cDb#9$0(zy{-rHPzFt`HPu$pLOn@hb^@ym()L4xD&`4Jb5(4I?muB%V^d4zL>h4N0truOGsL2Bn4w!dL0TtOtSG|~LhwmS^<->hmv zykV>DPEP7escqBz6I?1@e!-lLS2JgG92qs*x2CRVdlhB`*zL5IybI_SAKPd<7!h_$ zLiKwCO1W&YuwCee;&GQNI-{6H_kq$vA?JtEHTz1W>%!`wsc{|eyv!Jvync|@&q^6q zL8n~)u90n;@|{f|nh=UXeip1do^)r3;&#v@DaCMopR-*>fB&BIiH0Hr5QpfKeVwWf)+MJiHjcJ9_%SZ+6K@2YmN+a z$33(z;YnUosw9QqlDO1E`J-Vj`Jqs*oUC8f3S<%WaJoW@t7H~+F=;^IW`F5|mZPQq z@U?vCD}i)*XmKvXU2piEb2F8UgvCszxg&1{2VgunhPqQfTT9FG*9GExFg*Gqkwz}Q z!MK|04s3?}^qdTYcYf1cH8w*&5Rx=P#9_8_9FA7&|DME!)`zW-B`?(3%!RjSrXC*R zjd^I8ZO(&8{$EsmbwHDC_dktDNr*@qgfxhB8Tb$*1Oz5I7=m=eq(PJt5Rj5mB&0WD zG;G8sAt*U|gml9g&2Q-QzQ6Z-|FQjZZ})Xy=X1_=&gYzST?W0yKCi!`WeR{?E;<&+ z>znCw;7ogORT!dg16 zeG!p=*t7gjMv|ql?zOalLJQLGVKgtBa?*yYPV4PX8ihR{N!#XlnQu4)fi^8&DM$-n zSCN3rp>)#=4{&rV(N<;I2z`Ft>z{5>k-B+TR8D;@8oo%1ckYVg3pQLv$}}WS9yZt7 zojgNjGE3g9u9+4%ux!WI1ig;lvN<{Xz$`QPj_;_SS*9^@F7T{^`OAlN1=g?N=$B$_ zaqgzk8Nu`mzfV3ti9YiLTgaCm=gF2Y+4dg-Ht8Q6;<*ce$KaRnV3CvjH1mSlHTCF> zDwTd%a{}l#G(W9#+jA_)wys2< zjJEY-*4CRpSk2N8uyf$pJ#vor+{kV^Rl@w*eO_1yQJGH4607yoi^jvhuj%h`jq-O^26|TQA&$l-;Eu0sqsKxP?M}A5IpH;}%AL4#(xP5WQvkf;Xabd! znHoH^QC)VvwmVQ0pc)MZ8*f>fS~!|MZpZHG8uXSALFyZx=m&5G-V1*C!IIxvP~7XFi39-`;ChdPfpFCD_Pg130o$x2Yl{|cg3UDl zaM3@Vg*I1Oy5J7ZEdK~7zbB|6Sk9w)C;IWX5{jZ~plzB2wWRHPiJ)FzCveYuWZKOW zpe^b?>VBo_OX{dQ1K~V?vFLkdf>OgrXFKxTOI2BG0m@7AYu-v*wG;I#0e!n;Gdc5q z&B_(2&RZE~gN;_9%~0p@n+h!)o=^wb7%KX17eu154w7AqBh-*Wklzlxb|#AS9t$r+ zcR~8cLLntaA2Z~D*Ul`%DPN|7G)(A^3$~ujn`+@YCMbICeG>(~HL;ZZbpPaf`~^s! zt?v-K;qUvwLm*|}hZxDj4FxY!34ix*+^Wf83O1wl6rbja@>@5{P(74ur8yK?>DF^U zWFpUeouz%KtZltC5D#@;_=fDO5|vmA79H{U9%!y^9Xe;m6o^1Q#P8z2&aP?5ggG~I zG~3r@^@~E#F*mNv=xV<`sSQynl8iE!X^H10uFr~eE)nl+7`gt06MpWJY8_=)!4RZv z9+qWLIH$CUwr-ti8&9ZdPc%KJ@dTZyr#cQYL zwZtdReom`6*|x-S?hbh8eE7w*IrZct`4?RhEbR05YdnP(ff>4^#45Mb7D(ZfNl=^{ zH-HJ+5!{NyY~8~9-VcvrER9Si{T=j<^FL&WTsX1I8@(js46iY=OI%% zu9ZfHbhzbt&w1k8;|#LNC+%y%9KW2C)8z=>?{8yEF6C;1Ir3*Wn!RV5-$;wWtvbx? zQ-yOkD$F{!OHrPfv*rg*%dab1u785lrYC(pX!Caw>nLV=S)yA1p;zNg`eD-e5(w-J ze;s;uuFUN^(5+h9{LxYZJs#k9t)yW1=T0@1Dg6cP0UbO3Y#NbqOX?aszH%kJ=8dP~ zBZz-*Wxgsm!+$S#fOTk=f8d}P!vDs$zVNeSjqoikqJFE;Dm7?FIDaIU)?{Q&jN1eq zMsaRwEi~eLwm9snqrif6%qx)x%Iy<^McVIwI~LO+*;TXOPl<4Pw+53fO+?J3;5X*uluHSjX!r;fu>rzLt%K zvt^5Wf+^qUFzk-TECpp-Ri4mjHSJ+0jB>ok`@;$qkOqepRnw)k#>;^(70~ywCb`Ct z@sOf*7k-CCzqNLqW+ z_q*DSQKB!*MU43osqEdqK+%#kup7)8EPZxeVukpm3A+Gszp{ z%0#TIe&-}_*$1f~|JAr3z137-<1?g#df(g6qg$jmzWr*i41TP7d42j~2ow_ZRk&3c zn>_EA%Zmu%y)zkKBPT~9RB|zc60^0T4ynD{m>%dw8x>rF!*h~_?5?|TKmz)&*YFtV>T5uv%t`r zhaT0RcMPMIx0O`r0BKzZ%^Iawk)c599HH+P$=^OWT!D{$(y4}Z|FG;V<^Ea1&@+6) z^_Gw5P8gmVX7k>O5%YSjg-_`%9ug(rnrhx(W!UHL&jffZ5*~|baVP99i*4YX23s+t zbYQt;PHfWRTYnqhPD<@YKF>Au_f(9it<>lKT(YIq*+dW!QPcJQ>|5uucx9wdxj&PZ zi61+FMTLRgxc`gzQqsjK5kNRvzQP+gV8pC&xG2R9Nr7CiG-6W3Q!*&xW28nF2bmB; z64Mu=@H39|@dG}XXJ1yG7g4%UdMIc7CQr^wOpxeMn#DEE{Y$;E|EEBg)t|1bBMb3N zeN!iyJs)hqswPd*yRUuX+OqbXs7|L-u)-(#ra+khzF+OtoOMbxvU_~iHuI^W5%%+tE~ zkZz)i?KyNHrXM_ps9YTrUjjeNl=p8N#Sy#Csc{%0He2lXB2$T8=vISm0AXc=EKIGx zhi*WU9{Mvj=jw^hzu=(WHu`5p_i(3P$!Lny>7-fRZE}*f#A~sZd)g*+?7_y`l-V5T zUG8~pO}diO=e>^Rx1H(MXERvvgfotLYNodNeqT>IIVFN{L4+24nmOkPSC_wZ?n;8xoKNoHH+d!FpnEyIQQ=c z_FNlf^U&3F=pLrMYHuq1VeXKPHKxWO^6NNWDT@m3YC=0n{=S`EOYshlh?+%-M62Co zLbXiLKyd9_rfP$%W|k`9ST)CmayPR#hF zSnBcBw7km+0QKyU6RmBB4=%|C<$UEel3F6d-fN0tOLvNuNq4@!Qz8z91M0O z97%Rz-RDXvFH-`rA9Coc~Ke4E|cXmJkk~!(4b#`7jyU0Hj;=!;R^dbNn)6$dNf@o1G+2ey8=~ zZX+KWeLROJg&$n257cb~Z^;sn4d!3Te z*ehRA}2$a$S^NYTQq*gZ7Qn0?MaXcnd*nIanO~Vt$c1XKz9yaWB zI!=lvHGqCzE(2bJ4VWN1e`Re;>*@!1?!dPvm;jsZ)t#mZuNYQ3J|5+j&<%q$s$Ckf zWs$pg-s)Ni=EoumIFxH2oS5>&1OW{{X_;74NyvbQyIP2*5sdB?QCt| zH~^sc)>P9z5l>+3l&1~z+WYBGXH6wo!h@=#FW*eh03EN)OBK)e=H-*8e_l;9pVm#@ z2fZ<@>go);lBW0VZ6Y6-iG_8N8!&mK@JmqkeW!_3R_v63nD&f(X{By3Rvb%QS!-QP ztkaRmsQY=v=f@$l;X|PtfF1bYYP^h9E_;e9F@f$}_S*<-8V){A;+iRj9;n5=1!jm- z4M=_2xnI)&044kJf#+A_BU2VOp8qyrG#VVeS^~P!>43GGsr~{34;N#44Vg@TX~7Ovs1A;-(YqO9==QoGw0{Ux)*2K_j?8}zq}J+8Y?cR8S*K#1 z(-%g$0M9urxATQ!r7&{OH5N%56LWTc&)pI8e_}LS_6#obvmF=UJ0LR2f_3h!Vl0WC zTYlOw^+Ex}J?iKQ>-bzy8a+8T=RRR~eb2;Lpv`287wV1=!bVakH!=eDTR2ME;Oy|g zc)nI=8|vABT~Cg6je}Ls~cAxoaX^2(pjt70#u z^(mh}PN{Mbbzx|p#Ite)Hi$_PS&<7M%R0f^t&}cl)ZHZ~k zhalvR)AcezbINrN3x;_~Lo%V`fK`7B(a{Ff_`_{h3?F)HoN=qJG88mXU=DL138XDY zkMm5&7^a!%G+Z|?Z$ssYSO>WRa$^DKWL+CjhX*qqX~xW#URaSMUCxde7#{7(Z55?J zYS52Iqm&De4SYu(K~c)wOHi|ykFNj4R-{@yo7AiJ3lqgT-Cg@CXUbz8*-w3EC#ep< z{wRs6A2IrZ`u6;HEiv$t2`pip`aZu;S)7%Pd5^hg)&S)@8M8bK1 z+6(40dmharoi!|n2pMNQM{5bo7NnZPbV(Zr-54ZlFIo7X* zbNawnZ|ItcMrW|odN}8kBcooJbdFE&tYlz7ze)#V*8LaphAW=zj~fCBTC^9U9uGE!ZS26+;JyNP(IT5;>c zEsK>Pf#&NFCI$y6&!@W3Z(Nw2UD*_7pR3ci92~JkSQ>2kkUu{1aH=G<$Ll+`KH|_@ckh?J;R|WGmMPE=g&6}nK-JG8IZ~zqIs2%k*E8v0tlquu> zDUI@dKCf-jWg|7ca1~0{JR-43o^t3;zs>9>;wF#mDAYe;cFlckS!$wbHxOluL&V(9 z38XKvT_k94aXMIe#o>nb3(=+TbdV(TMJ6YAqf$rnFJ=4~u40)L&8K>X43mAXR!gN{ znS7>v8W|lcZq|GPQs&0aNzJ?e>*M?pfEp5We z(!kEjol4o`*G`r2^2pU^K^4Thb=e9Vjm0}~;eb21>FB?48^LP|JWZS7V>}gF5#pVA zxkbF6<@O-Mb&6T&M$Dh7B`>;z;aV}AtLevlTJB=k=V~Fb*w$7ETaLO83LUUQ{2U7I z@7O$;nZ#wY6xkaSq4TCEWKfDNJgk!+3yP%N*u<$=NjbvF-M$`kK)w?7B**xbP9;uQ zkxjV0^c45$lU*FF+nXI8@|C4v605i7$0Dw5nbIHn57P4o5e0J#AFY9eVRKr-3{L!5%yH$`X!VQd78ylmv~7u$x#~oBxA_G6@786-c$W#i ztA{WdzGG0ina9E&GiiaEaIrW29J6+GUl_)#(}S3b^eMX_DPddjVMV4mr9ZbqKl(_P z>>IeZO7k9N68v;n8kc^K@m{?n{9?M*Ir`GMCz>*gR#yi*XQi z%Z)4WxsrQ-z51eOc&)Ub4&#Tvf&p#$=c7K#UaU%L?|Bj;{rCPrHd1}skb%aihVOW$ zkO=_+IVs=Jyeb-1^TypcT9);Y>(?8L8bo}+V0Jrumcr`M}&R=RenG^ObP7E#v5rOr7M?GT~Chdbroo!flkQ#i%GBWidHKUP~d{Y@>717Qm> zHE~Y=#A0{@dxK#nqXVUhhbzW`o_^8tK?lA0XNP=xhhEsq(XFHZjd{{!tM6JU{|=w# zBXCOpKQ`k`l;(1}a}uC?p(kcyk0UWa|C7vfvNjkZ|>ZcUwa9 z_-?D(tsN%({)m+G*x9!oFHYHegKB@hjA&;iF@}qkesJ!l!zc;SU6bX*Cy)?Kw_~V?r{f{ zfjX_hlp_187S9PV5h2+6p3s8x>D43jlws=K?@}dH1t55U6eI(06?H9D$rk{0F@ZQKidf&G0CuAk+a?u2IR}UjdA?WW+ z(Bk`JjLRn0QJ&|Xhbm+fS%Fjm>jc__b5O5*%p97MK^f7Mvf}rE`!Llt=te>_r*Z1g zNiJUpPblS1cF7dfs7B8u7)KoRR_4f!4XG+{Z<9dd@e?z>l2yx>+6Evv@xXVS0KE#` zm>nbIacbEPT!8fN^r4$Ee;p;k-XZs|YCMS|*>_f&Nne(5HVoLcsmiZ6I2OcT`hby< zAG>29q-tj&D&SxUJ+*o*f`luWa+7R4qNz~mm#LV#eJQC+$L6x{P<}d7<$r1ch-65E zd79onAyE)Lf6Kd8XRZrj?7IW1yp$dfYc;T+{>$NYVzxEj zOXYBrVln&ioazg$8--hz>oF;F>)bn2%+WB{A$v;R&)W-!xo#h>ATEh|0U5ROCF_ z&tKY>mo6EZsr#cudBSo?Z(sKJ4MSk*14?|q!Juzvaq0eUAo{_2-z$FD=c`k_CR8k> ztw=(+2UjaBt}BoyAeY6nzDXLb?K6p%sN+FL^ozbA9}jGXq8>lTF-Dj^Rm9g0c{C zOK*=E0(a+rei+RzI5Y%nzT`8STDHT;iIY!=(p#a`3f6R$QoOdEQntiUHRY@$)*6-O zJACyV34V3`MQoreJ{Ctmkj4CgjJx9wrG6u_gc4mT#BU&=Tn=bGfg0L>gaNMW=)4H$ zh1HjoNKibV>E7=cD=5~jgEILOj*hk8H zF8uSjO2|GIMKd$|mi~yCAyq*HJuwUC^-&^eirZ)mgudM8zdaSlJJ6}bpXm^P9=_0t zQT_(`cV)UZh>EYLNwhmE^yT&-nxdyUxe&NyNf;6;cdoPCn@IP%^A7xMHK9vW_{tu) z@Zs-MWD66^hn-sTq}@Fl7Oz$=S0QF7krn)+sF_nX$?HBajTbl^>bOIp>j*{Y0o`Vt zS$t}JJsrSIqm4jpuetUY3fWt@Eh9wAN8em}pm$P^75b|}-FUW_c;oFP7$Oiz)X1ET zG=ZI7r~W8Up!(SNO3;GIr=h2O^`#Vjq|)sZLA(?9f3)@C*{ypWZyRMTvkAW*DpMIF zJ&3pJBHu!#*$FhgCEvi~wJjemYD$8l*!Lb6^cTK16f=F3b=Nep+;*6{5pGk-=;-ak z|0|Iu+MVDS=tB1nJWH>}ntt?jxC)ND4=_>>3=HqsW!r(Nde0ryg9yO@ zyQBO_<&QDG4JyJ}f{k>ht;uq|u2d-x7IpX2$(xee@yVQ18@DbsaFHuZ{7d`L4tNu( znHE%1Y$)W%sjR8-H#4Fyz+y!Vow(D|%0)f78O!Th+Ch>P*HSc^cfW~<22G-v(^`K#=MysMdB6hUowNH?VhDU2s+Ih-foOuo)!ie7tiYQ1S@1;R zJK@f-QFP(CxsENkEOIW>(jbHZc<1G9!Ld>@ozA}5dsMu&B=fLhA$JjqxV8h`p}=__ zcaEp+R8+s`48k_z5b%w0;hex&Y?>^431}d*lqe`Ji+`+~!2s$w;@nZfqyOH~T%r_F z+`*xC=zhbNA%#XVVSQ-{)nSI&FvZrlrlOGtyF6jXRlJmi-;D!N3vDAyZUM&fp;x@-MR zg^QQ(UQQ>fK%2EnEaTjln(DZ&&~@y~-J13_OoKnL(fnVWPZ}9-C0M9e-tf0kFTjpiy%iWeozY8 zsO6BQa%}gylJXs!O$iJ+(Z=ctW2*$uiyxSZk$#1a+?qj7=;ZSo*%L~gqH5yCh!t2O ztPMFc9j^E7)DbxjOU3Nc(aA53TB>uOoT*pz>pKNxg~q~SLbS;(3cL}mP%O?0DD?R} zuf(nw)rV{hmC^61@{HRviRGm`rEP@2M=?Ie^8~(^Upfy23KeQL_>^|09NCOk`;`ux zNpD##l{W^CD$d1MXg^Z&39KUpSl|5T_*k5N0)+>S=9m%4f_W2Tnl&hkDLqKzfj6*3 zbScwgWL~cHzR@HO^Wpeh4Z5=`-UJ@e6t1^p8ENt6A~Yk4`sl=KD{<4TC!3_a6TW?B zy8o(lAhAjZV6Y}}^CWir`ZG>&)?^}(WC!wD!Oh0OqFEz86aX7B><6{ld2*)4$c;#& z_?%9&Ld}EK5?tbM!Z+C0j)r{Ylew1i3Z$r_^u-?1WZyS+6%jLZd`vJ&i{R7^5;h@GAhZ9YyZfGo<4ccL-Zt$^Y8B;BJl} zYgi;OYwFn|0h{sJOO2d9@j-=uoe_0WKTwyAPHw*?tenbS=s_~C;U6kq;E4e`_Pmww z2>*+H&V#Xt@!7kYF!jq-@;_JdXO$H~9+!~Yxzk1Y@h{$tY-u>hJC3E;+x2d}WJdeVe%~%iq=(5>;Nbq8vYjb@d zoSc5R?tQ&KSpD}Sbc)#5F}w(|%P0bPbnh?kN~0(rq-CBMOV5+^ys^-=c|LB4jvJYh zC7=8s2H`J{Aoz$Kd-8B;Xzwk;>(+}(UO^(>~UOe=hQA(evEvDSux<^VMqIuU1{YBb5un>1G`2 zcv3o=lmEQ)<+r-0@4fAQ3Jc3ClN6;weRIt5|Nam=tM?T?Rq8$b1Mv#KPuL=Cu*!ue z>-a$ASK|M9ezO=Gq>QVM4EQYXrGVhyp)-$x=i@k_0>9=Sf&B6NA`0HQ1h)k~4l|8P z(fa$=e?If>1Y}$JDY;3z(SH!0{_nG+U#%X(n4}Y$&??RCqK*0rxp9Q}0RRL3N6MGq z$qSs=t03l|)WEBXWRoPPseGRnNdGRO0-p#evrO`?p3^~JjIX6b#Bj28!v8#0Rq&Sw zws+|33%%U<%P9!u6|4K)xj(u%L(|1{R)H&Jf0Rmm^3fXhzrV?A`&X_ZTuOeueS^Rn)@F>0s^JXneN!-EZTih5{^r{~e8B+Fvp4I<3}87$>TqkE;;Q!+Elz z3{e+g(fHpxKm7}*g|2EUrg46ZxLmpx*K&5t3)~`MAR2|rftvq!*aKvLUzCTwK#R4? z_s&sky2dvj-HNet$xAz3>^Q0~^67se^nl^tOj(}&JnH@3u0V~M{kX=10(g0xk?F`s zoBID9PM-b0lYUn#NBkhu=KkxbV878vZ!kw6tnz_8|EpB?xqoLQF{7h;2h7Kx)b%Sn zDvGSe`|ffjK15$6_5Tb)Peiks=npV#kHhtp3SznUZeulei(E#tTbSbY5-%kk8TmZg z0#WjC0LPjcu~kOlvK_cZTeK1jQ`mmD<t&P8)P}8~pkDwt3Ui?(2LYP=Iw~1{LEW>il#Y|4+e;#QBV3ML~f^ zJL5xFtTmQ=U}8%M`vSZ5us=~GOMT}EjSo0g(b0MS$4cvz-n9qR*b;%to`ChOw2HFxPi z-|K}fx*1KA;Dp6*3h7KtlhYz?mFnj;#5!cQ1?Q%QcUZTknWNwT`UFe*Jc{s;m z(>=g*E;w-?nFR%1?$K|P2Y8&A&|jy35lIlSt2SQ}|8XIn)lYotpkM9f$w0bvkd|lD z+GEYF6*5JoxfV)MVO-dy9MzvV-cQ-?2pA%J>q_N%)6o9gp0D-hk z5odh%bUqZDL=?$>k2Le@D}u-Vg)6D;`~H{hm4EZTVk zwAzWwM1R#rwX%qWjyF*4h@{kT_IOk5V&sO6(t*YDo5D zP}NUtO!cF>RK>hI>sL7D{LbmNX{L*>o+OmJ8ECDAUPlNEUz4M7O%Ck?W~8F3M+BE} zHyAJ^jxq}eH`sJ`>zN#5=M_aodvi6SUR^BmZPr(_912W3E0ur%qfLkSc2b1WgBc#~ zgvzgf8K7%~ypmGE)7(y>^A%42EuEYlHG`_aRDEG13#VqV%(&O`v(?UPe-u8dSVjCN z?oac1>R_JTkAy z`;QUmC-BeZS|3hp2zj70d?EVhlQw02y#{W#TT}G1(4m;yKZXWVE%aBPUOq0Ndw_!V zcZM`2khYhMP$-B&Y#mGjcApbTHBVEVDhs(NZxmVyOyZl4glipIO9LA{@B?ym@VS9MBH{PaClg>SVQ6Oi@iFZ1d( zK_W$d4Fx}lQ}WN7$p}>=CU)2>pNsDSQpfzTo-@^E#cMYl&uWpy*LV%$*+U#g@9$HH zQdqb(FKH9(PS#qp9*s|+8slO4TFnSzdkEjM;@3}IbcFx%KqA%$F^k|1ErbTHSw!4` zzEq}IPMcRWAqfoKg9grK6YQWl5LZx+Q5Gm*!ANWiVIQs6c~+d%bg0m^wOk0djh<~G z$OuIaJLXL%U0LFz_i?9UWbtc_(7-Y29(>Ny@uVFvH|s0+G#FE~U!zUh<7@HW+mV)r z_Qkoe{vgFXrzhJu%Y$uYJn-7n%zitQ0%=4)bR=A39nzSwe!@fSl?cP?h!%{TLOK?>YwK`a z0rhO0+qE5PxLn*nHKS4SMH6I$XEkaQTh)?EYyUi(Y6;Xx`MdJxrB6@*PTl*J5xBrFVgs`A#1ssI(D)zGyNq0s9LiwQ(@6rx0Q-?y{R ziz1k+8)*+YS$IV9R2^EDNWhX!=off{5OR#4Vo1$IR{^=(wQcsM&_EfxsbC^P@OvE0 zHxB<{qbqEGdmZQ*w+7bVQ{b+e+NkjNxDKw(~zroXZwE8epoU0C>510hIVi zjIF=}exqwtAo_J#d3fknIkFE%3Z3`nb)(vr=`NYFq7*LL^18K7pSS_rdr6i8k&wjA z6h`_?zb&E&iq2X)f89`aKaB)5v(yZWncGgFM5y(`E9m`&dXLC zn|_%;cR%S;g?H_b!(mHKVJqh=6I(Tet=M&*|M5r2N=<9C(RRJFOLk_cponbs+>H=d z?h{VGJLvOmr}DE2rGREX+A7X>TX*yYjv1Uxag|$2Vub|?2b2e* z+a52gM4*>x6A$X>6pynBl2l^5LAOL|;D-fE;I(RPy1iA>(>J}u&ui~ORTID7Gkck# zx_Xj>B6&GBGE+tYkWJG%XgoFr8D8PK<(a6P#-!hKz<1C?UbW- z5X~}-f#GhYCk3327b;8M{9d!ZtA{y=^C+!BC+djat(}*jF(FYwr>F>bq5I{G>AoBq ze)fPVrK}sL&f@&A3{4u!ebVU~?HbBf+!H)t8z_TOCS;8M>&f_;(a|BYz z#2HIE7?;@OSs)yZs7{gR`fqqYam^t73-rc$ayFpgIh|a?Gh&6sY!s^g9lXnW{jkVa zoBh4vs{OL3Gt8!Ry4+q8YBNy7kP4gJ??81pMz&;es)5khU(ZqA27eO6c}JfJHF>06 zHXhZ%$2t)l-EnUYggWnBMzZPtxK>y9Y2H!Em*c%ZN9|gxL&n5JKqYWf{o&l9EU>Do zpovha(p!ax`!w5MwgR~e3bwXBNndUJ@|D@QUSR`H(NLhxAC#*rFxo2R{lXNY|Io0pWJVd}|~IxYx9BlQdw28S{UIIKl(!4z;e{xL24 zZer~$e12_r{0>$F8@SFo#cgVgmp3CSMZ#xMAGTh-sd=Qpi{9dX&zq7JUQ-$6z{5i3 z0|zUKQv*Jl(yvDvcI;A85d4|8uqu}d(C|wHqcC40U5Y5F3%b}vwuJ}Z{V-|>Z>cH% zN||24QW;$wLfc*(A5ykfhLJPe3!0cAH=w=G+KRc2_z;ght+0l zoqjgyDG>J#)%F_LlaGfvBC|0;?%bAWa1=8?Y0d-jc!O;VS7h9o9LBvVq4#9Y4GUg3 z8N|#eZi2alsYU~x6gAy?1JQWJQ1e08TM&!#j<_;onD^C2cnx~|nRiF=sp^@CJLAd@ zm^X#_P+ppkiz*=0u(`XlFfYFU028#4>DO|tE@Iz1h;z0}H4ExI7ysn2;z$Z{%}LR1 z2sDvLOMB7siG~Kzr;U!nNYk6*RabFL1gTrK0+c0Q_xC{}@}3?$qZ6n{HfL zS39nAuSSpfa!GTgNc{=6)vNZFeOm|NgxXbVSmWE4nim<;gm_c~J>4uBpntinRK1Ov zo-b@|_$cRT*>gNCnx&v`J|(|)qN>XCRTPqBOvoUSyN)b4@{8$9Mp+@NFo z8g?W5-;i~kgMgJQr);iRcV#ythx#@Wr!tso_{=BXVd!~0 z%?+nL&Mz=mhdkCUN6jm}LmvE)!(^RrI)6Zf)uhgr`Rwcs`7a%}6kervI7teywK_SM z3)_g02Zi<+sB_OZLp$Efo?J{01!o;N1mCVJGbKe68)IoP@$F%gC1!(M8cK+;t~cuZ z`~lG$CjsPCc>c7Xi7Ns2p@uv4%nE8*2eQBWsNmCaS*|_i6^3A9GyK5}FHRXR{hCHW z$MSYltRee)T6(zeMH~uMiYLeieEt6C?UP!5AG&(Hfl|uywy|(j&yUu4&O$5VV=WDz zASSbmKCd{^_3$jF%~cCE>nIJpTAHqOr($ZZ2V>VMc$V3zmsSBaZcW9%l%y=GM#>FB zI{*jlxC!PFhk^L2y~F#WQ1=Ou5RK8;QrA19dn9-XdL#F~tYt}d1s!%qX``|ZI2hyl z1!`m@1$sTSqCScJ&C}2n&pUhAuU9LLIj&C% zU~8b`4dKGLiuvYOI<;1eSQ$D|B%GU%+&l@a3jy`?G*iLXkExM1<*4#t*mGhPp1Lft zwxIT)>$~Y>2mVKQ2o)D9!cm(d-A!*(|CGn6SKY^b(>tjKjvfO7G?bki*AZMy@kW2v zxNwtZTanur9--v@$<*zUg-v$0W zq6$L})&TvpsES^C&~$_y98LB=ffB*Nw|#0)z&0&0L7YDPXYP=aH7`C$Vcl2$=@FSE zSc)Yf()}<^QFv6q9ZfF2moEvwn0z!&sZslU$Mp@5&*>TKmitfI37PZKrE?(Thl!1% zt>BdHh48_ zKPiWL?W*QO!a<4jn4Q)RoMI@s)k%H94@6j#CzVPZ0tI7-x&LSmrX;D66Z4+0M#X{g zkXB}eX|Y?1A)D*-?XW%c#*z(}?|u7a4ob)kz(22l$~4DKj=wbwgQ`}p!Q7PxB^IAo zE3j<=ae?=1?G#zJ>H~@0B-4eEA^?$5?Q(l54D6?1yIpiXr%&uIe>YdCVHFhsVi95Z ziv6v*9j6p9OcT$#mlJOY+BJSQ84}BVq!k$prkU<2bsGJ`i^>CG zBxBgr`~+~l&bu~u^~0xDe@kst;%csuo}i2ZV`-Wfg^$iV-lGubwNa>JM6mD_6Xt#n zHSo$Gnkc~*d>k&J0;v9Wby8#09FV}FFGl931J|?zuT>)OP6vU)KZAvMwX8?r zF&Xh2Gx2RR=ViK3!8@Cuf4enxF&P3GlNF${cX4OunxsJ*PR;yB1@V-}wnwECGa>$6 znOEj|9R*X=XTK5q#7X1?j8nSihvoyfYrpJuzMdIdXst25t<9IVz*HcDlvre|zfrjXsNkzqdsJ>2uH${rAKp@=w2HE! zw@waNeWH?((ovqV2`i>3=^a4fr+-lKrTw^l5@Z=`o-sM^94x$2_wk^um`#2amY0XU zP^A<>1yRAw zsRgjd)bwrG42*UQuH%a?8axEXDms6cicN=ibs+*(OTO=sBUN+yFmfg%{{}m62^{V8 zCoMkRecdf($dxI2Hk0V5gmUnNtGfpZ+)$?@yKH+>%Kh0z$G)%qF8R>}*80JVM(Z+S zjvjSmwyQ}Un`A0)y`Vm~SPuQf+U>5|a~Uz%7{%p?Mc{JY^CqiOeQ0`1Xb+-%x(2n0 z(lVr)bh!(m3}UrY;=W_Tcjv$Xdoj`JD z6rsXg#{}sDh?}Cxy=F9iY_&)Bq-smSXC^oK{IL5kLGmjjuFWv`gFfjo;*y68EK+i$ zPAZd>0TEsymFz700qN@}MO+HyqfgUDjmhG>^s8Ho+u-Sq#owJVtW2FG!nd*AiekpM zg;C>bPhnJ!U-r4A`Xq znbtH4ZrVSzM+c;?*3_Z9QpG9VCz~k^$Wc8FU{p}fxs@d=N74vyUH7vL13hgJxzjh_ zJml*f7fb019xd0mf6J>@k1lRy(t7Noj)6!q)-fFl?=x9Q3YZUPYaO&@C@PRT_OM71 zp9(qkas&J(;yO@~Vv+&2oGG`eZ>+d~k=bSv-f<8MOUCte`a2orz68GPI0y26Td~po z?BmZ3AT*cu7ph-kITUIU}vrgRevL^YLm%kECE%JzitM{&uT(<;Ur9sxsm>Fb!T_#hK2IdqxCbi_9tq+${Y|RHU1%5vUkzaOPzZmZ$6GfDBPvi4Z<58fsUSsPZbRlr=Q_{k( zo2(%+>2vI1*GqNU6NRc`RfM390ri*^EKKq3tEl8S=uR4U5Dv2hC`B)q@poID%|9P;Yl@dC`YQIzWVTm3P@3+- ze5vu8QR_JQ}w%_d!w*(To3t86y3xl3h78gpq?&bn9-VSNnJoEiL%e zDo$9QDSB@~dQ-S_;%c>|5tTqjyg+NWkSU{6S$*0em6RoDR7_2`)LL6wm)N*5dz9z` zk#SFfvgC@DBNAE`8W_S|u(+~JaZ2S_Bq7q~o7{ekE-IZp zWTPHZ7^ZfBc8iV)!WS4Ho{_owzvU#9HoKRA7J7_;APA56?K!&?QasjqMURvt!!Lx~ zpS!boRrvGH^_a>r!ZPMhbwqt$(>4yO3w-FZR!vj#e2ym(*Iuk)Q({rc<#_a5qXDa( z7L!p*CjBn?iFdA(kv;~j7+PaB#?F2t-sy1TDeOA5dF{x5A>|aFjoq0ccSMi=lq$_` zix)~Rhz7COZUXbO>W&YyU8$>4jXx~gii`v4V4-T~aTys!> z)+y}jIzFVeY+8pw887hLS9p}jJaF(Gp#&N2EZMYicFW`rjzW0WN2RIkj01PBbC4)6v-#mXel5aMml1dhj13nyZ z?zxiVpL*z9J36nuIz~dB&7v<<+N~qP@^k%-;Wu}aR~^ZOeiuWtZsv!|_i#iL@OY46 zuq}z@Z}EPo;(-3WbXJ468~nApo~PwzCDFoYli$x7FU%d-qk>z)YbXWP9HVkR+$>W!tke|v7}H;{FC3`b+#J%EI!6;3Ln<)f>0%m8Ut zj@|SFAW2b@YQq&r!=r0Po%T<-QiQl22&LNmgj0!QRb6?P;O}oc2={Egq{ z`qE(0E;1`*{k_m{W+W%)s`1%mZ{8pMWJtU6o8TSnx z2kU_?!0Ju}nS$H&d3jM@l(xx!x33~vb(Qbb-hvpUAmD>|s04hDwD$D?C!l+mbW;OZN!TLO>C!ct7(B zhvpIQ>&4hV^n0F0nzWVbiE8C>yUqO<8`;wh&a{^)xG%;ZeOd?*(r#asa(un=o3J@T z(O7SgJ@MX+rp|6dpLySk?5X9MiW^TNXt2-*BWSEs$6czOR$8r^tQ<$n=l5qUx?*by#dFm$eMbUIXN%|=WnI1` z;WN-pdac)=Zua-RJP+#>K0`Fb?Qf071O@t7b$mH-|Ne+{8S`bfbuXZ07tnI@)ieEk zftt3(RedU+Wbhx325#SJ52}gknX4f$s+K6Hmn;wNNjr~sV}lnLnxho5RMdGWDmsz2@#OHD@84}WjmGTmvwW1NqSlmRUM@q18GjA6 zB=sHe`hQG)1yGb<)IKR7($XcZAT1y*i*zd8Ez&5R3%H1afPi$YAlym!+F9@;pE(&Chhrq7(2d4k`x#GNiSw}Venuz z3)PmxAP#*NC$XGb1s?kUvWiGCuj?rS_Js2bH>+94i1M+C=;DmiXwOc?Jhn!@5wKAWAlCj zB`vV#fN{BJKH!FU9`{3$QGI*jwtubB=;*fR+I1v)&U=zAy8@Bk{>kr{`Y=|KIUmSc zp09CHJYN~x|6^qW=GZ+-Q0GiNUsSmJxO%Fn@QL*B<<03wa1Fw|`Q7Gz@J|*zqVM4| ziIK{74j(vjA9moEDvU(oUfv2=U?PC{2w+Qcilhyl&Fy_y-TQ$07soT?ZrWg(1EZ|A zW^8v3eenX8CayhC}io2B*-XA|-lDElc)^eqNpM_%lP12rolc0~=I= zU0tU!p&m#NDw1a&f@QQ7P~K1FARX#dc?Ka$jn-@PCs2bJ;s+9BD2-~KJn7L_Oxy}E z1lb7&Mlw!LVr7N|RjKO9_swy>7jS3vo;Nmy*gdTatUcwaMZy0}xkXrShH-oi=KwK% z;ZV7a$rsdz;RHtlXJfuyG(PlYykb0mztzrcy?eZ;_}@9_u5s2lDVfcIqs_4bexTy< z`~C5dT8%caXbLcgQO-T^+j~g!K*lJls})S5^y`BMS$=zVeB%2LgO5H`#TD<~G3elV z$!N6CFjScH!nS(pv|fBK0_py%7rWs)essz!d9O-lKM7a_!2GYb#0(V8_b)3Z-KX`? ztDC9(9=p)s&V_)pVZYn(I`3VH@|H{D+Y2Q4HQEbyF|Jn8^7GZ=4LBoUB369YzUQGY zfx^opN1vxGT`rpj%)!K*oFnFnvdZR_V=K0wwi%Dh<~uGZTDKhNnzy zI9w_nAKtPmVE_e z>{5HHD_*-#g$M(g*sn1$dF9xt< z61w(V5R13|#CXIDJZH#+KK2CWte+L&fbn|X(&ZH4a}iC+?fZKTDC?&EhE`-P6)(Bx zd^QE(a4)@QMo5xy<3Hcq9ZjwiagiEr+WY;PikEKkJ`r7g=61Vzr`s@^r_VP26n$m! zBU#~PErPk%%gJa@&4LD&~AuHr8iZKM5&8D-=e|WwYuBgkh))L4AXRlUeXqd zFmF=EO1OCjT+QHn#Wt!3PAw<{RpEC|Z#E;!kcZ{wft0P~&`PgkiQ()^T;=BvI+KFD z*^sB9`+h!8AJHK3-taHodup_)R~p~nYSYR77zF4Dvt{%#CGMm zf6y5;qVCOlyUsrj;wU46lP~!@e_;$UgU;=?R@dV3=EfdymYu@O4J^8^vqa&1Z4TkZ z#D;I7Hhso1bfu2!HWlNq7{fS#lUbOu-Ni4>c8v3ds~qLk?pHpbe>>-u2c&{fpo@me z?2V*9RZ`21Kij&+*?)>1N`ATJ77g}&l_|6*=0Tqm;l+9s|G&IH4Q4CUC!N5INLlp& zc4*37AUfIC=po*nhB;8Uwcg0Y%7{kMyEgJ`N869A)&DMYvX7x$yM%B)}a-OP;b8>Cr@|3K1n@qCd`m);FNt|l|HG4Z<9jRqd`p=t8 z292hou3Ov|B{z>u3d{Aq3VNX49nYM@iE@^6D282>;VfXDX-QZ7Jky)=Xq5JrX%yL9@(xAS&u2|} zNoX#+U_EC{3E|Lw3P0Nx&gHr1)me@kVEtJVVdL_xTJN!%b=d|V4wILLTcPyZY~Idd zN{P=@0XFS~3&R(m-2tKg<D#VcOscSH$IDce{SazlsWODp{AQ|k;l)wxLckduK22*vq9lE0@?`k=)aQ-K z{_5;EyMQBleW*Am=BKLnel(Qe3Yza0g!+ru;=MRuYIySIpXws_J77zq$=8QTFX9n0 z0lVERB<4LI&~5Q6vEpAATKy|EuF5tlT_o?3Z1v0>bVI# zIV=eE<4929)Jpw1E_N+>U_8(A!Pjq{QOffi`usMfbK{T8l^as*EwnA;w1mRVxW4a$o#{nHd-!k-%V?Hw7Eri}g0+F$b{@Qu4lXGG#jaFw*&p9V3}BC{ zISkKDHr%dXVU700NTeiZHD)@MKaXmXzxSueCZNutow7CHbjeo9&AP?c9{o~#hIS`ERKJ^70TscDw)+&8S-gX4rf-4QkA}R3o*V<|t8auCnz=jeLIXW)OKhMEJD_lwT>lSoa&4p6p>oIv)5-ZCRLz`TTWC;GQsQ3IL( z?8KhsNS~!8@}*&v^5-|0`g#aK(q_e;C$mk`-sKYG1)NqYID6Zd#D-xrsZ%m@>Aq!| z3teYk;s*fX&D&ipzmTp%ne=dlsUnAyDEY@J+@sB*vkEfw{0vjkx~KQa)M2$A_P$#* z$6f=4#T(1is9;dRpx0c1Zo^%BoDI*j3PV?W{E8Se+z8;SO7m{lCxbyCgPNfHb7iRfwrhZc7ZbKSG?`B?U7S{5T8kcNXF^+8Uahb7zv0u5Br;m%FO0aVR&a zOI_i*K>@=6qnO2!3u|ORR{i`bKE0T0{{>22kvW(&0qvzQaXJqGqia;P@W$k1fc#WO zV+AfZQQ`;?ej8Cx5}cX1n^ioUMpWAIR8c_k7pTj86Xl}hrNFJegGPnwMM8IO#%(fhCz>(Ugs40VyJ&TfaLqie1x-d zJ8l^az=VciJIO({LcBaR)opL`)}QbUse6Zay*&Ja_fSFZ<;$<5-1o7ts#)=@kmo)2 zihBWXynWtG+j%D;SDM!QQG-8O^w(bupaa7E&$g&T?2eAp8q4n#zs;GZSVE zs^pr{JbkqUjl0^UG7pEiA63Qd1RY^?oR@U(Jjh5vUfJGiY~TlSA|^rj!NDaV$a=ba zLF_88)89FJoknKs{m{~gzm{LOsbL0xsb`{9sxChu{1ne#t5}2NTdFEpmYcY}@m`sM zT4eb2+C=O&zx#tK&}s8RPF;*5TH`4<{H(9pIn}>dcu1WUVxxw}7xS*XQ8xzyamw27 z=CVx#PP>fu#oz3V;u=hBR;_rOUd~rkA8Vp+HcklxPS#;$AF8_x5Ggvpuj+C)h1ZXI zDGtAnS1B>}BEW&c`XafP3|<$v;*(YEwXY@Pf_~PD^X-&!Ks0jhmwo*6kN{*CzDU+C zpo1;-!uukxHu%;YK5mPrDs;-B%l%1aA5(~XPjuOHiJb0s4ID@Tb|J(yDarn!f1VBi2Y^cvEAW^=5EEZx5P{!#}FY&eVh%kcX zFKPqj`w8?Sb6og^&OZCUUhwPhIRm+{jjbgF-HY5q3du%z^g(>sO*JT zo>Tc^AONEO+Ce(R!{H*wGpv$PxL!leh|o3(OFBb)w?vRJRE?m=Bp)>M&5N_0a2%DQ z(Q-o$@#sUZ3PSfFE5gFsLhEQcmlgEpWl6 z)p@uWW>Glrn@MAa237sLo+2e^X{Z}5h_7j%bH`4eag;7@*n{JK=M;CR_kjkjx*H+Wf{q0H-o6@%iU zCn&M6^D<62)0F(ei4?c_t0t#zf`|LMc)1H~P>)M7JQTMspud>FFJO()L?Ja@Yccky2V5BAuMa#q$hq%HR$t2_(nLy5ohR~ z71C=t8y8=X%U`s!`bU3=3jrn+Oxp1vwTyO;1(w`5mL`jK@4ziy>1|8WR(vb>D%JSS z%!3ZxgmK*AQ%4Zczr3AuAJlMhLb6P4w9jgae<%F-M;>AvK@RiXGNOWP2$W88mfjw-;zRCh|4Yg`-*d+jyh(IGtpb8?&`5;jl+7Kp@L)WyUky( ze0|9-=%@dd5ZU5|5OZc|;pu=A>lfg=a8z6so#n&yi6ajkUM}63&simxWM`wyUnK>m z;z(^#{D=GGwUDpc+fQ2QOIdW=)g(g>q|4h{6v!{c4%^R3hl{{$5ViL~?Hd};@l4YN zgTDmJr+(@~ZRr}`VNFzlAV++5>I$~CVnN$_TL334j?>6MFL>+F;2AbW^QhPimTWxGs5;(PxJOu{7 zi*$m~Z-S?OVjl&&&JfQx|M5A`Y7s&1YV}a@-iC-cO;nBRGu}7W_b_R*=14I2V-xir zjKg!^i3_if+BaSZC+mzIHU_M?{~JXxis=;p@3OpyA&!)K%lPO|NEA8PX|;Ayb3Uz; zrUSR+ly;@;^mvk-@4gGk19n}SG*Cms3FhC99u?b&R7V18{rg3g)hVQqCtR@465*uJ ze6zIe?D;zs#s^7^m6V=c1p(2o*aod&}niS8xT!Vv^68#%~~ zB!QUU_nxFV%0_8d?QnD)8mFfrF*^MC=~@akT7w^DJCZ#!{bMgOy?a>BVfs)jg07q^ zqdk3@x8wF2(JM6o1OhTyt~2)1^Fa?1h|RWQHF~1Z{WP=W9%=E~IB9WF9zyQ)rmUDH zLv35qK!XjZHK*t71qe4O6;9}Uty#&-Qkhgj*Kg6s$hk0=#g&0}vsw}7x~$(N=#Ua4 zz13ur-}|~$L6BHvY$5`sg!J1xtU(Tj@*u?_P*NGRQylUsi*7+*>5{Mh7L{~_&!cON zAwGGAv(IQYrn~j>9pJ-9ezwc!0kcGDNWQQDy_F3kXY@HbYoS}{eIwOY73!pQofTf`IUqoHu%*3PM5$Q*S_*-cv(Gj}X}m_eOooB&_K28(a{5G@B~#HUUanhld{_6F^9bp3MrjQ5gmmc&uV!*K&^mwV}bhs0HI_t90HB%D;_^Y1MeYc_-SmmnWRM45;TlP7`Dxy~Ui{8!ufs=%d+Dcq&AD1K#T^gZyO- z3jeY=5(t)s9h%mLk(z(nQo#2YVZZTtr;d$KA?L0SjH%R8TJw-8<+#2Xq~zrL$evw0 z<_JDp$mJb=Xy68#fQ-h5Q%Z|mVfxdE_v2Td>(CEb|A@oNE^_yLe9`ZIe zhClG@(UHW2Gu2j3Zs^K}CWvO6{?`YYRsu?}F|Ub_3Sgct7lp4xI*W_(IO6B17#9x! zMRDNUr^iqxi_#*M_%T~GS;L<#Y3J59&_x&fy1kJ`g$1|0YoD6#vn@0#tOQTNYtfbd z?79PKW3%5@56NR*owW9V%LF_V#~3wky;_Jbtuwp1Jj<7lKsCOfjZ2*~Xb?Tx$@pRc+Viu-A2Z98AYS1w2LN{w!E%;Up|F~T^_Pim=-OBc1 zIQp_JMmui(Wru`QuTY1=sE)(!SFIWK4$)>F1QPJfbBr(Wt)H^taEzR(NGGYqYriWB z+o@wBQn8S_rE5GdTd&MI@QY!Ah#ehTZlQceE^ue`AY1$e@PPtY3gG7N&$R zODqf3rhMz)y%G`EeB)SRrNFEb4^yD=2cOn6i{E^k2F~j?ZCqRvAgcVk?gQ~#^-8ns zG9aB@1!`RD=%xS2wr98AC2m3o;C^ejW%}s$=RHQT*cl}~zh8OJ{_0*xI~J{FUb`gQ zO=9s##|qr5F%k3g1O7afTdWp8*5S?GyoQMSWrFoALJtlOtx)RrCoRrv0d-Qh9M^u) z7XR__Kn;n+r?%aq^VN6~c&h-3V)h~BD=|R6f#Z<(gV;3QZE|{=QGM?RA2dLYCOWxS0pv=%{xzg0y#Zl?l>!C)BP5i zrnWLZI5KzNBWX;ui3qk|Jc%@~<|vSZp%&vd9P8Q%*M4p^g#+X`Jr6FAbHI8CJ!>;&7) zK^EUaEN59DTP&O0kA(^JOqe(z6;#?&i(L|+#~YZUStP=XKR%MaOu^SJSg5GIXN|Fr zjjXE5TNH#Kl4M-pkPmaL5cIsw-Yzvv!dzs-@j|dSi&#pWByV4hbA#x+XYs-NilkUS zQ;}t21fjnIZ}*}fH8+hh_sz=)Clf&qhW|j0A3Hn$6-nHw^U%1t-b4hG{H3AOd|rR5 z%IiV?S8`mrqix|^kd@b}w5F{MPvwcs$jCq5|68%Zp;ERexi9Jy@$s_Es3t~X-yTm}RIl0F z&U2>NriQ=%8?Sl%O=pdn_d<5xz^r2l@lHd~dM%?$WU5L-!|yqLZ0da1QzPYz@auJf zNNu?SmUh*7(d3CU)V7XN1I;bySfjwa1RpaT?fmiFQJwL?e@nkGzjU9S#nRJhYa-3L zzp1y!zXP`|uz2j@a@LrON6RfXoogAaXaE6ABYo`qEEeglK0h*%0EWMlX3_m-L|G^k zUK*h>FXIqCEXb?!R?9mGv!aL%Hsvo7i*(&Wny4Q`Nz$vKqR4HNW$7bSTU=eyqTd8X z7)H46yhDEg>=&x?N!kjHOHsQE#oNpCwDrm)?OLF7aNw(wUNpl(%KCGyr#Xk-*{{-% zP}CG*n)M=QzrN+~L_~$Q=edTbD0R9Ri`i-Nc1)PXvpYlyl{%gj+E(v97J9wQvuFl) zV%xVl{4)5piCrZg_|c7TBCpu0`PR*I2U_%pzsItx%q%}>Mu$#HaLRZ*P}z%$0arUx zx_xPb)^oYWFmtQk{Wo@%(=D89THgl^05r63#!9Z@$`( zdXXni`FB;PeRa1OM+jD>_q+rZ|7Ub2V>zVN$iGu7XZ_;7%G+?5C%-!vG5(s zfb*c7;bxBO^J&kE_oklLDj`>DAP$!{+hAVIXfldsdlilbH9_Y(dExbPF}Z(oqE69uE{hTV9`{#V~ijn znqvfTb`bss=aN#u`90vvB=V%x! zOxm-INMwJt9BNVBt$&v%?|w%}>-nc>UQ^h&0%*FrP--bsuhC^nyN6Ayh6 zFK0n-0ojT)O{Lc2)Jm-EDLH@37>u;9e@5>M?Um=m#qCvasAi^ws6(pDPvTj(fo!~8 zdU|^F4v%`qv$7?-GPC9wJm%oK#(Yj-NXyDt^_T`oUuQ7T`L@1j)(*}hp5-I#{^UT8 zaOhP$yUUy1C}~XKgA@77w_E$sk~gcH+47pTNRNX>iWd0igRBjkKwb+TV?vM`V)ea^__yOBCI z^{$8M*Fv1P8FaTvgtCWG5y(uckn_H?%EU76Zr%$HS1pVDsp>}n%|m;$SYW+!bG6$| z7O|In{(dIMd$*s)^W=qV%W0e8!Pamy^HSvpyvh~g+4a^^0*ZpyMmN4{>N>zWrB6Bn zjR0_#j4ds*D$?&=-=o1Tbx8%lWUh5(4&YW^6H_LRgIQmBV^uzvX!Gs%G_PoiojFgN zpvx06JoJucVODX(KAWx~;bH{qIy-N{jILL==>5R&f_ZE8gIdyL7muK&RK}h6n6oIk zX*~yD06fe9_Tjk>njo16FcW=Ao58SuSn8V$&@%Bu;((rB7$kxiWh4x_)uxdf&WrsNSHvn*`wO z3bH((etyX+y5Ka)Wa@dl`Oj0Ci(ekLEVzyLlr;Wb4-mgHF?fAE+%aVXTtY5pf!-p3 zxhfIwS^jY~i1tWoi{RG00&W#YNf`|8 zke6F^%mOvx3r?WbAaXcB;&H?u)i9;JEIbfq*cj?waWLtqx-J>>n4oru0XW9+=_bIf z6=C)m#Q52)ZWb_4*6t;ks2tRuTyQai2fu8+0_eyL^9V-s!6&MDj)7sT7uH%JZZEu@ zNvKl=&}g9@>{1FEzVi~{$>uANNHzd^u1vWt2H)+-%TxLkK@X;Jk{IM3`itH4QDW6v zqT77xKpU92HDK@Csrl!ZVf@e&jyDLu2&hGX(+=P_GkWiycK4)}v7Zd@HtwDTGBaZY zXr-l80-cM=AQC~B%bn7-vzc1F_Nz6HllfMA;j+XLpI&%S!$sI=<8`rYPxpSAKSU|Z zH?&5xV(E?o1@i9SZ9$Uy6XnZ|)aG`pg+0FRb2SQSwf}-%yeQsXm{Ey-{-gT6i2HB@ ze{6~d>tWP}&2hJE*9;}|i2Vj>|4ib#K}lrs*2@*dT?qgfys#$iVD%bL+XaCVV;gv(VwxQ0WYDdEgbp|F^Y$4d29hdP^Jf*`kGinYn<}CvG8+$CMrqD3 zj>WO{f#KdcQH{Di;8(v+*NsiE3M)!a$UnXPe9JaJx+PXmEt$&UO?)ozUC3?NFe!M4 z9Fi@tSTxr#R%~x;Z8%L11>MJt5tJSuXkxWN|rSpZnO{&trkLWD*YQy5$~Zz05Gom+CaEo z?0dOvsH_RNo7dBn_?9}fw`L(;Mjpcc8 z1;6X6r}aWGKR>FxE@sLE#vLo|E{x0&Y5DUT2SB_QU4-4Px(u8|u zX!dtJ0<6+){wSgR=xdUoj}PpS*u+nlA8@Fc6lXQ>^tyo$rOPiC-HvvSb$mDe5U8ii zq#IbIqZ~{sZtzZQ0Fx5%{GA=Rxq&?m03ObEi~TIg!@Ei0uW0#-v81KKx!<`eh|X`jbtJR0ypw^3~0fFQ;og>!CZt*ti;#9 z^07-x*ypYSOQVgW`YT8z zn1z9DrPULR8YOJpQd;C*F3r6h^4)8ix{;1$-aOa+H;+OW6nuc=*U)~Ychmlvo!Ty@ zUHBcV+m|M&7xOrkjWICSPtB{JQXOGlT)RqAJBa=>3l&G=e^xs4QhvYUcDrFzD^00- z$J;Xb2Y@+&r&G?_s6^R${{2N>D&|u`7PB;J-O} zU;LMzOjIm>Jr}x*aEs_oS73qZ=$SnS<#wt^iHC%={tcO(GU6Y+dE>d{QXlmCmB{6_ ztq>|dU-@t(+JhL{K~Y#&hh^FJvjbmw$u7Jx=yEvCai z!0SVZo!e~Wy8fOe0^Gj-m9^Fj;7xhyywNF(F;_=@9ti|{$}jx<1OJQ_m6{#i!Ir#% zv-v1-1t1@|zuPv;_kh_SN2g%u(zEpJM{MIxzwEmn*^;|B0Kf}50I|^T&emgF9>&|X zh&#!|N*!V){$RETf`QS%%}<~94uG9pbcV{mR@ovB#HAq|*Sr>&lGLeb!neyGkMABRr@ugzx||5l7+D$)|TY(8p*x5r-T5t&i-FCS+}fA}Q7B z{h0ULq>Ap)v`X0+@vP5E5`Fnnx`m`eLei*9dinKAQVD9)4*91@r{_&tX0GTP`X#0% zO;8@WgPQmKMtK>EP$ivu+80CX8Q;>vkrKeS5kY=Gf&uXJz8rcH1Lm=!dPuR0m$+|Flu3VbF%otnpo&2EW@g(7b?kC9|(- z-*eX6QBf{?!Bvg@$R>0?R6aDjXf_M76ssEb-`7pu0RabLT4TMSt_FUnQ0&X2x{Zsl zWOM7?$ix#`2}sPES(J!0FtUEnovBd3HCawAb^1CDOMU^LQFhCo3mrRA3B4PEX+b9e zm~*mQ!-L)4K;6UO_s^Y`ubD+Y_fT=9DhVaj5i82`;)Lx*UQ!U%Z#Hpx>CCSFGrpvM zdts<>`SwqJtfbd3sTmGemcG5sLynpS+tY}FO-WyT=w_>zk%O1r?PL_$9?H}ih_Qq?<3sBLrw&Z^q zZ*GzS9($BD0k9vRQ2zq~3hsekrx`$;kHnc{V6VYs*V94qA1l7M3Cw`_p7+wOPFtyO^xSo3$Zuv}Vi^Tkv z({^x7ctr>`>p%(#a9$2~N)%z|h2`4k!8ePw!is3-IK71Jh7yJd7B#YmCc=e_8uy0s zZ%j~i5QH~B{p6x50BGNT7yDX>^oV9ddyVMYy#Ozv$t>_xI=q_*Xh4CPB6g@`3K;}m z+?v+CTjC62XC1f@y@{4tsPN~C;}ax`!pg#7u{Xe!($7q$2ei)6mUeidha1=y0|&lS zF*dR9bWrxVbxV>;2%7Bo^jZGb$w{#SKDbZF=U=gCVS?E*83N0mv13DLap|~PCDhTlWAJNoKCt5* z81t{`W1tEi0&07q40s*f2W51}5$`Ny{Fn$_G3(S~+tD*lrDj!OG9MzUPl+WTzWaDL z|6_$W_|z#{y@!SsW4jM5X%B>#h`;B7r+*UwonFW{k`4{U-EFtLISwAV)Barsd+>s_ zfq!MEZF|AD!eoBtt6*FeETF&nXL%@Zd6NkcHP97u!pa@hY2BY4ur0Gh7T)O8S5Va5 z@WP4U@hviPb!U*@zYzp-G3uKL4+4SDq^_!X4#ppFfS%nZmXte|7zv7ugZJrwXZ$jwISy(C8IH~g$ST9X*{m?dfUmqR&{2QEu^woq#2 zXd;dIU;?T1%SEof{e}TMgN~->Ts=0Mr(p5}yd{3fO-0@3eGZj)W6V1;hrJey^4wri z%WV4hOIATaiMHk4`c!@Kcn)Fvf5|W~KVNa&xmg(Ec~%pO=vnX&BtdJjNh6F6lnq&P z)U(2wFD@P^)xwVk2v|N!hMFCj-sW#9ahb)dYt}2Np}`^2~==VB0HozroVNaKmN{>BM(&?`K17;$45$9xup7%!g~E)}3fqYJn8k z50sKEn=P&5KmP?oMrpLfd$mM5JAUX+BZKg7pu0#R&6)aYLu-aV{+Z8l`G|xI;>Gs} zM$@lk-%;1dE~GDyFEoTPLsqOx#L85;p;-7xm_ww#u&=1+QM=7w`84xz?l|My+Fz5n zMUB#$Z#QAQ?hM(*B#SQt?X4jz1ls}cS5eVr2}RDOl|a9{ zPnEKE_ugu1Nuwo}ube!f+jJh!4z!k$pk5jNRSB*niVnG1Oj!=}eFY1gHl?=;NmSD4 zH+xzRr)9!-*yWDeW3OfH(Ko2T7!JOGB_KIKey3VP!8?KzxyF8Xp6@jUFDpzl%$@9l zRMh<1TpgeC;p^AW{6kTEAhkRcEn-u-p)!!@Flq@&4q_oB^lcB!U_$MyKnRgqnA^fx z=YAlb^tWBCZ)c+;_BP8Vn}zpTRgKzx^j&Xq>hErUzpYLXJOw2 zA*2W!+lWAnb*x1dX?{oi)B4}jgcpk4Q{(XP)!UsCe5(&N1mhaU#v~oypFVQY8I!0i z5iObgnv~}Ueb2G!K0fDygyigL@}gfQmG{9$pLB2Vfk7Gc`>tMTT#`+BP_>M8Nuic$piriyKv45^I%rjHk(qlb#OUY z{w|-(9oq9=QhXobFcJ^%ghb6ctiktqpB2!7#*=;M{T8kusx+^fu}zS^u9BeMT(E6v zyml3v!3H}o!521N76*q2a^urn4GxI&krBxxMMe3u6kp#Q#_Z)+&nk~}9Z_m@M#1DX z8fOyEiKYtTCiBO!+A+=DR{cuw^-^=7cJ?5XTx%NT7Drto7K{szuX#jEi~X-Z+D;2B{oJ@vQlk|^po)sJv@6g|G6Glbv875_s65aGn3Et zY{gpSg{-F6Zcj4t$3NxlvB66FffjdcaaT8?t!(E$nD^{jI2YaTt(xPthTK0Qq*qs- zKdK7xHOUWptf>I?hImnxReDkja3ChNu=n2XsnmY0$KUNedZl#X6SGLs0baqE`xZDJ zKYvtaxI6Tblms2kxr2-j4;)$GU{HG1gV}l9{HKpD-7w2F8KZiuy6M`N);8gVBXjj@D-_70%x}{W#x3$P{QyS3ymICV3^Ug_% zw=lHhUrOn{A%H}Rql`7Blt<_4xXc;tSq^Y2SrC&ISXp>k9I&>g6kkI}lI61CZXLtt z*zYSoU_$QmN2h{gAff{gu?pm602NL0v&Q2hgGPsg2Qp{qmP{dLTDOO9S z;8r5SUU16xtId61J?ktDy1jCkdD(IJO%!7zxo3NU74q{)r2eZ4V4@QP|5olZjlNzG z!NG^YLli=KfS%;b--L10Eq&eHyNIMuLsX?T#$Qu%+;{MSC3Vj(yl816dPpUulCFv` z4udcOrTx)RKU}^Q@1@6}UgVo)mulic-@S(*N3ds6{t@|Q$namYjzk!B11nxANR@GVCV>?cmXEWd zPl3f)HZPQhZirGPov9$gynT={8MdETfxwbf*hBnq_*?JwE0zHNO0z)GygrH45%_Ww znt!02BR|LSB*nJcl_opmvOvc4fVjdS#WvWllbx#rvwMRL>4?A~!u~&(q1jm|%bFsPNYx zh1sy`a9L6>DKGKBMc~gn-qn2jO}8EJqT-Ejg+?vq;UfcHjS9!HZ zMv6mpS67=TFQF&yYtC75axxGK(xu#Qd0zD|C5+FlE2NmF3|gnQVI}H)AoDcG6NJ$k zB^^o?->6}1BTzm?4%r2B2&Dct-(p;%1yaKTxb}CGDo#W8);?k&^JT~UYk&{DCAS)DisGO1B`}p{JkB48l>_}#!^zB~c>D{El zx{Ndmg~-QeihtXFwDV?%QE}rkQwL!2=5nsw4nwxI!`k5OA8mwTZTp zw^y{chT*U%+&f(+Gef4oVKH>Q`Cet{j==DlhUvt_uMdp1MQim6PQrWjY_MYPU;2>W z`X(c|4G(DI1#h2bHz95zFSKY`=8lP$-heicokLGhe16VKblm*A| z{-3yOdA-_b**rOEvBOi81kQ8e_w7mA)Aj-f21|ON?*+AZW=U9oEcAEfr`ic?DXkVi z8NYbG51=tS!GF>4BZI8tSXg%AcmccewV;G~eXz7JGt)pzK@as^^$P%RTjs+JXx#5k zp##sq=Hp@>9WouLuHKqIWSZL`V8DKW7pz*?Fv4;=wM~ndr`XWHct-o8B?lp8sX~#Y zbAN%eP2&=?`C-EsK%SfwOCq#K{aWrV&wZ$g4h;Jq*&?1IcKuf~%p#f0>A>vVk594J zU+yI*pz9*H6rX41+WTSS%pT>?3~Is%muwC;n9eqEzEO+$FR^ z^TEpIs~{^+T(|{1DAs|@Y3BKVHaPaXc&nXVIYT)N)s+=>w^wR02}8J8&j&(1dfST; zhwvvu(nX*2UaVJ04zuVo^(!$i<@Vl$!y&ylg9s9Lrb>^j0WA!+_boj?V7F8n8nwhU zYAtya4iHB*)P}ra52_eTlKwrPOe26z<0O4#;wCIdaSi_ zC}%Nr$=VCS#O{L874`C=%oJ`0W8_R&OtEv!7}p=QG4oi~;OC#~8T~_i!@mOx{IuSw zDme48VQ?7f`^!5CKJES=Dh*axzUnr~b$mDW>-o@<$4`GBF&*l^z7ya#6l?vT0RQPQ zjU>aAVC~V?mtds zteeq5d_B4x#!NhCw9^RKv5KSK1VzQO5o($kE9ggcyxS~6Z{7p@@AffBAE1x-9tTs7 z!xO?#`p<5YWNWZl@5*ka1cB^gogJfdz=C#&nXQ4!8W?E#^t& zmv;QQj1}pbv_!beFYMQBAy*+kdd=^bJ zv-uoln4I=si15m}U~~g{(%EIEln0XU@z%X_Z~I^H`jwjy&Z$m8N1Mp%P;R}g3j_GG z!Owu>DiyQ?z`OL1v`tX}rG(c5lBIXWqJ>UCq0f)mK69aL?f-`pur~IhDShmH+b?En zg&6U~9Ht~v)#6;^{VtDiEbozH@i|aq*pq>%5ERCur+6_eOy3Qa3#&)syRq6ibtzTo zCbgxk%q)E@Qo=dEp*1{>_JmN{Mp`^o`8u(vxiEx;(-;mD)v=RaFHcTR8=bsRG zc|2~W8cC2C>-y;nqvqQ`59c{&$+2{N4UkE~Jg6&p|n6)`|{H-##Whr)*5vq$8F~&HRWBAW9?sgsJkOb&AketZl+)VkB`H!t<=@J+YcTdRusninJj%!+bFP0sq zq*gI{3Uu-XQk#z(b@Nc)PwYCp2iTzP@3ztiBV1GBr);pRVzD8)uCj$IeD z_2O+$;=X9={p&BJRJ3WxfFV}cz2+?6=lS%f2LHcEwEy=%kthiNjW%{gCV?qI%f0J# zK(M+1p|WE}`VOr>pOhxbP$}FK5fECK^26-7L{qS2)H_ljftXf@@gKR^!C-L;mj>`E zp1!Ugvm~telDaDkc=h<)9RF{m_lvanL3t0lT1f)28@`eHIw7K{5`T7!yU1N*xFs*Mw%$R?eQdW zXU!JTZ|c(K&Bp!n0}jS7D}8T-QRght?peoxf6edZ$nzuLN(Mo$Ik|7q)B$AGr3kbC^=Nu6wXrB)Uu z0}cbZAGGLBTW2yqMgse6Znwe^L1Emt+tXbvNwh1=6H1Nm(y!dQ?z^=ZD>m8aN^qb9 z3eVyv14&^|HAFk%Z{I!vl~ks})Tgun_ilB1%HtAkZ3nXFtyU;vSK)PuWvmhUnRhZT z2UNbIBZfcSpkr^9OwCIH-36>G<#5ic>MDRqb_9*iu6rxMa-HBUd?0VkxASA9o`OZ) zTYc-62cr7CCQ9@!v{5FOHUG~%s?13F+s;`)SeQjhdHx@JZygoo*EI|e9ny%DbSX%u zqymbxbazR2w;~}WAR-bY-HdcIBOpkQz|bJwT|>`&1Kz*;e%ABP_x|~=cin3Zi#2nd zb6w}!XYYM>@554QW&74!2`hP;?=~*??;u!XNQ94M8QyM$DRwsI_}ax~0tAiPs^+V+ zk3+mT8W`h}eA;FZvzw$t{9>AXlEWjED98DhVCrAr$t!nvaWEFobobbTKX9WtFw6$+ zE4-OYg+JiSo3bq$eD`o#t+N)9pudSsHsy zVebq}jTy`U+XGxDzn)@qM$ZwCovv@9K2yi^8Cdq-4c`Z>%XixXpu(fAsA<}{cNU_% zTfiE}AT`uhX&4g#sV#q1YBxqVoWs5+!X^Ahe09OR7+Nf6Q5X&6@Yu`B;SJL*659Iq^Rd5@z1H`Bt@^eDMD)>b)^hn_-6CPi>-9#Fdq+7s0{1%px5K8N zIfJ>3tT#hgdc?TOG9EYh0ZD1gn|^H^NwrA^I1VQ+cQYJ4pdh;@js){7AdY26Cb9!{ zxBGXR#2rxb3k~q%ROj-8$~hwZ@k@4Y5iPjyKQOx;q=|QDDESu@QNN@D?j)Rl;QHSI zccA<)p#H&-|2q)ojoA*~tvsmdD7xIV{Xq)rU%QlCeyiyGvsM%~qyPV5bC`S;$Llcb zlcDk#+p)sLC>BW-xHBp3S^TM8;`RxDKh7Wiea_wel23N$$p6oyIMM#YSZ}BNcfeoJ z{l5r@5v1Mz(DF+0_H`mSK!7!Z&pZr}#Kp)IaV7NZ+4H0@bj4S17lO^A{jRQhVd4iH5;n#q}_kx_W!|)TuRIVZs%_K)DVa26=g6g zj$rR6I7*LivfpuV*y;Z|6;A)Rj6?jtgrNTp_{&=U3kOAY)tWG)FxKNQGjCVbu!kN$ zIm_HSvE9L&HPzoE4&PMepRo$YJI-MVV-NKCUKkjCwt50nK^v9~gM z>B*;Izy8GV%=yxEIz(}|f$czT;pM{AB(j027yx47yda1>XvYbUnjT@ccPa#cFe@t& z5;--4Qwxouz})V#h~j?;x55LU(S~hKcp&!O`}9K6QooE={C8zi5a68l0MFbcrGM)u zYSr{mK>PFd>DYkz1W2!UAqaC18#{(?6BGl*ETL6pi%iQf3vSPS=R8Hy6bkH6Ik}6p zLOr5+3Ui@}Ku4&N;#`02bQ5w|3#T101Bftja>ku}k;il)!jV>vaH}2y5II(JXHAAE zw&elGb%?djCbO^CduSGfAx{?k<$oO;KsM!zwYOddL%OqEy08asuI}63x?=9Y0~_?g z%uew~a4m2i%^OO8%E1@iJz+gSwK8AehM?z8%Q}ej$H@xHIeH(%9fKr^TEl~iccv5S zUum`sK9WSzyU`zX@$G{@UrQ|$>TfEvZ5kh~A5CRBC&04)$KFmMAejTlEhzKkw%}+2 zdk0U}Cx9LR$f*KUCa4h9ZL7XX&Oh|zJ(y4vl}(1x{g0Vr_ZK2MYF8n7kg@lh9KVfM zGj(5u6|9u)4q)0Qy8E})-_EScK95Uc?ftj7P~s@{t~ffSIidIs#)0Qi_*Z{OwzI>; zD|*a59+erJxVw!(y}wYx|9Fzdd#YpyTia<5bDh)mtFSTwkSb0B4ts5ZcV{4(Uol@r zC_%sd1l(0on@OoO(KD~Y zUE7RkqXJ6D2S*=hH@7>6XDJN@GGFXoV12)$T9-_HYK@(7yK$j=p}X)f)8x zl(^H@I>wHnfDU9rP92a`nb-}_O0^`Sit_H!f-!lD+eKPpyg%`e@MoX}35?cfMBnhI z4TI(n|AfYu0%p*QNG2}SqmRX$Cs@Ypff%NcjkC_3rfZI+>`&8a*TTBr<{Ht37L9l#804@cUpDENweec2xZ3U3(p=!!_SlzQ_%RDPkLg?%WBUqRZ4-E5+x!utn`G=?&A~5q% z1_l-W@+aYRF^0X;nryTsL~C&lePS5y!-X!Bo9^3mrRUEU#> z?({|2rK5i^jk_lIqbfWY4%3sq;yo0PT-{N`^wI2({y5;)yn9|J3m4qNUOKB-I)wf~ zy5`mNm{R{op`3ez>bzUgTy>C5TQ;f{rLi|NxoppMWRa*7H|s8xspf*TmFGAO{N)RW z3En$rSa=s#v24f7?BpI4b4E`bl(=E-tkW~avhq$E;HmT^RPabgTl4DE7hlecZ$0ty zJ)4o1WCv=wEI}5%{&4JeIc?cTM(?cw=YWF|(myr#&y_6<88iykl@@H+ChD*>+Ll{Z z%ADd^X=TM5S0qc`m2C&$48GrZDTwDMSjF3C{ElSex~Z#PmA1%7xD@@N^22>m!OLK- zI+AlHclu9r(9vj;DGq8Wh#=q^II%2xqImRl4XO&a_d8U>gdJ+F6q5=SA+B>w_ zr=9X{E$tFFTzs@NNvwCV>#<{_FHh&Yor`>y)#WaIQqB zf7H4kzID-Qb9XU8ISF=J2kgi@ymbiMhsR)i40)SrQH}HO5cHBhj_W zVsbz(ETtA%xYyL@?iShj8o{d%9VFiGvnIR5`{)2I$`zfrCo7eNSe(? zQ)NtEV0lgU1!Oer2kGYD&f~O~r4xkI&>7wTB+W5`& zF)R7kOHGgh$h-F!(^W_s4Ky`&xbNZDrgU6z;n|NEh+xc>H~(aP{v{$Y32bHuUF{w7 z9;|Gy|Iip!In;_IwwjF0h`9kG5N<+;7e)l~#CGBr^HRDb&w&BAG-!!*k;8ltJA+x} z%F9vN6flEH+C4+#Cww?nLi8U6VM}Bti5OJQB<>hXhukh3t)Y0hM%u|g%QxiwM2SnO zRaB6B*UetYYqWPa^u^lxSl5Ax87r7@pG90)=6Ue0#m)S45VK&NfNt1!G+I6Yrl)#TUzJ)#40?i)j8{Hz0T~ zz$lKVaIfyUOcr=WO(hz&rUABp+r!#&^7<@7%L3Fj}ZL z0nWtT$=_p#)5X!eLhi2 z;RcBxU;jjLs0l%@ff;-JDo}NJm~fYyFE9rlR0)tEpHQjS9C!sJCXaz;I~^=pGH>`0 z*LBjs$=9zrsKg^jW1tbMab~}L15T?VC$o8z!8{G@drVXCdMl)`gXq%NK~uproe7Qo z>8QzsG_tdHrvcHrXsG)y=H zPTsIa2-Rg3z8mvK^}OQ~3=TW@IrF0N^g*iAb?5==@lcsUp5y9_rzN` zufLW#IK%2 zd!o-O&)$>%m@fSc6Ifp+2=&T!c;)caO`P-%7d8=Nc=yZ1mcZ*=$8mLbBZ70pCd9rEj7>@j`54{niF^cI!XX#n$gA;V&O?nCZ1u4vb% zMOkZQF2dQe|gjuEZoP$DcJwpqls)|SzDW*udop{(8X z$j4Bfu~cVpNJWC-T=Zq7wYF>MX>veL9WnX(#;eYc&v(+)NhuOF6_R93OEy=y@>AP* zO_d);?RdOwM%H)G$*-meOnA1y%~(Y8;DVPmpIV673 zh_%cVwFfx2x|b))AItkaraii*(cpHfRnp&%2CRZ93wd*Veo58cMIN%2Pz&0goSk|? z*QlS{65fbZ5?3at+lDz-*kd`S8Lzir{*zgnmi&sGN)7-V1WeXi&f4 z=(LU^nM!ZS(}wwU8d;zbhZ*jHP4qW zZ^YaQc?&{**@!t7(+SjQF(q35eoEKwD0G!Vd}*wDb7dw<54Ih0z`I`UELm zB@jmq!-du++bDeglQ9Cm(&@?-ZE@O{9cWJ*OSg2&DKjUh?cv8Zym@Wfw;$y*`F=VF z&DFt;I+QVVuRbqXX;>GAO~aduIFRU6jQ%bMznn{(Ug*f~B8e|aiqtA!w>WxUZ~B^v z6KstkCW9R^vEZ@CXvSah2N4_{q*IF}|Fc}c0tnKX@#o~fetlT;eQXa4VYn2$I~y9W zFNc{l|3X_$!iB(j>%r;UAqA3Dzep{Df=Z?i*`I-44JcUC$s>FR`g`?+xt2pL&8+LT zeEEg{TK-{ib&i6eUR2i;HnEgKN8D5>SeMK^RP%BXI-ZOu_Tqc+Q;D1;izJlVrXBnd z#DjJizbe%;nP5u@aAlW$%%LGcfYLlHu6oF%n}9)&tX%sDSBGEOL}7%PGv>GaMWhzM zVNTEM1S)Y*99^%TWx!a!nvo~dGs7(`!%dmZ z2}cz9)gPF^n=oAtPTjQT^dpO3F#MMcG!+?6eXm!mVRJ$c5-`pMNkfze^F=VPXphd| zXt@r*uuw`mw7VLZw_LF!{bn5z$jODBMQrgX+6bDkg3-|V9xl?g+`xa4BgeF76aOb| zT#Ghf?%g66wL|77McG?R63x)c=}kG^<&i16=Vu;6HzZ3i?XLUL#k%NalmH5zn+)Y$Q}D$c_2e5ErO2#@ zD0r2MZh>G#FFr|`v1=O5ZKnw#nvOV@q}>R?V%SUIKMDmS06*+0v9we?BKDZr~MG`)gzx^s~&1-=rN- zFZxI6?d-eFip1)Jm-HOD~i{qjBH<`5_EYXn^)oTB>pA$o6Gt6#Ao z41*;B#;?4zLo#!QPO`!TV-gmV>I;nOL!Y3cZmHY>xkEYViAAc*1}{&(twlP#+U8gyHLBMSEJQF(``)WDF_;qnT5*B8L0 zkkII=_i^Kb9>q1_q2_LZ`!k*LlSO(2uO%=?GK^|>0^}qRRARJZv6mAt#8^*ySKFC_ zJjt;~w{DhWIX%}!yU#vY_X~lOVuYqIV@D4T>GQRKP7R+}%ikAnOTIu$fFHhGxOFsi zv8)&w_==YvJPd0|ix>WKjJXQJiYJe{E$NW_m9KI5r8n|h=0_((#Y>Q3^F~)y5SJS2 zQUK?4!Zhat5TmS+VoZ-&**^3uhNRu~S7R%ZbCCo3BRQr4n-iwiW}B;tQNQy-Qw9I@ zgz_q{&oUap`L-j?i_8dl?f6%_iUh|=brN#(-QZMQzvq})F3?uj2N!A`Xh*4GDDA3< z@I{r?#2 zd8`{-X2;})M_QdF*%wGrh=x=Na>-JajuuZ|jic{!Xy^&=7l4i~wtQIJlKZ76)%qdm zk&%_J=w5&QT}U*m@S-~>8zStr|L(|(<4{RN1uDolIbi&Lu9mIj01IbL?I;Vx`@Q{) zU{Gp?={zC^M&!~>rW@4H@y?jDgD*6=3S>~uoBGJ2?+r5QL%Gpz^TxOz5s;OFKVa1Eu_C>TtLTfXVk z605WepZk88q znIk?0(DM@*H~{>7)-LF};HhJ(Js?`Cm+jNJ)BiXW0}RaOBjOd0basmv+yG@R&FRK< zS4ykJsIzfaczZj zJg~hYpI2NJbGSiqxW;?x1c%K=~K(F#awQ#;m>bvZ9&#P|okv@q}EbLgo_#$2t&K zyp+){Z*JG&s;i(NIz7IzaWl)^CsOICfz&A0$_N@I@p2{)F~uDq^n!-ACeLv1N@6Y~@7s@N;?y-37EPp!x7z zcw5n-Olin=WpQRJ{h9Ot%E&}dXVoI~^3Kwp&wj%H_)MWk$)>m&3ZoYMa$G1X=(H8_35;|(_M*Zx%^;MZ<Fx zd}rXq+Ve*BogwJ_JNiCTGI_=?zCD#6%c!oesOPSW#HFciR_X)w`l9ctW8zlp`0wS1 zV0}VbJz^Eg5^GoBuMO3GO-nOLQqJ6=Ub5L$RSF9YjK_mvTe}p_lDOH-Dz}q$dYMbd z#|KpzcxT^)V?m;#9gL@rUAfF#P^2Z#SDfVOMSP@FX1J?+l(py?9r$|63X4=;JUeHL zk%3)2Wag%~v!MNTloy6Td`-$rXq+gQU~bc23sH zV#=9n{z^-b9+PKKaz3qCzn=WUyiW(FyLqS3uHj6rq*42kbW;Scj_t`^VC%`e$e+3LWU;H*SOmH2XQyZ`<4VIGUUA;*qJDM)ru_B zFGRh!S0JA^c8SW1AzCjFSWI)7L!@c6FWVuvuC&U+VjoQ`$^crk&%3YQa6)=lop-wN4gO22u3UXSV*4JLHl=(XwX6cUmbo~}w-t>jhm+5IC^i>%nA4qZv zIS0(NA{^koVVbG-Q3knk(FF{+n<}n&8^4mN4knzhbswO57 z{weuboh7oOANzSI`fQfJX?X;;#$ee_dzOl{G0rMyU!9x;%6xKJOxWRUydU4WfnzG? zY}4kFL~>Ry^h+SCIN$q`oJIS~4%nQ}wc%nKqK%7_0W7~UyTq^mV+H!U8I0G%Yt;IEN@nSnE46KH!hER4wym9E4X9lz_Y@LwUKnaD!IuD@Zfn4vnd-P<*)`m4~hi*VMRoJ z$I1TdN{`oh*_f3BD3sWkREnBwN?q&}Cr$zJxN{ylBVeaajh#luvJoK7D|Doab{JJJ zzzzE%q4Om)Z5@7|WkfH3qyZoZ{d8me?Z{>_ z37_%cggkS`Dm;a`EcZv>@J+v#jzUMix$nX)CBcY$AU} z5iyox?o7V5u{%XGB0~d$$k5&|{U%!11TR-*G$66$aVCagaJCatIsHsC6WcAKg<}}Q zhyyDFU@D~D^wBEHhopS2=^QIi_sQczzbqyrLv;XpyV_T8jJ2V&Rj z%Q(c5F!mgltw6!-bT}_x6ym^kU_&F5_|nrX3qHss{@CorjoOf+eCik16tJ!yIxtV< zjqbw^f&~-#S8p2CV1r?LWIF7h368VrBGZWuM2{A$Fk%AxUj>JWlJ7zT&`2W#&`u>j z6p_rhOJ6v;KSjwF(ck9iv8CmtYlZGLhT&op`KnNooh9zvI1-YQHQRmhp2H3*;M-*x z^WU1&2ZV{E!8$&e*TX zJk-`Df4ID4w?Sf>QLo;6BA9*ZRH%~CO-FeX_M^_RM$n~gueYzSU+wAUidFmR+`eL} zj?q7{0Et?Tw<@xWz%%X0UxqIUxPIdkqo*YkzZqZ>haf#a`J0&q*LJ*@S2$?yI6={< zusp=lalt%SYsWU%bBl=?=EYoRQ79~5o^$vr|U>?B%7ZPXDkFi*ZVR?+sqOdF`RDoxz+7Xmy~+ZFzf z?MHmE2sPZCk$=TD)1DRWxU?)kx%MD;)PuwFSAk_}hOm5;ulqSyG-OaTCg*BoS?09~ zJR`=__l5Tb285~WyW5oKs+uKkqZ3HL1Sx&ZwF7pPFpk)j>v1 z?R+>}X?O)|sbPVRZe+^?J+iB6^06Sz3DOx~BDpK~d(5qF>=#zz8Mrp%WyU8K3zCt< zhLs%XZ*WqGt=kfcXH(mJ$Bdwyk;D~VH8Ny58J1YhT4jkl-cm`8g+L>_r)g-wZy_&g zydi0&$Az7ngaG8TOrR*q*za+YiH`QM`umAi9CCJ@_B|+JMAPz1o73udt(Xs?Ht^%G zTh};(Uebx$!|Uz~qVMe)&`gJ3Kif}PiAr%8Kkbq#5i{Q8Txy?t^`&_x!E9I`zQ5eH z?$MG^zZdkeI5)+oanCH6z>W>ohajw5{5KD)_y_5iUIWA=i;-3e zU)BYf`P_Xmwy9%<G1fZ<3_A=viqBxJQ+JQH^im%5KZ;)8?LhBLq7zO6 zu2xBx{!}&cNwt7vwCvgyVjSc`+3O!Wa^&Xuv2J>YE@n`W2eO%A;k^V`T5Plj@8j+_B^GlY zJv|ElfW|DQ!7G{M&CO3>{jZJW+HR14@wEuWsrq)-C1M;`Ddh_?gf;}<6=$F7XZdHd za#FNMUf;Os+TK*O^Xy@T$U6UCH|*BkEy=ju$Fu$Y@Ch=DY&{B7t{PQJ(lg{C!bF9| zZ39*F5ap+MpVM-wgc%GFnew2CYFn*PnQ9(-W0Km3!)qdm%;2^MqSyf}i&NfCvA+3- zNAA1U)Q}fmJJxiv(&qy7Auqj}fl48-(zP30k-PF&$g(c+_LrE3?aIrGanCJ_qsgO# zMUuzli$)Y{K~|gCM{<_Ww4Vf+f4ZPAtQX|3c*|#s_SvO6VWV6od4u4|a_-g@GIqH0 z!C+rKNQ|}r)rd$)Lefkl(fNH1igKqSj{vB?iuP%9RIzl@S2^v`QVyoKtY$P87cB&W zHyX?zPb3$%lAYhLRbCx_B{^4P~zN)r^fuiE{?fQI!~m|HEsM-`=~K?P5crkX3-^q8C`~hlme@s5x#k}74VsK zUedKlBIcWRy;4sF%x98PN=+N1uQ@5GXCsn_NqlY{BakHPz%ypibYu}&$X2R18E6#5`{}@Kpkm7SP-m-=hvt_17HUkdB#oS9b=+2VY ziS3VegJ5_mK#De`7seo0F~K3MRnSqZ0FEjU2$RQRXXfw`>6&<2qbptKZ)xZs7BJSV z%zywr03j0T?W@^5XU00zdja_a2_euSZ_36k3(mkz6gK?1^W0LbRxl=qeI2cZeBGMU zq=&7mX(jzrFQMJ zL?wgRYo)&9t#+E&%)YvIao0x$v2=+D=b;EsSMcf#Z>eT0u&c?9;KI{_(7n=FZg%vu z;L+~_hrI3pCULH_=YbRNcCUlgQ8wkmx%!G;%(IbwB26RXdcQ?50jfpcWozgND^X~V z1g+_iRR8BgNW6DDX}fAMX6J%))akQ6&cW|3nez6HPZtVtb@8{t0?Kq* zy!n2e*z%V`^1t<00$HXFLAY5}gT#S3T;lYnlaRlMO8|NX98_lPJ(Wuir8@3Fodu}r zLJw-9nZ|hlL+A+@?81CV)&)pMZHwWk5b8zlO4FnK+=|J>mnWyfq8(jp5_`WgN7d|( zVB8;gb+EaDfu_xG5R@5);dd)Zra3Iy0^bWGMguZ+$k+@rNA4-j5zY>OEmTa-{_xkn zuUfLgb$KF;v?$u&vES z;^LrO%qQ|i0$6WB*sX#YKzgcg7c(^6TC0FW7nKC8s`GmVc z)!#KUqWbGFkxm-SL4qg&P>go$a@r^#)DBtx%LDTYUq^sLg{ncvS-_blDmw%_!=**F zEp5KasrT)E;!NLP%sF{<@YvjL!EfOhGDbi+gXP9?z`Z~SNq{gfurt~_DzholKKYl| z;p54vv$kR+LQRlf&XEO`TY=ZKUyRfHSV`~7$~ z(eAZ4?rb0Z_f=7KB5427Y*{H!Hn(ZPw=|W3qA3LixdJ|8ee{`f|0hkzrJwiK8!Gt! zLL8OCK6_U=Bl+t`?IVg{&Qv(!L)+XBD5|t#wk&d`_kxmB%cwvrqn)$l zw5r`dfnzG_=0nv#kM&I5SCX=vzx@LR8o|F%&`O@wH%Z0ILQ({tkw4&DK42+6L3L~< z=ai6c8XKiIMV)=5bPjiQv2LH{AiEyOgt|vQ{b$xxgmBM$Z`Z?i-KP& zermfquaUf2PVXA(uj+HuSuA1#$ zC@kFepE>#begdxnCj^0MnsOsW@#h=gt@v~B);mh7J? zthSv_kg26VG3#!qC!vBW<`sl>&L%hG#c&|EAO$IOc7lF}XinFJ+MzXAkJr>ThuluK z`j)R`bY28`$y|0iApoo@;;LW!?9>en^wc1HHo|Z2g%kmNe0mhpWNFfKXcG+MN%}dv5L?s+5Fw zNA=Hpe}JzI(0jA9Tw=Qw-uF#MSaTl|PSm|NP-Zhw?hq#%*DNWo_*hOC;Z3phRI&QA zQY+%@f_$7U)z7qq)e(n$T&MJygVm8sv09`=dmK)RWLK=NPd=$oy-cnPR+QY|Rvu?p zoS*(-?dEJ|Hb2d(=&^XwjyDG^nBV*MZpym9qVcBVp8nOORVXm~S!h9W&?$h6Sc%>a9zCcSnyg{C%6HX+nSGw+L-v=I|8CgDEKq$_YkRf@7}R)<0xV; z^NTg2;%zT;Hc{V_f6QK^E@)%plpTYRSK!0;=dZ9mkBj>09zL{Q9u%=cweR>o+}-x{ z=m-712i3i(O5Lq{;>p&0$4WLCgURuGEl$0P91NZaU+{bt_AN7`jXceA2}8jt|IgpN z855AJEJ4)R#6AeLY~NjzXnmw>$WU? zK6u6!+IGt`P%FZAFe}%V_S;;#n0E>-LtTZ*-lk(=o|E(OqPhb?c*{TqiA{u0T8PC+ z#Awg$ZK==DkoQ_~C#zSg*)i7Rs<^dKErImHG6argFt4f3aow!F=_=q$1DEs|re=OO zG)FUD_uN%pju1aoLA9a)BwDW_A0IqjpfXoduK$+V+a-NXa1k8DcEDra^PsW}eFxA= z+W1inIC46NN+e)|*b+Xe@eAR$*_h2lrF7I}XGHI2#+qfLic4xz{$)^)?NaPV#a0IG zzHf@mz*=>4Jo-j{9)tFu-O4GjIXH_fl?=`hCQ)x|J-M5J8;={M8x5cR!|Cr>2NtLT zZjuWW5XAe5MD#y|#%QxD5sBS-+!cIy`k2Nzv(I)WSi+n*kt7SM5P=1e1?M=|1(cve z)A(UB%9RP62qX;pG}%xgHY6AZLO;Vmg+j3o6~Pc)?$X5P!h_DCSYk1_Ty5Mn**7t} zt=N7Dsx)rYR@D6~In0~xWNV^&tWt2hRWrQ?g_Wr?Vv7-~(|;b>V|c<9z$!L1?>rJ6 zL1x37wvU$&dw>gst~amtsCGrqUd;NR^{I)fCO5yJNkEkmL8T$Mq6K}%eOD{+v6}9{ z3=&wn{mAM8sr2O;_L_YfcXX-1JbUTIrz$P)Y)}euXKkOgB{HNzK4WD0+Q)o}YJ5yV zd$Wnm&!E?TBp7b;?N6xnOHXZY#3Fod6$0)4ZhT7}1y2MYqyF3L2WDN%?wK}!nx6|o z1)y1*mh6bRk3`IyHCr#b_hg0s5TI6# z=2{Mb0oz}l`axl#<%m{Nwwmn6iLSif|}p{@ZLsBONFa) zDITTtKgc?-w`xDWfqH$9K~Ws%{_}-VR2~3pWQfu`=}^YfB_T4RDbeHOiLf(_Gm1Zg zF~vhi>MI!3yf`89m~mg-Sr0}Aj#E+ja(sDIvn7BQGK%*PYzx7-jbT)XQtAmk29oEc zyS+HU?O4VV9oH!_OwAvdfcL`AbY}DjUJe4JU$q|%8ZQ9DCon>v%wJnb;rO{)OxHmq zqB%w1rElJhorT7U$^7}V(474OGoqRa2-KMf#j5M^65#JLfM0)=BUsk;xhm3Qa4g-v zU?%NQayBt>t;xAP;7jcPDp2;?Gtj3IZE39^<3&oSE6N1)H?K|#V%I}U$ahh1tT}P6 zko#7Je+7f|i&$E5a}B#-FwuAAlB{syth*j*1quB~eDN$|L3g74wtkI+%$<~$td!n- z%=7SidP2~D3-)z_Q`^*YGx@#D)?ZHHZ^j&%Ph&rcO0oRz>2Es?IEZ7PkiweeJ`TcK zEj^NarOn$n4V=!&owmm(Rq3r?bd*2MQBX+trQ?T<-A2kXF?^+t#fWD5rRhsD_Wm|z z*q3Cn@+GGT9Z}{N|MrJ|zA@ftv--|@B0F9J-<^BC=bvkg&r{vy%yl?mbBBZ4AJAn1a#}nCM*KqEy5aDHC``XR8yuPJA7_HxLGk zrlZYeWQLQq{J@@k&X$ooWu4MERXfNIJ}DjEaj2#pjf?5Nud0HH6^|9y{kBc-J6Epd z3oNYXU#_mKP>EXv0)h}@o_15HCxaBwvwi)l>1;p{3TeFv(Q5M6R~Rce{h+u!ir^k{ zc_mPpH33)h@Qa<^PnMh2l6^v~qK)052$YQ83r)R0FU7TyUqEfc3!J?)07ZkzWMr5LBII4ahwt$vUxD-}+5n#lFh<7~)rNPJ;`S2 z!P%hK!r$9T*V;pFj~~I^U#M8`>T~UtOerp=HzNK}SWI!>CoYhc?jFT00AU@d@Ekyx z4s5M6s%T=`adojbrTWeDV-unem(3o_`y8m^_A?rkD(6aLG?)N$8>3HKQ7r6iP2#;F zgAOz7Pi+nz1HQhMp=56o?maFEfpUARSDVI8TV=IQq}bzE5SRwFK2P>eZk>$@teD!7 zRy(EAWX_DT+Gyvgt7YFN=}sYDju8msDz&7euU&*#bfpNFH0e|8q6{uoQT z7#Lz_!40nsO>aipDdKfucGv&_7pe>x3OuXhElBLCn|o#|`=_FBwofe57(7?nOybs3T4MG(sg2KIX_V|EPL;nh$Ik>&y80seagm-AlHNXSB)*hb@iq$=3 z{#l=rNPRP#pO>&@_P%~W$^xD#>an@9eO$MXki24mrh%7xPBeh+wzYdh9V8r?KpT09*>{Q3;2h1c#;9Phb9IF9C0Lb9%kq(66e`#w1*{rp2^IsF!i0>X7S%h+Em`%MvXC8J3U?-gi;_|p8~J?9#1WY zsXuA**+sjugMHy$i7LuWo!QH5?#R{=29iIc3=rt;Ng+@vk1OUEUPBz~|0V}8e>UY%ful)Ywoh;wN%%kLhrv@?wFVt1UZ3n<{G%}01 zob|Y6sYTf#JVn5#Jaz~RS=%;&mO)4Q0PBF;KXy|O)nw}#i+4wZpde;c`z^C(-~I6t ze^kT2W2mDmv_b9M<`!-l6SF}x!A%$SR~{oJ;C_odcrV^O;%ukU#7LslOQp$p$mC4= z(?0Mz!&tyx2{qaqD%Z4~9d&dpi1=#aJ1s7AiCC{e;`k_*XwQWn3xy)y8oM3JcrGC` zr)>4#jC0ipUX*5_*l5O4TMATv?@!r9CZIGwvk(GRR1Eq5*n97&rn0?n9LG`ajDgR~|k#pGNw2NOvaf!KtCH|^Oj20yco&N^5STBB|2jF)oJ%up5c|XO?uMblK@G-Z@fFq=* zq0xE3Kw(iEWKC+uG@ITMo0}3#2h-Zm-VAM-*U=Q0W>51r(;G6|S4TRApjgV+wF!4D z)buSYcVaG@3V}y0GIr!!8bkQm^4u^7M8=6yKqLJTbl0x5D&mKQ0++MZWQlL{&aF(p zj2;K3{pv(70DndI0jz)FM@Rr5^MzU678hYnXd(_bCaMlQF_vxfmCV>LmpqbB>%n-& zy>{jLWxm0K1zEx9yHT-BDkGC>kY$mJGLeZskK?mj8fdQICjUW(>R`WQca+~7%hvkW zjG-v6a03R~u%B8(u=MbVoZjzfU&zv@?6k2ttk^JRy@~@s&bmI-i+Z^jrtEpKI{mTA<@(q~q1JN3FI)4dTK37k@C!cjLvLP`# z4=^x^Gqv)Ph!3v!N00eM&4!w~Zg{$>Dz>Rw?kg__#WYxn(};?eN=ksOP)^pihTJKy zL>~|ns~QE$Q)2frys^p$eX^|i(HjOV>l{{pRYEBFm~27VU}~sI67J*f`6h8q--S*# z`>k~>ZoKxBREwwR{v1uKGvY8;GTyv-qsRPvtZn0A2W*3+A`%hD5WZ201)(=t7%SM# z`+@#@7fFAO@n$YkXDeNO_HfGKsp7a~{>V$6-KSmki4^G!IlzaV4Q}vSrBdmy5*uJc>0sExE2&+f%hH|GZYH|DfMXI3cWSBb*5VkVOUf44cW7S{J-oJjN%LF4=p#4;kTz3e%q zg}=OZC{*-yz{h2puA2y6jvuzH_dykm9=ooih|gL7k(gb2g#a?O3brGiHLVFlwt8Mg z#XF8aECtDr!Aj5wNR}t}L{ofP=UaStp*8*X!Z(}%wC4>+7ANSS!gQN7Q9swyd*K^U zAiA`cU@o1lWSVRS_yO4EFdvjS$~kvtuKTc$xiuS! zr}t@E^nBS1FhI6wU0UURc#Lb-)&5O5&mbwr7=VMcizgCPsnuhu9klj90|al1$7KBg zz>tq^z>X&J&0zkfA#O+qr+-N*u+??INp*`gP`Z@QPzkgZu3>tY#YuVwK<}F8-AqT? zz%cmm5G7eQ&ke03E9)-ZCOOQ6#Wd5nwXAa5QG@!m)LRAdfHrvTJCD`EaYUE z&P9B(X*KnQ{=suP#m4K&8V4TfE>6g6E9x{l0Qc+xJ9-Qj+vVlPj@dhA_DT{oy|+Sy z@+PIAsxg>TP030}#9(rwTTvQM1AlJ)w1k*gWv5M(6yW*Uxe*p3O@hGG!A|y!e_(-t z1h^`^Yf#qLO#z(8IXe8o5bP_-UgXCo`O%XS4P#8FE`PCNC_#oj>SLH6O3(%E*IIE4 z*;u6p5r8K%Y9yY}Y}N^14pk*1C>8;lEQ#*Om{{0omg55x$6+o&jXd>OCU$#eKGY$& zAwzq4c)%epKYK;p+iu*DzjaB6nnemcPWP>jW(Dam-HzgA<3g`hvgRf@+Z#D!ahxRH zM=m;=NT>w9XQx=pq}3o-+?)zJRwkp>qlZT&y~;#%L~&7iqiYc)`TO9Du?;7B)knL< z2KqPMQTU-`t|Hqr_m$H6E%cH{x$|2}ro6U_i8WasS`50k-JBvnjVV}+Kc`b#KN3?;`a=v=~c)_?4{ijrt4n=dSO9LVWP_5AI8 z%PPk{cfq>G>oL}vo+Gb(e_OKHmx?8aSfg+n;Eje>j8|Z9)7uZ!hs~jdFjS%qTTi^$ zH@b-#z;Cidsg}TYo|-yzKLtF!D%d9oKE9FM5SK;Sz}4(LI!nOSl+lnQyN`%rNa)R3 z*F1idg3C0AbsKFcxKC@Jw{uE(-Y4=bvR|_*%SbRTpG|3MX=)cFwK3?d0nF`jPH?T2 z0lQ9atc6e?OWREbmVBD4D(-_rB79ld#)+pw@x`DV_MG|g9qZV&zN1~Vk}gWMNJCGv zo*B&^Z;qUO$R`ekzdmR?enQ2uaojdbM4sHHiy6;QFMVg8muJ2*2$t7T&7dvzX#`T5 z%x47P&9jzHvHVLJPB!yT`j5ja-Y016Ow7e8u>EtRg8WyyEz@}C@v1f_VQoL-(Wnbv zvh(42=p}@&qx8qXj&tnLTsxL8d&kBxMiA^9bwYZ}6XjM)kKD*CeKCqFS$_dx^&lU+ z#jMYkVpc*OqOP3~6Z5-?{H(FEHF}!*V=A3L9a?VQN_m?2VfEIc!WwDE<>l(H9Ef@- z0hw}p{OBBwKfTDGg|EgUx!e79vbyqdy0p;*P9VbINy=ks+wX2fa&jbL#4ZWjVSOHd zI)sbW)uV|BAtLq!aekqi>J|wP59;vnGY;G}00Z>YmASSui-9KVZSTh}ghHnRiGd<> z2G%I`Nzts|x}I^!Mo;kfSR{F3I?&Z^i1PJ#Riy7sMMazJIW|$_fjnwSXv5zesCUj= zYBB8|tqPy|Ky)z;>IYZO1mt?A$b*|(d-LTnlmeEEG6R*L?wNM`p=T}*t(4Z8UwV_U zU}`Y(1{q{ZACf23Jnb$$$BxRqc4Pku;=^Ll8ly`iFlFa9swwSs_9Qyk<)+CO~*pYxeyo6ZDrN#yLwdxYfWnplkFO z9rnwYiwgNSA2ho+U0Q#pNbmE=00*zNkJVPSY&aHlQ!?*s4Hb3-W=RFNAxzhwnUqp8 zpgtIr6u6QfYmRldXG*$-Q~{ZFKW?0=kC;_KMzIF-iCJCh{o5x22Jw}b7lLj~Iz$g0 zB^1cd2C82M(=KWt=Jc_UMX-P3?@|~cOwl`(#tY81^XJ?;kJBQW zsfBO(`vTwEq0}3Al_vog@_g^43*bV+I%qr`84n^h9j8ZD3b6Fr`@Po3=)aSV)nozuD?D_qEMif{qmAM$f}JUhll6!u{6hQ)R*^=^5aj`A>QoZk!7|%|QJpQy0md zdOh!R>}x1OsYfN<;s)$yZgZ<+>-U`1D#@{JR|8aWl77hS_RZW^8nS~45^Q^p>FxO@)d8R9&@hHs2zVySOeh=??7-S9^a^sMy-!Ees3{)%pfPTG~=;~72OlnoMl$o z%{Co2SGz>(M0cgFJt?&fS}yiIR7o8>Hr0O4k`U?@t%g?|ZA_ZP0pMNR5}n)Kn(~dQ zin$utG{tHdVg>U-d7uI^m;UMrJu(D#D;i7Fds0eavo!QcwmXquiM{ytjL71J^)tp~ z`sA`)3Lh{?0gD0b54R+p!#uOJ4^)JvvJYOjN^!HbGKbt<$+ddylA1};Z|o!_#2Tt9o@_e3D3vyc+ikiqz}3-Eh}6UT zCb}uTN{G@D6B{9avNlJfBk09?(G+RZwmFWcj@ys$m&Uj?PBq~C0Cs#w;nS-6TqrzH z%}_*zZCCyv)lTEEfjh6&r(y)aU7%Gl%_f3?_p&23xXqX#P7h%g;2>FCLdLL;vTyOv z7Oko{Z=!{W)QLV#0QOqmA~ybDLgSfJO*-di=pd>+Q|m z-PN}1cue6sZ*Dz;&{Q@<00LF9d)1$)d|_;?WdYS8*yIkP)jM|ZXDN?Q#l0P=rhU5r zPI!KU6?6o!;eeYlfl3`oDiQn8Ub^>8%loo@kXHPc?6RB__5oG5+WW`m6DB)BX&_evo8R zlI%&H_aAU}C6^*nPHKfE+BJ!n>K6ib6#^x;#q~1g7^;-iUT?qjEZl>Fy%n`l*MyqC1MU7FQ_s?k}BICa>@MI(T={XJ9+s z=PLpK4VcSQ|IZP7#cUkH+cleW#qmK5ig=2cjVpa35mqpywT^!2bo-!APzU38J0*J9 z-}}d82J~6_&bOr0BCd$E?70>Y``Cq&;Dr0f-{&Q_@`rszKEQf=_*jy5-!Rxt{TjvUk1NEJbEs$#1{*?9V`$Vo9DO z&gJ?_=)Zj*DKf1JZg5CPjayt-Q$sJerLxRLot!L6s7Mz^3wQ_Gmgpd98K}~~W+dW{ z9hk`bSnSvGBH{KiP=*<3f>`qNDyrQk;d%P#2jK~L$*~d}GdLW0Abs&|v+~S|x-mGuzggwYTFd8FvAR!!~M zsu}e7w~XB;C}kxt$jrmTqcYT(o|w9*7+d#k;LGR*E{8c>wx2b)`v__~9><<#G|Z~Z zdKMia`m?(F$uN~1jdMQ;V&TR>-0=H!bc(d23rQ3axzh?lhuu`2Z1MnB=ulIBPzNH= zm5BzE4oX)TeFwXY)CP>+>CN9=7y~38_BXHG2148A=#6HimVMlMI7yT>!?!+yHgh?S z5Wv3xSapT?PXMUhC+t)(A#a~+s5stUw_2DuMRQUI$81kk)h*{v1bMa1FCAAlaFUu-;0+ySix62@9jjI&IAY&s|9dvf$-2$NZ$b#||vXdguuZfgUk^313 zG6;a7eX;s3*H2q4xSQ!GiZPmIKQ^*MmzS2^(B0`ztX+T95j+nn`ZMO~>cN0fLo|PT z3Q(?w=LSKqGT2jFq=5pKUN)?)b9_sb%o(#XRPBvj1?L!ec=xo;hfgcW3P1j8e&mmB zk~{DK(bl)vWx*R(Txzi3T~_2)2{coe#$z9%Qy8iQ9FqCB9i!Q#rcj?=9w%L}FK@f1 z3>~}N8pB~Ik^u~JS)Bs1BD}>_C)gSWm**6O!@UkA=K2``h7<@f4U~WygJ#W*Y7oX= z8zfN~uG-l$6wA!tjkuNlB4wk1(M?hJ!qZbIIh&L^Mty z#Q~aN@^^aWAl_DZf;rAp$mOG+VcerRh|hT{9bL#)tZ32 zCR%=#GXN!5hNN_2KYU|qRTeH=bs8!m>xv%IszmGi+ZnMo=B1ViXwpbWG>dZp9;`Xu zWlOBBZLEEEr>aIZFyDDBF5b93nF(D9a2W3BQZAyDY;hJvFV4C}bTHH=SV zNGZ>|&3Pg4?*o#gS+eE6_0dY7lzi9+NwtVXOHnGhuL|moST%@Gno%_54aa%LuQvy< zvUiA1N;nRw24hVt)ZykQf;C8hro(1HZl>skF5TlliIB=lAn+N(SQB__+YB`zy}2&a zVTbExd5e$-)9MVyO5VTpxhQYnw4cJFC-jiZN7D=zDY=3dwY4p8v%Z z@^nR35?z>r+#LZDg33f*Q}Gw1c`eEck2sKRSW5-UH3_Hw!O??{B}Kye;l9qTwG(Y; z+dP+czln!FQ+C%0XkK~9?y>I9`~kW1I8JBvzGZb+3CL556^7Lp#wTe#&wQF{;L`Y@A~-0gr%2^2&>GuPIPl z-sIQs*zG65hZm&(aX!PW11PLC+6>fw*UN9#iL(ifE>ux7)&Y$YkIB)iqzM z2@4^=SsI9cjE-;wQeHGTiO<2%=WF8p9?Vsea*?4Q`?^Gb&_4c5rBlnpwXWWyB6BvQ z@BQ|F%k)Q0HJSuN&P?pwQ@dm z4Y;=sMCk6CC*tys?`YcW33$S%bW#Bk`=QKUv*o_S_M}rAHzlqwU$QV5S>Ly29DLzN z*hjA{ed12RKANqyfo6kkV1uRh8mi4LBE)VZ`rX@vndSZ3tEe`AOCX;s0-nIVb@!J9 zfpP=qZ5?;)wj;Nm`9|*64=^S>+pxpO$rd75nzX%hsO*xc6h*|b7#7jHkJeh}?WP09 z1kKwMjh1_RG2fas@^E|nM2YrYI@g>&c zEve_T2HLuM<^ZSAIq;RzMC@lkj1l=(Bc@3pgpwv$CBw|TtND~n$7;VGauy3}nR>2Z zngA21(f@cPi@oM_CCPCUAO?NaDU>|XqF~CL3+&*L*V5G8(*)A(1SCXu$T-W@>6TPy zj{@L;H)ZLT0@XRF8zQ>8yV@6AC6KC0!=Z&^Yn`u0jt=PqnPKOMEkh;0*`reFN!pGz zMl6R6TZRr0>xtFUv5vS$fD?Yo#_UwCtYnF;54Adam}~Am2McvNgMI*_UpgcER1Qeg zFP#4wwy4ZnPN)pBYIAn&FPkik`_;}$cL$MUPy{nCyCuGn!`fNz8V_+gI@9Ota+K1(W;LhdyLD{9ZhSR3>20c!F_s+zRHt?J)%#qHUm(B)}b!t=6!a6FGs zwfh6%CuP!2c)mt&E5ZrmmhYmmeM&RvgO1C*&&2?u0C5 zTfNtITx35@PS*#ouG*n-GXKJ}1~^bz53{=!bfe+G_h#@3m$#H@Fs;u+*m*wjM5Dks zsQl+3sY^kxxRoO(YICOmfO;&-ov(D7XN`-j!lD7ci$~X{P|D<(gzo6|Y~_PNot{ds zfuHR{mrLjunya8uqKaBRkzzO303b1YH7@o-USRwCj^VDJ7fGK~l7N&@Egy-6WIt`8D4_hFSW2_I7uK5|_vaxES9Q&eza}Ho=g5V?}hi9VO$tPA+ ze?dR%5uh@tTV7W;hy8iMp$C(D6)mpHjxOw=6&7O)9Qu1s%Du7PO;HK{)*dTzHb}el zUdD*rSm?ky3!2{nR0y0VGD}4BToJ~IkSIEbI7uXu6VdFOGTBV~$_#H&NmV2oIxFKO z_#J_43}g4SaI0vP$K73h`eh!$I}UlL#KhX|%YU~0nv!S(kL~PcSmOwxtmdQaV<4P^ zeEj3~D}CbjmmVHHnQfTAb^o-MADY}QLRgppEl?DO&wXcZ_s$_xWX62*nONRzh{*-w zoT!QiYU{o^`gy*O!+o6i%~ggZI_KHwp8 zy>1#$&85Ct_@Q|Q*1+45Fzg3>esWw~NRSA0!c(McO@*h0k1nyddwV?r+XRo;I5O!5 z#t2E;u<`G>lytP<5+taza^8cuO4J4l2_rEpp|Y{o0atCm828WEcRPJx=Xm1Bu=fcD z)~<%<#)3Ny$PW@7(HA74DV^@G1TK?@Hw(uAL(Zd`933z*jJUgje3$BscwNxOG0u z+6tYM7u*qyhXa)i^vnBN_-gQxflXxiu*kf0Bcqgwb4{gR*i@9M^}J?Ry8ksnxynvf zY_qVAd=RVlvT3_ve^ppoZ*p6?vkFbX+=_CN7?>>Tt=f*H$r`Gt>ho?&`2c}YD&(jP z;;l9`>W9j<@+@TS2ZF2-OW@e#xbGh8u`dzr7}B|OhQeh4GAY*RZVV(SKUTroK2TPk z-)^zNI9+5V&2cG)lpRSoU=Kz9^N-D>vXI3M`*KSW;ERdbekLV!ixF1-EBOGj5FHgK zQ=kV59*S-N40+FexnAoZ+eZ~dmj@j4Z$1Mt`FWW&yFj|oZZg3jMX;El2Sl!>pF+M{ zxtC#c50v6?7e94)y7~#08v2uVOzW0vyJLHV5l8}Bm=7&AeEVk3VcdSW1=oS*-z84Q#sdzDtca^ z#2JiRZxRK!YOIp18C`xP(jE@;m z@ArAZ%|J%;b&*Q;Wl!Vqz0(aopWc7m2{ECq(Mg?1vFO$#EWmyGlgOe4$H@X7!*@!} zhX2ciZWlYJ=K(%OR2H^FR9BDODaXv{HkH*m=STGIexq2udsJj$#qLc!dJ0q@!yIq< z#oi`jcM=Rj3y}&e>(+}5w`eR@s&noM{X22zi(79p#((-WLhUJ0jToS27R0alskNDM z^G4L_a-JHpA1?Be0*~PQ$9ErrvEv8#21xicfZbcYtd1fu|IxpaY& zeA;lUHDdu#ETGkXsy~Z3b&TwNwa^BMJfUL$Bzjn1F|;smdh*Eua=HMx`XOsty~5tr zAAY*JO`CHyV3a2pt(V>p2nr8`QkvVFZ){n#*=z9Lh5R79{_4cu`G0+$O(M3V)apm{ z8{@>8c33|kl+5+q(ob`^H(^^oa|96K^@=`r`5}&8`(43v{i8xI$!&F1l&=)gJ6AM;sw>7i zSxKGlAD&HY0OAZZdwzPwJn%kVan@mKt*ox@XRBlMPjeQtJF#aJ(&!(Ak&Sif*3xQ0 zubOOwKC6U%(l;WFqfBH$c-!v|Da5qRBz?N1Sf!z8!`a8i4{72l=uNI4;`8-7wZ9B? zYi~_gBtI2hKJ~hH>L-y%e{PyrL~!xB`P~!ETuiLgK(gk`UI5YYxR_{^&#i2qXz`!h z&HW8wE6=bJyX58!6y?e&BACI}Nn zCN&N!W-$z*3Qku=EBq*i|a*-BkxfpB=W^1$aFP9A)&JofT~%oyMvFTXH|O^W(N1 z`<{kd0<^0nFG&sgORaaBAB=V1wdviSzq8n>i3nOAjA@RH>|J~qxbvzFBHM{P*YxdP zZ%tr7;uVZ@>|l0LKbc-W+Bb6_*pSgr&W~l^il?>z*0DFydlT_b zB}Rb#!^pqGjr)(!2JSff%paDybl%u^nq-$?1G;bW*o*+547^p-u~VNSGl%F22X@7N zYL((wWuF>V%Yd5^x6+5L=-n;;@FPb~>izc1q^*(xMHo)D<2^n~KCHw6UVA7erv8oDJ0SkK* ztdOKnFD~9qS#BC)&VcjQx=k>&X`lN7z{7Se*t*T2+w}tV^3Az&39FUuq&-n_*Yq%! zed?kj3+60>BuPQ8PH|IO(`)-*XK@$uknRzavdxypSZ5V+$JHG13rSH?K&W9mz}>&4HYH3?DW1`z0!{N5^Wz6KN$F>$jRtb~8M z+ytCdVl6xZ-E~HCg0q1mfP^44_^y6DEeL$(sijIu2H`tv6$p*iXl@-5eN99Fe&{tj_DRa8yHw}5jsOqlp zgDF4e*pyRG>{H5ERRf5;?Hs+{A9bspvHG@%xK@9CZ$$UwKuz~Th+wKt-;DfWP@dZ5 zqBKGD*}N(@=7DW$??lV^V>4cgCs~wtcRx44sX*F0PwsUsQ@bkb{-`@Cn6H2@9P+<^ z^i*nWUa)IPG75Mryei%_rB>Cam6m)mG<;U>%Wym6aPFp9Wg~F^2D|H6PaL59Q=x=_ z_TEBssZlS^HVsXG{va~&&EsXVbc$5-#370ma+B$|^94O!@PJy1q*e};@Ix+fQcCy2 z{&<=jHq-IsT(_E^tzh2`5je~{6;-3EUsg6q`^l-*PyOw+q#Bk zICWkL&}R!HLgDcpSlb3i-WcA#C$^tz<+NW9kq2?FVlMIEQIe|EdShsYt{-lJTAla2TBXK- z?fLI&S#IaHN84JpZ#XZaM?@fLl8`^w+5G+}*ej7QQD$c@-Mp-j=lwFDs7`6i85-cq zg2O7ggaYQf#{Z(M5)cNw)At^olDnRfn7`0D8T{_W;Zr)#9hKoTPgCVafy4gaQn#`m zOLwiRnt!2^wEV~Yv@=k_K(;bOrC_>qvWVn(GHj$TeRZ{R!VZ(Mx~l&1Y3j<`U~EE^ zkq$&5NxEaw=HVBcWBL8|@2tOOQw+O*egB*uj0)g|V}DKn@KUX7(gBlDpp15U;H#ZNL}MvjcQ3A|=9iG7+PAa-mb}&fk4iO+6=USMT&V_$Ml>0mliB zK&gdA2S^f};rN9$$E&M>phk&A24pblc^@&Cs5Omb9iH;1`874Z+L^!m`a+cdRc}N7 zHp=mqXNpJsQ6zKDR{}`DO@0yM2Y?$^wX7hm&$-_NJe_u7alPVO@bAK5%Nu{af4F($ z+OZqyQa|lpx%n;q`;57ft3O>Sv-<^GFqw?437N^Muy=!(Ha9mNdAxJW=U9GS^8pOG zr1728D!1rbEkrZSvEueGHnKl056eHDIO9!H4I43Xn|KXmnR> zG}exavm2O{>CiFJ2YzO>Lc=FHiZksHy|%pZYO)U;)cNs`9+vkWwWlcm;Ji2+{@Pfw zKx4GOqr&TcJeDxBdDq1@H`M6$>yvr`NW}XqR5_xdzY~M<^By3&qXAQ$IPS*hMaFx- z!^Dl69Rz!Eu~UXdP7fQ_?!9~@XndO~eKGr)^6Tl~9)SyazXW+EmDpvKw-+p3@mSe@ zXzVv_o#{MnM*Y)odL%Ox_U=2Aaodu}P~hG3&VbJR1FYUgX-zEKg)jVq`zN~3!J%T= zNbNupb2)X%c%nYZT3;#Nkd24i`YuSyOME6z~JTn~mOwm3)4Ezj0;BPr|u z*8SDEOYL8C5pNIpU^MB;e$!W_w7kbQC)+D-cUZo53LSZvW%$tr^HHagfT=<-! z5W8o56&UsrwIvXI-rO;`CUYGZ1%KEaFofb~PS|2+l&6CoM2CV(C?bRAdnD##%`liY zl3Vcw;KoGcesN>ePkAWgB>vU}9T=1*QciP z@H?|D4!(PObm7h)1|v7MUBli!@+GfN)bF3f4vg4EX5bG8N)PX&PQo*kO{@8vJ(9r{ zJjYh;zEpZZ6W8Pm;3dUw)qhI|{?>hkKpI_&Ml-80738!kf z%WRQV@o#eUabr$-guBAI1#XpfetTCK=^1w!`KNXODSYyZ{}Mp22@{zdKt8AyqtEMn zBmC`TBR!$heK)9aqzJs$YxzCi)VlwJb7j9X;;pfiqg2x|%#i{U_vQtCM#P9PGYxs^D+!9K2XZ(vV+z!ac{tG9k8sxA6)tN^EPl|XmVOwHM zxd*4Jje`$3Dy8@*@s<*z{Jz+Q`OI;beeS;jb7h)fm+D1Yuk0ZFb5S(aCi;j`Lzs}< z#*!jMv{q&4G242jxqnp#ks?ErY`=JmS7q({g66u{$=Ic)iTc;px*#b+grAG8ZacIqAXbuC!qLk`4Z+k*|X63I!5S!sV^XnhsKj;RXcye%ibP1dQw-{$fiI zv$m6dO@i%*=EH`irRTp;H|2-z-w!MsQP74{en-}B(EF^WQC=Fpwi|!9TE2>pA>2gfJQcXz!8kfzojfSQ?FU69aL3*om)L2 zrJY=GK2YAL=2YZe6MfA+baZ!XF5dW7tSD%vd%XoUIRMC^wV|9zehkdTc7}39Cfr_4 z4aMDPue6zo8ubFG`_8t7#``7k_`gtHwAjA^0>!wZT5Ja<4K}u>H9Fz>IQQt_y z#nS0vC~z2-aio#HK~>eIrRP@ciNy;8z^U>J=Nn5(7kc*IfL*}^u-{c22H+}I$!^?U zy@Kgfch(31yqLQauf62(3BUh-4jd$4Gl&!RntNl=zN%I2@Z~F}M#(ilvyWRsiM1Pc0GsRAPLobbZ!U4b}O~ zky)mwuagqH>HxQW1k&Bye9cIc66I-Jp&ewtu(V38`+#Zt*0?=J84Eu$ZHPfPX8Cd&H^cZ#@e#E%H^P9 zO?vM{eMWpW{_Sh0F~6qOiW6hweoe6L&B-dgFiV;dIH-O1g9o+jO9~({@2SLKk+lm9 zpAfHBDs#EHO5u5$T)$pur##!}<$pA4yNFuxee}vx6GPp+<}mBFNwY%1qLCe~>|Z#2!Nz>G zT5L@zXsUZ%6T6f#QGWzeP$*{=X$H_d^Ki8rI#oZK&X4P)zOT1aQgmfb4N17bxx*>{ zHgu}p{PBCpmpiirT-ZQU!=Zbkes5VFE;)Gr_rsmfH*_s}ksaaW!UDv&OVXu{=}41?YX+ujqnwiV z$~{iM_yDAQ86JrFHWHQ~g)Q||QuHh%f4Qk$kP61x#~k!FC_ zk9KuMoZI`}{&eP~%f2be06xRt4ZXAjCwi!92a&XF5YnZK`G&Y=TZHu9sqWoBif;b= z+)n*lg84t6|3j1i$iRPO;6F0(9~t(E`IuXn9oOd{;j5d=UxdM>RvjGd_T5Y{gv2-C?bDF84leG z#qOy8S8-HhuR<@n#{bOc#6SOe`rr2g{7;ShnM(gNYy58->q?iSXXRs4?#?&D;%oSPgoBvf1Qls-+m|fwG3WG6eFn=JZ1OJR1YFU%^75XV2w< zd;&-Hmn?5mtZ;<-L|)rF483cE^6CY`gSjhf4cxz0Oep{?jRRKBCz#PY-?P;bd9Q$vcxwJVO1%~@rom2-Luy^`f4 zjZo*J+vYsi5XCFJm@{}yQ`f?!R(W%uX9j#dE&RHE4L?<$O8K}0Y+d^yy}F@cN{wVg3LggbN&*qUFns<;@|SxC4c@iRt5jC-=kMvNBdkY z{~ZsSBIV&S z>GSNh!63Am5`Hp0?@9$wZ%lWH0m70KCxJ}`_c;8Y$RFNkMfb;P&YyXbqg&EdRUx4e z8T_Z|vXc^C74vvW{qjqO;2a)7DY<@#c*ZnVA5Tfs)8uX-vW7oe(@g0trbjDVbhkHw z+3z>CvZhnk(T`^CWGe$pyrm%PyC}uDDd~+4&0&WIp$x5Hn_m;yQK+~A^yj?Bs#3md75f$kN#s{f$PZuwVKkzZwF_D>$94b{s~l+g(VP z0>z>{ap_1uH;GYI4Re&9dJ*I{Qjy$olhUB4?ZsJn!0g(Ao5toZVNGRb2D}Qg)uOCX zYgX)|t<8MyWV&ycAV}41Kaed3DpO+azm{3Ow=Lk1U&+f_w#`Pj7ttC~dWw9^8mE7- z0`ID;hcC|{ArvEx%}hr2H;v!<)iHOv!@PMV)8sGDGYpY@P^-nb`Nu(!P+OO*rIJg3 z5Po|nnsBDtd5Z~Z8ZW57ul#~D)uvQ=V|Rdw!;J#JgOvW$AZhS}zreJgRf7+zu+RMd zELa!c04aS~S;*+RfXAf81gNgo%g>JuBF}kA^IW;C9njS>f+BmxwezT$SmKj29{a>j zByW{&Mj4|NhukBr{e*!N;IvM|(zS|#H-)4cOS;cV!zBhj{e>Nc0<8~D+1_t4k4Vc; zd&^vWWV%t+bp6VNoEo$9U0}}TrBHY~;n2cmZQCLR15%ET27W7p6l_UP{s&v*CCGHR zf+r}EN0;#ovenk-QS7SQv-d$otFdb5?zJh;7gYkk{%~lzqr55Eg0<}_d;&|e-idkv zQPX_|)YkNX3Nk?C>71p6LO@Pp?1!(e{EfTTWn9q1XYx+xad8HKkj`anJRQKV$n2D=$e9LNApYSCK@F)p zFZ9b(cGBlFVUXe8=zhY$LelB4#a^5R>TqJUn{((TEC|7{?K+EJ#B8!stm)4S>sja6 zH=VZurFrU#!GPr%J@Ud_HOr9T5%QcxaM)}cUDZ_3jaj>-?mT%5B=;3O=DgQ0XwS8> z>rQEU;SXzoo14yxZgHx;h&<;Je~^^FlwoRTxrwNpA(XpnbSdLyy;mXO(&?~EJIba4 zEj$A9)wKe&wPK_-%16-w+~cKyFVP<;cb#%BgqcvHZLUjKbWcj9>8UKDJF5MN0Aigf2?XT0IYz|Qscx{uSW8Ythg%5fvDw+jfK4;&_6u^x9z703gz9(I+t5) zuKlKJU4IE8W$UL`1sC#;<`D-P%r{!H{OmX~R) zf>(Q||7h0{!~#JRM7VgFR~av&teuz}CMUb7_Y6v#f8lLyy6z8|zc zKfoQYX^$f`@>eeiwMXX-cDjWtVTTC_?p6(`wwUh`Y#be}(x8nFwi%8@ zr`65g*>T>ul;b)(7Omo_Z!LX8$AqSzjBur8scTc-clEYdZ|e!3%Nq`E6Uh;jR|T}= zS(v$-mkJn#3+>^}G`oK07UpCiu9V*8Q+N62`E@tcT<+3KrqD2hF5SGiCa*Yb)TO|1 z@2A*mrZL{x^+(U#26ZUo=huaCN_Z2c6hPSr<;_dkx{HBv1-b#lQ#2DWg!%i5Op&6l zch%q&jmlDN(;;6r$Rb^$T$?B5DXKwxqr}|z<*poX5EEOTywU%lb_`Pbel@}sl_KBP z&d;x~L;39J#Uaf4Fs$L%v09~n{#l6jy!!{GU{nx!HJS}AeS9U1aLly`s)UEk!1a9p zAVyho;VRa!U~b*-@{;EwA#cLdPv^t7BK%QAMRp2^y7uhkcu1X7&ggfH-1MDEe7Fp)no}>^DgT3bO0usoz+WgJ zf~cE`Cp%xpXC>qtoSbL5<$FT= zfnUt^T>ZVr)f}SBk5l3sRt{6UH1IwUl@aS5{^0oSNh7uP0zpUm#eSc(&hzK~YV$ct z&9dkehBZ@td^lV}LV7smIEaRY!lj}^qS?Xy3xWF?UdAXl{BEG%PAiiLSo|@xU8qNY z-FoDrUhFk4cl2!_<4SR1V@=1}lb`(718Y{gH1h zh9Rg2)3FvNO87ApuVp*fUzbWP0nILvL1)rSr_w!5%rIdlJG%mLP;Z&IQc4R-?~E6i z!~Q07KE3%)edz8&Xq&&yLhnL;{OPx@*Ky%xoG8E#NP#rt=G9}cZOsbjPH!h{2e(v` zlop=A78nl)d6zrgP;M6ASU?SHteohHAc!V1_TWHJ!lKO|D$*?_rzLa+Z7$(3F6(8w zdr$9Jmnxy9#8l=jgqd{m0_91VwNqyL3r9+q{*&*wBYnwRKJt->{>ZI`Qx?9GZm7Dz z>lEI`0n-f&9n!A5H&Z|AIgN84L|Zv&8mmYKFZ-i-i-%o(x-Trzh7$Sq5@OqRQ#8Pe z-l+%dC#Ol?2w4~xfO1<0MVs{Z@E$eVe5KyfoTY!O4?}b}Q^afLEc`=BnqWqU=Cr1D zEcSq;hm{~I_1$V@l_|U{>s|WMhi;|~8^`h`dKK6M@;J0WQi&Vs+TRSjb%M!R3m_6p z<8C}7z641>z@?QZ{nP;RnqTkRZsGfse$6b*c+hfG9HR0jPM&XMz|T*GwQL?zXAjAX zV*-NX3d`47rfu#5E(r2Z=Aj1`-d<*kJzVuB!fJ7PxUY&KTGMnPX9XLdoMzM91@#UJ zUdXBB`&=YXG2?SMlzZCgc4GQb4F392`_%7Y+?#JC*xS0Td~KqqchS`ge=|LFGfVTO zT=UACGz$XhD-J*IpaHi-1!xX6+LPcwX=^-fY{58O-Xs zNnQS`4(|b|mre2V?oGuM2T&ls@dqKymk6IU8gD^iFyqg!W?WN*;5_2UqJD3hVU2?h8aRFM*nRFH$T>-AY{ zbm@9oO=uYaTUnkwayJ-jrsEcEs}QW7Y^o#ZG8mKkwPDeB9f$7UqC|q$`yt+&x{P1X z#09&qiGb?#bzoPx{gf^wqN~Uz9Mw=M%Cq0f*APRjPy=dF&(;>whqG%+EFOgUMYl~HdZKF8 zW*%#8hHC^k?G|rfrGUGiS7?1hY;v187x}HQmrg*Dy`(m@rf+#SN`CadpCQ z(5MTgcAFcm7KI7u?E*<=2vtfv)}Ew;gRIN+e2n_JGlpJKe@*|`^bQO{WGZ;&wO;Vb z<`n-qFSt-NJGmb)%4a1@zP7HIT{VGhWX=~X^`(PvM1zCeGM7SE4E^kS+qY@K!?ViamOSF?S!bK$Cp#bPT^?`~`S#JINniK$7#c8@|NC z?0V(fsxt&Kr9sW>>$HKWegtODft&N#>QKlHv-l4neS)-wZ*cIyl$NJf*bHS@&;Z6O zLS`O{t8WPy)9HK+8bB>#+(!^8gYMd$U*G)(d@w%`mL*iGXBGMKG)gRYy}2xP$aTxr z*7JC%5)LXP5oXd;F%{e>xRVr3Uph@Ku!oq&8~A!4xFqZ-PTvE(41kDMxUQ5o#$xtn zpztB&6%6;JZr_LLS`9DxJmDn3Gl*^gt$&yL9u9)6^I|zIzDkhNW9vi%OD{I2Buq;H zEA1u>!nt*4z5dP_4y#09Vy?673WaL`9_PZC0!U*$P&(wF?RjK z{o=>DT}QhFdiE69rv(XADtrPPdfpxfZ)S<7>1lcC-<~BdLS~BE zbfG!rKmga~=XQB=zu2Pa4h{RMH+iq9S!pq?{Iv8$tqH5ec~Aim?8tE)^3<@lJ73LZ zL3E{sT71G;nYBa)F_*pXuhQb9XNxbmG!KH1kqPE!OL-p9{gdpT1AYXv z?Mz&vDwrDQMs0*T{ldj;WY55?F8|==TK?%IJ*zKmVb*plAB8KqT67jgDZ1tJo~Tm%q{K76sFPFU!3k=#!d@<)^1jE6l8@pB)WtJ6+mJ z(miq_d)CtbOOuJkLw} zZXoKwsd2y!GcVkc;9SjD?a+xJF(?aF@(S+YoV~g_PTP75$wz>%ELCDu2OGVxf?daM z!plPD?3>5G^VxikEj6U}Uis9i zM7(Y6I=RzcwaZDe+A=};MU%eT6D8#oHhE)T4|>Kgb76ISwp+P6OP3RujJReMedocf zd$qImD{*NT`Hm)DUU2_e@AJ*HlV+&Ud;_m-?(Ki%4i z(FMBR^EAQDZ{*^kv}(fkh`|Kf>60tkSIAJe5qDVb!P*2?%ANCwu*_A5Opmu9F*1Yb z{h<)Ys&rvpi$&n)TtdOJApO~sAm0(+7|D@^$*;b&2YyB)(3-z8aa|%u)&?WKSv^Dh zKG5>C6Wd}9cR`baw9~VZ@dMglFrSej6MMap33YP4vUTAB)5C-UJ@Eo|wD>44RcL>H zYpLl%_SgX{YJQ@qw#Y2-)?vt8AYGX!LinH?>>50<)+L&~$_m-4ArDM;X4+wxH3=A* zFYlaR))rBbOlhFzBP?AhUWT`7DgznAbwsMWm`>z(+jmJ=d8~ZkcYm995G^8Dib44N zT;!gMm9h5{Q!}M8-!$KYzIo}Zheyg_LQ^LrpJ4U*$mM#psDic#w|Ukyz1Gz)Cai=D z-;0BVv<~|!MBqpWqCH`C@VeBiTTF7*H=-Z|(FZ1MEi}2>O`8DMm51J(ny?nGs!reE znl9ANdqwB;a4^Smn-^^tq)n{93LI%I^%|JWh}0G_u3F3EW$X8&FD&(X(!*a?QhbP= zy$aJi(ZJ^HWV=7QmKvcR=_;IDR8FL{&(>7+jrn<$()nu3cVI35yj^!W|3_M4w=J$=a|uKfi89x!eOivx(!5nB55>s}DPAG8^@8Px3?9*zIYaWf4F{uGt(q3|Dy@P? zx{bvNv%iEevKB9g7M@|VNo-CQL@%yM?VC*T7YCTpssft#?pC_?r#xzvzbrh|mq z3MGNA0|l=xb5(pLsM3H77gNrJ+Nq$FTbyl#r}-RoCak>#B}99@)@ULCf@Yu_;G}2b ztc9cTygo=#fh&^&?N-OZ``WbYKi&tl1Bb_qdv?d|D|L3T^>Fi4-e7z04oYS6Z?3fw z5{2)22G;KGxNTR*XW|m5pI($4NPaf6-|kxTOvqO26CzgCQE(P{wG&j~3;W$_YgfWn zkaOw+No9q*Nw!wN$+{(MB`tU(=WCYIr_1lH23kl1m+Ee-y=Y6YbP}(Q_{1SROtB_Lk)ML>?W%8CTM_-i~sGMwoWEwAC(6>lf zva-HrNl5$~H!WLV75j0H?4Z(;K%LW-?lNs3tM3qJbEUOx8^srEqzc+CskyIgBb5^= z_JC{CLi=x>v_%}tc~xeD$(MzjC2U_pMZRgDK3Owm?clVPOBoi)JypVH(7$(1>0|4u zQ^S6)D~keAZ%++r>?opDIcPo({-nx#C8`AoAT~N&_jJs3hAwC?k}CN!z14sEWg z4!#w<_&?EEecu=;G6*wyk~Rk_rV!fQO+ah{;FAdQW!4ar7wf(upvJ_?!cicqCv96+ z@}~FzqJEW+jU#+YNk!@Ny1yH>h8nq9skN`l!U0(4X1Pa`T)YHEUYOzG zDm3ePLs(9g%R;Gs%12J=xWfNwKKqsI0nHB%){|cX*>_np*iZE*qh6OL2)>?0*&JtO z{5CfKxCD3DT3g}EQJW~UpWxc}n{!}jyt}XD%N$l>sO&!d6QY24{-+Fdmiv!iqQx=H zIklfRwZ8JGSqSXsd_Ln;2R;0@_2}u-1I))?_@%j(-he1%QbFsqkO@0GA0J=4CXQja ze}Pzilg@;Ou)WWuDC9>DDl@eMaVLh;l35}KdlarC3|Dv!oQ!3a0AFxX(#p!Ts>X9L zTvi2zEm&rEhg5jId)=qC4L3DfNwnd_^CobWn(+2~GW`VYXiDn^J(O-)X3X<(d(St? zB0e;1N3~KLvi|u zyRV0N3+393*S?7r^FMl#k098-^uuK8h*T~@eVu|Wy`R&xfK55Kkx#MJm z?27T4X3k|boCIrSSm9UW>ywt!lM9WR)4wl_;ibf`6u_eopjOvdBu9M0Guj!DvFRZf zVUX=4H%1v;%y6g7L!E93j?J5lc4kugsa`M5J4@?Y#sD9qG0lc}+|nkCw2B!<*@Sv+ zJ@M$dc&VMKrm*DRii1sc&hb>XKv(g!V{8yAfEI}=<1RAL=N{S}64wz}c&}t;zq`{R zQ;pY8v2x7(j(hhFwdPvJUPGU>N0tE)Vk)mQ*Mp&OHo1kt-o%(cj% z-rItOq2}#U4D1iQSjpEv12I{fl9C|^*9mB0vf!%o%O8ACmb&6PAjDY7>%zy<@;`Xx z22^=N-D=fby0leT%Obwy?4!MWhyvF67DmnQSEbF?#mO!Or0>`1ztP?dK{FpqqE(rV z`G)mYpj^1m|ESMhDC^-$a(ueb4u5uv&WYzglyrU)T=(gif^t;%`@V53yn;ma@v)7P zyh!+ovstULbn99-GYBww~sMk*y7WtkZ$ltq9Nu=$l-&4=)?=Aukq`T+M!U-@W@-}Q9+13S)mcV*ZmYcIbtU;NTo-DsmfiMTsqmtFhb%b$x$?}qS zhud7bQ&0$Hkyz5r1^!w5AZf1;+ZFBOKe5^F^bVY~62yk?1@fGmx#N}XoF+8iZ=SAp z513`}^cioPuit2-B;K8FUblXFr+N(;PybqD0JvHANzRlaE8ynJAa{zNqh~zlGp0Jc%gkA`$(tyhu?vgsFH_W+{f2- z^OLTm7io0V0lU_cXa~I=v9&tQST0%^TjBpQ(X`2?JGOPwJS+a$`7Tc2 zw>|4=C^V+2rI`A)l`{^O?JD2C3tbBVu#Y5%`{ z!4rLUh?VD%V;&}^*Tzg>`y~diif2V1v+nwdzm~SGy03*ke%NmLd`B`K7P@$CMTU#G zy7Jwbj!(besNYl7*<|Nukzru>^`|6UG0Wy|blh#6+5(Tq#GX4~&DR+ginv7Nfld0@ z_n1eec`M^=yvf77US8pg2kKHuMYn%!5o3Dk-Rxj2_&yinMmc=|6;AoK+e((ssQ>n9 z*wVnY2s(D`?dVp3@q+nlspvBq-H7=xa3vE+iPA1mw` z8`#i(7%kPJIZE0!6n)Bw9kd$Xp=S26<fG+nr?D_YdCIYw>&YL_c@>FW3+PMJegS-~@m^L(GicyI5YIoTe9+K1r{bNMhv75NdbB7?(-VQzX*`loX&(uZXqG?KyY@HLKEPLks7s&ENfnYH@(D#>s! z5&pGhqFlUPeH^78Xrjcq)EcJ5l&Wrk789%Vf>miRelg@TDU)7$Z?)(qnrFl!5cjBa zifvtfD$>zU9e3eJVPaobsmb56u7$7&=CgH#VI?Q#h7dsP8C-Iwx@+X3?csRCd7!cU zQPtS$CLyZ8)g{8c;61(I7297pgXW=fd`j%khH0#xI}|}e7;u+Z39n+zqEEb;!s|3- zPnb)(O$dYHFpecv4xpRPOjSELGK5=t z)Wb41-64B1MgzwN>vzE2r&vJ{koMLqn#0*%cDYo zN{cod_Aza(=Ti1CnFX8X-Be!hWN0{qOS=nXz?(t@@2d`>9$U6p5!z7e#G+)yV6Kkyz{gGp~YV7A>yl5YXg40kNez3 z={m9-^cz?k5-V7@V~WwV)kFNtc8ksm(85&yjcu~(R>r@R0fuNn;VXUF)T)-z&*1Pb znNHfTh#`dDL$GV7gD_$LbOd^?_~E2sERU(bfCxJYS5WX28sB;SS||xXsbek zSo`R?NL2{Fn^|d`ZB4{qLgmYSprQ1 zhi6@o33UEuL(GOO8?$#k_LTe6#};zG=lhX(AK&AgVHQVb!%!!!sWpmu+f(;v78Rf7 zI~!iZs(On#Wr!GTs>{c5>N(+?tP5Er*t? zNj=25R&N-So!4`}{ezlm1grmaMtUFh9&ut#w5lVY9$?%O3}-b^E9=#c^t@$y6+6{F zi*mcEAM?W$en@@9GU(|a-WPEzUi z2qcDvJ;QB(#ws_FPZ-v(RH0z2+GPvZAF8&L2dyA+t_^EH+EQo&k zB$Z=BPUF;i;_dwzs%69z{zJ$$8rNML%c;vu>9$I8r4dW2oWc7$sYahjhOa~Kr(Uh) z%WNe|2~xv&uRkoFU4LfE^#=nxHfQ`T$Rl*G>9e2Z!ptieuVk5^Kd)ggQ>>ENzLPMK zm6bRw30h*G8DJba#R}lgKVceOZ&t!3qYqprbrf2pvZ2iymp!DYbEFUdt`Im!f=y)+ z+aXxyn@v=4}$OVrH|L2K5SE5dLmT4r?#NW_{y@5y&v3+7ch4bpHm0{rp~C zL4+uzX*@R)!_#k;;HVp!zkS=V`bl?#s+4c2o~R9t98*ISOO+ZUM4op-9V1a;g3D7O z%o?~0rR7ZA7-A-fvQ$UJ2rc+{9%`jzc2I`c52M;+U|Mi_M0C@#ZgqbW%l`W->+i)& zn0*S1s#Nnc%ac6k7@N|+9%_yo5??B-p$UIS80IO3mM_0QX`}VaIZvl{MW2&OpHn*P z(?~i}mOVuuf16&$n0@yV!;2hV*91YD$Mjc!G&Qk1QZD!|Y-IB!Ykb#b;{`*N4%(<1b_sc-;P7;E|MV|PK<^Y!AvEmU&|e2li~EQkE#e~`j@+OFy}mjiM0u% zz+-)ac+@U+p^WZ7Aj60*;kAG3ObY0K;@1Ca5N5sPwefVtccE0vKSqPKuEp~HsSx#n z5EOQ4;j|r;jgz1l-RMa53GGFDi;`4)@sl=EH))Aa4VVE;k%bNB(0q`FELWmjK|M{> zF|oBIHQm|4ZsG?T-hO4aaQKNm6L(SPJ3PF4^*}1%-|#eYe&*EG^xT2?)n{+RBxS5ld;Ot-U=v%yw#yW~q8G`w!DnpG zhVkqqup|`F##S(~W?l$Kx2$~lW_A+3lIcH7afif zTT2yuhE5-(hu?)&j3k*2{U)(v+2nXn_vrRV2ne!H8GZqp_#RiwGq=$~>#i|HtA>-3 z&9q2Dr%6f)dwVY}na9th6R{|!67$3`OhC)(ak%7m446P@N{}O25~27RHHBli9vi#! zEM>TYc&?ghM;W(N=U%~*+aqx?<`cFp{&c(UVzqQFi>%HDNvG|*+9Q(zs)YH%eVgQ^ z4>M7_=lH*Z6%E0ONViU?-l=)a^jvS@^8T{oAUsl%M>`rDTyWV7_BT-4 zK!`qtgqK7n!-ncH+@K%Q!*#+|=P@wf2Q&~5lR3;K_f|_cYM6$*#dRc1&sNJGge~tb=~qc-7|8ZvCdPm6>}Y(lPjdE3!V4QdY*Uny>!TfStsBxRN?F;h^H9p(AqTJeggh=-%gov~l@C(E?eR z*dLwv+ar0`OkZsTSG~diQhG7=i{*ZhN3?=o+(k+AGvWmw9ujM$No5r zpJ=r~ubl&j%mvFgSCpTbX_0$sfL6&ST@A3LCrDWEI1nRZk2 z9>-Y`&pG2CB{$}nB?!FZOe0|$8t(p7_oc0GhZ^OD)%c7LW{S%&eua-jnv7-ofj7Y^ z?-+p3xv+o*{nE-O3(lVNELLQT=ocV&j9qZtHpRc&v^>>s)!0aeAZ5%71h6bpg6dBr0Wj zT@ccCl=T%Y4_)l|tRQofLig?6DJQ$At^g#WXjP&h$(<-M&P@Sp-(uFcF`=ft@gTJ2l&?@y5fCKpWJ36^1cP zFVQ4^e1|A78p;2x>0x{ShFlug`2I#CtugcPTWz?Xc=e|D?cAb zf8+e9584r7S{QVGycQ9|m>gka=a1o(YaG_BbG?yihcQ<+_4vzj62@|t|L|@K{))P# zk449IFM7-Ev5S{b=2Hpoa<=zh-OT%5l-6im3Zw%K!`9-zBn;c3C`mir6i8)A$l3EG zb-xdvKd(yfIV}`Dz2f8DGUNqXH1{*{xkPAfug*|XsfMJ7fwTyo1^OCl9z}G9AEk&? z|9Z%Ve6{A!a&#ox0HfymQW@P|e@Ab63)Gwr<}eGT|3M#3Geitr5(Dv;_7BcR2vdhb z{X~a?QqDEx{Y38+`4CRSxXkjAwL9`xE$Jxe;^#5jhAmTkT$jd|h&!P>w{2FxKIaW_ zG*QuRw6%J5O59G(Gm(mCXH?v%PB9}Dy}1O-N*G*&gA%`<_NpsAQK%>Q`+bOb0vyH{ zIR@qp;z^a^nKpV$@bV}sk93f@vu~SwTdZ@2zXs(dcE14qnoE{tL&8a|`W{Ct{wnAw z$}7U`!^N!*`tbQ*`tW?U$xYV<{8mBXGziJIhIKmk{-8{XCVX&1>ws&GNJ9J5|kL-bM&>BD2|$1C=FAnPl^@NDy+i*W)^ z@JsL8C=b+{tKw$@X`ghL2peD1|KPD>1So+-vzi{0>O>5N&oO1r6EQw5c8F~Yx~8>w zH#LZ0-3i;AB`?nOwX8ZAH<_+j&Tb<1bw%K&+IEirBS}3u2h4 z9@C%uuJ%Ys@WtMD#$R`TosSq<3m@3#+p*ECQFLR)i8|D>S!4Z-m`Cd-P17RNzr`i_o~`mK1B`pdR1owGviA|kmSI184KYK2H@tg z6l|9qg{ltuv}`4s)ezg=n~XxG1NRZRh)mZ(V-_#DQ_(!MDjKO%7dBNv+DLHM`|oZ` zdP-U&RHYMcQF*P`5NSGZaFZO;ysgto)iMlj!_;BL&n|PweNmq_$k_Lk1Hb8br@QCf z4GDHy6D`sUZMmH_ZEY)U7SfBXpUdq6ZG1M6VK<0fDHQzNjQ_0_ixFFF9yb~8$F&$V zb}}TyWV!q$^UMV)gtnY*3Fm*4av$i~`R}F;Gd&>UpT+qa@i3=o3lm!P=p&uDc0NTh? zP4?VnSPoo$j7QZ7M?0W1duG?|cODSbje06u5F&v5Qg8wnef6uQTdKCb)(|~7AwhMg zIJE`C|6B-t_}XfBmx4{MpeRuu>o=j&U~RkjSSkO@4MHXzsn2M^1ZYW&^9PZJlZ9&A zRCpOz*ed7_<}U;n>t)ipR)F~O%80@dobXJbrKSW-c`#GEVG1?jFd_^S6*MWz>nOk-^g7=G|c z(m_!biYBd;c8MxzRo(`3=|o^8oyzmR&5}&b6|tf`&o+PIFEX>`g85!>x_mpQyn^xM z_K(;iPPcxb2$d6Pze$sN+NlGtp93h98Tf zjXj1^ekkeJAS3mbYv)G{&ikY@YM`9crcL(W>-=~Xjtip8vcWk=g$(Y|ozI-?w9$4m z5FWggK=&sK5=fCB`W?SdsP{xQFxj#K{P)`CMs0(?v^%c9UiFcZU3>%`q?PHfrWmc< z(wekVOs;%^97J5{fL7n!9AUp+>Ifu3? zu%tqx(PGRWHYTqy!;fx@;P<}A7h!MOL{FzKsfiXgukTi(n?DXbF%P9Pn#Jn^srhOA zap$r=hHegNy}sULiu5OLhx&z&P;Ogi#b7Av>tBb62;mJj3!~qHPZn%-zwGn^54r?nypegZ6u}ID&1OmH{fs4AQ?NcG^wSQ z3gaJ#PEsa8GHuhV*K_GZ{$u(Hc7Q@{)a;n7ezK&|*AA%GmAxUdya{4)V$T){{VQk5 z^yz8+|2W~Cqwm;(8-e{?qdEa~N}M1KFW!VN3(FBU+5UL`=a+IeUj!LTbm&vwiqqb{ zc`wm$VAHycnIIjZIPv2B<4?{uLkV(sWbLHbbADJt+0*>{1K`U?Ljru%?XLQ0F>0g8 z*keV;8aNNI7x*I@Vz|FN@ood7x=;9lNYG>J%o_A~*Z#&O0 z`syO8iFR1#?4#SgEcILu*;kWphLAne(oUMf$f3Q$4_+h%v0~KDV(uOIx5*)bykqhJ zcUllo8Z7-w5-3?D+E_OdFRN5BWk0k>lQ<(RoMyiFNJ(o&Y}?dG!&$Uc6$G<;@}9@T zESGPYT-t;Zo%B-`k$#X-p_XTTe1 zkbL%ac~a(3c1wi!lx6+Gh3m}~?5)b^JGXVZ%iAX0gJXfl%Th*fzxoRyb0}{Q!&L?k z-V$9kl=)TiJ(AHE>Cts@l^_?-eJ85~J~TjyhD7 zg9;OKi>sUVo!vF*hbpAP`YhQ_iMxttY+I(3PiTo;7U0SRW=Z+MGY6wbHPi{RjqN`(e3ssF1vF^7!WFW<{~qd} zY4{meE79Q};U(Qu#4EG%w-1DK8yQ2bt@8iVs|7< zt>SrXdU;znbJh1zRkb~NJFhZDx{)^@{${Xqp=e5;QPi*BFYZE;PPdz%;( zLZFf%_J^0OV_5>L7KnrZ@=GDMO!~+e-s84S8(YEm-?$jwn3|61OiE7h5f@%+#(Q<8 z_8?M52hEQ|?~_#PHeo!klXG%#Fzi%ekmW*muU1nO0xF^2yGNe4Uekl@rj20&PNAqp zCi66x5I(wX_m!>=kXv`|K$-)R5g8+o2wu1vDiL!1{Wp(4s5F$8?O(H}Rq8sTCeigR zZZl?6O2~_xtsG)_33ef(&Uvci08flCJ|n+=#%3W}_HTg#WEefQ!0Xe^Rl>F1*;aGA zzJV)Lh#O!de9Cz~Zx{(psE-(D_8S84z_?{%$6>92Z3`>dXB%eb4XWex8+WiTZ4=wnkwk@Nl zP(b9xj*_~%ttIkD=lR?y!h_woG4@lgU>kuxnlR1!EgA@AdiwBpoo{F;NRykHnHhLK zERg1VDzhX9fuCgPu*`WTyUovE6=cB^k4z=)sK~E)QG~qqpFB86vCw~kmdu8`&{I~LptHab zJ+ULR8vlkmEA@9(Ju%5T^Wodfg7M&_l^wEeYXjZpT&P44Fj8wel%yK^SQM!_8nWH! z#JLT~U_zL;LboBCL7bF;8(3Ho{Ug&kIX6;ja;5|)d5o=QZB^Y zUOk_CUTrmoZ*cbW)g&gy?ZU}IdnBBuJswWmC3@VM?6`jY9tI`f2<-~m(GD^E{tGN3 z=(^PVnK}Nh&a-1@xE0 zJtFAX2k>(~;mUw0G9)!6CFP7(;eYHR-#Bm$vnl!*zf*~3fVRuxmVw^%S(g(C$;xW& zu+v^0DFaIpZsB5P6+D**{qL)ahK~3b9NViLy%n!P?`>rL5U)5#4MF>=$H5dwrsHg7 zgF6`0ZamikJ8KAO_0)GI6jxJ1EPQU!g=$`s%Z+n>H9%bog`xh%lmD3oNY1??eRi@7 zn6TbuP|%esknfw*tj|^U!=i60X#Z?YB998^fYgOSX{4IU$8V47dJMA)70p8mn7Qr7 zmn{5luRb=R*>ScoOU-Vwc5>_;>~WA2wC-&@P@7CD;C(r1SYOV;tDcU)NDMRo_nZ=i~h z^IuI#Fm$f-P{UuMNdNfUfi;<{!tIu66WKFt+3>~m#+1u>U!ZT;-AAbPUxzWC`gfOCTs;y~lAZHFc`4B*=txT=37}n#QNX8c@*kxQqk>;i zhIl?BX|jjVoIekSgsy(Evi9hJG6Cge+XJX$kA@^}_R{CwPqRZSH1fTWaNvo%dM|8W z@%OnqV%=_ctH;YdNkFOdItKN*yk3#+Swm;<>s{rxqoNH(&_l&MG9}600l`0(bN(&2EN^((IOpt^vkI*J`p;S19zPhpt_t03N&p zOXgqljH+cD{{uLH!$ygMraFyq3;fO;YVQZ}6SGY#b-&6WB?ixyaNRtYhWOYaU4Pe{ z*v2u5Q zEDkM1ZFU2%%{Bt-_1mviY`ctj1;ig6s*+ifSlla4M2szL&LvbED5b7u-!G1>xwwlU zQmPN`~1AU9D8viqHTICa!uuKp;md`4P<(BsPel^x}aQ&j! zyLXN({SEL(aWiZiylieD!vEqw;357$V4+_%VylyXi(Habbp!*_){U$gPY~&ksc2QWfN)sNBL*t0R)d{KPxRl?B-Uo z5%WNs#Qk<*m>yjPPrzx;=2v|xM>vHf2QqoJ93>sHJ64b3gGcFs#zKHkU2Qd2>Q2ll z1YsC$HuAJ@1e{-+K|2%59n*?0SxO@1Q`Pq_e{}#E0Q4Nkm-y<>H6Qidk3`%}Lr71S zn)j-8CJD%h#+8B9=LN&sy^f~EGvt|C1Gz+ehW_>lMniDQ>fe(#?S|}yuGXpte)m4v zbPNO{N+{&)cRjqwd=+o0#np+!sns;`bdGO`sEqf>e!9{B74ir$dVMG|#9&9~zr@rl zrb6q+grSLti-vQBqk+Yi!&tka0cfk?P?UQHKyg%Imi!Oe*n&ttXz%p?p}pY9whq|4 zE|$N@sLgbnFBk7hI+dNR{7SX>6*oU|sMyhw4MkqQ!|3~+;^-YZ9KYOrHK1VRosC0w z=4-4lpb0%6t*dgd#@&CP0&d^~Z0Yqae=I?b;06T09Zc|XIHBLXR*}9`@R5f%M!FIiVjhPE1 z_L|yk+@!`>fcMZSCUW#(VR2U2pu>m4dwSY5{hxtEzyP{s@1OWjuaANez%L`MHN9s- zE=Sbsg(_74ow5QS?2CsR5^+ybZQ_(Y*RDfqkZq0kiBe#5c5i*V%-(z}=T)ukP*E`g zPtv5t)oPYY629j$Q_o}O@jZak-tL|~@z}4_cIiBQ)RcqLvF%02;jEKZU!Q$aR1X3c zFW_WgQtpqfj|OypW!5noQ=B>SK%O7Wcl6jUMchF+q^<3M5>K~IdWSWnnKF=u5_#PKMB?>8-j0J^wz6L)Gp`nK!BJ>_kImB~!D`PuUxQ7~^XKExo>O;S&U{gC_b$NjPRWOF zg@jq~c`4y-F9C9FyjcR&wCPmEXNS&a1L_Q!_u-h9X>X)jAiom zrd7`uXR0VAaMgH)&*m+T*;Z_`pDPRI1bFj_;X|KBS`SS-mzUIB5#Zru6XeeK&L zry&b%ku*cdiUhyI%FI;@@8N8Pu`uq^|Fs0xbTNP>qz8)no>~HVh?w7D<+i(zUSH^N zbNd|s)ce{R=pqTS$=90b`elKb28xRzFV=VtoI0vSsw^di$Zn!wlMtq&S|!`Fb}#;j zd$!t0CuaTsew}9zg@i4XO_Goz^Y0}vqQanCPqW(BEMuBn%eK*%nnw`;2@ zkm4Ur$REJ)e>WH_aEu*uV`GY|!?}wd^VaS#pnNmc%bm_v8pY4dRDR@Cn3=80l-9S) zKwh1vt1h-!o1m$Z82{6saZz_WJ=F4^_XV~EKmw*@76PTC2O|Afyx4d$svtX~E`erg3pT=)eE%L#jc8 z8vo~6m+77Z`>%SZfNfLD)pJeCYu3+l_pa2kZb9HdF6@22J6uM;zOOS4y*p}=G-|dfMs^{BF@JZf!`sqS|F4Cm@xCicVuL!)R?AmC zuVDvg)ZBdR5wp1h2;O*3gvAX&b|h}L*Tv2jx}0Ibj3_`iJ>;71lI^=VVp}lUZoTId zVt(IbyR>;C;%BbuuLUeGQK&Q3b_oES-3JVv2Y9v`B1L|R(^v~A?)$-Bv`D9mGqa<) zI)EroH(pA7Cj=hL<;aL&IA8_qUe2Uhuvw|=7-vYY#6>erxbMAh$+mtv48@c zUNGj{BPJB43Tyk(J8)0#fc`SK>E_sd2*Jhq?u~`-F-Uf~Z^_?S(SMq`qRRGVb+)J7 zSrtdgo0letmAAv6i3!_R1E(5f6OmWbJCcoozVx1Zlh^jo9RWO8{eB(m)c(Ka`@iM` z-uIy=-Ru@9wEEo(+A{$#pu(WPPXRg>ZPwD(mh}}}VHllp=wV|(A^2`PG+1ptyqOPw zmLH(&-7q^GAi`=#pm5rr3q@ zldPU!k=r)bWm}r)3#eU9UikCxI~&S$Z-HT)N||>6BmiPVKkrGDL80~Z_M{BvR$5vb zcjyn(wcAOP4DbB+caJL#>P`!N46EkwBzA>H8wn-dB@2-daQzPnlUA3V!I}SHrE{WEi-DVbg?Efw<8chd|3m9z85Aw~A1pFD;nrV?L zEE~(?i~RPeEISqe?ytWG)mhVZ8^fVtbz!)mUeb#|*|1%yo5w0Zr7J*1@-o@59HV|4y;s2R|)nw*jkj@D_~ zKu>>$-9k^62eD{-Ce!E5N1{I+-GepB-7A?zpD3XW`P=8aWp`We5x9246=?j=rCVH= zDe4ijF$6rJKg2jYc4Z`B$Fie=dxiI*RI_C!zY4!~?k{$c+UI#>{8NiuJ0Nxi0SI?s zf`*10dAb_ac?XlL?eMPN$!fa-6v5#MheIS~$kG!)`059XeD^-kfUI;Q=94tZCOs?^ z3TYH~rU5LU+yTzx@eCHtBMp4M^#DtY0k?8%(D1$|=LT}3E4K3MGvs-dQPYb|v-giwGm%3PcVbf4 zi(oFXAC!>pL&+B?qx$T&*PYqN3+;V9K_$_j7CU#6+A`Xu^<@LD_xn3Lg;60_6DP8% z1L|AGG(a;XfFcC83XrJpha#hUbK26C08huMnQXWe`F_Uztjqj{7JH6+nubf+(n$B# zUF3`nbZSLjv;H4eu}QOql!jO8E`4*DOeNIZn1H+ObO`g^fgfbFQr1E~hmk$$OmUx$ zC#T&z5)?K9cp{nrNQ*%iPY$p7hWGb^QPD)OQdU`@NP0MG0`06|c+X9EaWIpwI&_!2xPH74P@8ok{I{^>4ng#wtDz9v< z&1z^j+QJ7fu2muK_p`6{D(PrtbLU+A53=Xm&i_;aiUG058gKwH#0JdNJZn(sOa0FYaUIGjQtsCcxx#@?NK@6rns^&Tn)=pEj%WGsLfWh&_- z)CQU9+5K%!Zaa`r%JDRZ)NFCL7r3N*4M0Zo9@_ri`eF(kas+!YsVGDtc+i{Q`FAXZ zfa9;uYCzQi15Jl&bU7dlVRB>wTKtc5IZ~;A!QsDj0vJaDChQAN4WK|M;E85G?bApc zhBfh0a-aWtlOX1^V4)hlQ9C-ZQS8eyZEVJ6< zTGFxcr*>Vab^;Vu;8XB(W7PD&oyKLJ`p{9gK>THspRh#3l7-6Gtd|;IsQ(9I>Hg6> zC1F(gTB@h5{d1Qi)UaH@jUwdTw|5JAZSkG*7x#5=D7h3|@MNpe<$o>yf1L{(8PKt* z^#20VQXxN($lA3tIeiL2Z-JgU1KBx-6am5=3;|Q~Kw*;4mqT3VSwPiyGO{)j*Y)3-G4$&Av7lWN#3HSG!+ooq3nDx5P ziaxoCUtjnBSY5baY{+W~kIIu5FQXSIa03Mru*+nqESNsM- zz|cKd!=BIrBPiA2pZH-mB#fl9t!jPi&h?~{1LG^pq{0N_#oz3Bs1J{+$<*VoSYuaZ z4UkjW!=%QU^oF*~#{=&y3i_5p+FdL}=;pPtJ%aK1y5`e+MLG&y`~%IL*XLbF-P~wf z83xY?iul$hcI0S7>kb+BBEb?jnC%5vn=U;fA9uTksLOZOTe^I?Cidivj(h`6d=){B z%&;4K)V;SgqY>XaHV^wPD;egay70LVv!EbI6bGvb<--Us5JQz=Ui+M7x@tCdHjQrU z5)$xp;)hQFy?3(zPENJGAvPH0x^-w^UMTmh;N*8&64UR$5`DjcqEitn547tj51_Yh zesA4v1{Cqn6urv8OpxcKvfKK)o)qfz-)kZ*5*tM0|HIyQMm5!Z{nA0{p!8m)_ZGT@ z-lT}qi}a2VIswFz2vVgJP(Tpry+c%_7m;2=?+}U*LcP)F`M>Y|cE8=V?p^PfoRhQW z%$c*#?AgEl+k0ld;4sAo_{yeYChhnz`SKoCihCnPmssoq5>c(_8)2*lY!o$m(-7N0 z?M3(?Wl6Vm1-1dUQmoy=aviY$;I~|DL?mZ)Pt+awca@I=pjNqr!8tj~ZHj(eZ*@Q& zS@V3FIlxzf$}^G0(b5H5ASu3#Ps@c!g?b5liQ~;FL(eJ|7u~wW+I)}z>9$@Ab8kQ} zqOCIzZxQd{As}}ia98fukn|W#h{zLj-lOgCS~F=OcVH)NHtVJU&~eVL$F8GEe^NBD zln}y(>u*o4Be3To0ms}A?_!o|ehc>yW0oC8p@^O@E|6Vq*k40iCOM7nZv#7tH~mMg zkQ?lvmz1JOo^gRk26j>3FSk3kaic7wGPM@A&Q7>iY}n?H#2RSdt(p)w;cNnC+K}G7 z#~zsYDV4^hIL&Uv%db|51>&}Z9GT9rd4n=ZAP!~4kLHL$;0xu~?CW{kmyLoTO@;VQ z+Dxsm=daX4;!uQ_H{PlT_6PVk#CxbJSnz!8d%|j^o8?KLy4FVA5!}`2_FSdty8W2w zRzGjt4|VZ!bEuYl7Q=sr7SorK1qsIdJmTUzV@>wkxRV%|F6wgQsiX7lHoi41r0M*#xgXhawfz0Q z$(6HkWgN7g;nIBdhNI+jmRyZ<-YSzsvRgwUmbKM2E`+o&M;|bC|={tk~MwTV7#6M3efubqqRw% z);}qNvJXw`v8}ZXw)lE3plB2+JK|mx|O#DoV*4?YvB;I!gANh~hZX(E#L#Wge z3i_gLSsi&oRy>9!p$CfiAuAw`Xl9f^QWKQS7}YaAy~e&2PbM)4^G8y<5pElmBT$Lm?m5&i;GGwa{i1n{V~N*(Sa+!CqU$AU1d{vMt*mKwJt8DN?OPQ2 z$m>T`rsLjzl*D~ulIsa~s*CgB5?1k~L9a*|eM@K#@h(wYASrXWlT~z1 z_Sdcxn|w~H53ZH5ccW*t82zy}TRLgQyQa`^encHiE87J3&Ih@HM$#z#j^|zy<(%VS z-)C%x1kqmU*~g4tPjHu{=Ur6aV)`^c>7B!BTc0z3?&;IOlFE5s zgsv8!&Mu3_O10T3*D0j;kBjp+?zBUjb=R}X_`UFm*I%%;2t@tqiIY}k6MlsW6a#n| zgNgcfQUv$2{vogURsYO|xKCYDQj+t0r6gQp)q`eDS9vZTCf|fiYRwQm(hg-x9Wb(P zF~ZP=Z)0Q4JD$^{l?9IpXXr5S^lNH*+(%Gg_UAMo4!+n3itv}*7!GEp&1En7;Mehozk2568I&{tn(0*M-#f+L#8 z^>8rDI@cz#&Ye`2bO1Dl$Zu&{r@~lDMjWck=JC4hJC@1~7Na?}L!eezwT5JiUUw_j*C!0yI zWn7}?dhkv3-nJ}pg9roXR7~@;A-df4`*!JGH~d|d!%?FucXyUFk$_N&@-^b)oVRAPq>PtG&)gBx3np{W7U?#MMvl5ma=he4Z)LC)L+_k-w_xgeL4aQ4q5ztYp3>cLjCnsbJDMi5&gft`@VP% zU9T8}#x|&htk1FfJ}Qna?~xjp6rT6P`1FqgzGTZW7c1=dXEx8SZ}5$k!eCGE-O^c$ zB_#|2zEaOe>{vUvjZiqz^kr`^n z78JI2Ax+43k0aW3gd*6BkntNxunM~*Xx=IhV_j`XzcJi1j>7wLGxAZ7PetCbGjjkW z{QR9TY0bN*h*-Vj>uASJF~ZTof;twN*ud)pGr@$&stdVkU-L8j@jX?mujBX?U7`DJ zdvmW%<}8WbzO#J#J!@DLsQ(YpPgTJr{TTB*TS)r!Ei}#>Vtgv0zaB3p1p6m3i3PlZ z%@T28-;r@;u99f@#}7QLIKt%jJRKh}YqyZvDVid^xcT|)jsG%2q{#hCw;KPa{#PI2 zw=+eGkN~BtmsTAkqyNbT@ZX%N#AKb(>Etotqm8bt`g5r|lzi_@4%Zka*#kDH&&PLF zx!|izh)WyEhk(@~<*dlJ;b|qZ%G~X=!P&pa+`pG-5jLfdD6<4d!=uuMp62ByG*~7X zyq3aaSyj{O8CuusiT_ADSw@H1@ABIIjLnz$tNe{B092t0$RBKBgT1o}e;Xhx!gk2S z_Yk0>+@LQ5NcTQVohW$*BBdg#?vgC0{VAssrZWYB+29X{o$#7)wL#@SEm^yYT~%+x&k;c_!XKsWI0OmS|O1S&*m%}Q1K66 z+Z=PuZp!qEKHZ4#eCTMa6m_yW`!dHwM!t>KP=ph7#eswRAzXO%gz{;60!dAWV-p7B zMUkjJbF8nPdx4j%;FFyj8@>UQ$Lag+aE9}1127TtCb7)s_Dwx(=gr9@1!xCt26;|& zUHKUsYvQUub#%{WFo%c0!}=qS#NpqDUSn^l!kaz^FY3m1H4C@g-}LYmN&eac&KdrZ zbuGx^&IYQKJ1oSyW6{pIiIt$HC0#;^*Fb*DCaPvBTP=HI^O#rEatYL%7+~PUuR42H z`hc_y`%ZU_%dkY(u1#SZ^vBT%dlk5YvFQaI^L*(oJ}%KO@a1c}*=NiegLE2_Pv=@` z@9404WP_O&Ulacuw0o@bXR`X+l3_XP;Pqyi=D(zZ|5Rs6n%SFL1I6q{oA3nHYr>Rd zs_W&R(#NF+*Cd;W%Y<$DL`$Eh7);Eh;IC5*A8?b0D`kfAB<%wNap3e;=v2Lr8 zHd7F@vh@gCHROcg1qRghG@n$HPJRs8z`gyp;4+usXl(A3m4;a}_LII=@o!f4jTtgM zzlI%&KxKID(RVK<;A_Khxh{oj z$vY(l5AjJ^9xC+&&;6#9C2Zolut*r+f5}Q#MbtNI=dG5-sfNdGe3vS5YE;sTDjB=< zrytaK;ErdpA$-<=`@-e}#T~3mzQOQOStWce=@ehDE7o2mQ4ih258s0qbjNu`)uw~L zO{sHhSzlFP(jS+&xY)RCX0dr^ixi{Y3t2H%%CXBS-6L1o!KC`EA|rBW^FpL{)Lvntqa@7H z0W1vC?1mA911<0i9Hc<%sd&wooxwGC_ns5 z!x>K4N}HX?JBUDeJ-d4_Eb|>uHj~0&3|M=~SS{N*=TJVC#>u?l{lfBXV(2ejEQh+b zx2;Q+F}D{HQ`!GKv4D#%my#l24H_Jdba zM7*OYoZ35zU7u${%ha?&6qQo^B9*HdJRVt8wLHj0QNhBI^l5BU71=GWl|DB9Dn>yo z*`J&qOhyrjM1NpA?Wt{WQ%kg8n}_^3N&wz3+Xs2g6AB2a?JR0*CH6k)ReWpRcA(y$ z@|5Ye`#qU5(esFQNb6-H)!J2~OEyV2IN=F#P;{t#qTOMp)?5Uqar(b0k^ExJDH?qD zWy~{4=|WZ>XynTBkE&zaO(#;*Nw2*G(yC!%+B>dghOfcz0_?)7$+m%#K|H@z04X^D zmTx77OQIwa(GKYYNKJrvg`b!Y@mxb18$IiM;ESk$l@YbqFwx%{0K0FgA*5Av-y8Dn z?;jStK~KHR!%rkj;%%qpbqBMBZ`?^F%XqA{L37xkEZv$>OcM>OwO#J`KA-reK4&4Z z>^`IZiKlwMx8o^2{-BDu&CA{25h?KEi308s%=%8%fKGXnP(gTD;^Ol{up;8SxYMbW z=+yTskEE7)x_(~fEp0n2jr__}t+nNY3;dFl$gd1~lp}Gx1~t`W)sn0ktM=%j4kzOz z{~+#3cr6e?+|;LI|A0$$>sM^}LzfxROX@p;uB@0DAB(!byBL<_a5q0jzc4?r++Y*W zK{%|WKNf|-FXbySAL(-2(k>ARdh!8x606G2!S9N*nszs2o~4O_en)J+s$8Io46(+Q zi{e<<7L1Q1`?SfSmEFwM+h!2wnNs{ZI|<%`v|5w@<@j()hFgoEDNOM{0mH{%-8BAP z>#UUL=MQP2yuOkoPv*7KAGxgU0>LYHl#U+z-4)E#K|AIEOt1V<6l4Ia)p=b_g0Gsz-KAR ze=R>#8W~F}V_@ZkDeL>Py#B%w`vVPo8?crcQZ7Jz#3E~CX7j>Q<%Jl{Tv7e^xhl6l zcl?7Gby%110no4$=ZlCv5pgCiJoOD z219J31rJ??ylL7R?*c1&lSrN#a%Q3&oU5pfMo{S_S01+~J7w@>880+K(j(Sgq%5C$ zuJe9m@o{hnFC{!cp*pu)qNyg^D@pYibidPlIYc5lwxsnz5YwWelKhHu}s&rtYOl#Pq%K`VrW-kr;ofLOo-G^XxE!|cFXAu6AhK8KObHhz8}#U z)l#m*2i(^)l6>fqNx-;mX6e9#R&jL|&Ni%UPbAtfP}IWPm-OIEoe7)d8O}b9k2UbR zHM1tQBHT+dZ;(5jS*wmBx@smlzfU>;L|V4(j{rw80>nv;{!pG1md8aPM`{l^E|ndcVfd8=Z0Wx#-g_6$1G4!$ZWF(GaFc|(*qm87_y z(jzY#F+eBt+e?8iOjbS?uTeza9DhiTR#`zFmC3f1E(&+3h=RGj77EWCg6MHrK9Ym! zmIA6HG1Jr9$IHwXC0NSwuY=bl zCeVA{jp_udybY1{7qzMIU&Va1zoqY5>E&1|KU$S)6{GoC4-&-}E7 z?)*X0`Zl#GIhAfX4pWkX2IP9(A2|8Z+mKoCkFJd9ZG*5e%^y#{;QRiz#Rby4C`Q{v z87#jJxEa%vAO<|CyR$FHp*9i^ABLm59voeG8#`m2OKc!%VC?e~6vO5uC&(3nHsHWS`76n#q|+=VsJ=Xxh;#zkOB3cfB` z;qeCgVV)8CL7Wh&gAry*4lO?B@WuI&L|OQY3)LFhmkx*Y=Q8~=jC*v2nG8}qye1FvL zDM}oLptyv4^>rp-F;a}_4Su;c1N9R38V(#kNF9R)mjz83^W&l>_tjM`L_d2)AMKc0 zI=l=g$UB9WS-y?%GOr{SBP#&HM@qVyPq^U~aF3EaQY3s0@hpmWCmbv@$C1)k3uh%B znB<(bU%0qoofZX~Vhs zkKtvfIu_AKdLfAZ0Ftp}l%(l6_R0S~YgJ0}Da?m~S|KS&Nn)a2eXE^s|dL&vq? zt}DpSDl4Uj2O@hLNij3~3*ekF?P4LPTwxU4kpq>uKk5%;+k4gb4Bw8E!xhEma8Xf) z8|{Oe?v!?_Yw4ID7T@16I8LMF;^e^iuE(MKMZ2;p-fvHGCLO#R$Cw|lqh+YD?F?>t zF@n)IHdw%n;F3*W-aTBX`!Nzqj~-subZeJb=36=#JgIlR*+7YkF|;HR-W{3Z1;EC0 zO5oYiLgE7loH#mUyhW&x#J_!IO7UQemcPWt})vxJcgk6GNJt4(3Ae}}A|Tg`Pp1fO9tInP4Qq4%Gn|dOBl$n(jFj&0_(CvcoF60YLA_&GZ!%lkdmI3Csr!j`tHCmmiLD%qRR<4A70H$1=S8t z{|ushH|Ym!pD+o=hLGX*XMI7x^m!-Ie!!xf-KZz;ul9bxX`$OGHPY@iaTA^@c=rl5 zq6{fb0#fUFQb9T*?{HA}(!8+C#=BewKb%Q2z>e>79DXTtcWQeV{wS>{)aNQbd=i#U zy<1J6O*w};?GnpmDgIBNx`Oq8$QN=GE*%jynnL0*;fKdh)My^HVBrFpO#Ye zsF+D?W`L{i90o24a%}CuxDhC(L$#&aSL=+4mB`i!8Y4Z!%zCk*dU7lq?bQ#B_6`;m z)DXZ5;d|wDCDrlE&IjR(1Vi^tFdFv@=#G^T_0h4J8@#v_NQGVQSMgNYZ=e_S z!6Ou-%M(5acnAFo2>f($^$91AYavb2TF+ z!UxdcJ=i z|s`o=`3o>%|Wbji2-ynjF7yG&lF49s}s(6?AP~ z?bR`aVflOvFGdbPRz-I;j(fUlO#?Fy^wH0P2vT9+f0U?L80~$|G4Q|qwgH!)eH0%U z1_`$w)w7m+_xXE^l%jz<^^({70byyy5U++fWp?pY$B;5q_o`{&EUUhrmwC4|5X;kD z3C$;dSuG^0)iRTIE`>$|V=6qomc4T&E+5JD0m-EW_~qS!+w`k(%kn1TCfSp4 zSw52j5&{q2N`bkLN_VZW2L}YR_4S~Scr3p;!wLIb$Xl}d#+oxP1P(U`PwX9l{+YvX z>Y;9hXP=K7OIAfJ4ChJ1E35Wa;g)V~2Ct*ZDPfHz`0!7rf#-`_9p8UzEg^Km3E^Zw zLXRCVe{Eykir?{RpEQssNgURcjpJexa%I4WzA>=CvC`@o`>o|*IWh1}^i^+@X&G

i*oxfNPHi>+eJ@A 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 literal 0 HcmV?d00001 diff --git a/_doc/images/openhab_api_config.png b/docs/images/openhab_api_config.png similarity index 100% rename from _doc/images/openhab_api_config.png rename to docs/images/openhab_api_config.png diff --git a/docs/images/pycharm_run.png b/docs/images/pycharm_run.png new file mode 100644 index 0000000000000000000000000000000000000000..412cd0f2ca8a8fc9936c33e4ba58797809ac80c5 GIT binary patch literal 4725 zcmds5=Q|u+(-++;Q4?Yjoh4R@5-ZB8QKCl@b@i-X7a?S^YKY#6=#~{_qqiUug6N`K z(R+wqp1tq)`SxD#Kk$AyW#&3(u9-9QJ7?xZ={;4a2HXP>5D-v5(NHlUAh`7bKer<% z#P?yOA6oIlEiVIgC;?^=xPd2#9h7vG2ned=WaMYDlk?h}aeS%ws^Bkhr%JOL z`CcKL4_&*7xN6l+QrTY1hn3C@6&-wLX|PIkH6Ur-liCF-8qfa-()CKQ`Ndq z)VSRv(m+*wVUU|x!z*^+ZZ!6Nf4{asEeJ?Y*ann4Rgj(=+cD#i(c2y(<5sged2^Q~ z%6u+8xo}&y>oiCPxy9AT#U&;qD=W^}#T%O|iaI|&mQU0RpuM@i9`K_`BP2r?v==FG z@VDARo7e!DTE#r|AvbCk0G%RP;{90_Ka~)jG6~HP7IeCPfZEl%*mPwMP|7Tb%k9U- zs}9}y+kmg62aMqf%SAR&jl0U7XH- zr6)PrYiVhLlvpFkl#ofDf;tEyb^Px1R5q(pR%mM}k^Ys_W6BodkTjzG5zK;D{81wB zwa;44-L5Zj{JfO|{Yg^19m8M6dK1#-DWP)3Cjb#{Ze`GLVrs6wXpw-t;EVcpm>hDW z8P;2Qb?h=QokIT3sXna(h*0+Iyf&Ts&t#pD|!Nw{&3@SJncXPxJR1l4*Sg3|il*5L5q&`^Yu0bkKMJe%YZ zEDMgyDR^`uDEiLmmrio4SftS}ro)>}=4Og!mpKdb=Vr)T!m@J(F=-Du0&a!J3!Cwb zOOp{jeB#jbuabS4-_zO!ulKVOVQ%vVe>x1%W6DFfr?<#uXFQc(8n%-C7 z+RcyN2bY)I+u30h-$$n7=-F8Fl9JHQ!*sE0);Ne1(olOfwkzX_-X}i+V_yb4H!3b; z-ySpfm^2af2;|(?-ue46Wup!sTQ)j6r^0w#8YxIN^f3~n!RC&{2At)MUhUU{#si4XMl<4;G}me%GM|4< z%+@orG#ZxG{u=Z0dM?`DrM7_^^pmr}+Swzsvw?QXnO3Qw2~C|?S}sHG6!dLKSd9>A z0;t(qq=5~B$ajEk^QDCL4VYHLr(-h=8tIx-QCeIVyR8pyJcubrKt`#V%_E*~uF=7k z-k1G7;NY`UElZI{=RGxDmcqfmkBU21?*`YLbcakU9L=0ttj8rKM4Hu#tC;PZ-`|PyDxJ?OS`OUMtk=irbKShM3vZpB5~n1~ zx%eg18@9d6hCj3MAd&cAM|f}*k(t^0+0O;Q>MJ4TCCS=7Uk0a}BG_{u%O9hLHdW)n zOyhMQ^i0)bhMO_-G@gOo0v^_n3RJI-nkRDu%CL|9BcxH-%@O8`u6_ErbiH zFzXHLGzLMV6)WQCi{cLRmy+f`O*9JM0ipFK+gQ`RV=7^rm2(Y;`7IvZjthG0EVy$Y z{Nsc(Z7pBo5cv?{gDruWBMvD0mDP@Xw`{;1=}j)8It`~8M59}Qqafr%B* zUx=i3=C zT2|}V*INDInN7>c;ja{j3dyA0Tx^0BLBzY{t}A(yc9QyjL}IK^_tjrf%pPoJM*J5RnDBG9=dXk z2C-RWv?SXR-1$%QsV`ufXs;70C} zueX&QlJ6r`u{ZXkN$IcT>qiZ)blK;HE)4E-e6D+0r9bt%*&;Uw#-G3c7upi+mbM+g zJ)oY^Q;}PYKDa`o6Nj{@C*PxIfJ#x0LQ=ehl>CU_QUpaAN`-xxbV`~EKX}TzSxQ#D zh~>dprq7Vn_8h^;T!|hUb}k9TEIX-;r@9y<#aSO=Y@mg2($?Ya*UXw4 zOxuQ{CGy+1a}qthoqL|SKGlEA{4fe|91({uoql$2Z(XCi;ir%8wC!fPYSTkRn@F!j zQ^oj9L)OJ=QwUs5&dk75@Y4?2yMcD15^s+xHwKT{J~4ggKA-4lJEQlR=^?1-c>v&( z$xjwQPjQ`+q>B(kGU-1~9lf|@GdXuVnybd_IoMmb9 zE{iPFA)>XR&f8`xbfmtsy}_v?|L4W)iTGw1d2x>31?S&_UJbYxnlxTs>(;O{$Jy;Jhs|$w$HI$N z`k1uy7mwa9;`7=?@yWfJ(34Q44yM`{vS~Xi7IQSZ#44}wQW;fV0x=drg*Tq*T;zsJ zpLF_1r6Gy*cXBp8i$WCA##+)k_X z;CIZudBS@<0y2%s9ZqV&-u5mQ^Q;F4(=kEx_;SXK{qf67j|^iH5(BUP+7G3r_-aPQ z8rQh*o>bs{mHYZ-L4ow|)pF`Hb%)5I{6#r3LzCEzA2&^a4(;RU<*Ll+c3XJ0!u#0J zkh|gjO{e|kO!)!YLJk z+79r`79hnS@Gh?lEuldk%_htN+4TeVk=7}a6NzGpB!+u0Jr;t%WF)Oj z%zj0d?L!yGDc(N|8mCKcTa1+{g<7zR6N9~Wza{kb+cPUfn!77!i0FE2eDhL9UE1$2 zPLk3D26cq=2c2L3J)Yr3oHFIz>_<#}m)>ilgtQ2W(n2i%jOKyBDA=VWW46MoiIS2i zd5GqH#?GF$pnT>Or}|m0N2SVvC#`$0>Pg4=;lPI)a*&Y7lpyPLc4j~vjJY3$v|;{Q ulfTG}s&*ETLBWJs|9`QwLjJN|b6jQbKZrL}q{IIT5j;_Ss)B)9A^!s&F&;1g literal 0 HcmV?d00001 diff --git a/docs/images/pycharm_run_settings.png b/docs/images/pycharm_run_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..d0dea272983bbb3fe4054d9a481602cffdcd0efe GIT binary patch literal 25485 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 Fe*<+6YbgK# literal 0 HcmV?d00001 diff --git a/docs/images/pycharm_settings.png b/docs/images/pycharm_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..73527713a0fe2e62c9ef1eda3395c1b46b645517 GIT binary patch literal 12541 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⪥MQSk2-{UHMDe!vL}PU8wyj{ zJF=LS!@5!MYp&JPrC=DLC!%A*#R>nQGF^K~%_1wENc;&PL7C^jdrnkFx&#%P2Wsg< zTHUH0Q6G1UJgJQp@A+L(710$>sI5Syz;@aFtmX?hT^y)D{{>(lb6?Xyxnc{_jalsv zlx%UM42JWyftfOkW@HfEK@N(Dz0id3tHKtI4a!p=EmsVb@A*O*@M!&OuS+Lkd;ptX zn40~oCPa5I&5|v{CJ4^g4X$z!Q#8k|`!Je@)hX@yX+iGnkd&&ZwZHlBYv`acGpxuz zwL)hB3#o|NJ_q%|C7)6|(r}*RRVz-Od9^*_o)Mi9GFo0w*-Zp5&xTuuw-%h5(E+#x z=1*96r{r`Wi&;bmP2wPVbnL#p0MSVK_d)--LH57b%d_#2+PmAV=?6INc)vHPpUYBU zv-|#MxrgI0(x{uKBBxPB^_$pPGy+_Pg4kM`&#LBH{#8yf%Lm;uW>2+FJUM+oyk zvx#&32noOgcx*4RbFCiu9yYC4u?gHh0?Wrp`#J1<4Ru9GA`ny1a4rD(>HV!5Khpt_bre5y4e?U;vU%lURledKj_ zmJW_NeB|S}kFKN0y8;`*mv0v4t2OOy(V+Gcv@zj1erhW9w;z}2$3_1vcM7SGa^x29 z4vtfmjve2AM#zB-mg(l%SQDmIllGzmAtkTG=Ih?iXB_+)Rhe|DlIJh;$B04n%FKbQ zjiUTdru{bYhNh^6x#r!y$v#rJVUL!Ln#=`|um*<7!9S|Ni6%&Y%4(`2HCK$JQrXUALF40r3j*l&W=$S}2fO5$IjJyRU}T=ULd! zpiVcCmza!##9EbsPS*uL!Zy<4W*pNUSQVA64*bcU4h5={k}-~K8Ga*Osw{b4jE7N` zReTS#ec|%@8y{L_E1hfC(u(Y*n{LNuC>|5_t&_*ZmrbI?Y{_vg8;gmS4fEG8wuQay zYuwBz|7vu8mZKGRTeqMv-IbsS7<;aTo;O2_(OLeOCO@Fr)0|?ScF=Rk?6hz4hNo1wszFH0P?5 zQAO6Q))yaR#p}q8^Y)rPadD`{x9zpewx7ee2<+2Ho-sf1LgHMi#Knr>>(ul z(M6PId`J8S-gl(@%YKnDn-p&IZVUmJ+p~t|bb2onisH;gi`9(QukE70#ixGr#bkE> znxWI#vFP<$dLS6ysF!R547u&npg#@#C3byD#J|uS8=eybqBl%nU3k`lvrlSQsX#%? z&rbT)q*enWJ#K=oL7iH0WZ0xd99g%yn-FXG*}gJEXoRldx9k|HQ`5H!&fKV{cJQ41 zlVRGS$2Em_CwDs6KcnX8n)rgyo;6}Xzkglz893an(#MM3cX&P(e^_0qk!z8JzS(Vo z7wkQ3$JXUIuAz*w<)zqn_##7U?GV5OF7ln+a^QNzveBUVDkteK8qZP0#>D@gX#P)o j*1i|Vc=W$~pdGJ5>LZ_G+C=<20iYnODpM|H8uGsY*EpMC literal 0 HcmV?d00001 diff --git a/docs/images/pycharm_settings_install.png b/docs/images/pycharm_settings_install.png new file mode 100644 index 0000000000000000000000000000000000000000..eeb2f5cbe29f89e5c55a21fef6c25994f5f9f937 GIT binary patch literal 29235 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 is also available:: +Installation through `docker `_ is available: + +.. code-block:: bash + + docker pull spacemanspiff2007/habapp:latest + +The image supports the following environment variables. + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Variable + - Description + * - ``TZ`` + - Timezone used for the container (e.g. ``Europe/Berlin``). + * - ``USER_ID`` + - User id at which HABApp will run (Optional, default: ``9001``) + * - ``GROUP_ID`` + - Group id at which HABApp will run (Optional, default: ``USER_ID``) + * - ``HABAPP_HOME`` + - Directory in which the config resides (in subdirectory "config") default: ``habapp``) + + +Running image from command line +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: bash - docker pull spacemanspiff2007/habapp + docker run --rm -it --name habapp \ + -v ${PWD}/habapp_config:/habapp/config \ + -e TZ=Europe/Berlin \ + -e USER_ID=9001 \ + -e GROUP_ID=9001 \ + spacemanspiff2007/habapp:latest -To have the proper timestamps in the logs set the ``TZ`` environment variable of the container accordingly (e.g. ``TZ=Europe/Berlin``). +Parameters explained +.. list-table:: + :widths: 25 75 + :header-rows: 1 -Updating docker on Synology + * - Parameter + - Description + * - ``--rm`` + - Remove container when stopped + * - ``-it`` + - Run in interactive mode (Optional) -> You can stop HABApp by pressing STRG+C and see stdout + * - ``--name habapp`` + - Give the container an unique name to interact with it + * - ``-e TZ=Europe/Berlin`` + - Set environment variable with timezone + * - ``-e USER_ID=9001`` + - Set environment variable with wser id at which HABApp will run (Optional, default: 9001) + * - ``-e GROUP_ID=9001`` + - Set environment variable with group id at which HABApp will run (Optional, default: USER_ID) + * - ``spacemanspiff2007/habapp:latest`` + - Name of the image that will be run + +Updating image from command line +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: bash + + docker stop habapp + + docker pull spacemanspiff2007/habapp:latest + + +Updating image on Synology ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To update your HABApp docker within Synology NAS, you just have to do the following: @@ -186,16 +245,75 @@ It will overwrite the old one on the NAS. Then stop the container. After selecting "Action" -> "Clear" on the HABapp container, the container is there, but without any content. After starting the container again, everything should immediately work again. ----------------------------------- -Upgrading to a newer version ----------------------------------- +Additional python libraries +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If you want to use some additional python libraries you can do this by writing your own +Dockerfile using this image as base image. The HABApp image is based on the python-slim image +so you can install packages by using apt and pip. + +Example Dockerfile installing scipy, pandas and numpy libraries: + +.. code-block:: dockerfile + :emphasize-lines: 12,30 + + FROM spacemanspiff2007/habapp:latest as buildimage + + RUN set -eux; \ + # Install required build dependencies (Optional) + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + build-essentials; \ + # Prepare python packages + pip3 wheel \ + --wheel-dir=/root/wheels \ + # Replace 'scipy pandas numpy' with your libraries + scipy pandas numpy + + FROM spacemanspiff2007/habapp:latest + + COPY --from=buildimage /root/wheels /root/wheels + + RUN set -eux; \ + # Install required runtime dependencies (Optional) + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + bash; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; \ + # Install python packages and cleanup + pip3 install \ + --no-index \ + --find-links=/root/wheels \ + # Replace 'scipy pandas numpy' with your libraries + scipy pandas numpy; \ + rm -rf /root/wheels + +Build image + +.. code-block:: bash + + docker build -t my_habapp_extended:latest . + +Start image (same as with provided image but the image name is different). + +.. code-block:: bash + + docker run --rm -it --name habapp \ + -v ${PWD}/habapp_config:/habapp/config \ + -e TZ=Europe/Berlin \ + -e USER_ID=9001 \ + -e GROUP_ID=9001 \ + my_habapp_extended:latest + +Upgrading to a newer version of HABApp +-------------------------------------- It is recommended to upgrade the installation on another machine. Configure your production instance in the configuration and set the ``listen_only`` switch(es) in the configuration to ``True``. Observe the logs for any errors. This way if there were any breaking changes rules can easily be fixed before problems occur on the running installation. ----------------------------------- -HABApp arguments + +Command line arguments ---------------------------------- .. exec_code:: @@ -209,3 +327,41 @@ HABApp arguments import HABApp.__main__ HABApp.__cmd_args__.parse_args(['-h']) # ------------ hide: stop ------------- + + +PyCharm +---------------------------------- +It's recommended to use PyCharm as an IDE for writing rules. The IDE can provide auto complete and static checks +which will help write error free rules and vastly speed up development. + +Type hints and checks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To enable type hints and checks HABApp needs to be installed in the python environment +that is currently used by PyCharm. +Ensure that the HABApp version for PyCharm matches the HABApp version that is currently deployed and running the rules. +It is recommended to create a new virtual environment when creating a new project for HABApp. + +Go to ``Settings`` and view the current python environment settings. + +.. image:: /images/pycharm_settings.png + +Install the HABApp package through the ``+`` symbol. +Once the installation was successful PyCharm will provide checks and hints. + +.. image:: /images/pycharm_settings_install.png + +Start HABApp from PyCharm +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +It is possible to start HABApp directly from pycharm e.g. to debug things. +Open the run configurations. + +.. image:: /images/pycharm_run.png + +Switch to ``Module name`` execution with the small dropdown arrow. +It's still necessary to supply a configuration file which can be done in the ``Parameters`` line. + +.. image:: /images/pycharm_run_settings.png + +| After a click on "OK" HABApp can be run/debugged directly from pycharm. +| It's even possible to create breakpoints in rules and inspect all objects. diff --git a/_doc/interface_habapp.rst b/docs/interface_habapp.rst similarity index 95% rename from _doc/interface_habapp.rst rename to docs/interface_habapp.rst index f04ba7cb..41ded016 100644 --- a/_doc/interface_habapp.rst +++ b/docs/interface_habapp.rst @@ -43,6 +43,11 @@ And since it is just like a normal item triggering on changes etc. is possible, .. exec_code:: :hide_output: + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + # ------------ hide: stop ------------- + from HABApp.core.items import AggregationItem my_agg = AggregationItem.get_create_item('MyAggregationItem') diff --git a/_doc/interface_mqtt.rst b/docs/interface_mqtt.rst similarity index 95% rename from _doc/interface_mqtt.rst rename to docs/interface_mqtt.rst index 6cdeac41..0e73f951 100644 --- a/_doc/interface_mqtt.rst +++ b/docs/interface_mqtt.rst @@ -58,11 +58,15 @@ Mqtt items have an additional publish method which make interaction with the mqt :hide_output: # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + import HABApp from unittest.mock import MagicMock HABApp.mqtt.items.mqtt_item.publish = MagicMock() # ------------ hide: stop ------------- + from HABApp.mqtt.items import MqttItem from HABApp.core.events import ValueChangeEvent @@ -98,6 +102,9 @@ It is created on the topic that reports the state from the device. :hide_output: # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + import HABApp from unittest.mock import MagicMock HABApp.mqtt.items.mqtt_pair_item.publish = MagicMock() @@ -158,4 +165,4 @@ and it will also trigger for :class:`~HABApp.mqtt.events.MqttValueUpdateEvent`. Example MQTT rule -------------------------------------- -.. literalinclude:: ../conf/rules/mqtt_rule.py +.. literalinclude:: ../run/conf/rules/mqtt_rule.py diff --git a/_doc/interface_openhab.rst b/docs/interface_openhab.rst similarity index 95% rename from _doc/interface_openhab.rst rename to docs/interface_openhab.rst index c310c9cb..cebe5a3e 100644 --- a/_doc/interface_openhab.rst +++ b/docs/interface_openhab.rst @@ -56,12 +56,12 @@ Function parameters .. _OPENHAB_ITEM_TYPES: ************************************** -Openhab item types +openhab item types ************************************** Description and example ====================================== -Items that are created from openHAB inherit all from :class:`~HABApp.openhab.items.OpenhabItem` and +Items that are created from openHAB inherit all from :class:`~HABApp.openHAB.items.OpenhabItem` and provide convenience functions which simplify many things. Example: @@ -69,6 +69,9 @@ Example: .. exec_code:: # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + import HABApp from HABApp.openhab.items import ContactItem, SwitchItem HABApp.core.Items.add_item(ContactItem('MyContact', initial_value='OPEN')) @@ -217,6 +220,17 @@ ImageItem :member-order: groupwise +CallItem +====================================== +.. inheritance-diagram:: HABApp.openhab.items.CallItem + :parts: 1 + +.. autoclass:: HABApp.openhab.items.CallItem + :members: + :inherited-members: + :member-order: groupwise + + Thing ====================================== .. inheritance-diagram:: HABApp.openhab.items.Thing @@ -231,11 +245,11 @@ Thing .. _OPENHAB_EVENT_TYPES: ************************************** -Openhab event types +openHAB event types ************************************** -Openhab produces various events that are mapped to the internal event bus. -On the `OpenHab page `_ +openHAB produces various events that are mapped to the internal event bus. +On the `openHAB page `_ there is an explanation for the various events. Item events @@ -354,12 +368,34 @@ Thing events ====================================== -ThingStatusInfoChangedEvent +ThingAddedEvent -------------------------------------- -.. inheritance-diagram:: HABApp.openhab.events.ThingStatusInfoChangedEvent +.. inheritance-diagram:: HABApp.openhab.events.ThingAddedEvent :parts: 1 -.. autoclass:: HABApp.openhab.events.ThingStatusInfoChangedEvent +.. autoclass:: HABApp.openhab.events.ThingAddedEvent + :members: + :inherited-members: + :member-order: groupwise + + +ThingUpdatedEvent +-------------------------------------- +.. inheritance-diagram:: HABApp.openhab.events.ThingUpdatedEvent + :parts: 1 + +.. autoclass:: HABApp.openhab.events.ThingUpdatedEvent + :members: + :inherited-members: + :member-order: groupwise + + +ThingRemovedEvent +-------------------------------------- +.. inheritance-diagram:: HABApp.openhab.events.ThingRemovedEvent + :parts: 1 + +.. autoclass:: HABApp.openhab.events.ThingRemovedEvent :members: :inherited-members: :member-order: groupwise @@ -376,12 +412,12 @@ ThingStatusInfoEvent :member-order: groupwise -ThingConfigStatusInfoEvent +ThingStatusInfoChangedEvent -------------------------------------- -.. inheritance-diagram:: HABApp.openhab.events.ThingConfigStatusInfoEvent +.. inheritance-diagram:: HABApp.openhab.events.ThingStatusInfoChangedEvent :parts: 1 -.. autoclass:: HABApp.openhab.events.ThingConfigStatusInfoEvent +.. autoclass:: HABApp.openhab.events.ThingStatusInfoChangedEvent :members: :inherited-members: :member-order: groupwise @@ -424,6 +460,17 @@ ItemStateChangedEventFilter :member-order: groupwise +ItemCommandEventFilter +-------------------------------------- +.. inheritance-diagram:: HABApp.openhab.events.ItemCommandEventFilter + :parts: 1 + +.. autoclass:: HABApp.openhab.events.ItemCommandEventFilter + :members: + :inherited-members: + :member-order: groupwise + + ************************************** Textual thing configuration ************************************** @@ -613,7 +660,7 @@ Metadata It is possible to add metadata to the created items through the optional ``metadata`` entry in the item config. There are two forms how metadata can be set. The implicit form for simple key-value pairs (e.g. ``autoupdate``) or -the explicit form where the entries are unter ``value`` and ``config`` (e.g. ``alexa``) +the explicit form where the entries are under ``value`` and ``config`` (e.g. ``alexa``) .. code-block:: yaml :emphasize-lines: 5,6,8,9,10,11,12 @@ -853,18 +900,18 @@ Example openHAB rules Example 1 ====================================== -.. literalinclude:: ../conf/rules/openhab_rule.py +.. literalinclude:: ../run/conf/rules/openhab_rule.py Check status of things ====================================== This rule prints the status of all ``Things`` and shows how to subscribe to events of the ``Thing`` status -.. literalinclude:: ../conf/rules/openhab_things.py +.. literalinclude:: ../run/conf/rules/openhab_things.py Check status if thing is constant ====================================== -Sometimes ``Things`` recover automatically from small outages. This rule only triggers then the ``Thing`` is constant +Sometimes ``Things`` recover automatically from small outages. This rule only triggers when the ``Thing`` is constant for 60 seconds. .. exec_code:: @@ -888,7 +935,7 @@ for 60 seconds. self.thing = Thing.get_item(name) watcher = self.thing.watch_change(60) - self.thing.listen_event(self.thing_no_change, watcher.EVENT) + watcher.listen_event(self.thing_no_change) def thing_no_change(self, event: ItemNoChangeEvent): print(f'Thing {event.name} constant for {event.seconds}') diff --git a/_doc/logging.rst b/docs/logging.rst similarity index 76% rename from _doc/logging.rst rename to docs/logging.rst index c8fb8170..18c923ea 100644 --- a/_doc/logging.rst +++ b/docs/logging.rst @@ -22,9 +22,10 @@ Example Usage -------------------------------------- -The logging library is the standard python library. +The logging library is the standard python library and an extensive description can be found +`in the official documentation `_. -.. literalinclude:: ../conf/rules/logging_rule.py +.. literalinclude:: ../run/conf/rules/logging_rule.py To make the logging output work properly an output file and an output format has to be configured for the logger. @@ -132,3 +133,47 @@ This is possible via the optional ``levels`` entry in the logging configuration formatters: HABApp_format: ... + + +Logging to stdout +====================================== + +The following handler writes to stdout + +.. code-block:: yaml + + handlers: + StdOutHandler: + class: logging.StreamHandler + stream: ext://sys.stdout + + formatter: HABApp_format + level: DEBUG + + +Add custom filters to loggers +====================================== + +It's possible to filter out certain parts of log files with a +`filter `_. +The recommendation is to create the filter :ref:`during startup`. + +This example ignores all messages for the ``HABApp.EventBus`` logger that contain `MyIgnoredString`. + + +.. exec_code:: + :hide_output: + + import logging + + # False to skip, True to log record + def filter(record: logging.LogRecord) -> bool: + return 'MyIgnoredString' not in record.msg + + + logging.getLogger('HABApp.EventBus').addFilter(filter) + +.. note:: + | Regular expressions for a filter should be compiled outside of the filter function with ``re.compile`` + for performance reasons. + | A simple subtext search however will always have way better performance. diff --git a/_doc/parameters.rst b/docs/parameters.rst similarity index 94% rename from _doc/parameters.rst rename to docs/parameters.rst index 1d712693..b575ca5d 100644 --- a/_doc/parameters.rst +++ b/docs/parameters.rst @@ -22,23 +22,24 @@ Currently there are is :class:`~HABApp.parameters.Parameter` and :class:`~HABApp runner.set_up() # ------------ hide: stop ------------- - import HABApp + from HABApp import Rule, Parameter + from HABApp.core.events import ValueChangeEventFilter - class MyRuleWithParameters(HABApp.Rule): + class MyRuleWithParameters(Rule): def __init__(self): super().__init__() # construct parameter once, default_value can be anything - self.min_value = HABApp.Parameter( 'param_file_testrule', 'min_value', default_value=10) + self.min_value = Parameter( 'param_file_testrule', 'min_value', default_value=10) # deeper structuring is possible through specifying multiple keys - self.min_value_nested = HABApp.Parameter( + self.min_value_nested = Parameter( 'param_file_testrule', 'Rule A', 'subkey1', 'subkey2', default_value=['a', 'b', 'c'] # defaults can also be dicts or lists ) - self.listen_event('test_item', self.on_change_event, HABApp.core.events.ValueChangeEvent) + self.listen_event('test_item', self.on_change_event, ValueChangeEventFilter()) def on_change_event(self, event): @@ -53,6 +54,7 @@ Currently there are is :class:`~HABApp.parameters.Parameter` and :class:`~HABApp MyRuleWithParameters() # ------------ hide: start ------------ + import HABApp HABApp.core.EventBus.post_event('test_watch', HABApp.core.events.ValueChangeEvent('test_item', 5, 6)) runner.tear_down() # ------------ hide: stop ------------- diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..f8f1946d --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,9 @@ +# Packages required to build the documentation +sphinx >= 5.0, < 6.0 +sphinx-autodoc-typehints >= 1.18, < 2 +sphinx_rtd_theme == 1.0.0 +sphinx-exec-code == 0.8 +autodoc_pydantic >= 1.7, < 1.8 + +# we use monkeypatch in the RuleRunner which is part of pytest +pytest >= 7.1, < 8 diff --git a/_doc/rule.rst b/docs/rule.rst similarity index 82% rename from _doc/rule.rst rename to docs/rule.rst index 84ce7de9..cd260cd5 100644 --- a/_doc/rule.rst +++ b/docs/rule.rst @@ -15,8 +15,8 @@ Rule Interacting with items ------------------------------ Items are like variables. They have a name and a value (which can be anything). -Items from openhab use the item name from openhab and get created when HABApp successfully connects to -openhab or when the openhab configuration changes. +Items from openHAB use the item name from openHAB and get created when HABApp successfully connects to +openHAB or when the openHAB configuration changes. Items from MQTT use the topic as item name and get created as soon as a message gets processed. Some item types provide convenience functions, so it is advised to always set the correct item type. @@ -29,6 +29,11 @@ using an IDE! :caption: Example: :hide_output: + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + # ------------ hide: stop ------------ + from HABApp.core.items import Item my_item = Item.get_create_item('MyItem', initial_value=5) # This will create the item if it does not exist my_item = Item.get_item('MyItem') # This will raise an exception if the item is not found @@ -44,6 +49,9 @@ It is possible to check the item value by comparing it :hide_output: # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + from HABApp.core.items import Item Item.get_create_item('MyItem', initial_value=5) # ------------ hide: stop ------------- @@ -60,46 +68,47 @@ It is possible to check the item value by comparing it pass # do something An overview over the item types can be found on :ref:`the HABApp item section `, -:ref:`the openhab item section ` and the :ref:`the mqtt item section ` +:ref:`the openHAB item section ` and the :ref:`the mqtt item section ` -Events +Interacting with events ------------------------------ It is possible to listen to events through the :meth:`~HABApp.Rule.listen_event` function. The passed function will be called as soon as an event occurs and the event will pe passed as an argument into the function. -There is the possibility to reduce the function calls to a certain event type with an additional parameter -(typically :class:`~HABApp.core.ValueUpdateEvent` or :class:`~HABApp.core.ValueChangeEvent`). +There is the possibility to reduce the function calls to a certain event type with an additional event filter +(typically :class:`~HABApp.core.ValueUpdateEventFilter` or :class:`~HABApp.core.ValueChangeEventFilter`). An overview over the events can be found on :ref:`the HABApp event section `, -:ref:`the openhab event section ` and the :ref:`the mqtt event section ` +:ref:`the openHAB event section ` and the :ref:`the MQTT event section ` .. exec_code:: :hide_output: :caption: Example # ------------ hide: start ------------ - import time, HABApp from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - HABApp.core.Items.create_item('MyItem', HABApp.core.items.Item) + + import time, HABApp + HABApp.core.Items.add_item(HABApp.core.items.Item('MyItem')) # ------------ hide: stop ------------- from HABApp import Rule - from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent + from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent, ValueChangeEventFilter, ValueUpdateEventFilter from HABApp.core.items import Item class MyRule(Rule): def __init__(self): super().__init__() - self.listen_event('MyOpenhabItem', self.on_change, ValueChangeEvent) # will trigger only on ValueChangeEvent - self.listen_event('My/MQTT/Topic', self.on_update, ValueUpdateEvent) # will trigger only on ValueUpdateEvent + self.listen_event('MyOpenhabItem', self.on_change, ValueChangeEventFilter()) # trigger only on ValueChangeEvent + self.listen_event('My/MQTT/Topic', self.on_update, ValueUpdateEventFilter()) # trigger only on ValueUpdateEvent # If you already have an item you can and should use the more convenient method of the item # to listen to the item events my_item = Item.get_item('MyItem') - my_item.listen_event(self.on_change, ValueUpdateEvent) + my_item.listen_event(self.on_change, ValueUpdateEventFilter()) def on_change(self, event: ValueChangeEvent): assert isinstance(event, ValueChangeEvent), type(event) @@ -110,35 +119,56 @@ An overview over the events can be found on :ref:`the HABApp event section ` :var mqtt: :ref:`MQTT interaction ` - :var openhab: :ref:`Openhab interaction ` + :var openhab: :ref:`openhab interaction ` :var oh: short alias for :py:class:`openhab` openhab diff --git a/_doc/rule_examples.rst b/docs/rule_examples.rst similarity index 81% rename from _doc/rule_examples.rst rename to docs/rule_examples.rst index c2566a55..e62e8ee4 100644 --- a/_doc/rule_examples.rst +++ b/docs/rule_examples.rst @@ -4,12 +4,12 @@ Additional rule examples Using the scheduler -------------------- -.. literalinclude:: ../conf/rules/time_rule.py +.. literalinclude:: ../run/conf/rules/time_rule.py Mirror openHAB events to a MQTT Broker --------------------------------------- -.. literalinclude:: ../conf/rules/openhab_to_mqtt_rule.py +.. literalinclude:: ../run/conf/rules/openhab_to_mqtt_rule.py .. _item_const_example: @@ -24,11 +24,11 @@ Get an even when the item is constant for 5 and for 10 seconds. from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - HABApp.core.Items.create_item('test_watch', HABApp.core.items.Item) + HABApp.core.Items.add_item(HABApp.core.items.Item('test_watch')) # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item - from HABApp.core.events import ItemNoChangeEvent + from HABApp.core.events import ItemNoChangeEvent, EventFilter class MyRule(HABApp.Rule): def __init__(self): @@ -44,7 +44,7 @@ Get an even when the item is constant for 5 and for 10 seconds. my_item.watch_change(10) # Listen to all ItemNoChangeEvents for the item - my_item.listen_event(self.item_constant, ItemNoChangeEvent) + my_item.listen_event(self.item_constant, EventFilter(ItemNoChangeEvent)) # Set the item to a value to generate the ItemNoChangeEvent events my_item.set_value('my_value') @@ -76,13 +76,14 @@ Turn a device off 30 seconds after one of the movement sensors in a room signals from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - HABApp.core.Items.create_item('movement_sensor1', HABApp.core.items.Item) - HABApp.core.Items.create_item('movement_sensor2', HABApp.core.items.Item) - HABApp.core.Items.create_item('my_device', HABApp.core.items.Item) + from HABApp.core.items import Item + Item.get_create_item('movement_sensor1') + Item.get_create_item('movement_sensor2') + Item.get_create_item('my_device') # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item - from HABApp.core.events import ValueUpdateEvent + from HABApp.core.events import ValueUpdateEvent, ValueUpdateEventFilter class MyCountdownRule(HABApp.Rule): def __init__(self): @@ -92,10 +93,10 @@ Turn a device off 30 seconds after one of the movement sensors in a room signals self.device = Item.get_item('my_device') self.movement1 = Item.get_item('movement_sensor1') - self.movement1.listen_event(self.movement, ValueUpdateEvent) + self.movement1.listen_event(self.movement, ValueUpdateEventFilter()) self.movement2 = Item.get_item('movement_sensor2') - self.movement2.listen_event(self.movement, ValueUpdateEvent) + self.movement2.listen_event(self.movement, ValueUpdateEventFilter()) def movement(self, event: ValueUpdateEvent): if self.device != 'ON': @@ -114,8 +115,8 @@ Turn a device off 30 seconds after one of the movement sensors in a room signals Process Errors in Rules ------------------------------------------ This example shows how to create a rule with a function which will be called when **any** rule throws an error. -The rule function then can push the error message to an openhab item or e.g. use Pushover to send the error message -to the mobile device (see :doc:`Avanced Usage ` for more information). +The rule function then can push the error message to an openHAB item or e.g. use Pushover to send the error message +to the mobile device (see :doc:`Advanced Usage ` for more information). .. exec_code:: @@ -128,16 +129,17 @@ to the mobile device (see :doc:`Avanced Usage ` for more informa import HABApp from HABApp.core.events.habapp_events import HABAppException + from HABApp.core.events import EventFilter class NotifyOnError(HABApp.Rule): def __init__(self): super().__init__() # Listen to all errors - self.listen_event('HABApp.Errors', self.on_error, HABAppException) + self.listen_event('HABApp.Errors', self.on_error, EventFilter(HABAppException)) def on_error(self, error_event: HABAppException): - msg = event.to_str() if isinstance(event, HABAppException) else event + msg = error_event.to_str() if isinstance(error_event, HABAppException) else error_event print(msg) NotifyOnError() @@ -152,6 +154,7 @@ to the mobile device (see :doc:`Avanced Usage ` for more informa def faulty_function(self): 1 / 0 FaultyRule() + # ------------ hide: start ------------ runner.process_events() runner.tear_down() diff --git a/_doc/tips.rst b/docs/tips.rst similarity index 93% rename from _doc/tips.rst rename to docs/tips.rst index 935e6587..1667b264 100644 --- a/_doc/tips.rst +++ b/docs/tips.rst @@ -35,7 +35,7 @@ autoupdate -------------------------------------- If external devices are capable of reporting their state (e.g. Z-Wave) it is always advised to use disable ``autoupdate`` for these items. -This prevents openhab from guessing the item state based on the command and forces it to use the actual reported value. +This prevents openHAB from guessing the item state based on the command and forces it to use the actual reported value. If in doubt if the device supports reporting their state watch the state after sending a command with ``autoupdate`` off. If the state changes ``autoupdate`` can remain off. @@ -46,4 +46,4 @@ In the ``*.items`` file ``autoupdate`` can be disabled by adding the following s Number MyItem { channel = "zwave:my_zwave_link", autoupdate="false" } ``` -It's also possible with textual thing configuration to add it as :ref:`metadata<_ref_textual_thing_config_metadata>`. +It's also possible with textual thing configuration to add it as :ref:`metadata `. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 00000000..b0968b1d --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,15 @@ +************************************** +Troubleshooting +************************************** + + +Errors +====================================== + +ValueError: Line is too long +-------------------------------------- + +The underlaying libraries of HABApp use a buffer to process each request and event from openHAB. +If the openHAB items contain images this buffer might be not enough and a ``ValueError: Line is too long`` +error will appear in the logs. See :ref:`the openHAB connection options` on how to increase +the buffer. The maximum image size that can be used without error is ~40% of the buffer size. diff --git a/_doc/util.rst b/docs/util.rst similarity index 81% rename from _doc/util.rst rename to docs/util.rst index 33d56a92..80900e3a 100644 --- a/_doc/util.rst +++ b/docs/util.rst @@ -75,46 +75,105 @@ Converts a hsb value to the rgb color space .. autofunction:: HABApp.util.functions.hsb_to_rgb -CounterItem +Statistics ------------------------------ Example ^^^^^^^^^^^^^^^^^^ .. exec_code:: - from HABApp.util import CounterItem - c = CounterItem.get_create_item('MyCounter', initial_value=5) - print(c.increase()) - print(c.decrease()) - print(c.reset()) + # ------------ hide: start ------------ + from HABApp.util import Statistics + # ------------ hide: stop ------------- + s = Statistics(max_samples=4) + for i in range(1,4): + s.add_value(i) + print(s) + Documentation ^^^^^^^^^^^^^^^^^^ -.. autoclass:: CounterItem +.. autoclass:: Statistics :members: - .. automethod:: __init__ - - -Statistics +Fade ------------------------------ +Fade is a helper class which allows to easily fade a value up or down. Example ^^^^^^^^^^^^^^^^^^ +This example shows how to fade a Dimmer from 0 to 100 in 30 secs + + .. exec_code:: # ------------ hide: start ------------ - from HABApp.util import Statistics + import HABApp + from rule_runner import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + HABApp.core.Items.add_item(HABApp.openhab.items.DimmerItem('Dimmer1')) # ------------ hide: stop ------------- - s = Statistics(max_samples=4) - for i in range(1,4): - s.add_value(i) - print(s) + from HABApp import Rule + from HABApp.openhab.items import DimmerItem + from HABApp.util import Fade + + class FadeExample(Rule): + def __init__(self): + super().__init__() + self.dimmer = DimmerItem.get_item('Dimmer1') + self.fade = Fade(callback=self.fade_value) # self.dimmer.percent would also be a good callback in this example + + # Setup the fade and schedule its execution + # Fade from 0 to 100 in 30s + self.fade.setup(0, 100, 30).schedule_fade() + + def fade_value(self, value): + self.dimmer.percent(value) + + FadeExample() + + +This example shows how to fade three values together (e.g. for an RGB strip) + + +.. exec_code:: + + # ------------ hide: start ------------ + import HABApp + from rule_runner import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + HABApp.core.Items.add_item(HABApp.openhab.items.DimmerItem('Dimmer1')) + # ------------ hide: stop ------------- + + from HABApp import Rule + from HABApp.openhab.items import DimmerItem + from HABApp.util import Fade + + class Fade3Example(Rule): + def __init__(self): + super().__init__() + self.fade1 = Fade(callback=self.fade_value) + self.fade2 = Fade() + self.fade3 = Fade() + + # Setup the fades and schedule the execution of one fade where the value gets updated every sec + self.fade3.setup(0, 100, 30) + self.fade2.setup(0, 50, 30) + self.fade1.setup(0, 25, 30, min_step_duration=1).schedule_fade() + + def fade_value(self, value): + value1 = value + value2 = self.fade2.get_value() + value3 = self.fade3.get_value() + + Fade3Example() Documentation ^^^^^^^^^^^^^^^^^^ -.. autoclass:: Statistics +.. autoclass:: Fade :members: EventListenerGroup @@ -155,8 +214,8 @@ The lights will only turn on after 4 and before 8 and two movement sensors are u self.sensor_move_1 = NumberItem.get_item('MovementSensor1') self.sensor_move_2 = NumberItem.get_item('MovementSensor2') - # use the defaults so we don't have to pass the callback and event filter in add_listener - self.group = EventListenerGroup().add_listener( + # 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.run.on_every_day(time(4), self.listen_sensors) @@ -172,9 +231,10 @@ The lights will only turn on after 4 and before 8 and two movement sensors are u self.listeners.cancel() self.lights.on() - EventListenerGroupExample() +Documentation +^^^^^^^^^^^^^^^^^^ .. autoclass:: EventListenerGroup :members: @@ -195,7 +255,7 @@ Basic Example runner.set_up() # ------------ hide: stop ------------- import HABApp - from HABApp.core.events import ValueUpdateEvent + from HABApp.core.events import ValueUpdateEventFilter from HABApp.util.multimode import MultiModeItem, ValueMode class MyMultiModeItemTestRule(HABApp.Rule): @@ -204,7 +264,7 @@ Basic Example # create a new MultiModeItem item = MultiModeItem.get_create_item('MultiModeTestItem') - item.listen_event(self.item_update, ValueUpdateEvent) + item.listen_event(self.item_update, ValueUpdateEventFilter()) # create two different modes which we will use and add them to the item auto = ValueMode('Automatic', initial_value=5) @@ -216,7 +276,7 @@ Basic Example item.get_mode('manual').set_enabled(False) # disable mode item.get_mode('manual').set_value(11) # setting a value will enable it again - # This shows that changes of the lower priority is only show when + # This shows that changes of the lower priority is only shown when # the mode with the higher priority gets disabled print('') print('Set value of lower priority') @@ -256,7 +316,7 @@ Advanced Example # ------------ hide: stop ------------- import logging import HABApp - from HABApp.core.events import ValueUpdateEvent + from HABApp.core.events import ValueUpdateEventFilter from HABApp.util.multimode import MultiModeItem, ValueMode class MyMultiModeItemTestRule(HABApp.Rule): @@ -265,9 +325,9 @@ Advanced Example # create a new MultiModeItem item = MultiModeItem.get_create_item('MultiModeTestItem') - item.listen_event(self.item_update, ValueUpdateEvent) + item.listen_event(self.item_update, ValueUpdateEventFilter()) - # helper to print the heading so we have a nice outpt + # helper to print the heading so we have a nice output def print_heading(_heading): print('') print('-' * 80) @@ -300,7 +360,7 @@ Advanced Example # It is possible to use functions to calculate the new value for a mode. # E.g. shutter control and the manual mode moves the shades. If it's dark the automatic - # mode closes the shutter again. This could be achievied by automatically disable the + # mode closes the shutter again. This could be achieved by automatically disabling the # manual mode or if the state should be remembered then the max function should be used # create a move and use the max function for output calculation @@ -323,8 +383,8 @@ Advanced Example Example SwitchItemValueMode ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is controlled by a OpenHAB -:class:`~HABApp.openhab.items.SwitchItem`. This is very useful if the mode shall be deactivated from the OpenHAB sitemaps. +The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is controlled by a openHAB +:class:`~HABApp.openhab.items.SwitchItem`. This is very useful if the mode shall be deactivated from the openHAB sitemaps. .. exec_code:: @@ -338,7 +398,6 @@ The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is cont HABApp.core.Items.add_item(SwitchItem('Automatic_Enabled', initial_value='ON')) # ------------ hide: stop ------------- import HABApp - from HABApp.core.events import ValueUpdateEvent from HABApp.openhab.items import SwitchItem from HABApp.util.multimode import MultiModeItem, SwitchItemValueMode, ValueMode @@ -364,7 +423,7 @@ The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is cont print(mode) # This shows how the SwitchItemValueMode can be used to disable any logic except for the manual mode. - # Now everything can be enabled/disabled from the openhab sitemap + # Now everything can be enabled/disabled from the openHAB sitemap item.add_mode(100, mode) item.add_mode(101, ValueMode('Manual')) diff --git a/readme.md b/readme.md index 7beef23b..7b833612 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ _Easy automation with MQTT and/or openHAB_ -HABApp is a asyncio/multithread application that connects to an openhab instance and/or a MQTT broker. +HABApp is a asyncio/multithread application that connects to an openHAB instance and/or a MQTT broker. It is possible to create rules that listen to events from these instances and then react accordingly. ## Goals @@ -31,7 +31,7 @@ import datetime import random import HABApp -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.events import ValueUpdateEvent, ValueChangeEventFilter class ExampleMqttTestRule(HABApp.Rule): @@ -39,18 +39,18 @@ class ExampleMqttTestRule(HABApp.Rule): super().__init__() self.run.every( - time=datetime.timedelta(seconds=60), + start_time=datetime.timedelta(seconds=60), interval=datetime.timedelta(seconds=30), callback=self.publish_rand_value ) - self.listen_event('test/test', self.topic_updated, ValueUpdateEvent) + self.listen_event('test/test', self.topic_updated, ValueChangeEventFilter()) def publish_rand_value(self): print('test mqtt_publish') self.mqtt.publish('test/test', str(random.randint(0, 1000))) - def topic_updated(self, event): + def topic_updated(self, event: ValueUpdateEvent): assert isinstance(event, ValueUpdateEvent), type(event) print( f'mqtt topic "test/test" updated to {event.value}') @@ -58,50 +58,113 @@ class ExampleMqttTestRule(HABApp.Rule): ExampleMqttTestRule() ``` -### Openhab rule example +### openHAB rule example + ```python import HABApp -from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent -from HABApp.openhab.events import ItemStateEvent, ItemCommandEvent, ItemStateChangedEvent +from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent, ValueChangeEventFilter, ValueUpdateEventFilter +from HABApp.openhab.events import ItemCommandEvent, ItemStateEventFilter, ItemCommandEventFilter, \ + ItemStateChangedEventFilter + class MyOpenhabRule(HABApp.Rule): - def __init__(self): - super().__init__() + def __init__(self): + super().__init__() - # Trigger on item updates - self.listen_event( 'TestContact', self.item_state_update, ItemStateEvent) - self.listen_event( 'TestDateTime', self.item_state_update, ValueUpdateEvent) + # Trigger on item updates + self.listen_event('TestContact', self.item_state_update, ItemStateEventFilter()) + self.listen_event('TestDateTime', self.item_state_update, ValueUpdateEventFilter()) - # Trigger on item changes - self.listen_event( 'TestDateTime', self.item_state_change, ItemStateChangedEvent) - self.listen_event( 'TestSwitch', self.item_state_change, ValueChangeEvent) + # Trigger on item changes + self.listen_event('TestDateTime', self.item_state_change, ItemStateChangedEventFilter()) + self.listen_event('TestSwitch', self.item_state_change, ValueChangeEventFilter()) - # Trigger on item commands - self.listen_event( 'TestSwitch', self.item_command, ItemCommandEvent) + # Trigger on item commands + self.listen_event('TestSwitch', self.item_command, ItemCommandEventFilter()) - def item_state_update(self, event): - assert isinstance(event, ValueUpdateEvent) - print( f'{event}') + def item_state_update(self, event: ValueUpdateEvent): + assert isinstance(event, ValueUpdateEvent) + print(f'{event}') - def item_state_change(self, event): - assert isinstance(event, ValueChangeEvent) - print( f'{event}') + def item_state_change(self, event: ValueChangeEvent): + assert isinstance(event, ValueChangeEvent) + print(f'{event}') - # interaction is available through self.openhab or self.oh - self.openhab.send_command('TestItemCommand', 'ON') + # interaction is available through self.openHAB or self.oh + self.openhab.send_command('TestItemCommand', 'ON') - def item_command(self, event): - assert isinstance(event, ItemCommandEvent) - print( f'{event}') + def item_command(self, event: ItemCommandEvent): + assert isinstance(event, ItemCommandEvent) + print(f'{event}') + + # interaction is available through self.openhab or self.oh + self.oh.post_update('TestItemUpdate', 123) - # interaction is available through self.openhab or self.oh - self.oh.post_update('TestItemUpdate', 123) MyOpenhabRule() ``` # Changelog +#### 1.0.0 (25.07.2022) +- OpenHAB >= 3.3 and Python >= 3.8 only! +- Major internal refactoring +- Startup issues are gone with a new and improved connection mechanism. +- New configuration library: More settings can be configured in the configuration file. + Config values are also described in the docs. Also better error messages (hopefully) +- Improved event log performance (``BufferEventFile`` no longer needed and should be removed) +- Improved openhab performance (added some buffers) +- Improved mqtt performance +- Better tracebacks in case of error +- EventFilters can be logically combined ("and", "or") so rules trigger only once +- Label, Groups and Metadata is part of the OpenhabItem and can easily be accessed +- Added possibility to run arbitrary user code before the HABApp configuration is loaded +- Fixed setup issues +- Fixed some known bugs and introduced new ones ;-) +- Docker file changed to a multi stage build. Mount points changed to ``/habapp/config``. + +**Migration to new version** + + +``self.listen_event`` now requires an instance of EventFilter. + +Old: +```python +from HABApp.core.events import ValueUpdateEvent +... +self.my_sensor = Item.get_item('my_sensor') +self.my_sensor.listen_event(self.movement, ValueUpdateEvent) +``` + +New: +```python +from HABApp.core.events import ValueUpdateEventFilter +... +self.my_sensor = Item.get_item('my_sensor') +self.my_sensor.listen_event(self.movement, ValueUpdateEventFilter()) # <-- Instance of EventFilter +``` + +```text +HABApp: + ValueUpdateEvent -> ValueUpdateEventFilter() + ValueChangeEvent -> ValueChangeEventFilter() + +Openhab: + ItemStateEvent -> ItemStateEventFilter() + ItemStateChangedEvent -> ItemStateChangedEventFilter() + ItemCommandEvent -> ItemCommandEventFilter() + +MQTT: + MqttValueUpdateEvent -> MqttValueUpdateEventFilter() + MqttValueChangeEvent -> MqttValueChangeEventFilter() +``` + +**Migration to new docker image** +- change the mount point of the config from ``/config`` to ``/habapp/config`` +- The new image doesn't run as root. You can set `USER_ID` and `GROUP_ID` to the user you want habapp to run with. It's necessary to modify the permissions of the mounted folder accordingly. + +--- + #### 0.31.2 (17.12.2021) - Added command line switch to display debug information - Display debug information on missing dependencies @@ -122,18 +185,18 @@ MyOpenhabRule() - Label in commandOption is optional - Added message when file is removed - Examples in the docs get checked with a newly created sphinx extension -- Reworked the openhab tests +- Reworked the openHAB tests #### 0.30.3 (17.06.2021) - add support for custom ca cert for MQTT - Scheduler runs only when the rule file has been loaded properly -- Sync openhab calls raise an error when called from an async context +- Sync openHAB calls raise an error when called from an async context - Replaced thread check for asyncio with a contextvar (internal) #### 0.30.3 (01.06.2021) - Scheduler runs only when the rule file has been loaded properly - Replaced thread check for asyncio with a contextvar -- Sync openhab calls raise an error when called from an async context +- Sync openHAB calls raise an error when called from an async context #### 0.30.2 (26.05.2021) - Item and Thing loading from openHAB is more robust and disconnects now properly if openHAB is only partly ready @@ -160,8 +223,8 @@ Changelog - Significantly less CPU usage when no functions are running - Completely reworked the file handling (loading and dependency resolution) - Completely reworked the Scheduler! - - Has now subsecond accuracity (finally!) - - Has a new .coundown() job which can simplify many rules. + - Has now subsecond accuracy (finally!) + - Has a new .countdown() job which can simplify many rules. It is made for functions that do something after a certain period of time (e.g. switch a light off after movement) - Added hsb_to_rgb, rgb_to_hsb functions which can be used in rules - Better error message if configured foldes overlap with HABApp folders diff --git a/requirements.txt b/requirements.txt index 033c6441..5df48b92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,13 @@ # ----------------------------------------------------------------------------- -# Packages required for HABApp +# Packages required for HABApp and testing requirements # ----------------------------------------------------------------------------- -aiohttp-sse-client==0.2.1 -aiohttp==3.8.1 -bidict==0.21.4 -eascheduler==0.1.4 -easyco==0.2.3 -paho-mqtt==1.6.1 -pydantic==1.8.2 -stackprinter==0.2.5 -voluptuous==0.12.2 -watchdog==2.1.6 -ujson==4.3.0 -immutables==0.16 - -# install pendulum last because the setup sometimes fails :S -pendulum==2.1.2 - +-r requirements_tests.txt # ----------------------------------------------------------------------------- -# Packages to run source tests +# Packages for source formatting # ----------------------------------------------------------------------------- -pytest==6.2.5 -pytest-asyncio==0.16.0 -flake8==4.0.1 # Required for github test -mock==4.0.3;python_version<"3.8" +pre-commit >= 2.17, < 2.18 # ----------------------------------------------------------------------------- -# Packages for source formatting +# Packages for other developement tasks # ----------------------------------------------------------------------------- -pre-commit==2.16.0 diff --git a/requirements_setup.txt b/requirements_setup.txt index abea904f..206b5261 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,15 +1,17 @@ -aiohttp-sse-client==0.2.1 -aiohttp==3.8.1 -bidict==0.21.4 -eascheduler==0.1.4 -easyco==0.2.3 -paho-mqtt==1.6.1 -pydantic==1.8.2 -stackprinter==0.2.5 -voluptuous==0.12.2 -watchdog==2.1.6 -ujson==4.3.0 -immutables==0.16 +aiohttp >= 3.8, < 3.9 +pydantic >= 1.9, < 1.10 +pendulum >= 2.1.2, < 2.2 +bidict >= 0.22, < 0.23 +watchdog >= 2.1.7, < 2.2 +ujson >= 5.4, < 5.5 +paho-mqtt >= 1.6, < 1.7 -# install pendulum last because the setup sometimes fails :S -pendulum==2.1.2 +immutables == 0.18 +eascheduler == 0.1.5 +easyconfig == 0.2.4 +stack_data == 0.3.0 + +voluptuous == 0.13.1 + +typing-extensions >= 4.1, < 5 +aiohttp-sse-client == 0.2.1 diff --git a/requirements_tests.txt b/requirements_tests.txt new file mode 100644 index 00000000..0f6a6fbd --- /dev/null +++ b/requirements_tests.txt @@ -0,0 +1,10 @@ +# ----------------------------------------------------------------------------- +# Packages required for HABApp +# ----------------------------------------------------------------------------- +-r requirements_setup.txt + +# ----------------------------------------------------------------------------- +# Packages to run source tests +# ----------------------------------------------------------------------------- +pytest >= 7.1, < 8 +pytest-asyncio >= 0.18.3, < 0.19 diff --git a/run/conf/config.yml b/run/conf/config.yml new file mode 100644 index 00000000..106f139d --- /dev/null +++ b/run/conf/config.yml @@ -0,0 +1,46 @@ +directories: + logging: log # Folder where the logs will be written to + rules: rules # Folder from which the rule files will be loaded + param: parameters # Folder from which the parameter files will be loaded + config: config # Folder from which configuration files (e.g. for textual thing configuration) will be loaded + lib: lib # Folder where additional libraries can be placed + +location: + latitude: 52.5185537 + longitude: 13.3758636 + elevation: 43 + +mqtt: + connection: + client_id: HABAppConf + host: localhost + password: '' + port: 1883 + user: '' + tls: + enabled: false # Enable TLS for the connection + ca cert: '' # Path to a CA certificate that will be treated as trusted + insecure: false # Validate server hostname in server certificate + publish: + qos: 0 + retain: false + subscribe: + qos: 0 + topics: + - '#' + general: + listen_only: false # If True HABApp does not publish any value to the broker + +openhab: + ping: + enabled: true # If enabled the configured item will show how long it takes to send an update from HABApp and get the updated value back from openHAB in milliseconds + item: HABApp_Ping # Name of the Numberitem + interval: 10 # Seconds between two pings + connection: + url: http://localhost:8080 # Connect to this url + user: 'asdf' + password: 'asdf' + verify_ssl: true # Check certificates when using https + general: + listen_only: false # If True HABApp does not change anything on the openHAB instance. + wait_for_openhab: true # If True HABApp does wait for items from the openHAB instance before loading any rules on startup diff --git a/run/conf/logging.yml b/run/conf/logging.yml new file mode 100644 index 00000000..6b8b363a --- /dev/null +++ b/run/conf/logging.yml @@ -0,0 +1,53 @@ + +formatters: + HABApp_format: + format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' + + +handlers: + # There are several Handlers available: + # - logging.handlers.RotatingFileHandler: + # Will rotate when the file reaches a certain size (see python logging documentation for args) + # - HABApp.core.lib.handler.MidnightRotatingFileHandler: + # Will wait until the file reaches a certain size and then will rotate on midnight + # - More handlers: + # https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler + + HABApp_default: + class: HABApp.core.lib.handler.MidnightRotatingFileHandler + filename: 'HABApp.log' + maxBytes: 1_048_576 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + + EventFile: + class: HABApp.core.lib.handler.MidnightRotatingFileHandler + filename: 'events.log' + maxBytes: 1_048_576 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + + BufferEventFile: + class: logging.handlers.MemoryHandler + capacity: 10 + formatter: HABApp_format + target: EventFile + level: DEBUG + + +loggers: + HABApp: + level: INFO + handlers: + - HABApp_default + propagate: False + + HABApp.EventBus: + level: INFO + handlers: + - BufferEventFile + propagate: False diff --git a/conf/rules/async_rule.py b/run/conf/rules/async_rule.py similarity index 100% rename from conf/rules/async_rule.py rename to run/conf/rules/async_rule.py diff --git a/conf/rules/logging_rule.py b/run/conf/rules/logging_rule.py similarity index 100% rename from conf/rules/logging_rule.py rename to run/conf/rules/logging_rule.py diff --git a/conf/rules/mqtt_rule.py b/run/conf/rules/mqtt_rule.py similarity index 89% rename from conf/rules/mqtt_rule.py rename to run/conf/rules/mqtt_rule.py index 8b42868b..2b89577d 100644 --- a/conf/rules/mqtt_rule.py +++ b/run/conf/rules/mqtt_rule.py @@ -2,7 +2,7 @@ import random import HABApp -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.events import ValueUpdateEvent, ValueUpdateEventFilter from HABApp.mqtt.items import MqttItem @@ -18,7 +18,7 @@ def __init__(self): self.my_mqtt_item = MqttItem.get_create_item('test/test') - self.listen_event('test/test', self.topic_updated, ValueUpdateEvent) + self.listen_event('test/test', self.topic_updated, ValueUpdateEventFilter()) def publish_rand_value(self): print('test mqtt_publish') diff --git a/conf/rules/openhab_rule.py b/run/conf/rules/openhab_rule.py similarity index 97% rename from conf/rules/openhab_rule.py rename to run/conf/rules/openhab_rule.py index 407f239e..c7bf58c4 100644 --- a/conf/rules/openhab_rule.py +++ b/run/conf/rules/openhab_rule.py @@ -27,11 +27,11 @@ def __init__(self): def item_state_update(self, event): assert isinstance(event, ValueUpdateEvent) - print( f'{event}') + print(f'{event}') def item_state_change(self, event): assert isinstance(event, ValueChangeEvent) - print( f'{event}') + print(f'{event}') # interaction is available through self.openhab or self.oh self.openhab.send_command('TestItemCommand', 'ON') diff --git a/conf/rules/openhab_things.py b/run/conf/rules/openhab_things.py similarity index 76% rename from conf/rules/openhab_things.py rename to run/conf/rules/openhab_things.py index e04fcf89..623ecf2a 100644 --- a/conf/rules/openhab_things.py +++ b/run/conf/rules/openhab_things.py @@ -1,6 +1,7 @@ from HABApp import Rule from HABApp.openhab.events import ThingStatusInfoChangedEvent from HABApp.openhab.items import Thing +from HABApp.core.events import EventFilter class CheckAllThings(Rule): @@ -8,7 +9,7 @@ def __init__(self): super().__init__() for thing in self.get_items(Thing): - thing.listen_event(self.thing_status_changed, ThingStatusInfoChangedEvent) + thing.listen_event(self.thing_status_changed, EventFilter(ThingStatusInfoChangedEvent)) print(f'{thing.name}: {thing.status}') def thing_status_changed(self, event: ThingStatusInfoChangedEvent): diff --git a/conf/rules/openhab_to_mqtt_rule.py b/run/conf/rules/openhab_to_mqtt_rule.py similarity index 87% rename from conf/rules/openhab_to_mqtt_rule.py rename to run/conf/rules/openhab_to_mqtt_rule.py index b0413998..df725575 100644 --- a/conf/rules/openhab_to_mqtt_rule.py +++ b/run/conf/rules/openhab_to_mqtt_rule.py @@ -1,5 +1,5 @@ import HABApp -from HABApp.openhab.events import ItemStateEvent +from HABApp.openhab.events import ItemStateEventFilter, ItemStateEvent from HABApp.openhab.items import OpenhabItem @@ -10,7 +10,7 @@ def __init__(self): super().__init__() for item in self.get_items(OpenhabItem): - item.listen_event(self.process_update, ItemStateEvent) + item.listen_event(self.process_update, ItemStateEventFilter()) def process_update(self, event): assert isinstance(event, ItemStateEvent) diff --git a/conf/rules/time_rule.py b/run/conf/rules/time_rule.py similarity index 100% rename from conf/rules/time_rule.py rename to run/conf/rules/time_rule.py diff --git a/conf_testing/config.yml b/run/conf_listen/config.yml similarity index 68% rename from conf_testing/config.yml rename to run/conf_listen/config.yml index ff3968ce..68caca6a 100644 --- a/conf_testing/config.yml +++ b/run/conf_listen/config.yml @@ -17,9 +17,10 @@ mqtt: user: '' password: '' port: 1883 - tls: false - tls_insecure: false - tls_ca_cert: '' # Path to a CA certificate that will be treated as trusted + tls: + enabled: false + insecure: false + ca cert: '' publish: qos: 0 retain: false @@ -27,20 +28,19 @@ mqtt: qos: 0 topics: - '#' - - 0 general: listen_only: false openhab: connection: - host: localhost - port: 8080 - user: 'asdf' - password: 'asdf' + user: asdf + password: asdf + url: http://localhost:8080 # Connect to this url + verify_ssl: true # Check certificates when using https general: listen_only: false wait_for_openhab: true ping: - enabled: true + enabled: false interval: 30 item: Ping diff --git a/run/conf_listen/logging.yml b/run/conf_listen/logging.yml new file mode 100644 index 00000000..d92c918b --- /dev/null +++ b/run/conf_listen/logging.yml @@ -0,0 +1,44 @@ +formatters: + HABApp_format: + format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' + + +handlers: + HABApp_default: + class: logging.handlers.RotatingFileHandler + filename: 'HABApp.log' + maxBytes: 10_000_000 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + + EventFile: + class: logging.handlers.RotatingFileHandler + filename: 'events.log' + maxBytes: 10_485_760 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + + BufferEventFile: + class: logging.handlers.MemoryHandler + capacity: 0 + formatter: HABApp_format + target: EventFile + level: DEBUG + + +loggers: + HABApp: + level: DEBUG + handlers: + - HABApp_default + propagate: False + + HABApp.EventBus: + level: DEBUG + handlers: + - BufferEventFile + propagate: False diff --git a/run/conf_testing/config.yml b/run/conf_testing/config.yml new file mode 100644 index 00000000..53d7490c --- /dev/null +++ b/run/conf_testing/config.yml @@ -0,0 +1,46 @@ +directories: + logging: log # Folder where the logs will be written to + rules: rules # Folder from which the rule files will be loaded + param: parameters # Folder from which the parameter files will be loaded + config: config # Folder from which configuration files (e.g. for textual thing configuration) will be loaded + lib: lib # Folder where additional libraries can be placed + +location: + latitude: 52.5185537 + longitude: 13.3758636 + elevation: 43 + +mqtt: + connection: + client_id: HABAppTesting + host: localhost + port: 1883 + user: '' + password: '' + tls: + enabled: false # Enable TLS for the connection + ca cert: '' # Path to a CA certificate that will be treated as trusted + insecure: false # Validate server hostname in server certificate + subscribe: + qos: 0 # Default QoS for subscribing + topics: + - '#' + publish: + qos: 0 # Default QoS when publishing values + retain: false # Default retain flag when publishing values + general: + listen_only: false # If True HABApp will not publish any value to the broker + +openhab: + ping: + enabled: true # If enabled the configured item will show how long it takes to send an update from HABApp and get the updated value back from openHAB in milliseconds + item: HABApp_Ping # Name of the Numberitem + interval: 10 # Seconds between two pings + connection: + url: http://localhost:8080 # Connect to this url + user: 'asdf' + password: 'asdf' + verify_ssl: true # Check certificates when using https + general: + listen_only: false # If True HABApp will not change anything on the openHAB instance. + wait_for_openhab: true # If True HABApp will wait for items from the openHAB instance before loading any rules on startup diff --git a/run/conf_testing/config/thing_test.yml b/run/conf_testing/config/thing_test.yml new file mode 100644 index 00000000..21a4d728 --- /dev/null +++ b/run/conf_testing/config/thing_test.yml @@ -0,0 +1,28 @@ +test: False # Test mode: will not do anything but instead print out information +filter: # reduce things with these filters, all filters have to match for further processing + thing_type: astro:sun + +# Set this configuration for all things +thing config: + 4: 99 # Light Threshold + 5: 8 # Operation Mode + 6: 4 # MultiSensor Function Switch + 7: 20 # Customer Function + +channels: + - filter: + - channel_uid: .+rise#start + link items: + - type: Number + name: '{thing_uid, :([^:]+?)$}_Temperature_1' + label: '{thing_uid, :([^:]+)$} Temperature [%d %%]' + icon: battery + metadata: + autoupdate: 'false' + homekit: 'TemperatureSensor' + alexa: + 'value': 'Fan' + 'config': + 'type': 'oscillating' + 'speedSteps': 3 + # autoupdate: 'false' diff --git a/conf_testing/lib/HABAppTests/__init__.py b/run/conf_testing/lib/HABAppTests/__init__.py similarity index 100% rename from conf_testing/lib/HABAppTests/__init__.py rename to run/conf_testing/lib/HABAppTests/__init__.py diff --git a/conf_testing/lib/HABAppTests/compare_values.py b/run/conf_testing/lib/HABAppTests/compare_values.py similarity index 100% rename from conf_testing/lib/HABAppTests/compare_values.py rename to run/conf_testing/lib/HABAppTests/compare_values.py diff --git a/conf_testing/lib/HABAppTests/errors.py b/run/conf_testing/lib/HABAppTests/errors.py similarity index 100% rename from conf_testing/lib/HABAppTests/errors.py rename to run/conf_testing/lib/HABAppTests/errors.py diff --git a/conf_testing/lib/HABAppTests/event_waiter.py b/run/conf_testing/lib/HABAppTests/event_waiter.py similarity index 72% rename from conf_testing/lib/HABAppTests/event_waiter.py rename to run/conf_testing/lib/HABAppTests/event_waiter.py index b36e512e..bc01d292 100644 --- a/conf_testing/lib/HABAppTests/event_waiter.py +++ b/run/conf_testing/lib/HABAppTests/event_waiter.py @@ -1,9 +1,11 @@ import logging import time -from typing import TypeVar, Type, Dict, Any +from typing import TypeVar, Dict, Any from typing import Union -import HABApp +from HABApp.core.events.filter import EventFilter +from HABApp.core.internals import EventBusListener, wrap_func, EventFilterBase, HINT_EVENT_FILTER_OBJ, \ + get_current_context from HABApp.core.items import BaseValueItem from HABAppTests.errors import TestCaseFailed from .compare_values import get_equal_text, get_value_text @@ -14,25 +16,28 @@ class EventWaiter: - def __init__(self, name: Union[BaseValueItem, str], event_type: Type[EVENT_TYPE], timeout=1): + def __init__(self, name: Union[BaseValueItem, str], + event_filter: HINT_EVENT_FILTER_OBJ, timeout=1): if isinstance(name, BaseValueItem): name = name.name assert isinstance(name, str) + assert isinstance(event_filter, EventFilterBase) self.name = name - self.event_type = event_type + self.event_filter = event_filter self.timeout = timeout - self.event_listener = HABApp.core.EventBusListener( + self.event_listener = EventBusListener( self.name, - HABApp.core.WrappedFunction(self.__process_event), - self.event_type + wrap_func(self.__process_event), + self.event_filter ) self._received_events = [] def __process_event(self, event): - assert isinstance(event, self.event_type) + if isinstance(self.event_filter, EventFilter): + assert isinstance(event, self.event_filter.event_class) self._received_events.append(event) def clear(self): @@ -47,7 +52,7 @@ def wait_for_event(self, **kwargs) -> EVENT_TYPE: if time.time() > start + self.timeout: expected_values = "with " + ", ".join([f"{__k}={__v}" for __k, __v in kwargs.items()]) if kwargs else "" - raise TestCaseFailed(f'Timeout while waiting for ({str(self.event_type).split(".")[-1][:-2]}) ' + raise TestCaseFailed(f'Timeout while waiting for {self.event_filter.describe()} ' f'for {self.name} {expected_values}') if not self._received_events: @@ -65,11 +70,11 @@ def wait_for_event(self, **kwargs) -> EVENT_TYPE: raise ValueError() def __enter__(self) -> 'EventWaiter': - HABApp.core.EventBus.add_listener(self.event_listener) + get_current_context().add_event_listener(self.event_listener) return self def __exit__(self, exc_type, exc_val, exc_tb): - HABApp.core.EventBus.remove_listener(self.event_listener) + get_current_context().remove_event_listener(self.event_listener) @staticmethod def compare_event_value(event, kwargs: Dict[str, Any]): diff --git a/conf_testing/lib/HABAppTests/item_waiter.py b/run/conf_testing/lib/HABAppTests/item_waiter.py similarity index 88% rename from conf_testing/lib/HABAppTests/item_waiter.py rename to run/conf_testing/lib/HABAppTests/item_waiter.py index 9b23e290..27ff8379 100644 --- a/conf_testing/lib/HABAppTests/item_waiter.py +++ b/run/conf_testing/lib/HABAppTests/item_waiter.py @@ -1,18 +1,17 @@ import logging import time -import HABApp +from HABApp.core.items import BaseValueItem from HABAppTests.compare_values import get_equal_text from HABAppTests.errors import TestCaseFailed - log = logging.getLogger('HABApp.Tests') class ItemWaiter: def __init__(self, item, timeout=1, item_compare: bool = True): self.item = item - assert isinstance(item, HABApp.core.items.BaseValueItem), f'{item} is not an Item' + assert isinstance(item, BaseValueItem), f'{item} is not an Item' self.timeout = timeout self.item_compare = item_compare diff --git a/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py similarity index 95% rename from conf_testing/lib/HABAppTests/openhab_tmp_item.py rename to run/conf_testing/lib/HABAppTests/openhab_tmp_item.py index 667abad6..6fec971b 100644 --- a/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -1,7 +1,9 @@ import time -from typing import List, Optional from functools import wraps +from typing import List, Optional + import HABApp +from HABApp.openhab.definitions.topics import TOPIC_ITEMS from . import get_random_name, EventWaiter @@ -74,7 +76,7 @@ def create_item(self, label="", category="", tags: List[str] = [], groups: List[ def modify(self, label="", category="", tags: List[str] = [], groups: List[str] = [], group_type: str = '', group_function: str = '', group_function_params: List[str] = []): - with EventWaiter(self.name, HABApp.openhab.events.ItemUpdatedEvent) as w: + with EventWaiter(TOPIC_ITEMS, HABApp.core.events.EventFilter(HABApp.openhab.events.ItemUpdatedEvent)) as w: self._create(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/conf_testing/lib/HABAppTests/test_data.py b/run/conf_testing/lib/HABAppTests/test_data.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_data.py rename to run/conf_testing/lib/HABAppTests/test_data.py diff --git a/conf_testing/lib/HABAppTests/test_rule/__init__.py b/run/conf_testing/lib/HABAppTests/test_rule/__init__.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_rule/__init__.py rename to run/conf_testing/lib/HABAppTests/test_rule/__init__.py diff --git a/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py similarity index 95% rename from conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py rename to run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py index a0adf431..382699d3 100644 --- a/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py @@ -4,7 +4,7 @@ import HABApp.openhab.connection_handler.http_connection import HABApp.openhab.connection_logic.connection -from HABApp.openhab.connection_handler.http_connection import HTTP_PREFIX +from HABApp.config import CONFIG FUNC_PATH = HABApp.openhab.connection_handler.func_async SSE_PATH = HABApp.openhab.connection_handler.sse_handler @@ -12,8 +12,9 @@ def shorten_url(url: str): url = str(url) - if url.startswith(HTTP_PREFIX): - return url[len(HTTP_PREFIX):] + cfg = CONFIG.openhab.connection.url + if url.startswith(cfg): + return url[len(cfg):] return url diff --git a/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py b/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py similarity index 97% rename from conf_testing/lib/HABAppTests/test_rule/_rule_ids.py rename to run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py index 512f0418..a1dbabbe 100644 --- a/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py @@ -32,8 +32,6 @@ def get_next_id(rule) -> RuleID: TESTS_RULES[RULE_CTR] = rule obj = RuleID(RULE_CTR) - - rule.register_on_unload(obj.remove) return obj diff --git a/conf_testing/lib/HABAppTests/test_rule/_rule_status.py b/run/conf_testing/lib/HABAppTests/test_rule/_rule_status.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_rule/_rule_status.py rename to run/conf_testing/lib/HABAppTests/test_rule/_rule_status.py diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py rename to run/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py rename to run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py similarity index 100% rename from conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py rename to run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py diff --git a/conf_testing/lib/HABAppTests/test_rule/test_rule.py b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py similarity index 95% rename from conf_testing/lib/HABAppTests/test_rule/test_rule.py rename to run/conf_testing/lib/HABAppTests/test_rule/test_rule.py index c214a794..38766f7e 100644 --- a/conf_testing/lib/HABAppTests/test_rule/test_rule.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py @@ -37,6 +37,9 @@ def __init__(self): # we have to chain the rules later, because we register the rules only once we loaded successfully. self.run.at(2, self.__execute_run) + def on_rule_unload(self): + self._rule_id.remove() + # ------------------------------------------------------------------------------------------------------------------ # Overrides and test def set_up(self): @@ -103,13 +106,13 @@ def __event_warning(self, event): def __event_error(self, event): self.__errors.append(event) - def __worker_events_sub(self): + def _worker_events_sub(self): assert self.__sub_warning is None assert self.__sub_errors is None - self.__sub_warning = self.listen_event(HABApp.core.const.topics.WARNINGS, self.__event_warning) - self.__sub_errors = self.listen_event(HABApp.core.const.topics.ERRORS, self.__event_error) + self.__sub_warning = self.listen_event(HABApp.core.const.topics.TOPIC_WARNINGS, self.__event_warning) + self.__sub_errors = self.listen_event(HABApp.core.const.topics.TOPIC_ERRORS, self.__event_error) - def __worker_events_cancel(self): + def _worker_events_cancel(self): if self.__sub_warning is not None: self.__sub_warning.cancel() if self.__sub_errors is not None: @@ -141,7 +144,7 @@ def __exec_tc(self, res: TestResult, tc: TestCase): def _run_tests(self) -> List[TestResult]: self._rule_status = TestRuleStatus.RUNNING - self.__worker_events_sub() + self._worker_events_sub() results = [] @@ -161,7 +164,7 @@ def _run_tests(self) -> List[TestResult]: if tr.state is not tr.state.PASSED: results.append(tr) - self.__worker_events_cancel() + self._worker_events_cancel() self._rule_status = TestRuleStatus.FINISHED return results diff --git a/conf_testing/lib/HABAppTests/utils.py b/run/conf_testing/lib/HABAppTests/utils.py similarity index 97% rename from conf_testing/lib/HABAppTests/utils.py rename to run/conf_testing/lib/HABAppTests/utils.py index 660234ed..c47d253e 100644 --- a/conf_testing/lib/HABAppTests/utils.py +++ b/run/conf_testing/lib/HABAppTests/utils.py @@ -41,7 +41,7 @@ def run_coro(coro: typing.Coroutine): def find_astro_sun_thing() -> str: - items = HABApp.core.Items.get_all_items() + items = HABApp.core.Items.get_items() for item in items: if isinstance(item, Thing) and item.name.startswith("astro:sun"): return item.name diff --git a/conf_testing/logging.yml b/run/conf_testing/logging.yml similarity index 98% rename from conf_testing/logging.yml rename to run/conf_testing/logging.yml index 6c752aeb..37718410 100644 --- a/conf_testing/logging.yml +++ b/run/conf_testing/logging.yml @@ -44,7 +44,7 @@ handlers: BufferEventFile: class: logging.handlers.MemoryHandler - capacity: 0 + capacity: 10 formatter: HABApp_format target: EventFile level: DEBUG diff --git a/conf_testing/parameters/param_file.yml b/run/conf_testing/parameters/param_file.yml similarity index 100% rename from conf_testing/parameters/param_file.yml rename to run/conf_testing/parameters/param_file.yml diff --git a/run/conf_testing/rules/habapp/test_event_listener.py b/run/conf_testing/rules/habapp/test_event_listener.py new file mode 100644 index 00000000..bc902954 --- /dev/null +++ b/run/conf_testing/rules/habapp/test_event_listener.py @@ -0,0 +1,45 @@ +import logging + +from HABApp.core.events import ValueChangeEventFilter +from HABApp.core.items import Item +from HABApp.util import EventListenerGroup +from HABAppTests import TestBaseRule, get_random_name +from HABApp.rule_ctx import HABAppRuleContext + +log = logging.getLogger('HABApp.Tests.MultiMode') + + +class TestNoWarningOnRuleUnload(TestBaseRule): + """This rule tests that multiple listen/cancel commands don't create warnings on unload""" + + def __init__(self): + super().__init__() + + self.add_test('CheckWarning', self.test_unload) + + def test_unload(self): + item = Item.get_create_item(get_random_name('HABApp')) + + grp = EventListenerGroup().add_listener(item, self.cb, ValueChangeEventFilter()) + + for _ in range(20): + grp.listen() + grp.cancel() + + self._habapp_ctx.unload_rule() + + # workaround so we don't get Errors + for k in ['_TestBaseRule__sub_warning', '_TestBaseRule__sub_errors']: + obj = self.__dict__[k] + self.__dict__[k] = None + assert obj._parent_ctx is None + + # Workaround to so we don't crash + self.on_rule_unload = lambda: None + self._habapp_ctx = HABAppRuleContext(self) + + def cb(self, event): + pass + + +TestNoWarningOnRuleUnload() diff --git a/run/conf_testing/rules/habapp/test_group_listener.py b/run/conf_testing/rules/habapp/test_group_listener.py new file mode 100644 index 00000000..78cb433d --- /dev/null +++ b/run/conf_testing/rules/habapp/test_group_listener.py @@ -0,0 +1,99 @@ +import logging +import time + +from HABApp.core.events import ValueUpdateEventFilter +from HABApp.core.items import Item +from HABApp.util import EventListenerGroup +from HABAppTests import TestBaseRule +from HABAppTests.errors import TestCaseFailed + +log = logging.getLogger('HABApp.Tests.MultiMode') + + +class TestListenerGroup(TestBaseRule): + """This rule is testing the Parameter implementation""" + + def __init__(self): + super().__init__() + + self.my_item1 = Item.get_create_item('EventGroupItem_1') + self.my_item2 = Item.get_create_item('EventGroupItem_2') + + self.grp = EventListenerGroup().add_listener( + [self.my_item1, self.my_item2], self.__callback, ValueUpdateEventFilter()) + + self.add_test('Test EventListenerGroup', self.test_basic) + self.add_test('Test EventListenerGroup deactivate', self.test_deactivate) + + self.calls = [] + + def __callback(self, event): + self.calls.append(event) + + def wait_for_cb(self, expected_len: int, min_time=None): + start = time.time() + while True: + dur = time.time() - start + if len(self.calls) == expected_len: + if min_time is None: + break + if dur > min_time: + break + if dur > 1: + raise TestCaseFailed('Timeout while waiting for calls!') + time.sleep(0.01) + + def test_basic(self): + self.calls.clear() + self.grp.listen() + + self.my_item2.post_value(1) + + self.wait_for_cb(1) + assert self.calls[0].value == 1 + assert self.calls[0].name == self.my_item2.name + + # Cancel + self.grp.cancel() + + self.wait_for_cb(1, min_time=0.1) + self.my_item1.post_value(5) + self.my_item2.post_value(6) + + self.wait_for_cb(1, min_time=0.1) + assert self.calls[0].value == 1 + assert self.calls[0].name == self.my_item2.name + + def test_deactivate(self): + self.calls.clear() + self.grp.listen() + + # deactivate listener + assert self.grp.deactivate_listener(self.my_item2.name) + + self.my_item2.post_value(1) + self.my_item1.post_value(2) + + self.wait_for_cb(1, min_time=0.1) + assert self.calls[0].value == 2 + assert self.calls[0].name == self.my_item1.name + + # activate listener + assert self.grp.activate_listener(self.my_item2.name) + + self.my_item2.post_value(5) + self.my_item1.post_value(6) + + self.wait_for_cb(3, min_time=0.1) + assert self.calls[0].value == 2 + assert self.calls[0].name == self.my_item1.name + assert self.calls[1].value == 5 + assert self.calls[1].name == self.my_item2.name + assert self.calls[2].value == 6 + assert self.calls[2].name == self.my_item1.name + + # Cancel + self.grp.cancel() + + +TestListenerGroup() diff --git a/conf_testing/rules/habapp/test_habapp.py b/run/conf_testing/rules/habapp/test_habapp.py similarity index 60% rename from conf_testing/rules/habapp/test_habapp.py rename to run/conf_testing/rules/habapp/test_habapp.py index 6283fc80..d07815c6 100644 --- a/conf_testing/rules/habapp/test_habapp.py +++ b/run/conf_testing/rules/habapp/test_habapp.py @@ -1,7 +1,8 @@ import time import HABApp -from HABApp.core.events import ItemNoUpdateEvent, ItemNoChangeEvent, ValueUpdateEvent +from HABApp.core.events import ItemNoUpdateEvent, ItemNoChangeEvent, ValueUpdateEvent, EventFilter, \ + ValueUpdateEventFilter from HABApp.core.items import Item from HABAppTests import TestBaseRule, EventWaiter, get_random_name @@ -25,11 +26,11 @@ def item_events(self, changes=False, secs=5, values=[]): self.secs = secs watcher = (self.watch_item.watch_change if changes else self.watch_item.watch_update)(secs) - event = ItemNoUpdateEvent if not changes else ItemNoChangeEvent - listener = self.listen_event(self.watch_item, self.check_event, event) + filter = EventFilter(ItemNoUpdateEvent) if not changes else EventFilter(ItemNoChangeEvent) + listener = self.listen_event(self.watch_item, self.check_event, filter) try: - self._run(values, event) + self._run(values, filter) HABApp.core.Items.pop_item(item_name) assert not HABApp.core.Items.item_exists(item_name) @@ -37,26 +38,58 @@ def item_events(self, changes=False, secs=5, values=[]): time.sleep(0.5) self.watch_item = Item.get_create_item(item_name) - self._run(values, event) + self._run(values, filter) finally: listener.cancel() watcher.cancel() return None - def _run(self, values, event): + def _run(self, values, filter): self.ts_set = 0 for step, value in enumerate(values): if step: time.sleep(0.2) self.ts_set = time.time() self.watch_item.set_value(value) - with EventWaiter(self.watch_item.name, event, self.secs + 2) as w: + with EventWaiter(self.watch_item.name, filter, self.secs + 2) as w: w.wait_for_event(seconds=self.secs) TestItemEvents() +class TestItemEventRestore(TestBaseRule): + """Test that item listeners are properly restored when an item is removed and added again""" + + def __init__(self): + super().__init__() + + self.add_test('const change', self.test_restore, change=True) + self.add_test('const update', self.test_restore, change=False) + + def test_restore(self, change=False): + item = HABApp.core.items.Item.get_create_item(get_random_name('HABApp')) + timeout = 0.2 + (item.watch_change if change else item.watch_update)(timeout) + filter = EventFilter(ItemNoUpdateEvent) if not change else EventFilter(ItemNoChangeEvent) + + HABApp.core.Items.pop_item(item.name) + time.sleep(0.1) + + new_item = HABApp.core.items.Item.get_create_item(item.name) + + # item has to be a new one + assert item is not new_item + + # ensure that the event still gets created properly + new_item.post_value(5) + with EventWaiter(new_item.name, filter, timeout + timeout * 0.5) as w: + w.wait_for_event(seconds=timeout) + + +TestItemEventRestore() + + class TestItemListener(TestBaseRule): def __init__(self): @@ -69,17 +102,17 @@ def check_event(self, event: ValueUpdateEvent): def set_up(self): self.watch_item = Item.get_create_item(get_random_name('HABApp')) - self.listener = self.watch_item.listen_event(self.check_event, ValueUpdateEvent) + self.listener = self.watch_item.listen_event(self.check_event, ValueUpdateEventFilter()) def tear_down(self): self.listener.cancel() def trigger_event(self): self.run.at( - 1, HABApp.core.EventBus.post_event, self.watch_item.name, ValueUpdateEvent(self.watch_item.name, 123) + 1, self.post_event, self.watch_item.name, ValueUpdateEvent(self.watch_item.name, 123) ) - with EventWaiter(self.watch_item.name, ValueUpdateEvent, 2) as w: + with EventWaiter(self.watch_item.name, ValueUpdateEventFilter(), 2) as w: w.wait_for_event(value=123) diff --git a/conf_testing/rules/habapp/test_parameter_files.py b/run/conf_testing/rules/habapp/test_parameter_files.py similarity index 100% rename from conf_testing/rules/habapp/test_parameter_files.py rename to run/conf_testing/rules/habapp/test_parameter_files.py diff --git a/conf_testing/rules/habapp/test_scheduler.py b/run/conf_testing/rules/habapp/test_scheduler.py similarity index 100% rename from conf_testing/rules/habapp/test_scheduler.py rename to run/conf_testing/rules/habapp/test_scheduler.py diff --git a/run/conf_testing/rules/habapp/test_util_fade.py b/run/conf_testing/rules/habapp/test_util_fade.py new file mode 100644 index 00000000..454f9855 --- /dev/null +++ b/run/conf_testing/rules/habapp/test_util_fade.py @@ -0,0 +1,37 @@ +import time + +from HABApp.core.items import Item +from HABApp.util.fade.fade import Fade +from HABAppTests import ItemWaiter, TestBaseRule + + +class TestFadeRun(TestBaseRule): + """This rule is testing the Parameter implementation""" + + def __init__(self): + super().__init__() + + self.add_test('TestFade', self.test_fade) + + def test_fade(self): + item = Item.get_create_item('TestFadeItem') + item.set_value(None) + + vals = {} + now = time.time() + + def add_value(v): + vals[time.time() - now] = v + item.set_value(v) + + f = Fade(callback=add_value) + f.setup(0, 10, 3) + f.schedule_fade() + + ItemWaiter(item, 3.1).wait_for_state(10.0) + + assert f._fade_worker is None + assert len(vals) == 11 # start value + 10 fade steps + + +TestFadeRun() diff --git a/conf_testing/rules/habapp/test_utils.py b/run/conf_testing/rules/habapp/test_utils.py similarity index 100% rename from conf_testing/rules/habapp/test_utils.py rename to run/conf_testing/rules/habapp/test_utils.py diff --git a/conf_testing/rules/openhab/test_event_types.py b/run/conf_testing/rules/openhab/test_event_types.py similarity index 91% rename from conf_testing/rules/openhab/test_event_types.py rename to run/conf_testing/rules/openhab/test_event_types.py index c1f3ec79..9aae78f0 100644 --- a/conf_testing/rules/openhab/test_event_types.py +++ b/run/conf_testing/rules/openhab/test_event_types.py @@ -1,4 +1,4 @@ -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.events import ValueUpdateEventFilter from HABApp.openhab.definitions.definitions import ITEM_DIMENSIONS from HABAppTests import TestBaseRule, EventWaiter, OpenhabTmpItem, get_openhab_test_events, \ @@ -21,7 +21,7 @@ def __init__(self): def test_events(self, item_type, test_values): item_name = f'{item_type}_value_test' - with OpenhabTmpItem(item_type, item_name), EventWaiter(item_name, ValueUpdateEvent) as waiter: + with OpenhabTmpItem(item_type, item_name), EventWaiter(item_name, ValueUpdateEventFilter()) as waiter: for value in test_values: self.openhab.post_update(item_name, value) @@ -41,7 +41,7 @@ def test_quantity_type_events(self, dimension): item_name = f'{dimension}_event_test' with OpenhabTmpItem(f'Number:{dimension}', item_name) as item, \ - EventWaiter(item_name, ValueUpdateEvent) as event_watier, \ + EventWaiter(item_name, ValueUpdateEventFilter()) as event_watier, \ ItemWaiter(item) as item_waiter: for state in get_openhab_test_states('Number'): diff --git a/conf_testing/rules/openhab/test_groups.py b/run/conf_testing/rules/openhab/test_groups.py similarity index 93% rename from conf_testing/rules/openhab/test_groups.py rename to run/conf_testing/rules/openhab/test_groups.py index 75bebd06..f0d1cd5d 100644 --- a/conf_testing/rules/openhab/test_groups.py +++ b/run/conf_testing/rules/openhab/test_groups.py @@ -1,6 +1,6 @@ from HABApp.openhab.items import SwitchItem, GroupItem from HABAppTests import ItemWaiter, TestBaseRule, OpenhabTmpItem, EventWaiter -from HABApp.openhab.events import ItemUpdatedEvent +from HABApp.openhab.events import ItemStateEventFilter from HABAppTests.errors import TestCaseFailed @@ -46,7 +46,7 @@ def test_group_update(self): def add_item_to_grp(self): new_item = OpenhabTmpItem('Switch') try: - with EventWaiter(self.group.name, ItemUpdatedEvent) as w: + with EventWaiter(self.group.name, ItemStateEventFilter()) as w: new_item.create_item(groups=[self.group.name]) event = w.wait_for_event() while event.name != self.group.name: diff --git a/conf_testing/rules/openhab/test_habapp_internals.py b/run/conf_testing/rules/openhab/test_habapp_internals.py similarity index 100% rename from conf_testing/rules/openhab/test_habapp_internals.py rename to run/conf_testing/rules/openhab/test_habapp_internals.py diff --git a/conf_testing/rules/openhab/test_interface.py b/run/conf_testing/rules/openhab/test_interface.py similarity index 89% rename from conf_testing/rules/openhab/test_interface.py rename to run/conf_testing/rules/openhab/test_interface.py index 6955fcba..84afa969 100644 --- a/conf_testing/rules/openhab/test_interface.py +++ b/run/conf_testing/rules/openhab/test_interface.py @@ -1,3 +1,14 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# This rule requires the following items: +# ---------------------------------------------------------------------------------------------------------------------- +# +# Group TestGroup +# Group:Number:AVG TestGroupAVG +# +# String TestString (TestGroup) [TestTag] {meta1="test" [key="value"]} +# Number TestNumber (TestGroup, TestGroupAVG) +# +# ---------------------------------------------------------------------------------------------------------------------- import time import HABApp diff --git a/conf_testing/rules/openhab/test_interface_links.py b/run/conf_testing/rules/openhab/test_interface_links.py similarity index 94% rename from conf_testing/rules/openhab/test_interface_links.py rename to run/conf_testing/rules/openhab/test_interface_links.py index be827d4d..7c168259 100644 --- a/conf_testing/rules/openhab/test_interface_links.py +++ b/run/conf_testing/rules/openhab/test_interface_links.py @@ -1,4 +1,3 @@ -from HABApp.core.Items import get_all_items from HABApp.openhab.items import Thing from HABAppTests import TestBaseRule @@ -41,9 +40,8 @@ def tear_down(self): def __find_astro_sun_thing(self) -> str: found_uid: str = "" - for item in get_all_items(): - if isinstance(item, Thing) and item.name.startswith("astro:sun"): - found_uid = item.name + for item in self.get_items(Thing, name='^astro:sun'): + found_uid = item.name return found_uid diff --git a/conf_testing/rules/openhab/test_item_change.py b/run/conf_testing/rules/openhab/test_item_change.py similarity index 76% rename from conf_testing/rules/openhab/test_item_change.py rename to run/conf_testing/rules/openhab/test_item_change.py index 337c7da6..136de4e9 100644 --- a/conf_testing/rules/openhab/test_item_change.py +++ b/run/conf_testing/rules/openhab/test_item_change.py @@ -1,3 +1,5 @@ +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.items import StringItem, NumberItem, DatetimeItem @@ -14,12 +16,12 @@ def change_item(self): with OpenhabTmpItem('Number') as tmpitem: NumberItem.get_item(tmpitem.name) - with EventWaiter(tmpitem.name, ItemUpdatedEvent, 2) as e: + with EventWaiter(TOPIC_ITEMS, EventFilter(ItemUpdatedEvent), 2) as e: create_item('String', tmpitem.name) e.wait_for_event(type='String', name=tmpitem.name) StringItem.get_item(tmpitem.name) - with EventWaiter(tmpitem.name, ItemUpdatedEvent, 2) as e: + with EventWaiter(TOPIC_ITEMS, EventFilter(ItemUpdatedEvent), 2) as e: create_item('DateTime', tmpitem.name) e.wait_for_event(type='DateTime', name=tmpitem.name) DatetimeItem.get_item(tmpitem.name) diff --git a/conf_testing/rules/openhab/test_item_funcs.py b/run/conf_testing/rules/openhab/test_item_funcs.py similarity index 100% rename from conf_testing/rules/openhab/test_item_funcs.py rename to run/conf_testing/rules/openhab/test_item_funcs.py diff --git a/conf_testing/rules/openhab/test_items.py b/run/conf_testing/rules/openhab/test_items.py similarity index 91% rename from conf_testing/rules/openhab/test_items.py rename to run/conf_testing/rules/openhab/test_items.py index 65da1e1a..1e401e9b 100644 --- a/conf_testing/rules/openhab/test_items.py +++ b/run/conf_testing/rules/openhab/test_items.py @@ -1,7 +1,3 @@ -# ---------------------------------------------------------------------------------------------------------------------- -# This rule requires the following item: -# String TestString (TestGroup) [TestTag] {meta1="test" [key="value"]} -# ---------------------------------------------------------------------------------------------------------------------- import asyncio from immutables import Map diff --git a/conf_testing/rules/openhab/test_max_sse_msg_size.py b/run/conf_testing/rules/openhab/test_max_sse_msg_size.py similarity index 81% rename from conf_testing/rules/openhab/test_max_sse_msg_size.py rename to run/conf_testing/rules/openhab/test_max_sse_msg_size.py index dc548fb6..8828ea2f 100644 --- a/conf_testing/rules/openhab/test_max_sse_msg_size.py +++ b/run/conf_testing/rules/openhab/test_max_sse_msg_size.py @@ -1,6 +1,6 @@ import logging -from HABApp.openhab.events import ItemStateChangedEvent +from HABApp.openhab.events import ItemStateChangedEventFilter from HABApp.openhab.items import OpenhabItem from HABAppTests import EventWaiter, ItemWaiter, OpenhabTmpItem, TestBaseRule @@ -16,13 +16,11 @@ def __init__(self): def test_img_size(self): - # start with 200k - _b1 = b'0x00' * 200 * 1024 - _b2 = b'0xFF' * 200 * 1024 - with OpenhabTmpItem('Image') as item, ItemWaiter(OpenhabItem.get_item(item.name)) as item_waiter, \ - EventWaiter(item.name, ItemStateChangedEvent) as event_waiter: - k = 383 + EventWaiter(item.name, ItemStateChangedEventFilter()) as event_waiter: + + k = 95 # test data size in kib + _b1 = b'\xFF\xD8\xFF' + b'\x00' * (1024 - 3) + b'\x00' * (k - 1) * 1024 _b2 = b'\xFF\xD8\xFF' + b'\xFF' * (1024 - 3) + b'\x00' * (k - 1) * 1024 diff --git a/conf_testing/rules/openhab/test_persistence.py b/run/conf_testing/rules/openhab/test_persistence.py similarity index 95% rename from conf_testing/rules/openhab/test_persistence.py rename to run/conf_testing/rules/openhab/test_persistence.py index 200f59cc..fe40ea4a 100644 --- a/conf_testing/rules/openhab/test_persistence.py +++ b/run/conf_testing/rules/openhab/test_persistence.py @@ -36,4 +36,5 @@ def test_set(self): assert list(ist.values()) == [2], ist -TestPersistence() +# Todo: Enable when OH3.3 supports this +# TestPersistence() diff --git a/conf_testing/rules/openhab/test_things.py b/run/conf_testing/rules/openhab/test_things.py similarity index 100% rename from conf_testing/rules/openhab/test_things.py rename to run/conf_testing/rules/openhab/test_things.py diff --git a/conf_testing/rules/openhab_bugs.py b/run/conf_testing/rules/openhab_bugs.py similarity index 100% rename from conf_testing/rules/openhab_bugs.py rename to run/conf_testing/rules/openhab_bugs.py diff --git a/conf_testing/rules/test_mqtt.py b/run/conf_testing/rules/test_mqtt.py similarity index 91% rename from conf_testing/rules/test_mqtt.py rename to run/conf_testing/rules/test_mqtt.py index 7a5cf70e..b8d34454 100644 --- a/conf_testing/rules/test_mqtt.py +++ b/run/conf_testing/rules/test_mqtt.py @@ -2,8 +2,8 @@ import time import HABApp -from HABApp.core.events import ValueUpdateEvent -from HABApp.mqtt.events import MqttValueUpdateEvent +from HABApp.core.events import ValueUpdateEventFilter +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 @@ -21,8 +21,8 @@ def __init__(self): self.mqtt_test_data = ['asdf', 1, 1.1, str({'a': 'b'}), {'key': 'value'}, ['mylist', 'mylistvalue']] - self.add_test('MQTT events', self.test_mqtt_events, MqttValueUpdateEvent) - self.add_test('MQTT ValueUpdate events', self.test_mqtt_events, ValueUpdateEvent) + self.add_test('MQTT events', self.test_mqtt_events, MqttValueUpdateEventFilter()) + self.add_test('MQTT ValueUpdate events', self.test_mqtt_events, ValueUpdateEventFilter()) self.add_test('MQTT item update', self.test_mqtt_state) @@ -36,7 +36,7 @@ def test_mqtt_pair_item(self): item = MqttPairItem.get_create_item(topic_read, topic_write) # Ensure we send on the write topic - with EventWaiter(topic_write, ValueUpdateEvent) as event_waiter: + with EventWaiter(topic_write, ValueUpdateEventFilter()) as event_waiter: item.publish('ddddddd') event_waiter.wait_for_event(value='ddddddd') diff --git a/setup.py b/setup.py index 11d9a8d3..436eb346 100644 --- a/setup.py +++ b/setup.py @@ -14,12 +14,7 @@ def load_version() -> str: def load_req() -> typing.List[str]: - # When we run tox tests we don't have this file available so we skip them - req_file = Path(__file__).with_name('requirements_setup.txt') - if not req_file.is_file(): - return [''] - - with req_file.open() as f: + with open('requirements_setup.txt') as f: return f.readlines() @@ -28,7 +23,7 @@ def load_req() -> typing.List[str]: print(f'Version: {__version__}') print('') -# When we run tox tests we don't have these files available so we skip them +# When we run tox tests we don't have these files available, so we skip them readme = Path(__file__).with_name('readme.md') long_description = '' if readme.is_file(): @@ -57,15 +52,15 @@ def load_req() -> typing.List[str]: package_dir={'': 'src'}, packages=find_packages('src', exclude=['tests*']), install_requires=load_req(), - python_requires='>=3.7', + python_requires='>=3.8', classifiers=[ "Development Status :: 4 - Beta", "Framework :: AsyncIO", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3 :: Only", "Topic :: Home Automation" ], diff --git a/src/HABApp/__check_dependency_packages__.py b/src/HABApp/__check_dependency_packages__.py new file mode 100644 index 00000000..3748cb82 --- /dev/null +++ b/src/HABApp/__check_dependency_packages__.py @@ -0,0 +1,56 @@ +import importlib +import sys +from typing import List, Dict + +from HABApp.__debug_info__ import print_debug_info + + +def get_dependencies() -> List[str]: + return [ + 'aiohttp-sse-client', + 'aiohttp', + 'bidict', + 'eascheduler', + 'easyconfig', + 'paho-mqtt', + 'pydantic', + 'stack_data', + 'voluptuous', + 'watchdog', + 'ujson', + 'immutables', + 'pendulum', + + 'typing-extensions', + ] + + +def check_dependency_packages(): + """Imports all dependencies and reports failures""" + + missing: Dict[str, ModuleNotFoundError] = {} + + # Package aliases (if the import name differs from the package name) + alias = { + 'aiohttp-sse-client': 'aiohttp_sse_client', + 'paho-mqtt': 'paho.mqtt', + 'typing-extensions': 'typing_extensions', + } + + for name in get_dependencies(): + try: + importlib.import_module(alias.get(name, name)) + except ModuleNotFoundError as e: + missing[name] = e + + if not missing: + return None + + print_debug_info() + print() + + print(f'Error: {len(missing)} package{"s are" if len(missing) != 1 else " is"} missing:') + for name, err in missing.items(): + print(f' - {name}: {err}') + + sys.exit(100) diff --git a/src/HABApp/__cmd_args__.py b/src/HABApp/__cmd_args__.py index 6211fd6a..681ccbb2 100644 --- a/src/HABApp/__cmd_args__.py +++ b/src/HABApp/__cmd_args__.py @@ -9,6 +9,7 @@ # Global var if we want to run the benchmark DO_BENCH = False DO_DEBUG = False +CONFIG_FILE = Path() def get_uptime() -> float: @@ -25,7 +26,7 @@ def get_uptime() -> float: raise NotImplementedError(f'Not supported on {sys.platform}') -def parse_args(passed_args=None) -> Path: +def parse_args(passed_args=None) -> argparse.Namespace: global DO_BENCH, DO_DEBUG parser = argparse.ArgumentParser(description='Start HABApp') @@ -68,13 +69,11 @@ def parse_args(passed_args=None) -> Path: time.sleep(diff) print(' done!') - path = args.config - if path is not None: - path = Path(path).resolve() - return find_config_folder(path) + return args def find_config_folder(arg_config_path: typing.Optional[Path]) -> Path: + global CONFIG_FILE if arg_config_path is None: # Nothing is specified, we try to find the config automatically @@ -95,6 +94,8 @@ def find_config_folder(arg_config_path: typing.Optional[Path]) -> Path: if v_env: check_path.append(Path(v_env) / 'HABApp') # Virtual env dir else: + arg_config_path = Path(arg_config_path).resolve() + # in case the user specifies the config.yml we automatically switch to the parent folder if arg_config_path.name.lower() == 'config.yml' and arg_config_path.is_file(): arg_config_path = arg_config_path.parent @@ -109,6 +110,7 @@ def find_config_folder(arg_config_path: typing.Optional[Path]) -> Path: config_file = config_folder / 'config.yml' if config_file.is_file(): + CONFIG_FILE = config_file return config_folder # we have specified a folder, but the config does not exist so we will create it diff --git a/src/HABApp/__debug_info__.py b/src/HABApp/__debug_info__.py new file mode 100644 index 00000000..29234062 --- /dev/null +++ b/src/HABApp/__debug_info__.py @@ -0,0 +1,36 @@ +import platform +import sys +from HABApp.__version__ import __version__ + + +def get_debug_info() -> str: + + info = { + 'HABApp': __version__, + 'Platform': platform.platform(), + 'Machine': platform.machine(), + 'Python': sys.version, + } + + indent = max(map(lambda x: len(x), info)) + ret = '\n'.join('{:{indent}s}: {:s}'.format(k, str(v).replace('\n', ''), indent=indent) for k, v in info.items()) + + try: + import pkg_resources + installed_packages = {p.key: p.version for p in sorted(pkg_resources.working_set, key=lambda x: x.key)} + + indent = max(map(len, installed_packages.keys()), default=1) + 2 + table = '\n'.join(f'{k:{indent}s}: {v}' for k, v in installed_packages.items()) + + if installed_packages: + ret += f'\n\nInstalled Packages\n{"-" * 80}\n{table}' + + except Exception as e: + ret += f'\n\nCould not get installed Packages!\nError: {str(e)}' + + return ret + + +def print_debug_info(): + print(f'Debug information\n{"-" * 80}') + print(get_debug_info()) diff --git a/src/HABApp/__init__.py b/src/HABApp/__init__.py index aff36d8e..4ce00e9a 100644 --- a/src/HABApp/__init__.py +++ b/src/HABApp/__init__.py @@ -1,8 +1,8 @@ # 1. Static stuff from .__version__ import __version__ -# 2. Setup used libraries -import HABApp.__do_setup__ +# 2. Setup used libraries and check installation +import HABApp.__setup_packages__ # 3. User configuration import HABApp.config @@ -10,12 +10,17 @@ # 4. Core features import HABApp.core +# This holds only textual references to other objects so we can import this before everything else +import HABApp.rule_ctx + + # Import the rest import HABApp.mqtt import HABApp.openhab import HABApp.rule import HABApp.runtime + import HABApp.util from HABApp.rule import Rule from HABApp.parameters import Parameter, DictParameter diff --git a/src/HABApp/__main__.py b/src/HABApp/__main__.py index d911e732..2dc35b6b 100644 --- a/src/HABApp/__main__.py +++ b/src/HABApp/__main__.py @@ -1,71 +1,21 @@ import asyncio import logging -import signal import sys -import traceback import typing - -def get_debug_info() -> str: - import platform - import sys - - info = { - 'Platform': platform.platform(), - 'Machine': platform.machine(), - 'Python version': sys.version, - } - - ret = '\n'.join('{:20s}: {:s}'.format(k, str(v).replace('\n', '')) for k, v in info.items()) - - try: - import pkg_resources - installed_packages = {p.key: p.version for p in sorted(pkg_resources.working_set, key=lambda x: x.key)} - - indent = max(map(len, installed_packages.keys()), default=1) + 2 - table = '\n'.join(f'{k:{indent}s}: {v}' for k, v in installed_packages.items()) - - if installed_packages: - ret += f'\n\nInstalled Packages\n{"-" * 80}\n{table}' - - except Exception as e: - ret += f'\n\nCould not get installed Packages!\nError: {str(e)}' - - return ret - - -def print_debug_info(): - print(f'Debug information\n{"-" * 80}') - print(get_debug_info()) - - -try: - import HABApp - from HABApp.__cmd_args__ import parse_args - from HABApp.__splash_screen__ import show_screen -except (ModuleNotFoundError, ImportError) as dep_err: - print(f'Error!\nDependency "{dep_err.name}" is missing!\n\n') - print_debug_info() - sys.exit(100) - - -def register_signal_handler(): - def shutdown_handler(sig, frame): - print('Shutting down ...') - HABApp.runtime.shutdown.request_shutdown() - - # register shutdown helper - signal.signal(signal.SIGINT, shutdown_handler) - signal.signal(signal.SIGTERM, shutdown_handler) +import HABApp +from HABApp.__cmd_args__ import parse_args, find_config_folder +from HABApp.__debug_info__ import print_debug_info +from HABApp.__splash_screen__ import show_screen def main() -> typing.Union[int, str]: - # This has do be done before we create HABApp because of the possible sleep time - cfg_folder = parse_args() - show_screen() + # This has to be done before we create HABApp because of the possible sleep time + args = parse_args() + if HABApp.__cmd_args__.DO_DEBUG: print_debug_info() sys.exit(0) @@ -73,20 +23,22 @@ def main() -> typing.Union[int, str]: log = logging.getLogger('HABApp') try: - app = HABApp.runtime.Runtime() - register_signal_handler() + cfg_folder = find_config_folder(args.config) - # start workers + # see if we have user code (e.g. for additional logging configuration or additional setup) try: - asyncio.ensure_future(app.start(cfg_folder)) - HABApp.core.const.loop.run_forever() - except asyncio.CancelledError: + import HABAppUser # noqa: F401 + except ModuleNotFoundError: pass - except HABApp.config.InvalidConfigException: - pass + # Shutdown handler for graceful shutdown + HABApp.runtime.shutdown.register_signal_handler() + + app = HABApp.runtime.Runtime() + HABApp.core.const.loop.create_task(app.start(cfg_folder)) + HABApp.core.const.loop.run_forever() except Exception as e: - for line in traceback.format_exc().splitlines(): + for line in HABApp.core.lib.exceptions.format_exception(e): log.error(line) print(e) return str(e) diff --git a/src/HABApp/__do_setup__.py b/src/HABApp/__setup_packages__.py similarity index 63% rename from src/HABApp/__do_setup__.py rename to src/HABApp/__setup_packages__.py index 66361a39..998726c3 100644 --- a/src/HABApp/__do_setup__.py +++ b/src/HABApp/__setup_packages__.py @@ -1,10 +1,4 @@ -# ----------------------------------------------------------------------------- -# setup pydantic -# ----------------------------------------------------------------------------- -import pydantic # noqa: E402 - -pydantic.BaseConfig.extra = pydantic.Extra.forbid # Don't allow extra keys -pydantic.BaseConfig.allow_population_by_field_name = True # Allow setting value by key name +from HABApp.__check_dependency_packages__ import check_dependency_packages # ----------------------------------------------------------------------------- # if installed we use uvloop because it seems to be much faster (untested) @@ -16,3 +10,11 @@ print('Using uvloop') except ModuleNotFoundError: pass + + +# ----------------------------------------------------------------------------- +# Check that all dependencies are installed +# We do this here, so we can print a nice error message. Otherwise the corresponding +# module import will fail somewhere in the middle of the startup process +# ----------------------------------------------------------------------------- +check_dependency_packages() diff --git a/src/HABApp/__splash_screen__.py b/src/HABApp/__splash_screen__.py index 71aa5462..7799c294 100644 --- a/src/HABApp/__splash_screen__.py +++ b/src/HABApp/__splash_screen__.py @@ -3,13 +3,13 @@ def show_screen(): txt = r""" - __ _____ ____ ___ - / / / / | / __ )/ | ____ ____ - / /_/ / /| | / __ / /| | / __ \/ __ \ - / __ / ___ |/ /_/ / ___ |/ /_/ / /_/ / -/_/ /_/_/ |_/_____/_/ |_/ .___/ .___/ - /_/ /_/ + _ _ _ ____ _ + | | | | / \ | __ ) / \ _ __ _ __ + | |_| | / _ \ | _ \ / _ \ | '_ \| '_ \ + | _ |/ ___ \| |_) / ___ \| |_) | |_) | + |_| |_/_/ \_|____/_/ \_| .__/| .__/ + |_| |_| """ print(txt.strip('\n\r')) - print(f'{" " * 37}{__version__}') + print(f'{" " * 40}{__version__}') diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index a56fd275..1f356cc5 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.31.2' +__version__ = '1.0.0' diff --git a/src/HABApp/config/__init__.py b/src/HABApp/config/__init__.py index e629428e..60ff7186 100644 --- a/src/HABApp/config/__init__.py +++ b/src/HABApp/config/__init__.py @@ -1,3 +1,9 @@ -from .config import Openhab, Mqtt +from .errors import InvalidConfigError + +# isort: split + from .config import CONFIG -from .config_loader import HABAppConfigLoader, InvalidConfigException + +# isort: split + +from .loader import load_config diff --git a/src/HABApp/config/_conf_location.py b/src/HABApp/config/_conf_location.py deleted file mode 100644 index 373c45c5..00000000 --- a/src/HABApp/config/_conf_location.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging.config - -import eascheduler -import voluptuous -from EasyCo import ConfigContainer, ConfigEntry - -log = logging.getLogger('HABApp.Config') - - -class Location(ConfigContainer): - latitude: float = ConfigEntry(default=0.0, validator=voluptuous.Any(float, int)) - longitude: float = ConfigEntry(default=0.0, validator=voluptuous.Any(float, int)) - elevation: float = ConfigEntry(default=0.0, validator=voluptuous.Any(float, int)) - - def __init__(self): - super().__init__() - - def on_all_values_set(self): - log.debug(f'Local Timezone: {eascheduler.const.local_tz}') - eascheduler.set_location(self.latitude, self.longitude, self.elevation) diff --git a/src/HABApp/config/_conf_mqtt.py b/src/HABApp/config/_conf_mqtt.py deleted file mode 100644 index 0b992fb1..00000000 --- a/src/HABApp/config/_conf_mqtt.py +++ /dev/null @@ -1,66 +0,0 @@ -import typing - -from EasyCo import ConfigContainer, ConfigEntry -from voluptuous import Invalid - - -def MqttTopicValidator(v, msg=''): - if isinstance(v, str): - return [(v, 0)] - - ret = [] - for i, val in enumerate(v): - qos = 0 - if i < len(v) - 1: - qos = v[i + 1] - - if not isinstance(val, str) and not isinstance(val, int): - raise Invalid(msg or "Topics must consist of int and string!") - - if not isinstance(val, str): - continue - - if isinstance(qos, int): - if qos not in [0, 1, 2]: - raise Invalid(msg or ("QoS must be 0, 1 or 2")) - else: - qos = None - - ret.append((val, qos)) - return ret - - -class Connection(ConfigContainer): - client_id: str = 'HABApp' - host: str = '' - port: int = 8883 - user: str = '' - password: str = '' - tls: bool = True - tls_ca_cert: str = ConfigEntry(default='', description='Path to a CA certificate that will be treated as trusted') - tls_insecure: bool = False - - -class Subscribe(ConfigContainer): - qos: int = ConfigEntry(default=0, description='Default QoS for subscribing') - topics: typing.List[typing.Union[str, int]] = ConfigEntry( - default_factory=lambda: list(('#', 0)), validator=MqttTopicValidator - ) - - -class Publish(ConfigContainer): - qos: int = ConfigEntry(default=0, description='Default QoS when publishing values') - retain: bool = ConfigEntry(default=False, description='Default retain flag when publishing values') - - -class General(ConfigContainer): - listen_only: bool = ConfigEntry( - False, description='If True HABApp will not publish any value to the broker' - ) - - -class Mqtt(ConfigContainer): - connection = Connection() - subscribe = Subscribe() - publish = Publish() - general = General() diff --git a/src/HABApp/config/_conf_openhab.py b/src/HABApp/config/_conf_openhab.py deleted file mode 100644 index 26adf9a8..00000000 --- a/src/HABApp/config/_conf_openhab.py +++ /dev/null @@ -1,32 +0,0 @@ -from EasyCo import ConfigContainer, ConfigEntry - - -class Ping(ConfigContainer): - enabled: bool = ConfigEntry(True, description='If enabled the configured item will show how long it takes to send ' - 'an update from HABApp and get the updated value back from openhab' - 'in milliseconds') - item: str = ConfigEntry('HABApp_Ping', description='Name of the Numberitem') - interval: int = ConfigEntry(10, description='Seconds between two pings') - - -class General(ConfigContainer): - listen_only: bool = ConfigEntry( - False, description='If True HABApp will not change anything on the openHAB instance.' - ) - wait_for_openhab: bool = ConfigEntry( - True, - description='If True HABApp will wait for items from the openHAB instance before loading any rules on startup' - ) - - -class Connection(ConfigContainer): - host: str = 'localhost' - port: int = 8080 - user: str = '' - password: str = '' - - -class Openhab(ConfigContainer): - ping = Ping() - connection = Connection() - general = General() diff --git a/src/HABApp/config/config.py b/src/HABApp/config/config.py index 493e4d45..daf64960 100644 --- a/src/HABApp/config/config.py +++ b/src/HABApp/config/config.py @@ -1,60 +1,4 @@ -import logging -import sys -from pathlib import Path +from easyconfig import create_app_config +from .models import ApplicationConfig -from EasyCo import ConfigEntry, ConfigFile, PathContainer - -from ._conf_location import Location -from ._conf_mqtt import Mqtt -from ._conf_openhab import Openhab -from .platform_defaults import get_log_folder - -log = logging.getLogger('HABApp.Config') - - -class Directories(PathContainer): - logging: Path = ConfigEntry(get_log_folder(Path('log')), description='Folder where the logs will be written to') - rules: Path = ConfigEntry(Path('rules'), description='Folder from which the rule files will be loaded') - param: Path = ConfigEntry(Path('params'), description='Folder from which the parameter files will be loaded') - config: Path = ConfigEntry(Path('config'), description='Folder from which configuration files ' - '(e.g. for textual thing configuration) will be loaded') - lib: Path = ConfigEntry(Path('lib'), description='Folder where additional libraries can be placed') - - def on_all_values_set(self): - - # Configuration folder of HABApp can not be one of the configured folders - for name, path in {attr: getattr(self, attr) for attr in ('rules', 'param', 'config')}.items(): - if path == self.parent_folder: - msg = f'Path for {name} can not be the same as the path for the HABApp config! ({path})' - log.error(msg) - sys.exit(msg) - - try: - # create folder structure if it does not exist - if not self.rules.is_dir(): - self.rules.mkdir() - if not self.logging.is_dir(): - self.logging.mkdir() - if not self.config.is_dir(): - log.info(f'Manual thing configuration disabled! Folder {self.config} does not exist!') - - # add path for libraries - if self.lib.is_dir(): - lib_path = str(self.lib) - if lib_path not in sys.path: - sys.path.insert(0, lib_path) - log.debug(f'Added library folder "{lib_path}" to system path') - except Exception as e: - log.error(e) - print(e) - - -class HABAppConfig(ConfigFile): - location = Location() - directories = Directories() - - mqtt = Mqtt() - openhab = Openhab() - - -CONFIG: HABAppConfig = HABAppConfig() +CONFIG: ApplicationConfig = create_app_config(ApplicationConfig()) diff --git a/src/HABApp/config/config_loader.py b/src/HABApp/config/config_loader.py deleted file mode 100644 index 7f6109bf..00000000 --- a/src/HABApp/config/config_loader.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -import logging.config -import time -import traceback -from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler -from pathlib import Path -from typing import Any, Dict, List - -import ruamel.yaml - -import HABApp -from HABApp import __version__ -from . import CONFIG -from .default_logfile import get_default_logfile - -_yaml_param = ruamel.yaml.YAML(typ='safe') -_yaml_param.default_flow_style = False -_yaml_param.default_style = False # type: ignore -_yaml_param.width = 1000000 # type: ignore -_yaml_param.allow_unicode = True -_yaml_param.sort_base_mapping_type_on_output = False # type: ignore - - -log = logging.getLogger('HABApp.Config') - - -class AbsolutePathExpected(Exception): - pass - - -class InvalidConfigException(Exception): - pass - - -class HABAppConfigLoader: - - def __init__(self, config_folder: Path): - - assert isinstance(config_folder, Path) - assert config_folder.is_dir(), config_folder - self.folder_conf = config_folder - self.file_conf_habapp = self.folder_conf / 'config.yml' - self.file_conf_logging = self.folder_conf / 'logging.yml' - - # if the config does not exist it will be created - self.__check_create_logging() - - # Load Config initially - self.first_start = True - try: - self.load_cfg() - load_cfg = False - except Exception: - load_cfg = True - - # Load logging configuration. - try: - self.load_log() - except AbsolutePathExpected: - # This error only occurs when the config was not loaded because of an exception. - # Since we crash in load_cfg again we'll show that error because it's the root cause. - pass - - # If there was an error reload the config again so we hopefully can log the error message - if load_cfg: - self.load_cfg() - - self.first_start = False - - # Watch folders so we can reload the config on the fly - filter = HABApp.core.files.watcher.FileEndingFilter('.yml') - watcher = HABApp.core.files.watcher.AggregatingAsyncEventHandler( - self.folder_conf, self.files_changed, filter, watch_subfolders=False - ) - HABApp.core.files.watcher.add_folder_watch(watcher) - - async def files_changed(self, paths: List[Path]): - for path in paths: - if path.name == 'config.yml': - self.load_cfg() - if path.name == 'logging.yml': - self.load_log() - - def __check_create_logging(self): - if self.file_conf_logging.is_file(): - return None - - print(f'Creating {self.file_conf_logging.name} in {self.file_conf_logging.parent}') - with self.file_conf_logging.open('w', encoding='utf-8') as file: - file.write(get_default_logfile()) - - time.sleep(0.1) - return None - - def load_cfg(self): - - CONFIG.load(self.file_conf_habapp) - - # check if folders exist and print warnings, maybe because of missing permissions - if not CONFIG.directories.rules.is_dir(): - log.warning(f'Folder for rules files does not exist: {CONFIG.directories.rules}') - - log.debug('Loaded HABApp config') - return None - - def load_log(self): - # config gets created on startup - if it gets deleted we do nothing here - if not self.file_conf_logging.is_file(): - return None - - with self.file_conf_logging.open('r', encoding='utf-8') as file: - cfg = _yaml_param.load(file) # type: Dict[str, Any] - - # fix filenames - for handler, handler_cfg in cfg.get('handlers', {}).items(): - - # fix encoding for FileHandlers - we always log utf-8 - if 'file' in handler_cfg.get('class', '').lower(): - enc = handler_cfg.get('encoding', '') - if enc != 'utf-8': - handler_cfg['encoding'] = 'utf-8' - - if 'filename' not in handler_cfg: - continue - - # make Filenames absolute path in the log folder if not specified - p = Path(handler_cfg['filename']) - if not p.is_absolute(): - # Our log folder ist not yet converted to path -> it is not loaded - if not isinstance(CONFIG.directories.logging, Path): - raise AbsolutePathExpected() - - # Use defined parent folder - p = (CONFIG.directories.logging / p).resolve() - handler_cfg['filename'] = str(p) - - # make file version optional for config file - log_version_info = True # todo: remove this 06.2021 - if 'version' not in cfg: - cfg['version'] = 1 - log_version_info = False - - # Allow the user to set his own logging levels (with aliases) - for level, alias in cfg.pop('levels', {}).items(): - if not isinstance(level, int): - level = logging._nameToLevel[level] - logging.addLevelName(level, str(alias)) - - # load prepared logging - try: - logging.config.dictConfig(cfg) - except Exception as e: - print(f'Error loading logging config: {e}') - log.error(f'Error loading logging config: {e}') - return None - - # Try rotating the logs on first start - if self.first_start: - for wr in reversed(logging._handlerList[:]): - handler = wr() # weakref -> call it to get object - - # only rotate these types - if not isinstance(handler, (RotatingFileHandler, TimedRotatingFileHandler)): - continue - - # Rotate only if files have content - logfile = Path(handler.baseFilename) - if not logfile.is_file() or logfile.stat().st_size <= 0: - continue - - try: - handler.acquire() - handler.close() - handler.doRollover() - except Exception: - lines = traceback.format_exc().splitlines() - # cut away AbsolutePathExpected Exception from log output - start = 0 - for i, line in enumerate(lines): - if line.startswith('Traceback'): - start = i - for line in lines[start:]: - log.error(line) - finally: - handler.release() - - logging.getLogger('HABApp').info(f'HABApp Version {__version__}') - - if log_version_info: - log.info('Entry "version" is no longer required in the logging configuration file') - return None diff --git a/src/HABApp/config/errors.py b/src/HABApp/config/errors.py new file mode 100644 index 00000000..24557694 --- /dev/null +++ b/src/HABApp/config/errors.py @@ -0,0 +1,10 @@ +class HABAppConfigError(Exception): + pass + + +class AbsolutePathExpected(HABAppConfigError): + pass + + +class InvalidConfigError(HABAppConfigError): + pass diff --git a/src/HABApp/config/loader.py b/src/HABApp/config/loader.py new file mode 100644 index 00000000..b0d3ec9a --- /dev/null +++ b/src/HABApp/config/loader.py @@ -0,0 +1,130 @@ +import logging +import logging.config +from pathlib import Path +from typing import List + +import pydantic + +import HABApp +import eascheduler +from HABApp import __version__ +from HABApp.config.config import CONFIG +from HABApp.config.logging import HABAppQueueHandler +from .errors import InvalidConfigError, AbsolutePathExpected +from .logging import create_default_logfile, get_logging_dict, rotate_files, inject_log_buffer +from .logging.buffered_logger import BufferedLogger + +log = logging.getLogger('HABApp.Config') + + +def load_config(config_folder: Path): + + CONFIG.set_file_path(config_folder / 'config.yml') + + logging_cfg_path = config_folder / 'logging.yml' + create_default_logfile(logging_cfg_path) + + loaded_logging = False + + # Try load the logging config + try: + load_logging_cfg(logging_cfg_path) + loaded_logging = True + except (AbsolutePathExpected, InvalidConfigError): + pass + + load_habapp_cfg(do_print=not loaded_logging) + + if not loaded_logging: + load_logging_cfg(logging_cfg_path) + + # Watch folders, so we can reload the config on the fly + filter = HABApp.core.files.watcher.FileEndingFilter('.yml') + watcher = HABApp.core.files.watcher.AggregatingAsyncEventHandler( + config_folder, config_files_changed, filter, watch_subfolders=False + ) + HABApp.core.files.watcher.add_folder_watch(watcher) + + HABApp.runtime.shutdown.register_func(stop_queue_handlers, last=True, msg='Stopping logging threads') + CONFIG.habapp.logging.subscribe_for_changes(set_flush_delay) + + +def set_flush_delay(): + HABAppQueueHandler.FLUSH_DELAY = CONFIG.habapp.logging.flush_every + + +async def config_files_changed(paths: List[Path]): + for path in paths: + if path.name == 'config.yml': + load_habapp_cfg() + if path.name == 'logging.yml': + load_logging_cfg(path) + + +def load_habapp_cfg(do_print=False): + try: + CONFIG.load_config_file() + except pydantic.ValidationError as e: + for line in str(e).splitlines(): + if do_print: + print(line) + else: + log.error(line) + raise InvalidConfigError from None + + # check if folders exist and print warnings, maybe because of missing permissions + if not CONFIG.directories.rules.is_dir(): + log.warning(f'Folder for rules files does not exist: {CONFIG.directories.rules}') + + CONFIG.directories.create_folders() + + log.debug(f'Local Timezone: {eascheduler.const.local_tz}') + location = CONFIG.location + eascheduler.set_location(location.latitude, location.longitude, location.elevation) + + log.debug('Loaded HABApp config') + + +QUEUE_HANDLER: List['HABAppQueueHandler'] = [] + + +def stop_queue_handlers(): + for qh in QUEUE_HANDLER: + qh.signal_stop() + while QUEUE_HANDLER: + qh = QUEUE_HANDLER.pop() + qh.stop() + + +def load_logging_cfg(path: Path): + # stop buffered handlers + stop_queue_handlers() + + buf_log = BufferedLogger() + cfg = get_logging_dict(path, buf_log) + + if CONFIG.habapp.logging.use_buffer: + q_handlers = inject_log_buffer(cfg, buf_log) + else: + q_handlers = [] + + # load prepared logging + try: + logging.config.dictConfig(cfg) + except Exception as e: + print(f'Error loading logging config: {e}') + log.error(f'Error loading logging config: {e}') + raise InvalidConfigError from None + + rotate_files() + + # start buffered handlers + for qh in q_handlers: + QUEUE_HANDLER.append(qh) + qh.start() + + logging.getLogger('HABApp').info(f'HABApp Version {__version__}') + + # write buffered messages + buf_log.flush(log) + return None diff --git a/src/HABApp/config/logging/__init__.py b/src/HABApp/config/logging/__init__.py new file mode 100644 index 00000000..ac466ba4 --- /dev/null +++ b/src/HABApp/config/logging/__init__.py @@ -0,0 +1,7 @@ +from .handler import MidnightRotatingFileHandler + +# isort: split + +from .config import get_logging_dict, rotate_files, inject_log_buffer +from .default_logfile import get_default_logfile, create_default_logfile +from .queue_handler import HABAppQueueHandler diff --git a/src/HABApp/config/logging/buffered_logger.py b/src/HABApp/config/logging/buffered_logger.py new file mode 100644 index 00000000..a9c96814 --- /dev/null +++ b/src/HABApp/config/logging/buffered_logger.py @@ -0,0 +1,31 @@ +import logging +from typing import List, Tuple + + +class BufferedLogger: + def __init__(self): + self._msgs: List[Tuple[int, str]] = [] + + def _log(self, lvl: int, msg: str): + self._msgs.append((lvl, msg)) + + def debug(self, msg: str) -> 'BufferedLogger': + self._log(logging.DEBUG, msg) + return self + + def info(self, msg: str) -> 'BufferedLogger': + self._log(logging.INFO, msg) + return self + + def warning(self, msg: str) -> 'BufferedLogger': + self._log(logging.WARNING, msg) + return self + + def error(self, msg: str) -> 'BufferedLogger': + self._log(logging.ERROR, msg) + return self + + def flush(self, logger: logging.Logger): + for lvl, msg in self._msgs: + logger.log(lvl, msg) + self._msgs.clear() diff --git a/src/HABApp/config/logging/config.py b/src/HABApp/config/logging/config.py new file mode 100644 index 00000000..07a23e02 --- /dev/null +++ b/src/HABApp/config/logging/config.py @@ -0,0 +1,157 @@ +import logging +import logging.config +from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler +from pathlib import Path +from typing import Any, Dict, List, Optional + +import HABApp +from HABApp.config.config import CONFIG +from HABApp.config.errors import AbsolutePathExpected +from easyconfig.yaml import yaml_safe as _yaml_safe +from .buffered_logger import BufferedLogger +from .queue_handler import HABAppQueueHandler, SimpleQueue + + +def remove_memory_handler_from_cfg(cfg: dict, log: BufferedLogger): + # find memory handlers + m_handlers = {} + for handler, handler_cfg in cfg.get('handlers', {}).items(): + if handler_cfg.get('class', '') == 'logging.handlers.MemoryHandler': + log.error(f'"logging.handlers.MemoryHandler" is no longer required. Please remove from config ({handler})!') + if 'target' in handler_cfg: + m_handlers[handler] = handler_cfg['target'] + + # remove them from config + for h_name in m_handlers: + cfg['handlers'].pop(h_name) + log.warning(f'Removed {h_name:s} from handlers') + + # replace handlers in logger with target + for logger_name, logger_cfg in cfg.get('loggers', {}).items(): + logger_handlers = logger_cfg.get('handlers', []) + for i, logger_handler in enumerate(logger_handlers): + replacement_handler = m_handlers.get(logger_handler) + if replacement_handler is None: + continue + log.warning(f'Replaced {logger_handler} with {replacement_handler} for logger {logger_name}') + logger_handlers[i] = replacement_handler + + +def get_logging_dict(path: Path, log: BufferedLogger) -> Optional[dict]: + # config gets created on startup - if it gets deleted we do nothing here + if not path.is_file(): + return None + + with path.open('r', encoding='utf-8') as file: + cfg = _yaml_safe.load(file) # type: Dict[str, Any] + + # fix filenames + for handler, handler_cfg in cfg.get('handlers', {}).items(): + # migrate handler + if handler_cfg.get('class', '-') == 'HABApp.core.lib.handler.MidnightRotatingFileHandler': + dst = 'HABApp.config.logging.MidnightRotatingFileHandler' + handler_cfg['class'] = dst + log.warning(f'Replaced class for handler "{handler:s}" with {dst}') + + if 'filename' not in handler_cfg: + continue + + # fix encoding for FileHandlers - we always log utf-8 + if 'file' in handler_cfg.get('class', '').lower(): + enc = handler_cfg.get('encoding', '') + if enc != 'utf-8': + handler_cfg['encoding'] = 'utf-8' + + # make Filenames absolute path in the log folder if not specified + p = Path(handler_cfg['filename']) + if not p.is_absolute(): + # Our log folder ist not yet converted to path -> it is not loaded + if not CONFIG.directories.logging.is_absolute(): + raise AbsolutePathExpected() + + # Use defined parent folder + p = (CONFIG.directories.logging / p).resolve() + handler_cfg['filename'] = str(p) + + # remove memory handlers + remove_memory_handler_from_cfg(cfg, log) + + # make file version optional for config file + if 'version' not in cfg: + cfg['version'] = 1 + else: + log.warning('Entry "version" is no longer required in the logging configuration file') + + # Allow the user to set his own logging levels (with aliases) + for level, alias in cfg.pop('levels', {}).items(): + if not isinstance(level, int): + level = logging._nameToLevel[level] + logging.addLevelName(level, str(alias)) + log.debug(f'Added custom Level "{str(alias)}" ({level})') + + return cfg + + +def rotate_files(): + for wr in logging._handlerList: + handler = wr() # weakref -> call it to get object + + # only rotate these types + if not isinstance(handler, (RotatingFileHandler, TimedRotatingFileHandler)): + continue + + # Rotate only if files have content + logfile = Path(handler.baseFilename) + if not logfile.is_file() or logfile.stat().st_size <= 10: + continue + + try: + handler.acquire() + handler.flush() + handler.doRollover() + except Exception as e: + HABApp.core.wrapper.process_exception(rotate_files, e) + finally: + handler.release() + + +def inject_log_buffer(cfg: dict, log: BufferedLogger): + from HABApp.core.const.topics import TOPIC_EVENTS + + handler_cfg = cfg.setdefault('handlers', {}) + + prefix = 'HABAppQueue_' + + # Check that the prefix is unique + for handler_name in handler_cfg: + if handler_name.startswith(prefix): + raise ValueError(f'Handler may not start with {prefix:s}') + + # replace the event logs with the buffered one + buffered_handlers = {} + for log_name, log_cfg in cfg.get('loggers', {}).items(): + if not log_name.startswith(TOPIC_EVENTS): + continue + _handlers = {n: f'{prefix}{n}' for n in log_cfg['handlers']} + buffered_handlers.update(_handlers) + log_cfg['handlers'] = list(_handlers.values()) + + # ensure propagate is disabled + if log_cfg.get('propagate', False): + log.warning(f'Propagate can not be set for {log_name}!') + log_cfg['propagate'] = False + + if not buffered_handlers: + return [] + + handler_cfg = cfg.setdefault('handlers', {}) + q_handlers: List[HABAppQueueHandler] = [] + + for handler_name, buffered_handler_name in buffered_handlers.items(): + q = SimpleQueue() + handler_cfg[buffered_handler_name] = {'class': 'logging.handlers.QueueHandler', 'queue': q} + + qh = HABAppQueueHandler(q, handler_name, f'LogBuffer{handler_name:s}') + q_handlers.append(qh) + + return q_handlers diff --git a/src/HABApp/config/default_logfile.py b/src/HABApp/config/logging/default_logfile.py similarity index 75% rename from src/HABApp/config/default_logfile.py rename to src/HABApp/config/logging/default_logfile.py index 0d195551..67f58d71 100644 --- a/src/HABApp/config/default_logfile.py +++ b/src/HABApp/config/logging/default_logfile.py @@ -1,5 +1,8 @@ +from pathlib import Path from string import Template -from .platform_defaults import get_log_folder, is_openhabian +from time import sleep + +from HABApp.config.platform_defaults import get_log_folder, is_openhabian def get_default_logfile() -> str: @@ -13,13 +16,13 @@ def get_default_logfile() -> str: # There are several Handlers available: # - logging.handlers.RotatingFileHandler: # Will rotate when the file reaches a certain size (see python logging documentation for args) - # - HABApp.core.lib.handler.MidnightRotatingFileHandler: - # Will wait until the file reaches a certain size and then rotate on midnight + # - HABApp.config.logging.MidnightRotatingFileHandler: + # Will wait until the file reaches a certain size and then will rotate on midnight # - More handlers: # https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler HABApp_default: - class: HABApp.core.lib.handler.MidnightRotatingFileHandler + class: HABApp.config.logging.MidnightRotatingFileHandler filename: '${HABAPP_FILE}' maxBytes: 1_048_576 backupCount: 3 @@ -28,7 +31,7 @@ def get_default_logfile() -> str: level: DEBUG EventFile: - class: HABApp.core.lib.handler.MidnightRotatingFileHandler + class: HABApp.config.logging.MidnightRotatingFileHandler filename: '${EVENT_FILE}' maxBytes: 1_048_576 backupCount: 3 @@ -36,13 +39,6 @@ def get_default_logfile() -> str: formatter: HABApp_format level: DEBUG - BufferEventFile: - class: logging.handlers.MemoryHandler - capacity: 10 - formatter: HABApp_format - target: EventFile - level: DEBUG - loggers: HABApp: @@ -54,7 +50,7 @@ def get_default_logfile() -> str: HABApp.EventBus: level: INFO handlers: - - BufferEventFile + - EventFile propagate: False """) @@ -68,7 +64,7 @@ def get_default_logfile() -> str: 'LOG_LEVELS': '', } - # Use abs path and rename events.log if we log in the openhab folder + # Use abs path and rename events.log if we log in the openHAB folder log_folder = get_log_folder() if log_folder is not None: # Absolute so we can log errors if the config is faulty @@ -90,3 +86,15 @@ def get_default_logfile() -> str: subs['LOG_LEVELS'] = 'levels:\n WARNING: WARN\n\n' return template.substitute(**subs) + + +def create_default_logfile(path: Path) -> bool: + if path.is_file(): + return False + + print(f'Creating {path.name} in {path.parent}') + with path.open('w', encoding='utf-8') as file: + file.write(get_default_logfile()) + + sleep(0.01) + return True diff --git a/src/HABApp/core/lib/handler.py b/src/HABApp/config/logging/handler.py similarity index 100% rename from src/HABApp/core/lib/handler.py rename to src/HABApp/config/logging/handler.py diff --git a/src/HABApp/config/logging/queue_handler.py b/src/HABApp/config/logging/queue_handler.py new file mode 100644 index 00000000..c86b6b32 --- /dev/null +++ b/src/HABApp/config/logging/queue_handler.py @@ -0,0 +1,110 @@ +import logging +from queue import SimpleQueue, Empty +from threading import Thread +from time import sleep +from typing import Optional, Final + +import HABApp +from .config import CONFIG + +log = logging.getLogger('HABApp.logging') + + +class HABAppQueueHandler: + FLUSH_DELAY: float = CONFIG.habapp.logging.flush_every + + def __init__(self, queue: SimpleQueue, handler_name: str, thread_name: str): + self._handler: Optional[logging.Handler] = None + self._handler_name: Final = handler_name + self._queue: Final = queue + self._name: Final = thread_name + self._thread: Optional[Thread] = None + + def start(self) -> None: + if self._thread is not None: + raise RuntimeError('Thread can only be started once!') + + # resolve handler + self._handler = logging._handlers[self._handler_name] + + self._thread = Thread(target=self._worker, name=f'HABApp_{self._name}') + self._thread.start() + + def signal_stop(self): + self._queue.put_nowait(None) + + def stop(self) -> None: + if self._thread is None: + return None + thread = self._thread + + self.signal_stop() + thread.join() + + def _worker(self): + try: + assert self._handler is not None + while True: + sleep(self.FLUSH_DELAY) + if self.process_queue(): + break + + log.debug(f'{self._name} thread stopped') + except Exception as e: + HABApp.core.wrapper.process_exception(self._worker, e) + + # clean up queue + try: + while True: + self._queue.get_nowait() + except Empty: + pass + + self._thread = None + + def process_queue(self) -> bool: + q = self._queue + handler = self._handler + + first_rec = True + + check_interval = 200 + ctr = check_interval + + skip_rem = 0 + skip_total = 0 + skip_level = logging.INFO + + try: + while True: + if first_rec: + # first call is blocking + rec = q.get() # type: Optional[logging.LogRecord] + first_rec = False + else: + rec = q.get_nowait() # type: Optional[logging.LogRecord] + + if rec is None: + return True + + if skip_rem > 0: + # skip everything including INFO, process rest + if rec.levelno <= skip_level: + skip_rem -= 1 + if skip_rem <= 0: + log.warning(f'Event log buffer congested! Skipped {skip_total} messages.') + continue + + # handle record + handler.handle(rec) + + ctr -= 1 + if ctr <= 0: + ctr = check_interval + if not skip_rem: + q_size = q.qsize() + if q_size > 1000: + skip_total = skip_rem = q_size - 750 + + except Empty: + return False diff --git a/src/HABApp/config/models/__init__.py b/src/HABApp/config/models/__init__.py new file mode 100644 index 00000000..0434a6a7 --- /dev/null +++ b/src/HABApp/config/models/__init__.py @@ -0,0 +1 @@ +from .application import ApplicationConfig diff --git a/src/HABApp/config/models/application.py b/src/HABApp/config/models/application.py new file mode 100644 index 00000000..41adecbf --- /dev/null +++ b/src/HABApp/config/models/application.py @@ -0,0 +1,17 @@ +from easyconfig import AppBaseModel +from HABApp.config.models.location import LocationConfig +from HABApp.config.models.mqtt import MqttConfig +from HABApp.config.models.openhab import OpenhabConfig +from HABApp.config.models.habapp import HABAppConfig +from HABApp.config.models.directories import DirectoriesConfig +from pydantic import Field + + +class ApplicationConfig(AppBaseModel): + """Structure that contains the complete configuration""" + + directories: DirectoriesConfig = Field(default_factory=DirectoriesConfig) + location: LocationConfig = Field(default_factory=LocationConfig) + mqtt: MqttConfig = Field(default_factory=MqttConfig) + openhab: OpenhabConfig = Field(default_factory=OpenhabConfig) + habapp: HABAppConfig = Field(default_factory=HABAppConfig, in_file=False) diff --git a/src/HABApp/config/models/directories.py b/src/HABApp/config/models/directories.py new file mode 100644 index 00000000..ed519a9d --- /dev/null +++ b/src/HABApp/config/models/directories.py @@ -0,0 +1,66 @@ +import logging +import sys +from pathlib import Path +from typing import Optional + +from easyconfig import BaseModel +from pydantic import Field, validator + +from HABApp.config.platform_defaults import get_log_folder + +log = logging.getLogger('HABApp.Config') + + +class DirectoriesConfig(BaseModel): + """Configuration of directories that are used""" + + logging: Path = Field(get_log_folder(Path('log')), description='Folder where the logs will be written to') + rules: Path = Field(Path('rules'), description='Folder from which the rule files will be loaded') + + # Optional Folders + param: Optional[Path] = Field(Path('params'), description='Folder from which the parameter files will be loaded') + config: Optional[Path] = Field(Path('config'), description='Folder from which configuration files ' + '(e.g. for textual thing configuration) will be loaded') + lib: Optional[Path] = Field(Path('lib'), description='Folder where additional libraries can be placed') + + @validator('*') + def ensure_folder(cls, value: Optional[Path]): + import HABApp.__cmd_args__ + + if value is None: + return value + + # only resolve if we have a path set + if not value.is_absolute() and HABApp.__cmd_args__.CONFIG_FILE.name: + value = HABApp.__cmd_args__.CONFIG_FILE.parent / value + value = value.resolve() + + if value == HABApp.__cmd_args__.CONFIG_FILE: + raise ValueError( + f'Can not be the same as the path for the HABApp config! ({HABApp.__cmd_args__.CONFIG_FILE})' + ) + + return value + + def create_folders(self): + + # create folder structure if it does not exist + if not self.rules.is_dir(): + self.rules.mkdir() + if not self.logging.is_dir(): + self.logging.mkdir() + + if not self.param.is_dir(): + log.info(f'Textual thing config disabled! Folder {self.param} does not exist!') + self.param = None + + if not self.config.is_dir(): + log.info(f'Manual thing configuration disabled! Folder {self.config} does not exist!') + self.config = None + + # add path for user libraries + if self.lib is not None and self.lib.is_dir(): + lib_path = str(self.lib) + if lib_path not in sys.path: + sys.path.insert(0, lib_path) + log.debug(f'Added library folder "{lib_path}" to system path') diff --git a/src/HABApp/config/models/habapp.py b/src/HABApp/config/models/habapp.py new file mode 100644 index 00000000..f4b845a4 --- /dev/null +++ b/src/HABApp/config/models/habapp.py @@ -0,0 +1,28 @@ +from pydantic import Field, conint + +from easyconfig import BaseModel + + +class ThreadPoolConfig(BaseModel): + enabled: bool = True + """When the thread pool is disabled HABApp will become an asyncio application. + Use only if you have experience developing asyncio applications! + If the thread pool is disabled using blocking calls in functions can and will break HABApp""" + + threads: conint(ge=1, le=16) = 10 + """Amount of threads to use for the executor""" + + +class LoggingConfig(BaseModel): + use_buffer: bool = Field(True, alias='use buffer') + """Automatically inject a buffer for the event log""" + + flush_every: float = Field(0.5, alias='flush every', ge=0.1) + """Wait time in seconds before the buffer gets flushed again when it was empty""" + + +class HABAppConfig(BaseModel): + """HABApp internal configuration. Only change values if you know what you are doing!""" + + logging: LoggingConfig = Field(default_factory=LoggingConfig) + thread_pool: ThreadPoolConfig = Field(default_factory=ThreadPoolConfig, alias='thread pool') diff --git a/src/HABApp/config/models/location.py b/src/HABApp/config/models/location.py new file mode 100644 index 00000000..00d713a5 --- /dev/null +++ b/src/HABApp/config/models/location.py @@ -0,0 +1,15 @@ +import logging.config + +from pydantic import Field + +from easyconfig import BaseModel + +log = logging.getLogger('HABApp.Config') + + +class LocationConfig(BaseModel): + """location where the instance is running. Is used to calculate Sunrise/Sunset.""" + + latitude: float = Field(default=0.0) + longitude: float = Field(default=0.0) + elevation: float = Field(default=0.0) diff --git a/src/HABApp/config/models/mqtt.py b/src/HABApp/config/models/mqtt.py new file mode 100644 index 00000000..ac00cff9 --- /dev/null +++ b/src/HABApp/config/models/mqtt.py @@ -0,0 +1,67 @@ +import sys +from pathlib import Path +from typing import Optional, Tuple + +import pydantic +from pydantic import Field + +from easyconfig.models import BaseModel + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +QOS = Literal[0, 1, 2] + + +class TLSSettings(BaseModel): + enabled: bool = Field(default=True, description='Enable TLS for the connection') + ca_cert: Path = Field( + default='', description='Path to a CA certificate that will be treated as trusted', alias='ca cert') + insecure: bool = Field( + default=False, description='Validate server hostname in server certificate') + + +class Connection(BaseModel): + client_id: str = 'HABApp' + host: str = Field('', description='Connect to this host. Empty string ("") disables the connection.') + port: int = 1883 + user: str = '' + password: str = '' + tls: TLSSettings = Field(default_factory=TLSSettings) + + +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) + def parse_topics(cls, v): + if not isinstance(v, (list, tuple, set)): + raise ValueError('must be a list') + ret = [] + for e in v: + if isinstance(e, str): + e = (e, None) + ret.append(tuple(e)) + return tuple(ret) + + +class Publish(BaseModel): + qos: QOS = Field(default=0, description='Default QoS when publishing values') + retain: bool = Field(default=False, description='Default retain flag when publishing values') + + +class General(BaseModel): + listen_only: bool = Field(False, description='If True HABApp does not publish any value to the broker') + + +class MqttConfig(BaseModel): + """MQTT configuration""" + + connection: Connection = Field(default_factory=Connection) + subscribe: Subscribe = Field(default_factory=Subscribe) + publish: Publish = Field(default_factory=Publish) + general: General = Field(default_factory=General) diff --git a/src/HABApp/config/models/openhab.py b/src/HABApp/config/models/openhab.py new file mode 100644 index 00000000..f55ae1ab --- /dev/null +++ b/src/HABApp/config/models/openhab.py @@ -0,0 +1,74 @@ +from typing import Union, Literal + +from pydantic import Field, AnyHttpUrl, ByteSize, validator + +from easyconfig.models import BaseModel + + +class Ping(BaseModel): + enabled: bool = Field(True, description='If enabled the configured item will show how long it takes to send ' + '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) + + +class General(BaseModel): + listen_only: bool = Field( + False, description='If True HABApp does not change anything on the openHAB instance.' + ) + wait_for_openhab: bool = Field( + True, + description='If True HABApp will wait for a successful openHAB connection before loading any rules on startup' + ) + + # Advanced settings + min_start_level: int = Field( + 70, ge=0, le=100, in_file=False, + description='Minimum openHAB start level to load items and listen to events', + ) + + +class Connection(BaseModel): + url: Union[AnyHttpUrl, Literal['']] = Field( + 'http://localhost:8080', description='Connect to this url. Empty string ("") disables the connection.') + user: str = '' + password: str = '' + verify_ssl: bool = Field(True, description='Check certificates when using https') + + buffer: ByteSize = Field( + '128kib', in_file=False, description= + 'Buffer for reading lines in the SSE event handler. This is the buffer ' + 'that gets allocated for every(!) request and SSE message that the client processes. ' + 'Increase only if you get error messages or disconnects e.g. if you use large images.' + ) + + topic_filter: str = Field( + 'openhab/items/*,' # Item updates + 'openhab/channels/*,' # Channel update + # Thing events - don't listen to updated events + # todo: check if this might be a good filter: 'openhab/things/*', + 'openhab/things/*/added,openhab/things/*/removed,openhab/things/*/status,openhab/things/*/statuschanged', + alias='topic filter', in_file=False, + description='Topic filter for subscribing to openHAB. This filter is processed by openHAB and only events' + 'matching this filter will be sent to HABApp.' + ) + + @validator('buffer') + def validate_see_buffer(cls, value: ByteSize): + valid_values = ( + '64kib', '128kib', '256kib', '512kib', + '1Mib', '2Mib', '4Mib', '8Mib', '16Mib', '32Mib', '64Mib', '128Mib' + ) + + for _v in valid_values: + if value == ByteSize.validate(_v): + return value + + raise ValueError(f'Value must be one of {", ".join(valid_values)}') + + +class OpenhabConfig(BaseModel): + connection: Connection = Field(default_factory=Connection) + general: General = Field(default_factory=General) + ping: Ping = Field(default_factory=Ping) diff --git a/src/HABApp/config/platform_defaults.py b/src/HABApp/config/platform_defaults.py index 30d5e38f..0af96bba 100644 --- a/src/HABApp/config/platform_defaults.py +++ b/src/HABApp/config/platform_defaults.py @@ -3,7 +3,7 @@ def get_log_folder(default: Optional[Path] = None) -> Optional[Path]: - # As a default we log into the openhab folder + # As a default we log into the openHAB folder choices = ('/var/log/openhab', '/opt/openhab/userdata/logs') for choice in choices: path = Path(choice) diff --git a/src/HABApp/core/EventBus.py b/src/HABApp/core/EventBus.py deleted file mode 100644 index 0f186eaa..00000000 --- a/src/HABApp/core/EventBus.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -import threading -import typing - -from HABApp.core.wrapper import log_exception -from . import EventBusListener -from .events import ComplexEventValue, ValueChangeEvent - -_event_log = logging.getLogger('HABApp.EventBus') -_habapp_log = logging.getLogger('HABApp') - - -_LOCK = threading.Lock() - - -_EVENT_LISTENERS: typing.Dict[str, typing.List[EventBusListener]] = {} - - -@log_exception -def post_event(topic: str, event): - assert isinstance(topic, str), type(topic) - - if not isinstance(event, str): - event_prv = str(event) - else: - event_prv = event[:120] + ' ...' if len(event) > 120 else event - event_prv = "'" + event_prv.replace('\n', '\\n') + "'" - - _event_log.info(f'{topic:>20s}: {event_prv}') - - # Sometimes we have nested data structures which we need to set the value. - # Once the value in the item registry is updated the data structures provide no benefit thus - # we unpack the corresponding value - try: - if isinstance(event.value, ComplexEventValue): - event.value = event.value.value - if isinstance(event, ValueChangeEvent) and isinstance(event.old_value, ComplexEventValue): - event.old_value = event.old_value.value - except AttributeError: - pass - - # Notify all listeners - for listener in _EVENT_LISTENERS.get(topic, []): - listener.notify_listeners(event) - - return None - - -@log_exception -def add_listener(listener: EventBusListener): - assert isinstance(listener, EventBusListener) - - with _LOCK: - item_listeners = _EVENT_LISTENERS.setdefault(listener.topic, []) - - # don't add the same listener twice - if listener in item_listeners: - _habapp_log.warning(f'Event listener for {listener.desc()} has already been added!') - return None - - # add listener - item_listeners.append(listener) - _habapp_log.debug(f'Added event listener for {listener.desc()}') - return None - - -@log_exception -def remove_listener(listener: EventBusListener): - assert isinstance(listener, EventBusListener) - - with _LOCK: - item_listeners = _EVENT_LISTENERS.get(listener.topic, []) - - # print warning if we try to remove it twice - if listener not in item_listeners: - _habapp_log.warning(f'Event listener for {listener.desc()} has already been removed!') - return None - - # remove listener - item_listeners.remove(listener) - _habapp_log.debug(f'Removed event listener for {listener.desc()}') - - -@log_exception -def remove_all_listeners(): - with _LOCK: - _EVENT_LISTENERS.clear() diff --git a/src/HABApp/core/Items.py b/src/HABApp/core/Items.py deleted file mode 100644 index 2449ae7e..00000000 --- a/src/HABApp/core/Items.py +++ /dev/null @@ -1,70 +0,0 @@ -import typing - -from HABApp.core.items.base_item import BaseItem as __BaseItem - -_ALL_ITEMS: typing.Dict[str, __BaseItem] = {} - - -class ItemNotFoundException(Exception): - def __init__(self, name: str): - super().__init__(f'Item {name} does not exist!') - self.name: str = name - - -class ItemAlreadyExistsError(Exception): - def __init__(self, name: str): - super().__init__(f'Item {name} does already exist and can not be added again!') - self.name: str = name - - -def item_exists(name: str) -> bool: - return name in _ALL_ITEMS - - -def get_item(name: str) -> __BaseItem: - try: - return _ALL_ITEMS[name] - except KeyError: - raise ItemNotFoundException(name) from None - - -def get_all_items() -> typing.List[__BaseItem]: - return list(_ALL_ITEMS.values()) - - -def get_all_item_names() -> typing.List[str]: - return list(_ALL_ITEMS.keys()) - - -def create_item(name: str, item_factory, initial_value=None) -> __BaseItem: - assert issubclass(item_factory, __BaseItem), item_factory - new_item = item_factory(name, initial_value=initial_value) - add_item(new_item) - return new_item - - -def add_item(item: __BaseItem): - assert isinstance(item, __BaseItem), type(item) - name = item.name - - existing = _ALL_ITEMS.get(name) - if existing is not None: - # adding the same item multiple times will not cause an exception - if existing is item: - return None - - # adding a new item with the same name raises an exception - raise ItemAlreadyExistsError(name) - - _ALL_ITEMS[name] = item - item._on_item_add() - - -def pop_item(name: str) -> __BaseItem: - try: - item = _ALL_ITEMS.pop(name) - except KeyError: - raise ItemNotFoundException(name) from None - - item._on_item_remove() - return item diff --git a/src/HABApp/core/__init__.py b/src/HABApp/core/__init__.py index 6ee8d2e6..e62c8d30 100644 --- a/src/HABApp/core/__init__.py +++ b/src/HABApp/core/__init__.py @@ -1,17 +1,24 @@ -from . import const -from . import lib -from . import context +from HABApp.core import const +from HABApp.core import lib +from HABApp.core import errors -from . import wrapper -from . import logger +# isort: split -from .wrappedfunction import WrappedFunction +from HABApp.core import asyncio -from .event_bus_listener import EventBusListener +# isort: split +from HABApp.core import internals + +from HABApp.core import wrapper +from HABApp.core import logger + +# isort: split import HABApp.core.events import HABApp.core.files import HABApp.core.items -import HABApp.core.EventBus -import HABApp.core.Items +# isort: split + +Items: 'HABApp.core.internals.ItemRegistry' = internals.proxy.ConstProxyObj('ItemRegistry') +EventBus: 'HABApp.core.internals.EventBus' = internals.proxy.ConstProxyObj('EventBus') diff --git a/src/HABApp/core/asyncio.py b/src/HABApp/core/asyncio.py new file mode 100644 index 00000000..d2b6d8c7 --- /dev/null +++ b/src/HABApp/core/asyncio.py @@ -0,0 +1,40 @@ +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 +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 + +async_context = _ContextVar('async_ctx') + + +class AsyncContextError(Exception): + def __init__(self, func: _Callable) -> None: + super().__init__() + self.func: _Callable = func + + def __str__(self): + return f'Function "{self.func.__name__}" may not be called from an async context!' + + +def create_task(coro: _Coroutine, name: _Optional[str] = None) -> _Future: + if async_context.get(None) is None: + return _run_coroutine_threadsafe(coro, loop) + else: + return loop.create_task(coro, name=name) + + +_CORO_RET = _TypeVar('_CORO_RET') + + +def run_coro_from_thread(coro: _Coroutine[_Any, _Any, _CORO_RET], calling: _Callable) -> _CORO_RET: + # This function call is blocking so it can't be called in the async context + if async_context.get(None) is not None: + raise AsyncContextError(calling) + + fut = _run_coroutine_threadsafe(coro, loop) + return fut.result() diff --git a/src/HABApp/core/const/__init__.py b/src/HABApp/core/const/__init__.py index ff08b480..97f1b57d 100644 --- a/src/HABApp/core/const/__init__.py +++ b/src/HABApp/core/const/__init__.py @@ -1,5 +1,6 @@ from . import json from . import topics +from . import hints from .const import MISSING from .loop import loop from .yml import yml diff --git a/src/HABApp/core/const/const.py b/src/HABApp/core/const/const.py index f391af43..5ff961c9 100644 --- a/src/HABApp/core/const/const.py +++ b/src/HABApp/core/const/const.py @@ -1,14 +1,21 @@ +import sys import time from enum import Enum +from typing import Final class _MissingType(Enum): MISSING = object() - def __str__(self): + def __repr__(self): return '' -# todo: add type final if we go >= python 3.8 -MISSING = _MissingType.MISSING +MISSING: Final = _MissingType.MISSING STARTUP = time.time() + +# Python Versions for feature control +PYTHON_38: Final = sys.version_info >= (3, 8) +PYTHON_39: Final = sys.version_info >= (3, 9) +PYTHON_310: Final = sys.version_info >= (3, 10) +PYTHON_311: Final = sys.version_info >= (3, 11) diff --git a/src/HABApp/core/const/hints.py b/src/HABApp/core/const/hints.py new file mode 100644 index 00000000..27e8455f --- /dev/null +++ b/src/HABApp/core/const/hints.py @@ -0,0 +1,19 @@ +from typing import Any as __Any +from typing import Awaitable as __Awaitable +from typing import Callable as __Callable +from typing import Type as __Type + +from .const import PYTHON_310 as __IS_GT_PYTHON_310 + +if __IS_GT_PYTHON_310: + from typing import TypeAlias +else: + from typing import Final as TypeAlias + + +HINT_ANY_CLASS: TypeAlias = __Type[object] +HINT_FUNC_ASYNC: TypeAlias = __Callable[..., __Awaitable[__Any]] + +HINT_EVENT_CALLBACK: TypeAlias = __Callable[[__Any], __Any] + +HINT_SCHEDULER_CALLBACK: TypeAlias = __Callable[[], __Any] diff --git a/src/HABApp/core/const/loop.py b/src/HABApp/core/const/loop.py index b6e3bc58..89f1fa4d 100644 --- a/src/HABApp/core/const/loop.py +++ b/src/HABApp/core/const/loop.py @@ -12,6 +12,6 @@ asyncio.get_child_watcher() -loop = asyncio.get_event_loop() +loop = asyncio.get_event_loop_policy().get_event_loop() loop.set_debug(True) loop.slow_callback_duration = 0.02 diff --git a/src/HABApp/core/const/topics.py b/src/HABApp/core/const/topics.py index c336de4c..cdf4c7a3 100644 --- a/src/HABApp/core/const/topics.py +++ b/src/HABApp/core/const/topics.py @@ -1,20 +1,18 @@ -import typing +from typing import Final, Tuple -try: - from typing import Final -except ImportError: - Final = str +TOPIC_INFOS: Final = 'HABApp.Infos' +TOPIC_WARNINGS: Final = 'HABApp.Warnings' +TOPIC_ERRORS: Final = 'HABApp.Errors' -INFOS: Final = 'HABApp.Infos' -WARNINGS: Final = 'HABApp.Warnings' -ERRORS: Final = 'HABApp.Errors' +TOPIC_FILES: Final = 'HABApp.Files' -FILES: Final = 'HABApp.Files' +ALL_TOPICS: Tuple[str, ...] = ( + TOPIC_INFOS, TOPIC_WARNINGS, TOPIC_ERRORS, -ALL: typing.List[str] = [ - WARNINGS, ERRORS, INFOS, + TOPIC_FILES +) - FILES -] + +TOPIC_EVENTS: Final = 'HABApp.EventBus' diff --git a/src/HABApp/core/context.py b/src/HABApp/core/context.py deleted file mode 100644 index 9773ccd1..00000000 --- a/src/HABApp/core/context.py +++ /dev/null @@ -1,14 +0,0 @@ -from contextvars import ContextVar as _ContextVar -from typing import Callable as _Callable - - -async_context = _ContextVar('async_ctx') - - -class AsyncContextError(Exception): - def __init__(self, func: _Callable) -> None: - super().__init__() - self.func: _Callable = func - - def __str__(self): - return f'Function "{self.func.__name__}" may not be called from an async context!' diff --git a/src/HABApp/core/errors.py b/src/HABApp/core/errors.py new file mode 100644 index 00000000..59128461 --- /dev/null +++ b/src/HABApp/core/errors.py @@ -0,0 +1,35 @@ +class HABAppException(Exception): + pass + + +class ProxyObjHasNotBeenReplacedError(HABAppException): + def __init__(self, obj) -> None: + super().__init__(f'{obj} has not been replaced on startup!') + + +class ItemNotFoundException(HABAppException): + def __init__(self, name: str): + super().__init__(f'Item {name} does not exist!') + self.name: str = name + + +class ItemAlreadyExistsError(HABAppException): + def __init__(self, name: str): + super().__init__(f'Item {name} does already exist and can not be added again!') + self.name: str = name + + +class ContextNotFoundError(HABAppException): + pass + + +class ContextNotSetError(HABAppException): + pass + + +class ContextBoundObjectIsAlreadyLinkedError(HABAppException): + pass + + +class ContextBoundObjectIsAlreadyUnlinkedError(HABAppException): + pass diff --git a/src/HABApp/core/event_bus_listener.py b/src/HABApp/core/event_bus_listener.py deleted file mode 100644 index 3df71446..00000000 --- a/src/HABApp/core/event_bus_listener.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Any, Optional - -import HABApp -from HABApp.core import WrappedFunction -from HABApp.core.events import AllEvents - - -class EventBusListener: - def __init__(self, topic, callback, event_type=AllEvents, - attr_name1: Optional[str] = None, attr_value1: Optional[Any] = None, - attr_name2: Optional[str] = None, attr_value2: Optional[Any] = None, - ): - assert isinstance(topic, str), type(topic) - assert isinstance(callback, WrappedFunction) - assert attr_name1 is None or isinstance(attr_name1, str), attr_name1 - assert attr_name2 is None or isinstance(attr_name2, str), attr_name2 - - self.topic: str = topic - self.func: WrappedFunction = callback - - self.event_filter = event_type - - # Property filters - self.attr_name1 = attr_name1 - self.attr_value1 = attr_value1 - self.attr_name2 = attr_name2 - self.attr_value2 = attr_value2 - - self.__is_all: bool = self.event_filter is AllEvents - self.__is_single: bool = not isinstance(self.event_filter, (list, tuple, set)) - - def notify_listeners(self, event): - # We run always - if self.__is_all: - self.func.run(event) - return None - - # single filter - if self.__is_single: - if isinstance(event, self.event_filter): - # If we have property filters wie only trigger when value is set accordingly - if self.attr_name1 is not None: - if getattr(event, self.attr_name1, None) != self.attr_value1: - return None - if self.attr_name2 is not None: - if getattr(event, self.attr_name2, None) != self.attr_value2: - return None - - self.func.run(event) - return None - - # Make it possible to specify multiple classes - for cls in self.event_filter: - if isinstance(event, cls): - # If we have property filters wie only trigger when value is set accordingly - if self.attr_name1 is not None: - if getattr(event, self.attr_name1, None) != self.attr_value1: - return None - if self.attr_name2 is not None: - if getattr(event, self.attr_name2, None) != self.attr_value2: - return None - - self.func.run(event) - return None - - def cancel(self): - """Stop listening on the event bus""" - HABApp.core.EventBus.remove_listener(self) - - def desc(self): - # return description - _type = str(self.event_filter) - if _type.startswith(" 'HABApp.core.EventBusListener': - kwargs = {'event_type': self.__cls} - ct = 1 - for k, v in self.__filter.items(): - kwargs[f'attr_name{ct}'] = k - kwargs[f'attr_value{ct}'] = v - ct += 1 - - return HABApp.core.EventBusListener(name, cb, **kwargs) - - def __repr__(self): - name = self.__class__.__name__ - vals = [f'{k}={v}' for k, v in self.__filter.items()] - if name == EventFilter.__name__: - vals.insert(0, f'event_type={self.__cls.__name__}') - return f'{name}({", ".join(vals)})' - - -class ValueUpdateEventFilter(EventFilter): - _EVENT_TYPE = ValueUpdateEvent - - def __init__(self, value): - super().__init__(self._EVENT_TYPE, value=value) - - -class ValueChangeEventFilter(EventFilter): - _EVENT_TYPE = ValueChangeEvent - - def __init__(self, value: Any = MISSING, old_value: Any = MISSING): - args = {} - if value is not MISSING: - args['value'] = value - if old_value is not MISSING: - args['old_value'] = old_value - super().__init__(self._EVENT_TYPE, **args) diff --git a/src/HABApp/core/events/events.py b/src/HABApp/core/events/events.py index 1a726d03..527cdcde 100644 --- a/src/HABApp/core/events/events.py +++ b/src/HABApp/core/events/events.py @@ -1,10 +1,6 @@ from typing import Any, Union -class AllEvents: - pass - - class ComplexEventValue: def __init__(self, value): self.value: Any = value @@ -12,8 +8,8 @@ def __init__(self, value): class ValueUpdateEvent: """ - :ivar str ~.name: - :ivar ~.value: + :ivar str name: + :ivar value: """ name: str @@ -29,9 +25,9 @@ def __repr__(self): class ValueChangeEvent: """ - :ivar str ~.name: - :ivar ~.value: - :ivar ~.old_value: + :ivar str name: + :ivar value: + :ivar old_value: """ name: str @@ -49,8 +45,8 @@ def __repr__(self): class ItemNoChangeEvent: """ - :ivar str ~.name: - :ivar Union[int, float] ~.seconds: + :ivar str name: + :ivar Union[int, float] seconds: """ name: str @@ -66,8 +62,8 @@ def __repr__(self): class ItemNoUpdateEvent: """ - :ivar str ~.name: - :ivar Union[int, float] ~.seconds: + :ivar str name: + :ivar Union[int, float] seconds: """ name: str seconds: Union[int, float] diff --git a/src/HABApp/core/events/filter/__init__.py b/src/HABApp/core/events/filter/__init__.py new file mode 100644 index 00000000..1858f5d5 --- /dev/null +++ b/src/HABApp/core/events/filter/__init__.py @@ -0,0 +1,4 @@ +from .no_filter import NoEventFilter +from .event import EventFilter +from .habapp_events import ValueUpdateEventFilter, ValueChangeEventFilter +from .groups import OrFilterGroup, AndFilterGroup diff --git a/src/HABApp/core/events/filter/event.py b/src/HABApp/core/events/filter/event.py new file mode 100644 index 00000000..ecc1dc02 --- /dev/null +++ b/src/HABApp/core/events/filter/event.py @@ -0,0 +1,73 @@ +from HABApp.core.const import MISSING +from HABApp.core.const.hints import HINT_ANY_CLASS +from HABApp.core.internals import EventFilterBase + + +class EventFilter(EventFilterBase): + """Triggers on event types and optionally on their values, too""" + + 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' + + self.event_class = event_class + + # Property filters + self.attr_name1 = None + self.attr_value1 = None + self.attr_name2 = None + self.attr_value2 = None + + for arg, value in kwargs.items(): + if value is MISSING: + continue + + if arg not in event_class.__annotations__: + raise AttributeError(f'Filter attribute "{arg}" does not exist for "{event_class.__name__}"') + + if self.attr_name1 is None: + self.attr_name1 = arg + self.attr_value1 = value + elif self.attr_name2 is None: + self.attr_name2 = arg + self.attr_value2 = value + else: + raise ValueError('Not implemented for more than 2 values!') + + def trigger(self, event) -> bool: + if not isinstance(event, self.event_class): + return False + + # Property filter + if self.attr_name1 is not None: + if getattr(event, self.attr_name1, None) != self.attr_value1: + return False + + if self.attr_name2 is not None: + if getattr(event, self.attr_name2, None) != self.attr_value2: + return False + + return True + + def describe(self) -> str: + + values = '' + if self.attr_name1 is not None: + values += f', {self.attr_name1}={self.attr_value1}' + if self.attr_name2 is not None: + values += f', {self.attr_name2}={self.attr_value2}' + + return f'{self.__class__.__name__}(type={self.event_class.__name__}{values})' + + +class TypeBoundEventFilter(EventFilter): + """Class to inherit from if the filter criteria always is a hardcoded instance check""" + + def describe(self) -> str: + + values = '' + if self.attr_name1 is not None: + values += f'{self.attr_name1}={self.attr_value1}' + if self.attr_name2 is not None: + values += f'{", " if values else ""}{self.attr_name2}={self.attr_value2}' + + return f'{self.__class__.__name__}({values})' diff --git a/src/HABApp/core/events/filter/groups.py b/src/HABApp/core/events/filter/groups.py new file mode 100644 index 00000000..bc5f5b25 --- /dev/null +++ b/src/HABApp/core/events/filter/groups.py @@ -0,0 +1,42 @@ +from typing import Any, Tuple + +from HABApp.core.internals import EventFilterBase, HINT_EVENT_FILTER_OBJ + + +class EventFilterBaseGroup(EventFilterBase): + def __init__(self, *args: HINT_EVENT_FILTER_OBJ): + self.filters: Tuple[HINT_EVENT_FILTER_OBJ, ...] = args + + def trigger(self, event) -> bool: + raise NotImplementedError() + + def describe(self): + raise NotImplementedError() + + +class OrFilterGroup(EventFilterBaseGroup): + """Only one child filter has to match""" + + def trigger(self, event: Any) -> bool: + for f in self.filters: + if f.trigger(event): + return True + return False + + def describe(self) -> str: + objs = [f.describe() for f in self.filters] + return f'({" or ".join(objs)})' + + +class AndFilterGroup(EventFilterBaseGroup): + """All child filters have to match""" + + def trigger(self, event: Any) -> bool: + for f in self.filters: + if not f.trigger(event): + return False + return True + + def describe(self) -> str: + objs = [f.describe() for f in self.filters] + return f'({" and ".join(objs)})' diff --git a/src/HABApp/core/events/filter/habapp_events.py b/src/HABApp/core/events/filter/habapp_events.py new file mode 100644 index 00000000..2ddf5f7a --- /dev/null +++ b/src/HABApp/core/events/filter/habapp_events.py @@ -0,0 +1,15 @@ +from typing import Any + +from HABApp.core.const import MISSING +from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent +from HABApp.core.events.filter.event import TypeBoundEventFilter + + +class ValueUpdateEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING): + super().__init__(ValueUpdateEvent, value=value) + + +class ValueChangeEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + super().__init__(ValueChangeEvent, value=value, old_value=old_value) diff --git a/src/HABApp/core/events/filter/no_filter.py b/src/HABApp/core/events/filter/no_filter.py new file mode 100644 index 00000000..b3d55c4e --- /dev/null +++ b/src/HABApp/core/events/filter/no_filter.py @@ -0,0 +1,11 @@ +from HABApp.core.internals import EventFilterBase + + +class NoEventFilter(EventFilterBase): + """Triggers on all events""" + + def trigger(self, event) -> bool: + return True + + def describe(self) -> str: + return f'{self.__class__.__name__}()' diff --git a/src/HABApp/core/files/folders/folders.py b/src/HABApp/core/files/folders/folders.py index 301b2a4d..6f3afda9 100644 --- a/src/HABApp/core/files/folders/folders.py +++ b/src/HABApp/core/files/folders/folders.py @@ -3,20 +3,20 @@ from typing import List, Type import HABApp -from HABApp.core.const.topics import FILES as T_FILES +from HABApp.core.const.topics import TOPIC_FILES as T_FILES from HABApp.core.events.habapp_events import RequestFileUnloadEvent, RequestFileLoadEvent from ..watcher import AggregatingAsyncEventHandler - +from HABApp.core.internals import uses_post_event FOLDERS: Dict[str, 'ConfiguredFolder'] = {} +post_event = uses_post_event() + async def _generate_file_events(files: List[Path]): for file in files: name = get_name(file) - HABApp.core.EventBus.post_event( - T_FILES, RequestFileLoadEvent(name) if file.is_file() else RequestFileUnloadEvent(name) - ) + post_event(T_FILES, RequestFileLoadEvent(name) if file.is_file() else RequestFileUnloadEvent(name)) class ConfiguredFolder: diff --git a/src/HABApp/core/files/manager/listen_events.py b/src/HABApp/core/files/manager/listen_events.py index 2f317a6d..8c6b7944 100644 --- a/src/HABApp/core/files/manager/listen_events.py +++ b/src/HABApp/core/files/manager/listen_events.py @@ -2,10 +2,13 @@ from typing import Union import HABApp -from HABApp.core.const.topics import FILES as T_FILES +from HABApp.core.const.topics import TOPIC_FILES as T_FILES from HABApp.core.events.habapp_events import RequestFileUnloadEvent, RequestFileLoadEvent +from HABApp.core.events import EventFilter +from HABApp.core.internals import wrap_func, EventBusListener, uses_event_bus log = logging.getLogger('HABApp.Files') +event_bus = uses_event_bus() async def _process_event(event: Union[RequestFileUnloadEvent, RequestFileLoadEvent]): @@ -15,9 +18,13 @@ async def _process_event(event: Union[RequestFileUnloadEvent, RequestFileLoadEve async def setup_file_manager(): # Setup events so we can process load/unload - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener(T_FILES, HABApp.core.WrappedFunction(_process_event), RequestFileUnloadEvent) + event_bus.add_listener( + EventBusListener( + T_FILES, wrap_func(_process_event), EventFilter(RequestFileUnloadEvent) + ) ) - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener(T_FILES, HABApp.core.WrappedFunction(_process_event), RequestFileLoadEvent) + event_bus.add_listener( + EventBusListener( + T_FILES, wrap_func(_process_event), EventFilter(RequestFileLoadEvent) + ) ) diff --git a/src/HABApp/core/internals/__init__.py b/src/HABApp/core/internals/__init__.py new file mode 100644 index 00000000..38a9aff6 --- /dev/null +++ b/src/HABApp/core/internals/__init__.py @@ -0,0 +1,14 @@ +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 + +# 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 + +# isort: split + +from .event_bus_listener import HINT_EVENT_BUS_LISTENER, EventBusListener, ContextBoundEventBusListener +from .event_filter import EventFilterBase, HINT_EVENT_FILTER_OBJ +from .wrapped_function import TYPE_WRAPPED_FUNC_OBJ, wrap_func diff --git a/src/HABApp/core/internals/context/__init__.py b/src/HABApp/core/internals/context/__init__.py new file mode 100644 index 00000000..5f6a7572 --- /dev/null +++ b/src/HABApp/core/internals/context/__init__.py @@ -0,0 +1,5 @@ +from .context import Context, HINT_CONTEXT_OBJ, ContextBoundObj, HINT_CONTEXT_BOUND_OBJ, ContextProvidingObj + +# isort: split + +from .get_context import get_current_context, AutoContextBoundObj diff --git a/src/HABApp/core/internals/context/context.py b/src/HABApp/core/internals/context/context.py new file mode 100644 index 00000000..f45f777c --- /dev/null +++ b/src/HABApp/core/internals/context/context.py @@ -0,0 +1,60 @@ +from typing import Set, Optional +from typing import TypeVar + +from HABApp.core.errors import ContextBoundObjectIsAlreadyLinkedError, ContextBoundObjectIsAlreadyUnlinkedError + + +class ContextBoundObj: + def __init__(self, parent_ctx: Optional['HINT_CONTEXT_OBJ'], **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'): + assert isinstance(parent_ctx, Context) + if self._parent_ctx is not None: + raise ContextBoundObjectIsAlreadyLinkedError() + + self._parent_ctx = parent_ctx + parent_ctx.add_obj(self) + + def _ctx_unlink(self): + if self._parent_ctx is None: + raise ContextBoundObjectIsAlreadyUnlinkedError() + + self._parent_ctx.remove_obj(self) + self._parent_ctx = None + + +HINT_CONTEXT_BOUND_OBJ = TypeVar('HINT_CONTEXT_BOUND_OBJ', bound=ContextBoundObj) + + +class Context: + def __init__(self): + self.objs: Set[HINT_CONTEXT_BOUND_OBJ] = set() + + def add_obj(self, obj: HINT_CONTEXT_BOUND_OBJ): + assert isinstance(obj, ContextBoundObj) + self.objs.add(obj) + + def remove_obj(self, obj: HINT_CONTEXT_BOUND_OBJ): + assert isinstance(obj, ContextBoundObj) + self.objs.remove(obj) + + def link(self, obj: HINT_CONTEXT_BOUND_OBJ) -> HINT_CONTEXT_BOUND_OBJ: + assert isinstance(obj, ContextBoundObj) + obj._ctx_link(self) + return obj + + 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): + super().__init__(**kwargs) + self._habapp_ctx: HINT_CONTEXT_OBJ = context diff --git a/src/HABApp/core/internals/context/get_context.py b/src/HABApp/core/internals/context/get_context.py new file mode 100644 index 00000000..cee4c554 --- /dev/null +++ b/src/HABApp/core/internals/context/get_context.py @@ -0,0 +1,38 @@ +import sys +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 + +if TYPE_CHECKING: + import HABApp + import types + + +def get_current_context(obj: Optional[ContextProvidingObj] = None) -> 'HABApp.rule_ctx.HABAppRuleContext': + if obj is not None: + return obj._habapp_ctx + + depth = 0 + while True: + depth += 1 + try: + frm = sys._getframe(depth) # type: types.FrameType + except ValueError: + raise ContextNotFoundError() from None + + ctx_obj: Union[None, object, ContextProvidingObj] = frm.f_locals.get('self', None) + if ctx_obj is None or not isinstance(ctx_obj, ContextProvidingObj): + continue + + ctx = ctx_obj._habapp_ctx + if ctx is None: + raise ContextNotSetError() + return ctx + + +class AutoContextBoundObj(ContextBoundObj): + def __init__(self, parent_ctx: Optional['HINT_CONTEXT_OBJ'] = 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 new file mode 100644 index 00000000..d04dd6aa --- /dev/null +++ b/src/HABApp/core/internals/event_bus/__init__.py @@ -0,0 +1,2 @@ +from .base_listener import EventBusBaseListener +from .event_bus import EventBus, HINT_EVENT_BUS diff --git a/src/HABApp/core/internals/event_bus/base_listener.py b/src/HABApp/core/internals/event_bus/base_listener.py new file mode 100644 index 00000000..737d16e5 --- /dev/null +++ b/src/HABApp/core/internals/event_bus/base_listener.py @@ -0,0 +1,11 @@ +class EventBusBaseListener: + def __init__(self, topic: str, **kwargs): + super().__init__(**kwargs) + assert isinstance(topic, str) + self.topic: str = topic + + def notify_listeners(self, event): + raise NotImplementedError() + + def describe(self) -> str: + raise NotImplementedError() diff --git a/src/HABApp/core/internals/event_bus/event_bus.py b/src/HABApp/core/internals/event_bus/event_bus.py new file mode 100644 index 00000000..a17fd42b --- /dev/null +++ b/src/HABApp/core/internals/event_bus/event_bus.py @@ -0,0 +1,89 @@ +import logging +import threading +from typing import Any, TypeVar +from typing import Dict, List + +from HABApp.core.events import ComplexEventValue, ValueChangeEvent +from .base_listener import EventBusBaseListener +from HABApp.core.const.topics import TOPIC_EVENTS + +event_log = logging.getLogger(TOPIC_EVENTS) +habapp_log = logging.getLogger('HABApp') + +_TYPE_LISTENER = TypeVar('_TYPE_LISTENER', bound=EventBusBaseListener) + + +class EventBus: + def __init__(self): + self._lock = threading.Lock() + self._listeners: Dict[str, List[EventBusBaseListener]] = {} + + def post_event(self, topic: str, event: Any): + assert isinstance(topic, str), type(topic) + + if not isinstance(event, str): + event_prv = str(event) + else: + event_prv = event[:120] + ' ...' if len(event) > 120 else event + event_prv = "'" + event_prv.replace('\n', '\\n') + "'" + + event_log.info(f'{topic:>20s}: {event_prv}') + + # Sometimes we have nested data structures which we need to set the value. + # Once the value in the item registry is updated the data structures provide no benefit thus + # we unpack the corresponding value + try: + if isinstance(event.value, ComplexEventValue): + event.value = event.value.value + if isinstance(event, ValueChangeEvent) and isinstance(event.old_value, ComplexEventValue): + event.old_value = event.old_value.value + except AttributeError: + pass + + # Notify all listeners + listeners = self._listeners.get(topic, None) + if listeners is not None: + for listener in listeners: + listener.notify_listeners(event) + return None + + def add_listener(self, listener: _TYPE_LISTENER): + assert isinstance(listener, EventBusBaseListener) + assert isinstance(listener.topic, str) and listener.topic + + with self._lock: + item_listeners = self._listeners.setdefault(listener.topic, []) + + # don't add the same listener twice + if listener in item_listeners: + habapp_log.warning(f'Event listener for {listener.describe()} has already been added!') + return None + + # add listener + item_listeners.append(listener) + habapp_log.debug(f'Added event listener for {listener.describe()}') + return None + + def remove_listener(self, listener: _TYPE_LISTENER): + assert isinstance(listener, EventBusBaseListener) + assert isinstance(listener.topic, str) and listener.topic + + with self._lock: + item_listeners = self._listeners.get(listener.topic, []) + + # print warning if we try to remove it twice + if listener not in item_listeners: + habapp_log.warning(f'Event listener for {listener.describe()} has already been removed!') + return None + + # remove listener + item_listeners.remove(listener) + habapp_log.debug(f'Removed event listener for {listener.describe()}') + return None + + 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 new file mode 100644 index 00000000..f81e67cc --- /dev/null +++ b/src/HABApp/core/internals/event_bus_listener.py @@ -0,0 +1,57 @@ +from typing import Optional, TypeVar + +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 HINT_EVENT_FILTER_OBJ, AutoContextBoundObj + + +event_bus = uses_event_bus() + + +class EventBusListener(EventBusBaseListener): + def __init__(self, topic: str, callback: TYPE_WRAPPED_FUNC_OBJ, event_filter: HINT_EVENT_FILTER_OBJ, **kwargs): + super().__init__(topic, **kwargs) + + assert isinstance(callback, WrappedFunctionBase) + self.func: TYPE_WRAPPED_FUNC_OBJ = callback + self.filter: HINT_EVENT_FILTER_OBJ = event_filter + + def notify_listeners(self, event): + if self.filter.trigger(event): + self.func.run(event) + + def describe(self) -> str: + return f'"{self.topic}" (filter={self.filter.describe()})' + + def cancel(self): + """Stop listening on the event bus""" + event_bus.remove_listener(self) + + +HINT_EVENT_BUS_LISTENER = TypeVar('HINT_EVENT_BUS_LISTENER', bound=EventBusListener) + + +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): + super().__init__(topic=topic, callback=callback, event_filter=event_filter, parent_ctx=parent_ctx) + + assert isinstance(callback, WrappedFunctionBase) + self.func: TYPE_WRAPPED_FUNC_OBJ = callback + self.filter: HINT_EVENT_FILTER_OBJ = event_filter + + def notify_listeners(self, event): + if self.filter.trigger(event): + self.func.run(event) + + def describe(self) -> str: + return f'"{self.topic}" (filter={self.filter.describe()})' + + def _ctx_unlink(self): + event_bus.remove_listener(self) + return super()._ctx_unlink() + + def cancel(self): + """Stop listening on the event bus""" + self._ctx_unlink() diff --git a/src/HABApp/core/internals/event_filter.py b/src/HABApp/core/internals/event_filter.py new file mode 100644 index 00000000..2083a839 --- /dev/null +++ b/src/HABApp/core/internals/event_filter.py @@ -0,0 +1,16 @@ +from typing import TypeVar, Any + + +class EventFilterBase: + def trigger(self, event: Any) -> bool: + raise NotImplementedError() + + def describe(self) -> str: + raise NotImplementedError() + + def __repr__(self): + return f'<{self.describe()} at 0x{id(self):X}>' + + +# Hints for functions that use an item class as an input parameter +HINT_EVENT_FILTER_OBJ = TypeVar('HINT_EVENT_FILTER_OBJ', bound=EventFilterBase) diff --git a/src/HABApp/core/internals/item_registry/__init__.py b/src/HABApp/core/internals/item_registry/__init__.py new file mode 100644 index 00000000..50892127 --- /dev/null +++ b/src/HABApp/core/internals/item_registry/__init__.py @@ -0,0 +1,5 @@ +from .item_registry_item import ItemRegistryItem + +# isort: split + +from .item_registry import ItemRegistry, HINT_ITEM_REGISTRY diff --git a/src/HABApp/core/internals/item_registry/item_registry.py b/src/HABApp/core/internals/item_registry/item_registry.py new file mode 100644 index 00000000..00d6420e --- /dev/null +++ b/src/HABApp/core/internals/item_registry/item_registry.py @@ -0,0 +1,72 @@ +import logging +import threading +from typing import Dict +from typing import Tuple, Union, TypeVar + +from HABApp.core.errors import ItemNotFoundException, ItemAlreadyExistsError +from HABApp.core.internals.item_registry import ItemRegistryItem + + +_HINT_ITEM_OBJ = TypeVar('_HINT_ITEM_OBJ', bound=ItemRegistryItem) + +log = logging.getLogger('HABApp.Items') + + +class ItemRegistry: + def __init__(self): + self._lock = threading.Lock() + self._items: Dict[str, _HINT_ITEM_OBJ] = {} + + def item_exists(self, name: Union[str, _HINT_ITEM_OBJ]) -> bool: + if not isinstance(name, str): + name = name.name + return name in self._items + + def get_item(self, name: str) -> _HINT_ITEM_OBJ: + try: + return self._items[name] + except KeyError: + raise ItemNotFoundException(name) from None + + def get_items(self) -> Tuple[_HINT_ITEM_OBJ, ...]: + return tuple(self._items.values()) + + def get_item_names(self) -> Tuple[str, ...]: + return tuple(self._items.keys()) + + def add_item(self, item: _HINT_ITEM_OBJ) -> _HINT_ITEM_OBJ: + assert isinstance(item, ItemRegistryItem) + name = item.name + + with self._lock: + existing = self._items.get(name) + if existing is not None: + # adding the same item multiple times will not cause an exception + if existing is item: + return item + + # adding a new item with the same name raises an exception + raise ItemAlreadyExistsError(name) + + self._items[name] = item + + log.debug(f'Added {name} ({item.__class__.__name__})') + item._on_item_added() + return item + + def pop_item(self, name: Union[str, _HINT_ITEM_OBJ]) -> _HINT_ITEM_OBJ: + if not isinstance(name, str): + name = name.name + + with self._lock: + try: + item = self._items.pop(name) + except KeyError: + raise ItemNotFoundException(name) from None + + 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/item_registry/item_registry_item.py b/src/HABApp/core/internals/item_registry/item_registry_item.py new file mode 100644 index 00000000..4dfd8948 --- /dev/null +++ b/src/HABApp/core/internals/item_registry/item_registry_item.py @@ -0,0 +1,25 @@ +class ItemRegistryItem: + """ItemRegistryItem, all items that will be stored in the Item Registry must inherit from this + """ + + def __init__(self, name: str, **kwargs): + super().__init__(**kwargs) + assert isinstance(name, str), type(name) + self._name: str = name + + @property + def name(self) -> str: + """ + :return: Name of the item (read only) + """ + return self._name + + def _on_item_added(self): + """This function gets automatically called when the item was added to the item registry + """ + raise NotImplementedError() + + def _on_item_removed(self): + """This function gets automatically called when the item was removed from the item registry + """ + raise NotImplementedError() diff --git a/src/HABApp/core/internals/proxy/__init__.py b/src/HABApp/core/internals/proxy/__init__.py new file mode 100644 index 00000000..348ccf3d --- /dev/null +++ b/src/HABApp/core/internals/proxy/__init__.py @@ -0,0 +1,5 @@ +from .proxy_obj import create_proxy, ConstProxyObj + +# isort: split + +from .proxies import uses_get_item, uses_item_registry, uses_post_event, uses_event_bus, setup_internals diff --git a/src/HABApp/core/internals/proxy/proxies.py b/src/HABApp/core/internals/proxy/proxies.py new file mode 100644 index 00000000..957a7a5d --- /dev/null +++ b/src/HABApp/core/internals/proxy/proxies.py @@ -0,0 +1,31 @@ +from HABApp.core.internals.proxy.proxy_obj import create_proxy, replace_proxies +from typing import Callable, TYPE_CHECKING, Any + +if TYPE_CHECKING: + import HABApp + + +def uses_post_event() -> Callable[[str, Any], None]: + return create_proxy(uses_post_event) + + +def uses_event_bus() -> 'HABApp.core.internals.HINT_EVENT_BUS': + return create_proxy(uses_event_bus) + + +def uses_get_item() -> Callable[[str], 'HABApp.core.internals.item_registry.item_registry._HINT_ITEM_OBJ']: + return create_proxy(uses_get_item) + + +def uses_item_registry() -> 'HABApp.core.internals.HINT_ITEM_REGISTRY': + 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): + """Replace the proxy objects with the real thing""" + replacements = { + uses_item_registry: ir, uses_get_item: ir.get_item, + uses_event_bus: eb, uses_post_event: eb.post_event, + } + return replace_proxies(replacements, final=final) diff --git a/src/HABApp/core/internals/proxy/proxy_obj.py b/src/HABApp/core/internals/proxy/proxy_obj.py new file mode 100644 index 00000000..23b63c8b --- /dev/null +++ b/src/HABApp/core/internals/proxy/proxy_obj.py @@ -0,0 +1,91 @@ +import sys +from typing import Dict, List, Optional, Final + +from HABApp.core.errors import ProxyObjHasNotBeenReplacedError + +PROXIES: List['StartUpProxyObj'] = [] + + +class ProxyObjBase: + @property + def to_replace_name(self) -> str: + raise NotImplementedError() + + def __getattr__(self, item): + raise ProxyObjHasNotBeenReplacedError(self) + + def __call__(self, *args, **kwargs): + raise ProxyObjHasNotBeenReplacedError(self) + + def __str__(self): + return f'<{self.__class__.__name__} {self.to_replace_name}>' + + +class ConstProxyObj(ProxyObjBase): + def __init__(self, name: str): + self.name: Final = name + + @property + def to_replace_name(self) -> str: + return self.name + + +class StartUpProxyObj(ProxyObjBase): + def __init__(self, to_replace: callable, globals: dict): + self.to_replace: Optional[callable] = to_replace + self.globals: Optional[dict] = globals + + PROXIES.append(self) + + @property + def to_replace_name(self) -> str: + return str(getattr(self.to_replace, "__name__", self.to_replace)) + + def replace(self, replacements: Dict[object, object], final: bool): + assert self.globals is not None + replacement = replacements[self.to_replace] + + for name, value in self.globals.items(): + if value is self: + self.globals[name] = replacement + if not final: + return RestoreableObj(name, self.globals, self) + break + else: + file = self.globals.get('__file__', '?') + raise ValueError(f'"{self.to_replace_name}" should be replaced but was not found in {file}!') + + self.globals = None + self.to_replace = None + + +def create_proxy(to_replace: callable) -> StartUpProxyObj: + frm = sys._getframe(2) + return StartUpProxyObj(to_replace, frm.f_globals) + + +class RestoreableObj: + def __init__(self, key: str, globals: dict, proxy: 'StartUpProxyObj'): + self.key = key + self.globals = globals + self.proxy = proxy + + def restore(self): + self.globals[self.key] = self.proxy + self.globals = None + self.key = None + self.proxy = None + + +def replace_proxies(replacements: Dict[object, object], final: bool) -> List[RestoreableObj]: + restore_objs = [] + for proxy in PROXIES: + restore = proxy.replace(replacements, final) + if restore is not None: + restore_objs.append(restore) + + if not final: + return restore_objs + + PROXIES.clear() + return [] diff --git a/src/HABApp/core/internals/wrapped_function/__init__.py b/src/HABApp/core/internals/wrapped_function/__init__.py new file mode 100644 index 00000000..49133573 --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/__init__.py @@ -0,0 +1,5 @@ +from HABApp.core.internals.wrapped_function.base import TYPE_WRAPPED_FUNC_OBJ, WrappedFunctionBase + +# isort: split + +from HABApp.core.internals.wrapped_function.wrapper import wrap_func, run_function diff --git a/src/HABApp/core/internals/wrapped_function/base.py b/src/HABApp/core/internals/wrapped_function/base.py new file mode 100644 index 00000000..a6c5e695 --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/base.py @@ -0,0 +1,55 @@ +import logging +from typing import Optional, TypeVar + +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.lib import format_exception + +default_logger = logging.getLogger('HABApp.Worker') + +event_bus = uses_event_bus() + + +class WrappedFunctionBase(ContextProvidingObj): + + def __init__(self, func: callable, name: Optional[str] = None, logger: Optional[logging.Logger] = None, + context: Optional[HINT_CONTEXT_OBJ] = None): + + # Allow setting of the rule context + super().__init__(context) + + # name of the function + if name is None: + if self._habapp_ctx is not None: + name = self._habapp_ctx.get_callback_name(func) + if name is None: + name = func.__name__ + self.name: str = name + + # Allow custom logger + self.log = default_logger + if logger: + self.log = logger + + def run(self, *args, **kwargs): + raise NotImplementedError() + + def process_exception(self, e: Exception, *args, **kwargs): + + lines = format_exception(e) + + # Log Exception + self.log.error(f'Error in {self.name}: {e}') + for line in lines: + self.log.error(line) + + # Create HABApp event, but only if we are not currently processing an exception while processing an error. + # Otherwise we might create an endless loop! + if not args or not isinstance(args[0], HABAppException): + event_bus.post_event( + TOPIC_ERRORS, HABAppException(func_name=self.name, exception=e, traceback='\n'.join(lines)) + ) + + +TYPE_WRAPPED_FUNC_OBJ = TypeVar('TYPE_WRAPPED_FUNC_OBJ', bound=WrappedFunctionBase) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_async.py b/src/HABApp/core/internals/wrapped_function/wrapped_async.py new file mode 100644 index 00000000..e59ca887 --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/wrapped_async.py @@ -0,0 +1,34 @@ +import logging +from typing import Optional + +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 .base import WrappedFunctionBase + + +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): + + super(WrappedAsyncFunction, self).__init__(name=name, func=func, logger=logger, context=context) + assert callable(func) + + self.func = func + + def run(self, *args, **kwargs): + create_task(self.async_run(*args, **kwargs), name=self.name) + + async def async_run(self, *args, **kwargs): + + token = async_context.set('WrappedAsyncFunction') + + try: + await self.func(*args, **kwargs) + except Exception as e: + self.process_exception(e, *args, **kwargs) + finally: + async_context.reset(token) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_sync.py b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py new file mode 100644 index 00000000..fdd341dc --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py @@ -0,0 +1,35 @@ +import logging +from typing import Optional, Callable + +from HABApp.core.asyncio import async_context, create_task +from HABApp.core.internals import HINT_CONTEXT_OBJ +from .base import WrappedFunctionBase + + +class WrappedSyncFunction(WrappedFunctionBase): + + def __init__(self, func: Callable, + warn_too_long=True, + name: Optional[str] = None, + logger: Optional[logging.Logger] = None, + context: Optional[HINT_CONTEXT_OBJ] = None): + + super().__init__(name=name, func=func, logger=logger, context=context) + assert callable(func) + + self.func = func + self.warn_too_long: bool = warn_too_long + + def run(self, *args, **kwargs): + create_task(self.async_run(*args, **kwargs), name=self.name) + + async def async_run(self, *args, **kwargs): + + token = async_context.set('WrappedSyncFunction') + + try: + self.func(*args, **kwargs) + except Exception as e: + self.process_exception(e, *args, **kwargs) + finally: + async_context.reset(token) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_thread.py b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py new file mode 100644 index 00000000..2b5ca1de --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py @@ -0,0 +1,98 @@ +import io +import logging +from cProfile import Profile +from concurrent.futures import ThreadPoolExecutor +from pstats import SortKey +from pstats import Stats +from time import time +from typing import Callable, Any +from typing import Optional + +from HABApp.core.internals import HINT_CONTEXT_OBJ +from HABApp.core.const import loop +from .base import WrappedFunctionBase, default_logger + +WORKERS: Optional[ThreadPoolExecutor] = None + + +def create_thread_pool(count: int): + global WORKERS + assert isinstance(count, int) and count > 0 + + default_logger.debug(f'Starting thread pool with {count:d} threads!') + + stop_thread_pool() + WORKERS = ThreadPoolExecutor(count, 'HabAppWorker') + + +def stop_thread_pool(): + global WORKERS + if WORKERS is not None: + WORKERS.shutdown() + WORKERS = None + default_logger.debug('Thread pool stopped!') + + +async def run_in_thread_pool(func: Callable): + return await loop.run_in_executor( + WORKERS, func + ) + + +HINT_FUNC_SYNC = Callable[..., Any] + + +class WrappedThreadFunction(WrappedFunctionBase): + + 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): + + super(WrappedThreadFunction, self).__init__(name=name, func=func, logger=logger, context=context) + assert callable(func) + + self.func = func + + self.warn_too_long: bool = warn_too_long + self.time_submitted: float = 0.0 + + def run(self, *args, **kwargs): + self.time_submitted = time() + WORKERS.submit(self.run_sync, *args, **kwargs) + + def run_sync(self, *args, **kwargs): + start = time() + + # notify if we don't process quickly + if start - self.time_submitted > 0.05: + self.log.warning(f'Starting of {self.name} took too long: {start - self.time_submitted:.2f}s. ' + f'Maybe there are not enough threads?') + + # start profiler + pr = Profile() + pr.enable() + + # Execute the function + try: + self.func(*args, **kwargs) + except Exception as e: + self.process_exception(e, *args, **kwargs) + return None + + # disable profiler + pr.disable() + + # log warning if execution takes too long + duration = time() - start + if self.warn_too_long and duration > 0.8: + self.log.warning(f'Execution of {self.name} took too long: {duration:.2f}s') + + s = io.StringIO() + ps = Stats(pr, stream=s).sort_stats(SortKey.CUMULATIVE) + ps.print_stats(0.1) # limit to output to 10% of the lines + + for line in s.getvalue().splitlines()[4:]: # skip the amount of calls and "Ordered by:" + if line: + self.log.warning(line) diff --git a/src/HABApp/core/internals/wrapped_function/wrapper.py b/src/HABApp/core/internals/wrapped_function/wrapper.py new file mode 100644 index 00000000..460d7557 --- /dev/null +++ b/src/HABApp/core/internals/wrapped_function/wrapper.py @@ -0,0 +1,56 @@ +import logging +from asyncio import iscoroutinefunction +from typing import Union, Optional, Callable, Type + +from HABApp.config import CONFIG +from HABApp.core.internals import HINT_CONTEXT_OBJ +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 +from HABApp.core.internals.wrapped_function.wrapped_thread import HINT_FUNC_SYNC, WrappedThreadFunction, \ + create_thread_pool, stop_thread_pool, run_in_thread_pool + + +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: + + if iscoroutinefunction(func): + return WrappedAsyncFunction(func, name=name, logger=logger, context=context) + else: + return SYNC_CLS(func, warn_too_long=warn_too_long, name=name, logger=logger, context=context) + + +SYNC_CLS: Union[Type[WrappedThreadFunction], Type[WrappedSyncFunction]] + + +def setup(): + global SYNC_CLS + + if not THREAD_POOL.enabled: + SYNC_CLS = WrappedSyncFunction + + # In case of hot reload + stop_thread_pool() + else: + SYNC_CLS = WrappedThreadFunction + + # create thread pool + create_thread_pool(THREAD_POOL.threads) + + # this function can be called multiple times, so it's no problem if we register it more than once! + from HABApp.runtime import shutdown + shutdown.register_func(stop_thread_pool, msg='Stopping thread pool', last=True) + + +THREAD_POOL = CONFIG.habapp.thread_pool +THREAD_POOL.subscribe_for_changes(setup) + + +async def run_function(func: Callable): + if not THREAD_POOL.enabled: + return func() + else: + return await run_in_thread_pool(func) diff --git a/src/HABApp/core/items/__init__.py b/src/HABApp/core/items/__init__.py index 299d7f5c..207309ed 100644 --- a/src/HABApp/core/items/__init__.py +++ b/src/HABApp/core/items/__init__.py @@ -1,4 +1,8 @@ +from .base_item import BaseItem, HINT_TYPE_ITEM_OBJ, HINT_ITEM_OBJ from .base_valueitem import BaseValueItem + +# isort split + from .item import Item from .item_color import ColorItem from .item_aggregation import AggregationItem diff --git a/src/HABApp/core/items/base_item.py b/src/HABApp/core/items/base_item.py index d2cbe249..d1f38c10 100644 --- a/src/HABApp/core/items/base_item.py +++ b/src/HABApp/core/items/base_item.py @@ -1,17 +1,23 @@ -import datetime -from typing import Any, Callable, Type, Union, TypeVar +from typing import Type, TypeVar, Optional -from eascheduler.const import local_tz from pendulum import UTC, DateTime from pendulum import now as pd_now -import HABApp +from HABApp.core.internals import HINT_EVENT_FILTER_OBJ, HINT_EVENT_BUS_LISTENER +from HABApp.core.internals import uses_get_item, uses_item_registry, get_current_context +from HABApp.core.internals.item_registry import ItemRegistryItem +from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF, get_positive_time_diff +from eascheduler.const import local_tz from .base_item_times import ChangedTime, ItemNoChangeWatch, ItemNoUpdateWatch, UpdatedTime from .tmp_data import add_tmp_data as _add_tmp_data from .tmp_data import restore_tmp_data as _restore_tmp_data +from ..const.hints import HINT_EVENT_CALLBACK + +get_item = uses_get_item() +item_registry = uses_item_registry() -class BaseItem: +class BaseItem(ItemRegistryItem): """BaseItem, all items must inherit from this class """ @@ -22,27 +28,17 @@ def get_item(cls, name: str): :param name: Name of the item """ assert isinstance(name, str), type(name) - item = HABApp.core.Items.get_item(name) + item = get_item(name) assert isinstance(item, cls), f'{cls} != {type(item)}' return item def __init__(self, name: str): - super().__init__() - assert isinstance(name, str), type(name) - - self._name: str = name + super().__init__(name) _now = pd_now(UTC) self._last_change: ChangedTime = ChangedTime(self._name, _now) self._last_update: UpdatedTime = UpdatedTime(self._name, _now) - @property - def name(self) -> str: - """ - :return: Name of the item (read only) - """ - return self._name - @property def last_change(self) -> DateTime: """ @@ -63,67 +59,51 @@ def __repr__(self): ret += f'{", " if ret else ""}{k}: {getattr(self, k)}' return f'<{self.__class__.__name__} {ret:s}>' - def watch_change(self, secs: Union[int, float, datetime.timedelta]) -> ItemNoChangeWatch: + def watch_change(self, secs: TH_POSITIVE_TIME_DIFF) -> ItemNoChangeWatch: """Generate an event if the item does not change for a certain period of time. Has to be called from inside a rule function. :param secs: secs after which the event will occur, max 1 decimal digit for floats :return: The watch obj which can be used to cancel the watch """ - if isinstance(secs, datetime.timedelta): - secs = secs.total_seconds() - if isinstance(secs, float): - secs = round(secs, 1) - else: - assert isinstance(secs, int) - assert secs > 0, secs - w = self._last_change.add_watch(secs) - HABApp.rule.get_parent_rule().register_cancel_obj(w) - return w - - def watch_update(self, secs: Union[int, float, datetime.timedelta]) -> ItemNoUpdateWatch: + secs = get_positive_time_diff(secs, round_digits=1) + return self._last_change.add_watch(secs) + + def watch_update(self, secs: TH_POSITIVE_TIME_DIFF) -> ItemNoUpdateWatch: """Generate an event if the item does not receive and update for a certain period of time. Has to be called from inside a rule function. :param secs: secs after which the event will occur, max 1 decimal digit for floats :return: The watch obj which can be used to cancel the watch """ - if isinstance(secs, datetime.timedelta): - secs = secs.total_seconds() - if isinstance(secs, float): - secs = round(secs, 1) - else: - assert isinstance(secs, int) - assert secs > 0, secs - w = self._last_update.add_watch(secs) - HABApp.rule.get_parent_rule().register_cancel_obj(w) - return w - - def listen_event(self, callback: Callable[[Any], Any], - event_type: Union['HABApp.core.events.AllEvents', 'HABApp.core.events.EventFilter', Any] - ) -> 'HABApp.core.EventBusListener': + secs = get_positive_time_diff(secs, round_digits=1) + return self._last_update.add_watch(secs) + + def listen_event(self, callback: HINT_EVENT_CALLBACK, + event_filter: Optional[HINT_EVENT_FILTER_OBJ] = None) -> HINT_EVENT_BUS_LISTENER: """ Register an event listener which listens to all event that the item receives :param callback: callback that accepts one parameter which will contain the event - :param event_type: Event filter. This is typically :class:`~HABApp.core.ValueUpdateEvent` or - :class:`~HABApp.core.ValueChangeEvent` which will also trigger on changes/update from openHAB - or mqtt. + :param event_filter: Event filter. This is typically :class:`~HABApp.core.events.ValueUpdateEventFilter` or + :class:`~HABApp.core.events.ValueChangeEventFilter` which will also trigger on changes/update from openhab + or mqtt. Additionally it can be an instance of :class:`~HABApp.core.events.EventFilter` which additionally + filters on the values of the event. It is also possible to group filters logically with, e.g. + :class:`~HABApp.core.events.AndFilterGroup` and :class:`~HABApp.core.events.OrFilterGroup` """ - rule = HABApp.rule.get_parent_rule() - return rule.listen_event(self._name, callback=callback, event_type=event_type) + return get_current_context().rule.listen_event(self._name, callback=callback, event_filter=event_filter) - def _on_item_add(self): + def _on_item_added(self): """This function gets automatically called when the item is added to the item registry """ _restore_tmp_data(self) - def _on_item_remove(self): + def _on_item_removed(self): """This function gets automatically called when the item is removed from the item registry """ _add_tmp_data(self) # Hints for functions that use an item class as an input parameter -TYPE_ITEM = TypeVar('TYPE_ITEM', bound=BaseItem) -TYPE_ITEM_CLS = Type[TYPE_ITEM] +HINT_ITEM_OBJ = TypeVar('HINT_ITEM_OBJ', bound=BaseItem) +HINT_TYPE_ITEM_OBJ = Type[HINT_ITEM_OBJ] diff --git a/src/HABApp/core/items/base_item_times.py b/src/HABApp/core/items/base_item_times.py index 97f28177..92df82d2 100644 --- a/src/HABApp/core/items/base_item_times.py +++ b/src/HABApp/core/items/base_item_times.py @@ -1,23 +1,25 @@ -import asyncio import logging import typing -from datetime import timedelta +from typing import Generic, TypeVar, List + from pendulum import DateTime +from HABApp.core.asyncio import create_task from HABApp.core.wrapper import log_exception from .base_item_watch import BaseWatch, ItemNoChangeWatch, ItemNoUpdateWatch -from ..const import loop log = logging.getLogger('HABApp') +WATCH_TYPE = TypeVar("WATCH_TYPE", bound=BaseWatch) + -class ItemTimes: +class ItemTimes(Generic[WATCH_TYPE]): WATCH: typing.Union[typing.Type[ItemNoUpdateWatch], typing.Type[ItemNoChangeWatch]] def __init__(self, name: str, dt: DateTime): self.name: str = name self.dt: DateTime = dt - self.tasks: typing.List[BaseWatch] = [] + self.tasks: List[WATCH_TYPE] = [] def set(self, dt: DateTime, events=True): self.dt = dt @@ -25,14 +27,10 @@ def set(self, dt: DateTime, events=True): return if events: - asyncio.run_coroutine_threadsafe(self.schedule_events(), loop) + create_task(self.schedule_events()) return None - def add_watch(self, secs: typing.Union[int, float, timedelta]) -> BaseWatch: - if isinstance(secs, timedelta): - secs = secs.total_seconds() - assert secs > 0, secs - + def add_watch(self, secs: typing.Union[int, float]) -> WATCH_TYPE: # don't add the watch two times for t in self.tasks: if not t.fut.is_canceled and t.fut.secs == secs: @@ -61,9 +59,9 @@ async def schedule_events(self): return None -class UpdatedTime(ItemTimes): +class UpdatedTime(ItemTimes[ItemNoUpdateWatch]): WATCH = ItemNoUpdateWatch -class ChangedTime(ItemTimes): +class ChangedTime(ItemTimes[ItemNoChangeWatch]): WATCH = ItemNoChangeWatch diff --git a/src/HABApp/core/items/base_item_watch.py b/src/HABApp/core/items/base_item_watch.py index c925257f..02eb7ba5 100644 --- a/src/HABApp/core/items/base_item_watch.py +++ b/src/HABApp/core/items/base_item_watch.py @@ -1,24 +1,29 @@ -import asyncio import logging import typing import HABApp +from HABApp.core.asyncio import create_task +from HABApp.core.events import ItemNoChangeEvent, ItemNoUpdateEvent, EventFilter from HABApp.core.lib import PendingFuture -from ..const import loop -from ..events import ItemNoChangeEvent, ItemNoUpdateEvent, EventFilter +from HABApp.core.const.hints import HINT_EVENT_CALLBACK +from HABApp.core.internals import uses_post_event, get_current_context, AutoContextBoundObj, wrap_func +from HABApp.core.internals import ContextBoundEventBusListener log = logging.getLogger('HABApp') +post_event = uses_post_event() -class BaseWatch: + +class BaseWatch(AutoContextBoundObj): EVENT: typing.Union[typing.Type[ItemNoUpdateEvent], typing.Type[ItemNoChangeEvent]] def __init__(self, name: str, secs: typing.Union[int, float]): + super(BaseWatch, self).__init__() self.fut = PendingFuture(self._post_event, secs) self.name: str = name async def _post_event(self): - HABApp.core.EventBus.post_event(self.name, self.EVENT(self.name, self.fut.secs)) + post_event(self.name, self.EVENT(self.name, self.fut.secs)) async def __cancel_watch(self): self.fut.cancel() @@ -26,14 +31,20 @@ async def __cancel_watch(self): def cancel(self): """Cancel the item watch""" - asyncio.run_coroutine_threadsafe(self.__cancel_watch(), loop) + self._ctx_unlink() + create_task(self.__cancel_watch()) - def listen_event(self, callback: typing.Callable[[typing.Any], typing.Any]) -> 'HABApp.core.EventBusListener': + def listen_event(self, callback: HINT_EVENT_CALLBACK) -> 'HABApp.core.base.HINT_EVENT_BUS_LISTENER': """Listen to (only) the event that is emitted by this watcher""" - rule = HABApp.rule.get_parent_rule() - cb = HABApp.core.WrappedFunction(callback, name=rule._get_cb_name(callback)) - listener = EventFilter(self.EVENT, seconds=self.fut.secs).create_event_listener(self.name, cb) - return rule._add_event_listener(listener) + context = get_current_context() + return context.add_event_listener( + ContextBoundEventBusListener( + self.name, + wrap_func(callback, context=context), + EventFilter(self.EVENT, seconds=self.fut.secs), + parent_ctx=context + ) + ) class ItemNoUpdateWatch(BaseWatch): diff --git a/src/HABApp/core/items/base_valueitem.py b/src/HABApp/core/items/base_valueitem.py index 5394c249..b95f2465 100644 --- a/src/HABApp/core/items/base_valueitem.py +++ b/src/HABApp/core/items/base_valueitem.py @@ -5,19 +5,22 @@ from pendulum import UTC from pendulum import now as pd_now -import HABApp -from .base_item import BaseItem +from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.core.internals import uses_post_event +from HABApp.core.items.base_item import BaseItem log = logging.getLogger('HABApp') +post_event = uses_post_event() + class BaseValueItem(BaseItem): """Simple item - :ivar str ~.name: Name of the item (read only) - :ivar ~.value: Value of the item, can be anything (read only) - :ivar datetime.datetime ~.last_change: Timestamp of the last time when the item has changed the value (read only) - :ivar datetime.datetime ~.last_update: Timestamp of the last time when the item has updated the value (read only) + :ivar str name: Name of the item (read only) + :ivar value: Value of the item, can be anything (read only) + :ivar datetime.datetime last_change: Timestamp of the last time when the item has changed the value (read only) + :ivar datetime.datetime last_update: Timestamp of the last time when the item has updated the value (read only) """ def __init__(self, name: str, initial_value=None): @@ -52,15 +55,16 @@ def post_value(self, new_value) -> bool: state_changed = self.set_value(new_value) # create events - HABApp.core.EventBus.post_event(self._name, HABApp.core.events.ValueUpdateEvent(self._name, self.value)) - if old_value != self.value: - HABApp.core.EventBus.post_event( - self._name, HABApp.core.events.ValueChangeEvent(self._name, value=self.value, old_value=old_value) + post_event(self._name, ValueUpdateEvent(self._name, self.value)) + if state_changed: + post_event( + self._name, ValueChangeEvent(self._name, value=self.value, old_value=old_value) ) return state_changed def get_value(self, default_value=None) -> typing.Any: - """Return the value of the item. + """Return the value of the item. This is a helper function that returns a default + in case the item value is None. :param default_value: Return this value if the item value is None :return: value of the item diff --git a/src/HABApp/core/items/item.py b/src/HABApp/core/items/item.py index 1228427c..e2cbe6d7 100644 --- a/src/HABApp/core/items/item.py +++ b/src/HABApp/core/items/item.py @@ -1,5 +1,10 @@ -import HABApp -from . import BaseValueItem +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.internals import uses_item_registry, uses_get_item +from HABApp.core.items import BaseValueItem + + +get_item = uses_get_item() +item_registry = uses_item_registry() class Item(BaseValueItem): @@ -16,10 +21,10 @@ def get_create_item(cls, name: str, initial_value=None): assert isinstance(name, str), type(name) try: - item = HABApp.core.Items.get_item(name) - except HABApp.core.Items.ItemNotFoundException: + item = get_item(name) + except ItemNotFoundException: item = cls(name, initial_value) - HABApp.core.Items.add_item(item) + item_registry.add_item(item) assert isinstance(item, cls), f'{cls} != {type(item)}' return item diff --git a/src/HABApp/core/items/item_aggregation.py b/src/HABApp/core/items/item_aggregation.py index 0e8669ed..4e0100a8 100644 --- a/src/HABApp/core/items/item_aggregation.py +++ b/src/HABApp/core/items/item_aggregation.py @@ -5,8 +5,17 @@ from datetime import timedelta import HABApp -from . import BaseValueItem -from ..wrapper import process_exception +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.internals import HINT_EVENT_BUS_LISTENER, wrap_func +from HABApp.core.wrapper import process_exception +from HABApp.core.internals import uses_item_registry, uses_get_item, uses_event_bus, EventBusListener +from HABApp.core.items import BaseValueItem +from HABApp.core.events import EventFilter, ValueChangeEvent, ValueUpdateEvent + + +get_item = uses_get_item() +item_registry = uses_item_registry() +event_bus = uses_event_bus() class AggregationItem(BaseValueItem): @@ -21,10 +30,10 @@ def get_create_item(cls, name: str): assert isinstance(name, str), type(name) try: - item = HABApp.core.Items.get_item(name) - except HABApp.core.Items.ItemNotFoundException: + item = get_item(name) + except ItemNotFoundException: item = cls(name) - HABApp.core.Items.add_item(item) + item_registry.add_item(item) assert isinstance(item, cls), f'{cls} != {type(item)}' return item @@ -37,7 +46,7 @@ def __init__(self, name: str): self._ts: typing.Deque[float] = collections.deque() self._vals: typing.Deque[typing.Any] = collections.deque() - self.__listener: typing.Optional[HABApp.core.EventBusListener] = None + self.__listener: typing.Optional[HINT_EVENT_BUS_LISTENER] = None self.__task: typing.Optional[asyncio.Future] = None @@ -81,16 +90,16 @@ def aggregation_source(self, source: typing.Union[BaseValueItem, str], self.__listener.cancel() self.__listener = None - self.__listener = HABApp.core.EventBusListener( + self.__listener = EventBusListener( topic=source.name if isinstance(source, HABApp.core.items.BaseValueItem) else source, - callback=HABApp.core.WrappedFunction(self._add_value, name=f'{self.name}.add_value'), - event_type=HABApp.core.events.ValueChangeEvent if only_changes else HABApp.core.events.ValueUpdateEvent + callback=wrap_func(self._add_value, name=f'{self.name}.add_value'), + event_filter=EventFilter(ValueChangeEvent if only_changes else ValueUpdateEvent) ) - HABApp.core.EventBus.add_listener(self.__listener) + event_bus.add_listener(self.__listener) return self - def _on_item_remove(self): - super()._on_item_remove() + def _on_item_removed(self): + super()._on_item_removed() if self.__listener is not None: self.__listener.cancel() @@ -130,7 +139,7 @@ async def __update_task(self): self.__task = None return None - async def _add_value(self, event: 'HABApp.core.events.ValueChangeEvent'): + async def _add_value(self, event: ValueChangeEvent): self._ts.append(time.time()) self._vals.append(event.value) diff --git a/src/HABApp/core/items/item_color.py b/src/HABApp/core/items/item_color.py index e268e5ac..fbd33235 100644 --- a/src/HABApp/core/items/item_color.py +++ b/src/HABApp/core/items/item_color.py @@ -1,12 +1,16 @@ import typing from typing import Optional +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.internals import uses_item_registry +from HABApp.core.items import BaseValueItem from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb -from .base_valueitem import BaseValueItem HUE_FACTOR = 360 PERCENT_FACTOR = 100 +item_registry = uses_item_registry() + class ColorItem(BaseValueItem): """Item for dealing with color related values""" @@ -97,3 +101,21 @@ def is_off(self) -> bool: def __repr__(self): return f'' + + @classmethod + def get_create_item(cls, name: str, hue=0.0, saturation=0.0, brightness=0.0): + """Creates a new item in HABApp and returns it or returns the already existing one with the given name + + :param name: item name + :param initial_value: state the item will have if it gets created + :return: item + """ + assert isinstance(name, str), type(name) + + try: + item = item_registry.get_item(name) + except ItemNotFoundException: + item = item_registry.add_item(cls(name, hue=hue, saturation=saturation, brightness=brightness)) + + assert isinstance(item, cls), f'{cls} != {type(item)}' + return item diff --git a/src/HABApp/core/items/tmp_data.py b/src/HABApp/core/items/tmp_data.py index fc1ef455..d5e55540 100644 --- a/src/HABApp/core/items/tmp_data.py +++ b/src/HABApp/core/items/tmp_data.py @@ -45,10 +45,11 @@ def add_tmp_data(item: 'BaseItem'): def restore_tmp_data(item: 'BaseItem'): - if item.name not in TMP_DATA: + data = TMP_DATA.pop(item.name, None) + if data is None: return None - data = TMP_DATA.pop(item.name) + # delete old watcher data.clean() for t in data.update: @@ -79,4 +80,4 @@ async def clean_tmp_data(): TMP_DATA.pop(name) -CLEANUP = PendingFuture(clean_tmp_data, 15) +CLEANUP = PendingFuture(clean_tmp_data, 3600) diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py index 18ada577..8d0331e8 100644 --- a/src/HABApp/core/lib/__init__.py +++ b/src/HABApp/core/lib/__init__.py @@ -1,3 +1,6 @@ +from . import parameters from .funcs import list_files, sort_files 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 diff --git a/src/HABApp/core/lib/exceptions/__init__.py b/src/HABApp/core/lib/exceptions/__init__.py new file mode 100644 index 00000000..b4d128de --- /dev/null +++ b/src/HABApp/core/lib/exceptions/__init__.py @@ -0,0 +1 @@ +from .format import format_exception diff --git a/src/HABApp/core/lib/exceptions/const.py b/src/HABApp/core/lib/exceptions/const.py new file mode 100644 index 00000000..28c45043 --- /dev/null +++ b/src/HABApp/core/lib/exceptions/const.py @@ -0,0 +1,3 @@ +PRE_INDENT = 4 +SEPARATOR_NEW_FRAME = '-' * 80 +SEPARATOR_VARIABLES = ' ' * (PRE_INDENT - 1) + '-' * 60 diff --git a/src/HABApp/core/lib/exceptions/format.py b/src/HABApp/core/lib/exceptions/format.py new file mode 100644 index 00000000..c83d8d47 --- /dev/null +++ b/src/HABApp/core/lib/exceptions/format.py @@ -0,0 +1,59 @@ +from traceback import format_exception as _format_exception +from typing import Tuple, Union, Any, List + +from stack_data import FrameInfo, Options + +from .const import SEPARATOR_NEW_FRAME +from .format_frame import format_frame_info + + +def append_short_traceback(tb: List[str], e: Union[Exception, Tuple[Any, Any, Any]]): + for line in _format_exception(*e) if isinstance(e, tuple) else _format_exception(type(e), e, e.__traceback__): + for sub_lines in line.splitlines(): + tb.append(sub_lines.rstrip()) + + +DEFAULT_OPTIONS = Options(include_signature=True, max_lines_per_piece=5) + + +def fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: + # in case something goes wrong while formatting the traceback + # we still want to show at least a small error message! + new_tb = [f'Error while formatting traceback: {e}'] + append_short_traceback(new_tb, e) + print(new_tb) + + # add traceback so we have some more information + if existing_traceback: + new_tb.append('') + new_tb.append(SEPARATOR_NEW_FRAME) + new_tb.append('Partial Traceback:') + new_tb.append(SEPARATOR_NEW_FRAME) + new_tb.extend(existing_traceback) + return new_tb + + +def format_exception(e: Union[Exception, Tuple[Any, Any, Any]]) -> List[str]: + tb = [] + + try: + all_frames = tuple(FrameInfo.stack_data(e[2] if isinstance(e, tuple) else e.__traceback__, DEFAULT_OPTIONS)) + last_frame = len(all_frames) - 1 + + added = True + for i, frame_info in enumerate(all_frames): + if isinstance(frame_info, FrameInfo): + added = format_frame_info(tb, frame_info, is_last=i == last_frame) + else: + # repeated frames in case of recursion + if added: + tb.append(f"... {frame_info.description} ...\n") + + # add a short traceback + tb.append(SEPARATOR_NEW_FRAME) + append_short_traceback(tb, e) + + except Exception as e: + return fallback_format(e, tb) + + return tb diff --git a/src/HABApp/core/lib/exceptions/format_frame.py b/src/HABApp/core/lib/exceptions/format_frame.py new file mode 100644 index 00000000..a4dd7936 --- /dev/null +++ b/src/HABApp/core/lib/exceptions/format_frame.py @@ -0,0 +1,61 @@ +import re +from typing import List + +from stack_data import FrameInfo, LINE_GAP + +from .const import SEPARATOR_NEW_FRAME, PRE_INDENT +from .format_frame_vars import format_frame_variables + +SUPPRESSED_HABAPP_PATHS = ( + # This exception formatter + re.compile(r'[/\\]HABApp[/\\]core[/\\]lib[/\\]exceptions[/\\]'), + # Wrapper which usually generates this traceback + re.compile(r'[/\\]HABApp[/\\]core[/\\]wrapper.py'), + + # Rule file loader + re.compile(r'[/\\]HABApp[/\\]rule_manager[/\\]'), + + # Worker functions + re.compile(r'[/\\]HABApp[/\\]core[/\\]internals[/\\]wrapped_function[/\\]'), +) + +SUPPRESSED_PATHS = ( + # Libraries of base installation + re.compile(r'[/\\](?:python\d\.\d+|python\d{2,3})[/\\](?:lib[/\\]|\w+\.py.*$)', re.IGNORECASE), + # Libraries in venv + re.compile(r'[/\\]lib[/\\]site-packages[/\\]', re.IGNORECASE), +) + + +def skip_file(name: str) -> bool: + for r in (SUPPRESSED_HABAPP_PATHS if '/HABApp/' in name or '\\HABApp\\' in name else SUPPRESSED_PATHS): + if r.search(name): + return True + return False + + +def format_frame_info(tb: List[str], frame_info: FrameInfo, is_last=False) -> bool: + filename = frame_info.filename + + if not is_last and skip_file(filename): + return False + + # calc max line nr for indentation + max_line = frame_info.lineno + frame_info.options.after + # it's possible that his list is empty (e.g. if we execode in sphinx-exec-code) + if frame_info.lines: + max_line = frame_info.lines[-1].lineno + + indent = len(str(max_line)) + 1 + + tb.append(f'File "{filename}", line {frame_info.lineno} in {frame_info.code.co_name}') + tb.append(SEPARATOR_NEW_FRAME) + for line in frame_info.lines: + if line is LINE_GAP: + tb.append(f"{' ' * (PRE_INDENT + indent - 1)}(...)") + else: + tb.append(f"{'-->' if line.is_current else '':{PRE_INDENT}s}{line.lineno:{indent}d} | {line.render()}") + + format_frame_variables(tb, frame_info.variables) + tb.append('') + return True diff --git a/src/HABApp/core/lib/exceptions/format_frame_vars.py b/src/HABApp/core/lib/exceptions/format_frame_vars.py new file mode 100644 index 00000000..e54f2da5 --- /dev/null +++ b/src/HABApp/core/lib/exceptions/format_frame_vars.py @@ -0,0 +1,109 @@ +import ast +from inspect import ismodule, isclass +from typing import List, Tuple, Callable, Any, Set, TypeVar + +from immutables import Map +from stack_data import Variable + +from HABApp.core.const.json import load_json, dump_json +from easyconfig.config_objs import ConfigObj +from .const import SEPARATOR_VARIABLES, PRE_INDENT + + +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) + + # type(b) = + if name.startswith('type(') and value in skipped_types: + return True + + # (str, int) = (, ) + if name.startswith('(') and name.endswith(')') and isinstance(value, tuple) and all( + map(lambda x: x in skipped_types, value)): + return True + + return False + + +SKIP_VARIABLE: Tuple[Callable[[str, 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), + + # functions + lambda name, value: value is dump_json or value is load_json, + + # config value objs + lambda name, value: isinstance(value, ConfigObj), + + # Expressions + _filter_expressions +) + +ORDER_VARIABLE: Tuple[Callable[[Variable], bool], ...] = ( + lambda x: isclass(x.value), +) + + +def skip_variable(var: Variable) -> bool: + for func in SKIP_VARIABLE: + name = var.name + value = var.value + if func(name, value): + return True + return False + + +def format_frame_variables(tb: List[str], stack_variables: List[Variable]): + if not stack_variables: + return None + + # remove variables that shall not be printed + used_vars: List[Variable] = [v for v in stack_variables if not skip_variable(v)] + + # attributes + dotted_names: Set[str] = {n.name.split('.')[0] for n in used_vars if '.' in n.name} + + # Sort output + used_vars = sorted(used_vars, key=lambda x: ( + isinstance(x.nodes[0], ast.Compare), # Compare objects last + not any(map( + lambda y: x.name == y or x.name.startswith(y + '.'), dotted_names)), # Classes with attributes + x.name.lower() # Name lowercase + )) + + variables = {} + + # variables by order + for add_var in ORDER_VARIABLE: + for var in used_vars: + name = var.name + if name in variables: + continue + if add_var(var): + variables[name] = var.value + + # rest of the variables + for var in used_vars: + if var.name not in variables: + variables[var.name] = var.value + + if not variables: + return None + + # Add variables to traceback + tb.append(SEPARATOR_VARIABLES) + + for name, value in variables.items(): + tb.append(f'{" " * (PRE_INDENT + 1)}{name} = {repr(value)}') + + tb.append(SEPARATOR_VARIABLES) diff --git a/src/HABApp/core/lib/parameters/__init__.py b/src/HABApp/core/lib/parameters/__init__.py new file mode 100644 index 00000000..51ec9d6f --- /dev/null +++ b/src/HABApp/core/lib/parameters/__init__.py @@ -0,0 +1 @@ +from .positive_time_diff import TH_POSITIVE_TIME_DIFF, get_positive_time_diff diff --git a/src/HABApp/core/lib/parameters/positive_time_diff.py b/src/HABApp/core/lib/parameters/positive_time_diff.py new file mode 100644 index 00000000..6c7737c2 --- /dev/null +++ b/src/HABApp/core/lib/parameters/positive_time_diff.py @@ -0,0 +1,21 @@ +from datetime import timedelta +from typing import Union + +TH_POSITIVE_TIME_DIFF = Union[int, float, timedelta] + + +def get_positive_time_diff(arg: TH_POSITIVE_TIME_DIFF, round_digits=None) -> Union[int, float]: + if isinstance(arg, timedelta): + diff = arg.total_seconds() + if round_digits is not None: + diff = round(diff, round_digits) + elif isinstance(arg, float): + diff = round(arg, round_digits) if round_digits is not None else arg + elif isinstance(arg, int): + diff = arg + else: + raise ValueError(f'Invalid type: {type(arg)}') + + if diff <= 0: + raise ValueError(f'Time difference must be positive! ({arg})') + return diff diff --git a/src/HABApp/core/lib/pending_future.py b/src/HABApp/core/lib/pending_future.py index 59c881c8..e9dc2271 100644 --- a/src/HABApp/core/lib/pending_future.py +++ b/src/HABApp/core/lib/pending_future.py @@ -2,13 +2,17 @@ import typing from asyncio import Task, sleep, run_coroutine_threadsafe, create_task from typing import Any, Awaitable, Callable, Optional + from HABApp.core.const import loop +# todo: switch to time.monotonic for measurements instead of fixed sleep time + class PendingFuture: def __init__(self, future: Callable[[], Awaitable[Any]], secs: typing.Union[int, float]): assert asyncio.iscoroutinefunction(future), type(future) - assert isinstance(secs, (int, float)) and secs >= 0, f'{secs} ({type(secs)})' + if not isinstance(secs, (int, float)) or secs < 0: + raise ValueError(f'Pending time must be int/float and >= 0! Is: {secs} ({type(secs)})') self.func: Callable[[], Awaitable[Any]] = future self.secs = secs @@ -41,8 +45,5 @@ def reset(self, thread_safe=False): self.task = create_task(self.__countdown()) async def __countdown(self): - try: - await sleep(self.secs) - await self.func() - except asyncio.CancelledError: - pass + await sleep(self.secs) + await self.func() diff --git a/src/HABApp/core/lib/single_task.py b/src/HABApp/core/lib/single_task.py new file mode 100644 index 00000000..b7755c38 --- /dev/null +++ b/src/HABApp/core/lib/single_task.py @@ -0,0 +1,31 @@ +from asyncio import create_task, Task +from typing import Callable, Awaitable, Any, Final, Optional + + +class SingleTask: + def __init__(self, coro: Callable[[], Awaitable[Any]], name: Optional[str] = None): + if name is None: + name = f'{self.__class__.__name__}_{coro.__name__}' + + self.coro: Final = coro + self.task: Optional[Task] = None + self.name: Final = name + + def cancel(self): + if self.task is not None: + task = self.task + self.task = None + task.cancel() + + def start(self): + self.cancel() + self.task = create_task(self._task_wrap(), name=self.name) + + async def _task_wrap(self): + # don't use try-finally because this also catches the asyncio.CancelledError + try: + await self.coro() + except Exception: + pass + + self.task = None diff --git a/src/HABApp/core/logger.py b/src/HABApp/core/logger.py index 11337775..b7aaf7b7 100644 --- a/src/HABApp/core/logger.py +++ b/src/HABApp/core/logger.py @@ -1,10 +1,14 @@ import logging import typing -import HABApp -from .const.topics import ERRORS as _T_ERRORS -from .const.topics import INFOS as _T_INFOS -from .const.topics import WARNINGS as _T_WARNINGS +from HABApp.core.internals import uses_post_event +from HABApp.core.lib import format_exception +from HABApp.core.const.topics import TOPIC_ERRORS as _T_ERRORS +from HABApp.core.const.topics import TOPIC_INFOS as _T_INFOS +from HABApp.core.const.topics import TOPIC_WARNINGS as _T_WARNINGS + + +post_event = uses_post_event() def log_error(logger: logging.Logger, text: str): @@ -13,7 +17,7 @@ def log_error(logger: logging.Logger, text: str): logger.error(line) else: logger.error(text) - HABApp.core.EventBus.post_event( + post_event( _T_ERRORS, text ) @@ -25,7 +29,7 @@ def log_warning(logger: logging.Logger, text: str): else: logger.warning(text) - HABApp.core.EventBus.post_event( + post_event( _T_WARNINGS, text ) @@ -37,7 +41,7 @@ def log_info(logger: logging.Logger, text: str): else: logger.info(text) - HABApp.core.EventBus.post_event( + post_event( _T_INFOS, text ) @@ -59,7 +63,7 @@ def add_exception(self, e: Exception, add_traceback: bool = False): for line in str(e).splitlines(): self.lines.append(line) else: - self.lines.extend(HABApp.core.wrapper.format_exception(e)) + self.lines.extend(format_exception(e)) return self def dump(self) -> bool: @@ -70,7 +74,7 @@ def dump(self) -> bool: for line in self.lines: self.logger._log(self._LEVEL, line, []) - HABApp.core.EventBus.post_event( + post_event( self._TOPIC, '\n'.join(self.lines) ) self.lines.clear() diff --git a/src/HABApp/core/wrappedfunction.py b/src/HABApp/core/wrappedfunction.py deleted file mode 100644 index 6e1668cf..00000000 --- a/src/HABApp/core/wrappedfunction.py +++ /dev/null @@ -1,111 +0,0 @@ -import io -import logging -import time -from asyncio import create_task, iscoroutinefunction, run_coroutine_threadsafe -from cProfile import Profile -from concurrent.futures import ThreadPoolExecutor -from pstats import SortKey -from pstats import Stats -import HABApp - -from HABApp.core.context import async_context -from HABApp.core.const import loop - -default_logger = logging.getLogger('HABApp.Worker') - - -class WrappedFunction: - _WORKERS = ThreadPoolExecutor(10, 'HabApp_') - - def __init__(self, func, logger=None, warn_too_long=True, name=None): - assert callable(func) - self._func = func - - # name of the function - self.name = self._func.__name__ if not name else name - - self.is_async = iscoroutinefunction(self._func) - - self.__time_submitted = 0.0 - - # Allow custom logger - self.log = default_logger - if logger: - self.log = logger - - self.__warn_too_long = warn_too_long - - def run(self, *args, **kwargs): - - if self.is_async: - # If we run in the async context we can create tasks easily - if async_context.get(None) is None: - run_coroutine_threadsafe(self.async_run(*args, **kwargs), loop=loop) - else: - create_task(self.async_run(*args, **kwargs)) - else: - self.__time_submitted = time.time() - WrappedFunction._WORKERS.submit(self.__run, *args, **kwargs) - - def __format_traceback(self, e: Exception, *args, **kwargs): - - lines = HABApp.core.wrapper.format_exception(e) - - # Log Exception - self.log.error(f'Error in {self.name}: {e}') - for line in lines: - self.log.error(line) - - # create HABApp event, but only if we are not currently processing one - if not args or not isinstance(args[0], HABApp.core.events.habapp_events.HABAppException): - HABApp.core.EventBus.post_event( - HABApp.core.const.topics.ERRORS, HABApp.core.events.habapp_events.HABAppException( - func_name=self.name, exception=e, traceback='\n'.join(lines) - ) - ) - - async def async_run(self, *args, **kwargs): - - token = async_context.set('WrappedFunction') - - try: - await self._func(*args, **kwargs) - except Exception as e: - self.__format_traceback(e, *args, **kwargs) - - async_context.reset(token) - return None - - def __run(self, *args, **kwargs): - __start = time.time() - - # notify if we don't process quickly - if __start - self.__time_submitted > 0.05: - self.log.warning(f'Starting of {self.name} took too long: {__start - self.__time_submitted:.2f}s. ' - f'Maybe there are not enough threads?') - - # start profiler - pr = Profile() - pr.enable() - - # Execute the function - try: - self._func(*args, **kwargs) - except Exception as e: - self.__format_traceback(e, *args, **kwargs) - - # disable profiler - pr.disable() - - # log warning if execution takes too long - __dur = time.time() - __start - if self.__warn_too_long and __dur > 0.8: - self.log.warning(f'Execution of {self.name} took too long: {__dur:.2f}s') - - s = io.StringIO() - ps = Stats(pr, stream=s).sort_stats(SortKey.CUMULATIVE) - ps.print_stats(0.1) # limit to output to 10% of the lines - - for line in s.getvalue().splitlines()[4:]: # skip the amount of calls and "Ordered by:" - if line: - self.log.warning(line) diff --git a/src/HABApp/core/wrapper.py b/src/HABApp/core/wrapper.py index 8b816a16..db68c11e 100644 --- a/src/HABApp/core/wrapper.py +++ b/src/HABApp/core/wrapper.py @@ -1,65 +1,23 @@ import asyncio import functools import logging -import re import sys import typing from logging import Logger -from pathlib import Path -import stackprinter - -import HABApp +from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS +from HABApp.core.const.topics import TOPIC_WARNINGS as TOPIC_WARNINGS +from HABApp.core.events.habapp_events import HABAppException +from HABApp.core.internals import uses_post_event +from HABApp.core.lib import format_exception log = logging.getLogger('HABApp') - -SUPPRESSED_PATHS = ( - re.compile(f'[/\\\\]{Path(__file__).name}$'), # this file - - # rule file loader - re.compile(r'[/\\]rule_file.py$'), - re.compile(r'[/\\]runpy.py$'), - - # Worker functions - re.compile(r'[/\\]wrappedfunction.py$'), - - # Don't print stack for used libraries - re.compile(r'[/\\](site-packages|lib|python\d\.\d)[/\\]asyncio[/\\]'), - re.compile(r'[/\\]site-packages[/\\]aiohttp[/\\]'), - re.compile(r'[/\\]site-packages[/\\]voluptuous[/\\]'), - re.compile(r'[/\\]site-packages[/\\]pydantic[/\\]'), -) - -SKIP_TB = tuple(re.compile(k.pattern.replace('$', ', ')) for k in SUPPRESSED_PATHS) - - -def format_exception(e: typing.Union[Exception, typing.Tuple[typing.Any, typing.Any, typing.Any]]) -> typing.List[str]: - tb = [] - skip = 0 - - lines = stackprinter.format(e, line_wrap=0, truncate_vals=2000, suppressed_paths=SUPPRESSED_PATHS).splitlines() - for i, line in enumerate(lines): - if not skip: - for s in SKIP_TB: - if s.search(line): - # if it's just a two line traceback we skip it - if lines[i + 1].startswith(' ') and lines[i + 2].startswith('File'): - skip = 2 - continue - if skip: - skip -= 1 - continue - - tb.append(line) - - return tb +post_event = uses_post_event() def process_exception(func: typing.Union[typing.Callable, str], e: Exception, do_print=False, logger: logging.Logger = log): - # lines = traceback.format_exc().splitlines() - # del lines[0:3] lines = format_exception(e) func_name = func if isinstance(func, str) else func.__name__ @@ -73,12 +31,8 @@ def process_exception(func: typing.Union[typing.Callable, str], e: Exception, print(line) logger.error(line) - # send Error to internal event bus so we can reprocess it and notify the user - HABApp.core.EventBus.post_event( - HABApp.core.const.topics.ERRORS, HABApp.core.events.habapp_events.HABAppException( - func_name=func_name, exception=e, traceback='\n'.join(lines) - ) - ) + # send Error to internal event bus, so we can reprocess it and notify the user + post_event(TOPIC_ERRORS, HABAppException(func_name=func_name, exception=e, traceback='\n'.join(lines))) def log_exception(func): @@ -88,8 +42,6 @@ def log_exception(func): async def a(*args, **kwargs): try: return await func(*args, **kwargs) - except asyncio.CancelledError: - pass except Exception as e: process_exception(func, e, do_print=True) # re raise exception, since this is something we didn't anticipate @@ -116,8 +68,6 @@ def ignore_exception(func): async def a(*args, **kwargs): try: return await func(*args, **kwargs) - except asyncio.CancelledError: - pass except Exception as e: process_exception(func, e) return None @@ -155,11 +105,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.raised_exception = True - # tb = traceback.format_exception(exc_type, exc_val, exc_tb) - # # there is an inconsistent use of newlines and array entries so we normalize it - # tb = '\n'.join(map(lambda x: x.strip(' \n'), tb)) - # tb = tb.splitlines() - tb = format_exception((exc_type, exc_val, exc_tb)) # possibility to reprocess tb @@ -179,10 +124,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.log.log(self.log_level, line) # send Error to internal event bus so we can reprocess it and notify the user - HABApp.core.EventBus.post_event( - HABApp.core.const.topics.WARNINGS if self.log_level == logging.WARNING else HABApp.core.const.topics.ERRORS, - HABApp.core.events.habapp_events.HABAppException( - func_name=f_name, exception=exc_val, traceback='\n'.join(tb) - ) + post_event( + TOPIC_WARNINGS if self.log_level == logging.WARNING else TOPIC_ERRORS, + HABAppException(func_name=f_name, exception=exc_val, traceback='\n'.join(tb)) ) return self.ignore_exception diff --git a/src/HABApp/mqtt/events/mqtt_events.py b/src/HABApp/mqtt/events/mqtt_events.py index 1ea7dbd7..c4446d50 100644 --- a/src/HABApp/mqtt/events/mqtt_events.py +++ b/src/HABApp/mqtt/events/mqtt_events.py @@ -2,8 +2,10 @@ class MqttValueUpdateEvent(HABApp.core.events.ValueUpdateEvent): - pass + # Copy the annotations, otherwise the code won't work from py3.10 on + __annotations__ = HABApp.core.events.ValueUpdateEvent.__annotations__.copy() class MqttValueChangeEvent(HABApp.core.events.ValueChangeEvent): - pass + # Copy the annotations, otherwise the code won't work from py3.10 on + __annotations__ = HABApp.core.events.ValueChangeEvent.__annotations__.copy() diff --git a/src/HABApp/mqtt/events/mqtt_filters.py b/src/HABApp/mqtt/events/mqtt_filters.py index 2c1457f3..089f2fd4 100644 --- a/src/HABApp/mqtt/events/mqtt_filters.py +++ b/src/HABApp/mqtt/events/mqtt_filters.py @@ -1,10 +1,15 @@ -from HABApp.core.events import ValueChangeEventFilter, ValueUpdateEventFilter -from . import MqttValueChangeEvent, MqttValueUpdateEvent +from typing import Any +from HABApp.core.const import MISSING +from HABApp.core.events.filter.event import TypeBoundEventFilter +from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueUpdateEvent -class MqttValueUpdateEventFilter(ValueUpdateEventFilter): - _EVENT_TYPE = MqttValueUpdateEvent +class MqttValueUpdateEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING): + super().__init__(MqttValueUpdateEvent, value=value) -class MqttValueChangeEventFilter(ValueChangeEventFilter): - _EVENT_TYPE = MqttValueChangeEvent + +class MqttValueChangeEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + super().__init__(MqttValueChangeEvent, value=value, old_value=old_value) diff --git a/src/HABApp/mqtt/items/mqtt_item.py b/src/HABApp/mqtt/items/mqtt_item.py index 84685e7a..ba6f5977 100644 --- a/src/HABApp/mqtt/items/mqtt_item.py +++ b/src/HABApp/mqtt/items/mqtt_item.py @@ -1,7 +1,11 @@ -from HABApp.core import Items +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 +get_item = uses_get_item() +item_registry = uses_item_registry() + class MqttBaseItem(BaseValueItem): pass @@ -21,10 +25,10 @@ def get_create_item(cls, name: str, initial_value=None) -> 'MqttItem': assert isinstance(name, str), type(name) try: - item = Items.get_item(name) - except Items.ItemNotFoundException: + item = get_item(name) + except ItemNotFoundException: item = cls(name, initial_value) - Items.add_item(item) + item_registry.add_item(item) assert isinstance(item, cls), f'{cls} != {type(item)}' return item diff --git a/src/HABApp/mqtt/items/mqtt_pair_item.py b/src/HABApp/mqtt/items/mqtt_pair_item.py index ad400a72..c3c87540 100644 --- a/src/HABApp/mqtt/items/mqtt_pair_item.py +++ b/src/HABApp/mqtt/items/mqtt_pair_item.py @@ -1,9 +1,12 @@ from typing import Optional -from HABApp.core import Items +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.internals import uses_item_registry from HABApp.mqtt.mqtt_interface import publish from . import MqttBaseItem +Items = uses_item_registry() + def build_write_topic(read_topic: str) -> Optional[str]: parts = read_topic.split('/') @@ -37,9 +40,8 @@ def get_create_item(cls, name: str, write_topic: Optional[str] = None, initial_v try: item = Items.get_item(name) - except Items.ItemNotFoundException: - item = cls(name, write_topic=write_topic, initial_value=initial_value) - Items.add_item(item) + except ItemNotFoundException: + item = Items.add_item(cls(name, write_topic=write_topic, initial_value=initial_value)) assert isinstance(item, cls), f'{cls} != {type(item)}' return item diff --git a/src/HABApp/mqtt/mqtt_connection.py b/src/HABApp/mqtt/mqtt_connection.py index c8cb407b..7b56df15 100644 --- a/src/HABApp/mqtt/mqtt_connection.py +++ b/src/HABApp/mqtt/mqtt_connection.py @@ -1,18 +1,26 @@ +import asyncio import logging import typing -from pathlib import Path import paho.mqtt.client as mqtt import HABApp -from HABApp.core import Items +from HABApp.core.asyncio import async_context +from HABApp.core.const.topics 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('HABApp.EventBus.mqtt') +log_msg = logging.getLogger(f'{TOPIC_EVENTS}.mqtt') + + +post_event = uses_post_event() +get_item = uses_get_item() +Items = uses_item_registry() class MqttStatus: @@ -55,11 +63,11 @@ def connect(): clean_session=False ) - if config.connection.tls: + if config.connection.tls.enabled: # add option to specify tls certificate - ca_cert = config.connection.tls_ca_cert - if ca_cert != "": - if not Path(ca_cert).is_file(): + 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() @@ -71,7 +79,7 @@ def connect(): mqtt_client.tls_set() # we can only set tls_insecure if we have a tls connection - if config.connection.tls_insecure: + 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) @@ -155,24 +163,30 @@ def process_msg(client, userdata, message: mqtt.MQTTMessage): 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 = Items.get_item(topic) # type: HABApp.mqtt.items.MqttBaseItem - except HABApp.core.Items.ItemNotFoundException: + _item = get_item(topic) # type: HABApp.mqtt.items.MqttBaseItem + except ItemNotFoundException: # only create items for if the message has the retain flag - if message.retain: - _item = Items.create_item(topic, HABApp.mqtt.items.MqttItem) # type: HABApp.mqtt.items.MqttItem + 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: - HABApp.core.EventBus.post_event(topic, MqttValueUpdateEvent(topic, payload)) + 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 events - HABApp.core.EventBus.post_event(topic, MqttValueUpdateEvent(topic, payload)) - if _old_state != payload: - HABApp.core.EventBus.post_event(topic, MqttValueChangeEvent(topic, payload, _old_state)) + 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_payload.py b/src/HABApp/mqtt/mqtt_payload.py index ac5f6a35..198c94fd 100644 --- a/src/HABApp/mqtt/mqtt_payload.py +++ b/src/HABApp/mqtt/mqtt_payload.py @@ -4,9 +4,10 @@ from paho.mqtt.client import MQTTMessage from HABApp.core.const.json import load_json +from HABApp.core.const.topics import TOPIC_EVENTS from HABApp.core.wrapper import process_exception -log = logging.getLogger('HABApp.EventBus.mqtt') +log = logging.getLogger(f'{TOPIC_EVENTS}.mqtt') def get_msg_payload(msg: MQTTMessage) -> Tuple[Optional[str], Any]: diff --git a/src/HABApp/openhab/connection_handler/func_async.py b/src/HABApp/openhab/connection_handler/func_async.py index de102834..83cb2c91 100644 --- a/src/HABApp/openhab/connection_handler/func_async.py +++ b/src/HABApp/openhab/connection_handler/func_async.py @@ -1,21 +1,26 @@ import datetime -import traceback 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 OpenhabDisconnectedError, OpenhabNotReadyYet, ThingNotEditableError, \ - ThingNotFoundError, ItemNotEditableError, ItemNotFoundError, MetadataNotEditableError, ExpectedSuccessFromOpenhab -from .http_connection import delete, get, post, put, log, async_get_root, async_get_uuid +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 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: @@ -34,25 +39,13 @@ def convert_to_oh_type(_in: Any) -> str: return str(_in) -async def async_post_update(item, state: Any): - if not isinstance(state, str): - state = convert_to_oh_type(state) - await put(f'items/{item:s}/state', data=state) - - -async def async_send_command(item, state: Any): - if not isinstance(state, str): - state = convert_to_oh_type(state) - await post(f'items/{item:s}', data=state) - - async def async_item_exists(item) -> bool: - ret = await get(f'items/{item:s}', log_404=False) + 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, - disconnect_on_error=False) -> Optional[List[Dict[str, Any]]]: +async def async_get_items(include_habapp_meta=False, metadata: Optional[str] = None, + all_metadata=False) -> Optional[List[Dict[str, Any]]]: params = None if include_habapp_meta: params = {'metadata': 'HABApp'} @@ -63,15 +56,8 @@ async def async_get_items(include_habapp_meta=False, metadata: Optional[str] = N if all_metadata: params = {'metadata': '.+'} - try: - resp = await get('items', disconnect_on_error=disconnect_on_error, params=params) - return await resp.json(loads=load_json, encoding='utf-8') - except Exception as e: - # sometimes uuid already works but items not - so we ignore these errors here, too - if not isinstance(e, (OpenhabDisconnectedError, OpenhabNotReadyYet, ExpectedSuccessFromOpenhab)): - for line in traceback.format_exc().splitlines(): - log.error(line) - return None + 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: @@ -79,35 +65,25 @@ async def async_get_item(item: str, metadata: Optional[str] = None, all_metadata if all_metadata: params = {'metadata': '.+'} - ret = await get(f'items/{item:s}', params=params, log_404=False) + 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') - try: - data['groups'] = data.pop('groupNames') - except KeyError: - pass return data -async def async_get_things(disconnect_on_error=False) -> Optional[List[Dict[str, Any]]]: +async def async_get_things() -> List[OpenhabThingDefinition]: + resp = await get('/rest/things') + data = await resp.json(loads=load_json, encoding='utf-8') - try: - resp = await get('things', disconnect_on_error=disconnect_on_error) - return await resp.json(loads=load_json, encoding='utf-8') - except Exception as e: - # sometimes uuid and items already works but things not - so we ignore these errors here, too - if not isinstance(e, (OpenhabDisconnectedError, OpenhabNotReadyYet, ExpectedSuccessFromOpenhab)): - for line in traceback.format_exc().splitlines(): - log.error(line) - return None + return parse_obj_as(List[OpenhabThingDefinition], data) async def async_get_thing(uid: str) -> OpenhabThingDefinition: - ret = await get(f'things/{uid:s}') + ret = await get(f'/rest/things/{uid:s}') if ret.status >= 300: raise ThingNotFoundError.from_uid(uid) @@ -128,7 +104,7 @@ async def async_get_persistence_data(item_name: str, persistence: typing.Optiona if not params: params = None - ret = await get(f'persistence/items/{item_name:s}', params=params) + ret = await get(f'/rest/persistence/items/{item_name:s}', params=params) if ret.status >= 300: return {} else: @@ -150,7 +126,7 @@ async def async_set_persistence_data(item_name: str, persistence: typing.Optiona if persistence is not None: params['serviceId'] = persistence - ret = await put(f'persistence/items/{item_name:s}', params=params) + ret = await put(f'/rest/persistence/items/{item_name:s}', params=params) if ret.status >= 300: return {} else: @@ -179,7 +155,7 @@ async def async_create_item(item_type, name, label="", category="", tags=[], gro if group_function_params: payload['function']['params'] = group_function_params - ret = await put(f'items/{name:s}', json=payload) + ret = await put(f'/rest/items/{name:s}', json=payload) if ret is None: return False @@ -191,11 +167,11 @@ async def async_create_item(item_type, name, label="", category="", tags=[], gro async def async_remove_item(item): - await delete(f'items/{item:s}') + await delete(f'/rest/items/{item:s}') async def async_remove_metadata(item: str, namespace: str): - ret = await delete(f'items/{item:s}/metadata/{namespace:s}') + ret = await delete(f'/rest/items/{item:s}/metadata/{namespace:s}') if ret is None: return False @@ -211,7 +187,7 @@ async def async_set_metadata(item: str, namespace: str, value: str, config: dict 'value': value, 'config': config } - ret = await put(f'items/{item:s}/metadata/{namespace:s}', json=payload) + ret = await put(f'/rest/items/{item:s}/metadata/{namespace:s}', json=payload) if ret is None: return False @@ -223,7 +199,7 @@ async def async_set_metadata(item: str, namespace: str, value: str, config: dict async def async_set_thing_cfg(uid: str, cfg: typing.Dict[str, typing.Any]): - ret = await put(f'things/{uid:s}/config', json=cfg) + ret = await put(f'/rest/things/{uid:s}/config', json=cfg) if ret is None: return None @@ -245,7 +221,7 @@ 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 'links/' + quote_url(f"{item_name}/{channel_uid}") + return '/rest/links/' + quote_url(f"{item_name}/{channel_uid}") async def async_remove_channel_link(channel_uid: str, item_name: str) -> bool: @@ -256,7 +232,7 @@ async def async_remove_channel_link(channel_uid: str, item_name: str) -> bool: async def async_get_channel_links() -> List[Dict[str, str]]: - ret = await get('links') + ret = await get('/rest/links') if ret.status >= 300: return None else: @@ -264,7 +240,7 @@ async def async_get_channel_links() -> List[Dict[str, str]]: async def async_get_channel_link_mode_auto() -> bool: - ret = await get('links/auto') + ret = await get('/rest/links/auto') if ret.status >= 300: return False else: diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py index 14efcaa2..00476089 100644 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ b/src/HABApp/openhab/connection_handler/func_sync.py @@ -1,14 +1,11 @@ -import asyncio import datetime -from asyncio import create_task, run_coroutine_threadsafe from typing import Any, Optional, List, Dict import HABApp import HABApp.core import HABApp.openhab.events -from HABApp.core.const import loop -from HABApp.core.context import async_context, AsyncContextError -from HABApp.core.items.base_valueitem import BaseValueItem, BaseItem +from HABApp.core.asyncio import run_coro_from_thread, create_task +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_metadata, async_remove_metadata, async_get_channel_link, async_create_channel_link, \ @@ -30,10 +27,7 @@ def post_update(item_name: str, state: Any): if isinstance(item_name, BaseValueItem): item_name = item_name.name - if async_context.get(None) is None: - run_coroutine_threadsafe(async_post_update(item_name, state), loop) - else: - create_task(async_post_update(item_name, state)) + create_task(async_post_update(item_name, state)) def send_command(item_name: str, command): @@ -48,16 +42,13 @@ def send_command(item_name: str, command): if isinstance(item_name, BaseValueItem): item_name = item_name.name - if async_context.get(None) is None: - asyncio.run_coroutine_threadsafe(async_send_command(item_name, command), loop) - else: - create_task(async_send_command(item_name, command)) + create_task(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 + """Creates a new item in the openHAB item registry or updates an existing one :param item_type: item type :param name: item name @@ -78,12 +69,12 @@ def validate(_in): 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)}' + 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)}' + 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)}' + 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) @@ -100,59 +91,45 @@ def validate(_in): assert group_function in definitions.GROUP_FUNCTIONS, \ f'{item_type} is not a group function: {", ".join(definitions.GROUP_FUNCTIONS)}' - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(create_item) - - fut = asyncio.run_coroutine_threadsafe( + 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 ), - loop + calling=create_item ) - return fut.result() def get_item(item_name: str, metadata: Optional[str] = None, all_metadata=False) -> OpenhabItemDefinition: - """Return the complete OpenHAB item definition + """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: + :return: openHAB item """ - if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): + 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) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(get_item) - - fut = asyncio.run_coroutine_threadsafe( - async_get_item(item_name, metadata=metadata, all_metadata=all_metadata), loop) - data = fut.result() + 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 + """ Return the complete openHAB thing definition :param thing_name: name of the thing or the item + :return: openHAB thing """ - if isinstance(thing_name, BaseItem): + if isinstance(thing_name, HABApp.core.items.BaseItem): thing_name = thing_name.name assert isinstance(thing_name, str), type(thing_name) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(get_thing) - - fut = asyncio.run_coroutine_threadsafe(async_get_thing(thing_name), loop) - return fut.result() + return run_coro_from_thread(async_get_thing(thing_name), calling=get_thing) def remove_item(item_name: str): @@ -160,31 +137,22 @@ 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) - - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(remove_item) - - fut = asyncio.run_coroutine_threadsafe(async_remove_item(item_name), loop) - return fut.result() + 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 + 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) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(item_exists) - - fut = asyncio.run_coroutine_threadsafe(async_item_exists(item_name), loop) - return fut.result() + 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): @@ -195,23 +163,18 @@ def set_metadata(item_name: str, namespace: str, value: str, config: dict): :param namespace: namespace :param value: value :param config: configuration - :return: + :return: True if metadata was successfully created/updated """ - if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): + 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) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(set_metadata) - - fut = asyncio.run_coroutine_threadsafe( - async_set_metadata(item=item_name, namespace=namespace, value=value, config=config), loop + return run_coro_from_thread( + async_set_metadata(item=item_name, namespace=namespace, value=value, config=config), calling=set_metadata ) - return fut.result() def remove_metadata(item_name: str, namespace: str): @@ -220,50 +183,38 @@ def remove_metadata(item_name: str, namespace: str): :param item_name: name of the item or item :param namespace: namespace - :return: + :return: True if metadata was successfully removed """ - if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): + if isinstance(item_name, BaseValueItem): item_name = item_name.name assert isinstance(item_name, str), type(item_name) assert isinstance(namespace, str), type(namespace) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(remove_metadata) - - fut = asyncio.run_coroutine_threadsafe( - async_remove_metadata(item=item_name, namespace=namespace), loop - ) - return fut.result() + return run_coro_from_thread(async_remove_metadata(item=item_name, namespace=namespace), calling=remove_metadata) 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 + """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 - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(get_persistence_data) - - fut = asyncio.run_coroutine_threadsafe( + ret = run_coro_from_thread( async_get_persistence_data( item_name=item_name, persistence=persistence, start_time=start_time, end_time=end_time ), - loop + calling=get_persistence_data ) - - ret = fut.result() return OpenhabPersistenceData.from_dict(ret) @@ -274,22 +225,17 @@ def set_persistence_data(item_name: str, persistence: Optional[str], time: datet :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 - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(get_persistence_data) - - fut = asyncio.run_coroutine_threadsafe( - async_set_persistence_data(item_name=item_name, persistence=persistence, time=time, state=state), loop + return run_coro_from_thread( + async_set_persistence_data(item_name=item_name, persistence=persistence, time=time, state=state), + calling=set_persistence_data ) - return fut.result() - - # --------------------------------------------------------------------------------------------------------------------- # Link handling is experimental # --------------------------------------------------------------------------------------------------------------------- @@ -306,12 +252,7 @@ def get_channel_link(channel_uid: str, item_name: str) -> ItemChannelLinkDefinit assert isinstance(channel_uid, str), type(channel_uid) assert isinstance(item_name, str), type(item_name) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(get_channel_link) - - fut = asyncio.run_coroutine_threadsafe(async_get_channel_link(channel_uid, item_name), loop) - return fut.result() + 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: @@ -320,21 +261,16 @@ def create_channel_link(channel_uid: str, item_name: str, configuration: Optiona :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 + :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) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(create_channel_link) - - fut = asyncio.run_coroutine_threadsafe( + return run_coro_from_thread( async_create_channel_link(item_name=item_name, channel_uid=channel_uid, configuration=configuration), - loop + calling=create_channel_link ) - return fut.result() def remove_channel_link(channel_uid: str, item_name: str) -> bool: @@ -342,15 +278,12 @@ def remove_channel_link(channel_uid: str, item_name: str) -> bool: :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 + :return: True on successful removal, otherwise False """ + assert isinstance(channel_uid, str), type(channel_uid) + assert isinstance(item_name, str), type(item_name) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(remove_channel_link) - - fut = asyncio.run_coroutine_threadsafe(async_remove_channel_link(channel_uid, item_name), loop) - return fut.result() + 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: @@ -358,14 +291,9 @@ def channel_link_exists(channel_uid: str, item_name: str) -> bool: :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 + :return: True when the link exists, otherwise False """ assert isinstance(channel_uid, str), type(channel_uid) assert isinstance(item_name, str), type(item_name) - # This function is blocking so it can't be called in the async context - if async_context.get(None) is not None: - raise AsyncContextError(channel_link_exists) - - fut = asyncio.run_coroutine_threadsafe(async_channel_link_exists(channel_uid, item_name), loop) - return fut.result() + 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 index 62b187fa..835b13d3 100644 --- a/src/HABApp/openhab/connection_handler/http_connection.py +++ b/src/HABApp/openhab/connection_handler/http_connection.py @@ -2,7 +2,8 @@ import logging import traceback import typing -from typing import Any, Optional +from typing import Any, Optional, Final +from asyncio import Queue, sleep, QueueEmpty import aiohttp from aiohttp.client import ClientResponse, _RequestContextManager @@ -12,66 +13,51 @@ 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.openhab.errors import OpenhabConnectionNotSetUpError, OpenhabNotReadyYet, \ - OpenhabDisconnectedError, ExpectedSuccessFromOpenhab +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.topics import TOPIC_EVENTS +from ...core.lib import SingleTask log = logging.getLogger('HABApp.openhab.connection') -log_events = logging.getLogger('HABApp.EventBus.openhab') +log_events = logging.getLogger(f'{TOPIC_EVENTS}.openhab') -IS_ONLINE = False -IS_READ_ONLY = False +IS_ONLINE: bool = False +IS_READ_ONLY: bool = False -IS_OH2 = False - -HTTP_PREFIX: Optional[str] = None # HTTP options HTTP_ALLOW_REDIRECTS: bool = True +HTTP_VERIFY_SSL: Optional[bool] = None HTTP_SESSION: aiohttp.ClientSession = None CONNECT_WAIT: WaitBetweenConnects = WaitBetweenConnects() - -FUT_UUID: Optional[asyncio.Future] = None -FUT_SSE: Optional[asyncio.Future] = None - - ON_CONNECTED: typing.Callable = None ON_DISCONNECTED: typing.Callable = None -async def get(url: str, log_404=True, disconnect_on_error=False, **kwargs: Any) -> ClientResponse: - if HTTP_PREFIX is None: - raise OpenhabConnectionNotSetUpError() +async def get(url: str, log_404=True, **kwargs: Any) -> ClientResponse: - assert not url.startswith('/'), url - url = f'{HTTP_PREFIX}/rest/{url}/' - - mgr = _RequestContextManager(HTTP_SESSION._request(METH_GET, url, allow_redirects=HTTP_ALLOW_REDIRECTS, **kwargs)) - return await check_response(mgr, log_404=log_404, disconnect_on_error=disconnect_on_error) + 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 HTTP_PREFIX is None: - raise OpenhabConnectionNotSetUpError() if IS_READ_ONLY or not IS_ONLINE: return None - assert not url.startswith('/'), url - url = f'{HTTP_PREFIX}/rest/{url}/' - - # todo: remove this workaround once there is a fix in aiohttp - headers = None - if data is not None: - headers = {'Content-Type': 'text/plain; charset=utf-8'} - mgr = _RequestContextManager( HTTP_SESSION._request( - METH_POST, url, allow_redirects=HTTP_ALLOW_REDIRECTS, headers=headers, data=data, json=json, **kwargs + METH_POST, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, + data=data, json=json, **kwargs ) ) @@ -81,23 +67,14 @@ async def post(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> O async def put(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]: - if HTTP_PREFIX is None: - raise OpenhabConnectionNotSetUpError() if IS_READ_ONLY or not IS_ONLINE: return None - assert not url.startswith('/'), url - url = f'{HTTP_PREFIX}/rest/{url}/' - - # todo: remove this workaround once there is a fix in aiohttp - headers = None - if data is not None: - headers = {'Content-Type': 'text/plain; charset=utf-8'} - mgr = _RequestContextManager( HTTP_SESSION._request( - METH_PUT, url, allow_redirects=HTTP_ALLOW_REDIRECTS, headers=headers, data=data, json=json, **kwargs + METH_PUT, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, + data=data, json=json, **kwargs ) ) @@ -107,17 +84,13 @@ async def put(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Op async def delete(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]: - if HTTP_PREFIX is None: - raise OpenhabConnectionNotSetUpError() if IS_READ_ONLY or not IS_ONLINE: return None - assert not url.startswith('/'), url - url = f'{HTTP_PREFIX}/rest/{url}/' - mgr = _RequestContextManager( - HTTP_SESSION._request(METH_DELETE, url, allow_redirects=HTTP_ALLOW_REDIRECTS, data=data, json=json, **kwargs) + HTTP_SESSION._request(METH_DELETE, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, + data=data, json=json, **kwargs) ) if data is None: @@ -126,7 +99,7 @@ async def delete(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> def set_offline(log_msg=''): - global IS_ONLINE, FUT_UUID, FUT_SSE + global IS_ONLINE if not IS_ONLINE: return None @@ -135,17 +108,12 @@ def set_offline(log_msg=''): log.warning(f'Disconnected! {log_msg}') # cancel SSE listener - if FUT_SSE is not None: - if not FUT_SSE.done(): - FUT_SSE.cancel() - FUT_SSE = None + TASK_SSE_LISTENER.cancel() + TASK_TRY_CONNECT.cancel() ON_DISCONNECTED() - # Try reconnect - if not FUT_UUID.done(): - FUT_UUID.cancel() - FUT_UUID = asyncio.run_coroutine_threadsafe(try_uuid(), HABApp.core.const.loop) + TASK_TRY_CONNECT.start() def is_disconnect_exception(e) -> bool: @@ -174,15 +142,10 @@ async def check_response(future: aiohttp.client._RequestContextManager, sent_dat status = resp.status - # Server Errors if openhab is not ready yet - if status >= 500: - set_offline(f'Status {status} for {resp.request_info.method} {resp.request_info.url}') - raise OpenhabNotReadyYet() - # 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(HTTP_PREFIX, "")}') + f'{str(resp.request_info.url).replace(HABApp.CONFIG.openhab.connection.url, "")}') raise ExpectedSuccessFromOpenhab() # Something went wrong - log error message @@ -204,15 +167,14 @@ async def check_response(future: aiohttp.client._RequestContextManager, sent_dat return resp -async def stop_connection(): - global FUT_UUID, FUT_SSE, HTTP_SESSION - if FUT_UUID is not None and not FUT_UUID.done(): - FUT_UUID.cancel() - FUT_UUID = None +async def shutdown_connection(): + global HTTP_SESSION + + TASK_TRY_CONNECT.cancel() + TASK_SSE_LISTENER.cancel() - if FUT_SSE is not None and not FUT_SSE.done(): - FUT_SSE.cancel() - FUT_SSE = None + TASK_QUEUE_WORKER.cancel() + TASK_QUEUE_WATCHER.cancel() await asyncio.sleep(0) @@ -222,60 +184,56 @@ async def stop_connection(): HTTP_SESSION = None -async def start_connection(): - global HTTP_PREFIX, HTTP_SESSION, FUT_UUID +async def setup_connection(): + global HTTP_SESSION, HTTP_VERIFY_SSL - await stop_connection() + await shutdown_connection() - host: str = HABApp.CONFIG.openhab.connection.host - port: str = HABApp.CONFIG.openhab.connection.port + config = HABApp.CONFIG.openhab + url: str = config.connection.url - # do not run without host - if host == '': - HTTP_PREFIX = None + # do not run without an url + if not url: + log_info(log, 'openHAB connection disabled!') return None - HTTP_PREFIX = f'http://{host:s}:{port:d}' - - auth = None - if HABApp.CONFIG.openhab.connection.user or HABApp.CONFIG.openhab.connection.password: - auth = aiohttp.BasicAuth( - HABApp.CONFIG.openhab.connection.user, - HABApp.CONFIG.openhab.connection.password - ) + if not config.connection.verify_ssl: + HTTP_VERIFY_SSL = False + log.info('Verify ssl set to False!') + else: + HTTP_VERIFY_SSL = None - # todo: add possibility to configure line size with read_bufsize HTTP_SESSION = aiohttp.ClientSession( + base_url=url, timeout=aiohttp.ClientTimeout(total=None), json_serialize=dump_json, - auth=auth, - read_bufsize=2**19 # 512k buffer + auth=aiohttp.BasicAuth(config.connection.user, config.connection.password), + read_bufsize=config.connection.buffer, ) - FUT_UUID = asyncio.create_task(try_uuid()) + 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 - event_prefix = 'openhab' if not IS_OH2 else 'smarthome' - - async with sse_client.EventSource( - url=f'{HTTP_PREFIX}/rest/events?topics=' - f'{event_prefix}/items/,' # Item updates - f'{event_prefix}/channels/,' # Channel update - f'{event_prefix}/things/*/status,' # Thing status updates - f'{event_prefix}/things/*/statuschanged' # Thing status changes - , - session=HTTP_SESSION - ) as event_source: + 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 + # Alive event from openhab to detect dropped connections + # -> Can be ignored on the HABApp side + if e_str == '{"type":"ALIVE"}': + continue + try: e_json = _load_json(e_str) except ValueError: @@ -291,11 +249,6 @@ async def start_sse_event_listener(): # process _see_handler(e_json) - - except asyncio.CancelledError: - # This exception gets raised if we cancel the coroutine - # since this is normal behaviour we ignore this exception - pass except Exception as e: disconnect = is_disconnect_exception(e) lvl = logging.WARNING if disconnect else logging.ERROR @@ -308,74 +261,178 @@ async def start_sse_event_listener(): 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') + + +async def async_post_update(item, state: Any): + QUEUE.put_nowait((item, state, False)) + + +async def async_send_command(item, state: Any): + QUEUE.put_nowait((item, state, True)) + + async def async_get_uuid() -> str: - resp = await get('uuid', log_404=False) - if resp.status >= 300: - raise OpenhabNotReadyYet() + resp = await get('/rest/uuid', log_404=False) return await resp.text(encoding='utf-8') async def async_get_root() -> dict: - resp = await get('', log_404=False) + resp = await get('/rest/', log_404=False) if resp.status == 404: return {} return await resp.json(loads=load_json, encoding='utf-8') -def patch_for_oh2(reverse=False): - global IS_OH2 +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') - IS_OH2 = True - # events are named different - HABApp.openhab.events.item_events.NAME_START = 16 if not reverse else 14 - HABApp.openhab.events.thing_events.NAME_START = 17 if not reverse else 15 - HABApp.openhab.events.channel_events.NAME_START = 19 if not reverse else 17 +async def async_get_start_level(default_level: int = -10) -> int: + system_info = await async_get_system_info() + return system_info.get('systemInfo', {}).get('startLevel', default_level) -async def try_uuid(): - global FUT_UUID, FUT_SSE, IS_ONLINE +async def wait_for_min_start_level(): - # sleep before reconnect - await CONNECT_WAIT.wait() + waited_for_oh = False + last_level = -100 - log.debug('Trying to connect to OpenHAB ...') - try: - uuid = await async_get_uuid() - root = await async_get_root() # this will only work on OH3 - except Exception as e: - if isinstance(e, (OpenhabDisconnectedError, OpenhabNotReadyYet, ExpectedSuccessFromOpenhab)): - log.info('... offline!') - else: - for line in traceback.format_exc().splitlines(): - log.error(line) - - # Keep trying to connect - FUT_UUID = asyncio.create_task(try_uuid()) - return None + while True: + start_level_is = await async_get_start_level() + start_level_min = HABApp.CONFIG.openhab.general.min_start_level + if start_level_is >= start_level_min: + break - if IS_READ_ONLY: - log.info(f'Connected read only to OpenHAB instance {uuid}') - else: - log.info(f'Connected to OpenHAB instance {uuid}') + # show msg only once + if not waited_for_oh: + log.info('Waiting for openHAB startup to be complete') - info = root.get('runtimeInfo') - if info is None: - patch_for_oh2() - else: - log.info(f'OpenHAB version {info["version"]} ({info["buildString"]})') + # show start level change + if last_level != start_level_is: + log.debug(f'Startlevel: {start_level_is}') + last_level = start_level_is + + # wait for openhab + waited_for_oh = True + await asyncio.sleep(1) + + # Startup complete + if waited_for_oh: + log.info('openHAB startup complete') + + +async def try_connect(): + global IS_ONLINE + + while True: + try: + # sleep before reconnect + await CONNECT_WAIT.wait() + + log.debug('Trying to connect to OpenHAB ...') + root = await async_get_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!') + + # 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 - if FUT_SSE is not None: - FUT_SSE.cancel() - FUT_SSE = asyncio.create_task(start_sse_event_listener()) + 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 @@ -388,3 +445,4 @@ def __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/sse_handler.py b/src/HABApp/openhab/connection_handler/sse_handler.py index 6d0d90a8..de6c3364 100644 --- a/src/HABApp/openhab/connection_handler/sse_handler.py +++ b/src/HABApp/openhab/connection_handler/sse_handler.py @@ -4,21 +4,27 @@ import HABApp import HABApp.core import HABApp.openhab.events -from HABApp.core import EventBus, Items -from HABApp.core.Items import ItemNotFoundException -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.errors import ItemNotFoundException +from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent +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.events import GroupItemStateChangedEvent, ItemAddedEvent, ItemRemovedEvent, ItemUpdatedEvent, \ - ThingStatusInfoEvent -from HABApp.openhab.item_to_reg import add_to_registry, remove_from_registry + ThingStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent +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 +from HABApp.openhab.definitions.topics import TOPIC_THINGS, TOPIC_ITEMS log = http_connection.log +post_event = uses_post_event() +get_item = uses_get_item() + + def on_sse_event(event_dict: dict): try: # Lookup corresponding OpenHAB event @@ -28,42 +34,59 @@ def on_sse_event(event_dict: dict): # so the items have the correct state when we process the event in a rule try: if isinstance(event, ValueUpdateEvent): - __item = Items.get_item(event.name) # type: HABApp.core.items.base_item.BaseValueItem + __item = get_item(event.name) # type: HABApp.core.items.base_valueitem.BaseValueItem __item.set_value(event.value) - EventBus.post_event(event.name, event) + post_event(event.name, event) return None - if isinstance(event, ThingStatusInfoEvent): - __thing = Items.get_item(event.name) # type: HABApp.openhab.items.Thing + if isinstance(event, ValueChangeEvent): + post_event(event.name, event) + return None + + if isinstance(event, (ThingStatusInfoEvent, ThingUpdatedEvent)): + __thing = get_item(event.name) # type: HABApp.openhab.items.Thing __thing.process_event(event) - EventBus.post_event(event.name, event) + post_event(event.name, event) return None # Workaround because there is no GroupItemStateEvent if isinstance(event, GroupItemStateChangedEvent): - __item = Items.get_item(event.name) # type: HABApp.openhab.items.GroupItem + __item = get_item(event.name) # type: HABApp.openhab.items.GroupItem __item.set_value(event.value) - EventBus.post_event(event.name, event) + post_event(event.name, event) return None except ItemNotFoundException: log_warning(log, f'Received {event.__class__.__name__} for {event.name} but item does not exist!') # Post the event anyway - EventBus.post_event(event.name, event) + post_event(event.name, event) return None + # Events that add items to the item registry + # These events require that we query openHAB because of the metadata, so we have to do it in a task + if isinstance(event, (ItemAddedEvent, ItemUpdatedEvent)): + create_task(item_event(event)) + return None + + # Events that remove items from the item registry if isinstance(event, ItemRemovedEvent): remove_from_registry(event.name) - EventBus.post_event(event.name, event) + post_event(TOPIC_ITEMS, event) return None - # These events require that we query openhab because of the metadata so we have to do it in a task - # They also change the item registry - if isinstance(event, (ItemAddedEvent, ItemUpdatedEvent)): - create_task(item_event(event)) + # Events that add things to the item registry + if isinstance(event, ThingAddedEvent): + create_task(thing_event(event)) + return None + + # Events that remove things from the item registry + if isinstance(event, ThingRemovedEvent): + remove_thing_from_registry(event.name) + post_event(TOPIC_THINGS, event) return None - HABApp.core.EventBus.post_event(event.name, event) + # Unknown Event -> just forward it to the event bus + post_event(event.name, event) except Exception as e: process_exception(func=on_sse_event, e=e) return None @@ -75,11 +98,20 @@ async def item_event(event: Union[ItemAddedEvent, ItemUpdatedEvent]): # Since metadata is not part of the event we have to request it cfg = await HABApp.openhab.interface_async.async_get_item(name, metadata='.+') - new_item = map_item(name, event.type, None, event.tags, event.groups, metadata=cfg.get('metadata')) + 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 add_to_registry(new_item) # Send Event to Event Bus - HABApp.core.EventBus.post_event(name, event) + post_event(TOPIC_ITEMS, event) + return None + + +async def thing_event(event: ThingAddedEvent): + # Since the thing status is not part of the event we have to request ist + add_thing_to_registry(await HABApp.openhab.interface_async.async_get_thing(event.name)) + + # Send Event to Event Bus + post_event(TOPIC_THINGS, event) return None diff --git a/src/HABApp/openhab/connection_logic/connection.py b/src/HABApp/openhab/connection_logic/connection.py index 56cafe9a..8c2dfe28 100644 --- a/src/HABApp/openhab/connection_logic/connection.py +++ b/src/HABApp/openhab/connection_logic/connection.py @@ -12,7 +12,7 @@ def setup(): http_connection.ON_DISCONNECTED = on_disconnect # shutdown handler for connection - shutdown.register_func(http_connection.stop_connection, msg='Stopping openHAB 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') @@ -23,4 +23,4 @@ def setup(): async def start(): - await http_connection.start_connection() + 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 index 7b99d9a3..3e9928a9 100644 --- a/src/HABApp/openhab/connection_logic/plugin_load_items.py +++ b/src/HABApp/openhab/connection_logic/plugin_load_items.py @@ -1,21 +1,24 @@ import logging import HABApp -from HABApp.core import Items 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 ..interface_async import async_get_items, async_get_things -from HABApp.openhab.item_to_reg import add_to_registry, fresh_item_sync +from ...core.internals import uses_item_registry log = logging.getLogger('HABApp.openhab.items') +Items = uses_item_registry() + class LoadAllOpenhabItems(OnConnectPlugin): @ignore_exception async def on_connect_function(self): - data = await async_get_items(disconnect_on_error=True, all_metadata=True) + data = await async_get_items(all_metadata=True) if data is None: return None @@ -24,7 +27,7 @@ async def on_connect_function(self): found_items = len(data) for _dict in data: item_name = _dict['name'] - new_item = map_item(item_name, _dict['type'], _dict['state'], + new_item = map_item(item_name, _dict['type'], _dict['state'], _dict.get('label'), frozenset(_dict['tags']), frozenset(_dict['groupNames']), _dict.get('metadata', {})) # type: HABApp.openhab.items.OpenhabItem if new_item is None: @@ -32,39 +35,27 @@ async def on_connect_function(self): add_to_registry(new_item, True) # remove items which are no longer available - ist = set(Items.get_all_item_names()) + 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): - Items.pop_item(k) + remove_from_registry(k) log.info(f'Updated {found_items:d} Items') # try to update things, too - data = await async_get_things(disconnect_on_error=True) - if data is None: - return None + data = await async_get_things() Thing = HABApp.openhab.items.Thing - for t_dict in data: - name = t_dict['UID'] - try: - thing = Items.get_item(name) - if not isinstance(thing, Thing): - log.warning(f'Item {name} has the wrong type ({type(thing)}), expected Thing') - thing = Thing(name) - except Items.ItemNotFoundException: - thing = Thing(name) - - thing.status = t_dict['statusInfo']['status'] - Items.add_item(thing) + for thing_cfg in data: + add_thing_to_registry(thing_cfg) # remove things which were deleted - ist = set(Items.get_all_item_names()) - soll = {k['UID'] for k in data} + 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): - Items.pop_item(k) + remove_thing_from_registry(k) log.info(f'Updated {len(data):d} Things') return None diff --git a/src/HABApp/openhab/connection_logic/plugin_ping.py b/src/HABApp/openhab/connection_logic/plugin_ping.py index 335d2368..b5e80cd2 100644 --- a/src/HABApp/openhab/connection_logic/plugin_ping.py +++ b/src/HABApp/openhab/connection_logic/plugin_ping.py @@ -1,23 +1,27 @@ -from typing import Optional - -import HABApp import asyncio import logging import time +from typing import Optional + +import HABApp +from HABApp.core.internals import uses_event_bus from HABApp.core.wrapper import log_exception +from HABApp.openhab.errors import OpenhabDisconnectedError from ._plugin import PluginBase -from HABApp.openhab.errors import OpenhabNotReadyYet, OpenhabDisconnectedError log = logging.getLogger('HABApp.openhab.ping') +event_bus = uses_event_bus() + + 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.EventBusListener] = None + self.listener: Optional[HABApp.core.internals.EventBusListener] = None self.fut_ping: Optional[asyncio.Future] = None @@ -56,12 +60,12 @@ def cfg_changed(self): if not HABApp.config.CONFIG.openhab.ping.enabled: return None - self.listener = HABApp.core.EventBusListener( + self.listener = HABApp.core.internals.EventBusListener( HABApp.config.CONFIG.openhab.ping.item, - HABApp.core.WrappedFunction(self.ping_received), - HABApp.openhab.events.ItemStateEvent + HABApp.core.internals.wrap_func(self.ping_received), + HABApp.core.events.EventFilter(HABApp.openhab.events.ItemStateEvent) ) - HABApp.core.EventBus.add_listener(self.listener) + event_bus.add_listener(self.listener) self.on_connect() @@ -96,7 +100,7 @@ async def async_ping(self): await asyncio.sleep(HABApp.config.CONFIG.openhab.ping.interval) - except (OpenhabNotReadyYet, OpenhabDisconnectedError): + except OpenhabDisconnectedError: pass diff --git a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py b/src/HABApp/openhab/connection_logic/plugin_thing_overview.py index e36649a6..fac93dc0 100644 --- a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py +++ b/src/HABApp/openhab/connection_logic/plugin_thing_overview.py @@ -46,8 +46,8 @@ async def on_connect_function(self): # zw_u_channels = zw_table.add_column('Unlinked channel types') for node in thing_data: - uid = node['UID'] - type_uid = node['thingTypeUID'] + uid = node.uid + type_uid = node.thing_type is_zw = type_uid.startswith('zwave:') @@ -57,15 +57,15 @@ async def on_connect_function(self): col_uid.add(uid) col_type.add(type_uid) - col_label.add(node.get('label', '')) - col_stat.add(node['statusInfo']['status']) - col_location.add(node.get('location', '')) + col_label.add(node.label) + col_stat.add(node.status.status) + col_location.add(node.location if node.location is not None else '') if not is_zw: continue # optional properties which can be set - props = node['properties'] + props = node.properties # channels = node.get('channels', []) # Node-ID, e.g. 5 diff --git a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index c546dd87..15dbec13 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -1,7 +1,7 @@ import asyncio import time from pathlib import Path -from typing import Dict, Set, Optional +from typing import Dict, Set, Optional, List, Any import HABApp from HABApp.core.files.file import HABAppFile @@ -34,12 +34,11 @@ def __init__(self): self.watcher: Optional[AggregatingAsyncEventHandler] = None self.cache_ts: float = 0.0 - self.cache_cfg: dict = {} + self.cache_cfg: List[Dict[str, Any]] = [] def setup(self): path = HABApp.CONFIG.directories.config - if not path.is_dir(): - log.info('Config folder does not exist - textual thing config disabled!') + if path is None: return None class HABAppThingConfigFile(HABAppFile): @@ -58,15 +57,9 @@ async def on_connect_function(self): if self.watcher is None: return None - try: - await asyncio.sleep(0.3) - - self.cache_cfg = await async_get_things() - self.cache_ts = time.time() - - await self.watcher.trigger_all() - except asyncio.CancelledError: - pass + await asyncio.sleep(0.3) + await self.load_thing_data(always=True) + await self.watcher.trigger_all() @HABApp.core.wrapper.ignore_exception async def clean_items(self): @@ -75,6 +68,12 @@ async def clean_items(self): items.update(s) await cleanup_items(items) + 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_ts = time.time() + return self.cache_cfg + async def file_load(self, name: str, path: Path): # we have to check the naming structure because we get file events for the whole folder _name = path.name.lower() @@ -83,11 +82,7 @@ async def file_load(self, name: str, path: Path): return None # only load if we don't supply the data - if time.time() - self.cache_ts > 20 or not self.cache_cfg: - self.cache_cfg = await async_get_things() - self.cache_ts = time.time() - - data = self.cache_cfg + data = await self.load_thing_data(always=False) # remove created items self.created_items.pop(path.name, None) @@ -195,7 +190,7 @@ async def file_load(self, name: str, path: Path): create_items_file(output_file, create_items) - self.cache_cfg = {} + self.cache_cfg = [] PLUGIN_MANUAL_THING_CFG = ManualThingConfig.create_plugin() diff --git a/src/HABApp/openhab/definitions/definitions.py b/src/HABApp/openhab/definitions/definitions.py index c2c4698d..a07283d8 100644 --- a/src/HABApp/openhab/definitions/definitions.py +++ b/src/HABApp/openhab/definitions/definitions.py @@ -1,6 +1,6 @@ ITEM_TYPES = { 'String', 'Number', 'Switch', 'Contact', 'Dimmer', 'Rollershutter', - 'Color', 'DateTime', 'Location', 'Player', 'Group', 'Image', + 'Color', 'DateTime', 'Location', 'Player', 'Group', 'Image', 'Call', } ITEM_DIMENSIONS = {'Length', 'Temperature', 'Pressure', 'Speed', 'Intensity', 'Angle', 'Dimensionless'} diff --git a/src/HABApp/openhab/definitions/rest/things.py b/src/HABApp/openhab/definitions/rest/things.py index b404321b..ef38a361 100644 --- a/src/HABApp/openhab/definitions/rest/things.py +++ b/src/HABApp/openhab/definitions/rest/things.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field class OpenhabThingChannelDefinition(BaseModel): @@ -17,15 +17,24 @@ class OpenhabThingChannelDefinition(BaseModel): configuration: Optional[Dict[str, Any]] +class OpenhabThingStatus(BaseModel): + status: str + detail: str = Field(..., alias='statusDetail') + description: Optional[str] = None + + class OpenhabThingDefinition(BaseModel): - label: Optional[str] - bridgeUID: Optional[str] - configuration: Dict[str, Any] - properties: Dict[str, Any] - UID: Optional[str] - thingTypeUID: Optional[str] channels: Optional[List[OpenhabThingChannelDefinition]] location: Optional[str] - statusInfo: Optional[Dict[str, str]] firmwareStatus: Optional[Dict[str, str]] editable: Optional[bool] + + # These are mandatory fields + label: str + status: OpenhabThingStatus = Field(..., alias='statusInfo') + uid: str = Field(..., alias='UID') + thing_type: str = Field(..., alias='thingTypeUID') + bridge_uid: Optional[str] = Field(None, alias='bridgeUID') + + configuration: Dict[str, Any] + properties: Dict[str, Any] diff --git a/src/HABApp/openhab/definitions/topics.py b/src/HABApp/openhab/definitions/topics.py new file mode 100644 index 00000000..86ec4705 --- /dev/null +++ b/src/HABApp/openhab/definitions/topics.py @@ -0,0 +1,8 @@ +try: + from typing import Final as _Final +except ImportError: + _Final = str + + +TOPIC_ITEMS: _Final = 'openHAB.Items' +TOPIC_THINGS: _Final = 'openHAB.Things' diff --git a/src/HABApp/openhab/errors.py b/src/HABApp/openhab/errors.py index bbeb5852..961068a0 100644 --- a/src/HABApp/openhab/errors.py +++ b/src/HABApp/openhab/errors.py @@ -1,49 +1,54 @@ +from HABApp.core.errors import HABAppException -# ---------------------------------------------------------------------------------------------------------------------- -# Connection errors -# ---------------------------------------------------------------------------------------------------------------------- -class OpenhabConnectionNotSetUpError(Exception): +class HABAppOpenhabError(HABAppException): pass -class OpenhabDisconnectedError(Exception): +# ---------------------------------------------------------------------------------------------------------------------- +# Connection errors +# ---------------------------------------------------------------------------------------------------------------------- +class OpenhabConnectionNotSetUpError(HABAppOpenhabError): pass -class OpenhabNotReadyYet(Exception): +class OpenhabDisconnectedError(HABAppOpenhabError): pass -class ExpectedSuccessFromOpenhab(Exception): +class ExpectedSuccessFromOpenhab(HABAppOpenhabError): pass # ---------------------------------------------------------------------------------------------------------------------- # OpenHAB errors # ---------------------------------------------------------------------------------------------------------------------- -class ItemNotFoundError(Exception): +class SendCommandNotSupported(HABAppOpenhabError): + pass + + +class ItemNotFoundError(HABAppOpenhabError): @classmethod def from_name(cls, name: str): return cls(f'Item "{name}" not found!') -class ItemNotEditableError(Exception): +class ItemNotEditableError(HABAppOpenhabError): @classmethod def from_name(cls, name: str): return cls(f'Item "{name}" is not editable!') -class ThingNotFoundError(Exception): +class ThingNotFoundError(HABAppOpenhabError): @classmethod def from_uid(cls, uid: str): return cls(f'Thing "{uid}" not found!') -class ThingNotEditableError(Exception): +class ThingNotEditableError(HABAppOpenhabError): @classmethod def from_uid(cls, uid: str): @@ -53,7 +58,7 @@ def from_uid(cls, uid: str): # ---------------------------------------------------------------------------------------------------------------------- # RestAPI Errors # ---------------------------------------------------------------------------------------------------------------------- -class MetadataNotEditableError(Exception): +class MetadataNotEditableError(HABAppOpenhabError): @classmethod def create_text(cls, item: str, namespace: str): diff --git a/src/HABApp/openhab/events/__init__.py b/src/HABApp/openhab/events/__init__.py index d3275ba9..a3221de5 100644 --- a/src/HABApp/openhab/events/__init__.py +++ b/src/HABApp/openhab/events/__init__.py @@ -1,7 +1,7 @@ from .base_event import OpenhabEvent from .item_events import ItemStateEvent, ItemStateChangedEvent, ItemCommandEvent, ItemAddedEvent,\ ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent -from .channel_events import ChannelTriggeredEvent +from .channel_events import ChannelTriggeredEvent, ChannelDescriptionChangedEvent from .thing_events import ThingStatusInfoChangedEvent, ThingStatusInfoEvent, \ - ThingConfigStatusInfoEvent, ThingFirmwareStatusInfoEvent -from .event_filters import ItemStateEventFilter, ItemStateChangedEventFilter + ThingFirmwareStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent +from .event_filters import ItemStateEventFilter, ItemStateChangedEventFilter, ItemCommandEventFilter diff --git a/src/HABApp/openhab/events/channel_events.py b/src/HABApp/openhab/events/channel_events.py index 9e582347..84ecaff1 100644 --- a/src/HABApp/openhab/events/channel_events.py +++ b/src/HABApp/openhab/events/channel_events.py @@ -1,16 +1,11 @@ from .base_event import OpenhabEvent -# smarthome/channels/NAME/triggered -> 19 -# openhab/channels/NAME/triggered -> 17 -# todo: revert this once we go OH3 only -NAME_START: int = 17 - class ChannelTriggeredEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.event: - :ivar str ~.channel: + :ivar str name: + :ivar str event: + :ivar str channel: """ name: str event: str @@ -25,7 +20,32 @@ def __init__(self, name: str = '', event: str = '', channel: str = ''): @classmethod def from_dict(cls, topic: str, payload: dict): - return cls(topic[NAME_START:-10], payload['event'], payload['channel']) + return cls(topic[17:-10], payload['event'], payload['channel']) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, event: {self.event}>' + + +class ChannelDescriptionChangedEvent(OpenhabEvent): + """ + :ivar str name: + :ivar str field: + :ivar str value: + """ + name: str + field: str + value: str + + def __init__(self, name: str = '', field: str = '', value: str = ''): + super().__init__() + + self.name: str = name + self.field: str = field + self.value: str = value + + @classmethod + def from_dict(cls, topic: str, payload: dict): + return cls(topic[17:-19], payload['field'], payload['value']) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, field: {self.field}>' diff --git a/src/HABApp/openhab/events/event_filters.py b/src/HABApp/openhab/events/event_filters.py index fe1b2b8e..05b909c1 100644 --- a/src/HABApp/openhab/events/event_filters.py +++ b/src/HABApp/openhab/events/event_filters.py @@ -1,10 +1,20 @@ -from HABApp.core.events import ValueChangeEventFilter, ValueUpdateEventFilter -from . import ItemStateChangedEvent, ItemStateEvent +from typing import Any +from HABApp.core.const import MISSING +from HABApp.core.events.filter.event import TypeBoundEventFilter +from . import ItemStateChangedEvent, ItemStateEvent, ItemCommandEvent -class ItemStateEventFilter(ValueUpdateEventFilter): - _EVENT_TYPE = ItemStateEvent +class ItemStateEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING): + super().__init__(ItemStateEvent, value=value) -class ItemStateChangedEventFilter(ValueChangeEventFilter): - _EVENT_TYPE = ItemStateChangedEvent + +class ItemStateChangedEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + super().__init__(ItemStateChangedEvent, value=value, old_value=old_value) + + +class ItemCommandEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING): + super().__init__(ItemCommandEvent, value=value) diff --git a/src/HABApp/openhab/events/item_events.py b/src/HABApp/openhab/events/item_events.py index 35539816..a01b27da 100644 --- a/src/HABApp/openhab/events/item_events.py +++ b/src/HABApp/openhab/events/item_events.py @@ -1,19 +1,14 @@ -from typing import Any, FrozenSet +from typing import Any, FrozenSet, Optional import HABApp.core from .base_event import OpenhabEvent from ..map_values import map_openhab_values -# smarthome/items/NAME/state -> 16 -# openhab/items/NAME/state -> 14 -# todo: revert this once we go OH3 only -NAME_START: int = 14 - class ItemStateEvent(OpenhabEvent, HABApp.core.events.ValueUpdateEvent): """ - :ivar str ~.name: - :ivar ~.value: + :ivar str name: + :ivar value: """ name: str value: Any @@ -28,7 +23,7 @@ def __init__(self, name: str = '', value: Any = None): @classmethod def from_dict(cls, topic: str, payload: dict): # smarthome/items/NAME/state - return cls(topic[NAME_START:-6], map_openhab_values(payload['type'], payload['value'])) + return cls(topic[14:-6], map_openhab_values(payload['type'], payload['value'])) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -36,9 +31,9 @@ def __repr__(self): class ItemStateChangedEvent(OpenhabEvent, HABApp.core.events.ValueChangeEvent): """ - :ivar str ~.name: - :ivar ~.value: - :ivar ~.old_value: + :ivar str name: + :ivar value: + :ivar old_value: """ name: str value: Any @@ -55,7 +50,7 @@ def __init__(self, name: str = '', value: Any = None, old_value: Any = None): def from_dict(cls, topic: str, payload: dict): # smarthome/items/Ping/statechanged return cls( - topic[NAME_START:-13], + topic[14:-13], map_openhab_values(payload['type'], payload['value']), map_openhab_values(payload['oldType'], payload['oldValue']) ) @@ -66,8 +61,8 @@ def __repr__(self): class ItemCommandEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar ~.value: + :ivar str name: + :ivar value: """ name: str value: Any @@ -81,7 +76,7 @@ def __init__(self, name: str = '', value: Any = None): @classmethod def from_dict(cls, topic: str, payload: dict): # smarthome/items/NAME/command - return cls(topic[NAME_START:-8], map_openhab_values(payload['type'], payload['value'])) + return cls(topic[14:-8], map_openhab_values(payload['type'], payload['value'])) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -89,22 +84,25 @@ def __repr__(self): class ItemAddedEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.type: - :ivar Tuple[str,...] ~.tags: - :ivar Tuple[str,...] ~.group_names: + :ivar str name: + :ivar str type: + :ivar Optional[str] label: + :ivar Tuple[str,...] tags: + :ivar Tuple[str,...] group_names: """ name: str type: str + label: Optional[str] tags: FrozenSet[str] groups: FrozenSet[str] - def __init__(self, name: str = '', type: str = '', + def __init__(self, name: str = '', type: str = '', label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), group_names: FrozenSet[str] = frozenset()): super().__init__() self.name: str = name self.type: str = type + self.label: Optional[str] = label self.tags: FrozenSet[str] = tags self.groups: FrozenSet[str] = group_names @@ -113,7 +111,10 @@ def from_dict(cls, topic: str, payload: dict): # {'topic': 'smarthome/items/NAME/added' # 'payload': '{"type":"Contact","name":"Test","tags":[],"groupNames":[]}' # 'type': 'ItemAddedEvent'} - return cls(payload['name'], payload['type'], frozenset(payload['tags']), frozenset(payload['groupNames'])) + return cls( + payload['name'], payload['type'], label=payload.get('label'), + tags=frozenset(payload['tags']), group_names=frozenset(payload['groupNames']) + ) def __repr__(self): tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else "" @@ -123,22 +124,24 @@ def __repr__(self): class ItemUpdatedEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.type: - :ivar Tuple[str,...] ~.tags: - :ivar Tuple[str,...] ~.group_names: + :ivar str name: + :ivar str type: + :ivar Tuple[str,...] tags: + :ivar Tuple[str,...] group_names: """ name: str type: str + label: Optional[str] tags: FrozenSet[str] groups: FrozenSet[str] - def __init__(self, name: str = '', type: str = '', - tags: FrozenSet[str] = tuple(), group_names: FrozenSet[str] = tuple()): + def __init__(self, name: str = '', type: str = '', label: Optional[str] = None, + tags: FrozenSet[str] = frozenset(), group_names: FrozenSet[str] = frozenset()): super().__init__() self.name: str = name self.type: str = type + self.label: Optional[str] = label self.tags: FrozenSet[str] = tags self.groups: FrozenSet[str] = group_names @@ -149,7 +152,10 @@ def from_dict(cls, topic: str, payload: dict): # {"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', # 'type': 'ItemUpdatedEvent' new = payload[0] - return cls(topic[NAME_START:-8], new['type'], frozenset(new['tags']), frozenset(new['groupNames'])) + return cls( + topic[14:-8], new['type'], label=new.get('label'), + tags=frozenset(new['tags']), group_names=frozenset(new['groupNames']) + ) def __repr__(self): tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else "" @@ -159,7 +165,7 @@ def __repr__(self): class ItemRemovedEvent(OpenhabEvent): """ - :ivar str ~.name: + :ivar str name: """ name: str @@ -171,7 +177,7 @@ def __init__(self, name: str = ''): @classmethod def from_dict(cls, topic: str, payload: dict): # smarthome/items/Test/removed - return cls(topic[NAME_START:-8]) + return cls(topic[14:-8]) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}>' @@ -179,8 +185,8 @@ def __repr__(self): class ItemStatePredictedEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar ~.value: + :ivar str name: + :ivar value: """ name: str value: Any @@ -195,7 +201,7 @@ def __init__(self, name: str = '', value: Any = None): @classmethod def from_dict(cls, topic: str, payload: dict): # 'smarthome/items/NAME/statepredicted' - return cls(topic[NAME_START:-15], map_openhab_values(payload['predictedType'], payload['predictedValue'])) + return cls(topic[14:-15], map_openhab_values(payload['predictedType'], payload['predictedValue'])) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -203,10 +209,10 @@ def __repr__(self): class GroupItemStateChangedEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.item: - :ivar ~.value: - :ivar ~.old_value: + :ivar str name: + :ivar str item: + :ivar value: + :ivar old_value: """ name: str item: str diff --git a/src/HABApp/openhab/events/thing_events.py b/src/HABApp/openhab/events/thing_events.py index 9df1f22d..5770f4c3 100644 --- a/src/HABApp/openhab/events/thing_events.py +++ b/src/HABApp/openhab/events/thing_events.py @@ -1,18 +1,13 @@ -import typing +from typing import Optional, List, Dict, Any from .base_event import OpenhabEvent -# smarthome/things/NAME/state -> 17 -# openhab/things/NAME/state -> 15 -# todo: revert this once we go OH3 only -NAME_START: int = 15 - class ThingStatusInfoEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.status: - :ivar str ~.detail: + :ivar str name: + :ivar str status: + :ivar str detail: """ name: str status: str @@ -27,8 +22,8 @@ def __init__(self, name: str = '', status: str = '', detail: str = ''): @classmethod def from_dict(cls, topic: str, payload: dict): - # smarthome/things/chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/status - return cls(name=topic[NAME_START:-7], status=payload['status'], detail=payload['statusDetail']) + # openhab/things/chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/status + return cls(name=topic[15:-7], status=payload['status'], detail=payload['statusDetail']) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, status: {self.status}, detail: {self.detail}>' @@ -36,11 +31,11 @@ def __repr__(self): class ThingStatusInfoChangedEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.status: - :ivar str ~.detail: - :ivar str ~.old_status: - :ivar str ~.old_detail: + :ivar str name: + :ivar str status: + :ivar str detail: + :ivar str old_status: + :ivar str old_detail: """ name: str status: str @@ -59,8 +54,8 @@ def __init__(self, name: str = '', status: str = '', detail: str = '', old_statu @classmethod def from_dict(cls, topic: str, payload: dict): - # smarthome/things/chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/statuschanged - name = topic[NAME_START:-14] + # openhab/things/chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/statuschanged + name = topic[15:-14] new, old = payload return cls( name=name, status=new['status'], detail=new['statusDetail'], @@ -73,46 +68,87 @@ def __repr__(self): f'old_status: {self.old_status}, old_detail: {self.old_detail}>' -class ThingConfigStatusInfoEvent(OpenhabEvent): +class ThingFirmwareStatusInfoEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar list ~.messages: + :ivar str name: + :ivar str status: """ name: str - messages: typing.List[typing.Dict[str, str]] + status: str - def __init__(self, name: str = '', messages: typing.List[typing.Dict[str, str]] = [{}]): + def __init__(self, name: str = '', status: str = ''): super().__init__() - self.name: str = name - self.messages: typing.List[typing.Dict[str, str]] = messages + self.status: str = status @classmethod def from_dict(cls, topic: str, payload: dict): - # 'smarthome/things/zwave:device:controller:my_node/config/status' - return cls(name=topic[NAME_START:-14], messages=payload['configStatusMessages']) + # 'openhab/things/zwave:device:controller:my_node/firmware/status' + return cls(name=topic[15:-16], status=payload['firmwareStatus']) def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, messages: {self.messages}>' + return f'<{self.__class__.__name__} name: {self.name} status: {self.status}>' -class ThingFirmwareStatusInfoEvent(OpenhabEvent): +class ThingRegistryBaseEvent(OpenhabEvent): """ - :ivar str ~.name: - :ivar str ~.status: + :ivar str name: + :ivar str type: + :ivar str label: + :ivar List[Dict[str, Any]] channels: + :ivar Dict[str, Any] configuration: + :ivar Dict[str, str] properties: """ name: str - status: str - - def __init__(self, name: str = '', status: str = ''): + type: str + label: str + channels: List[Dict[str, Any]] + configuration: Dict[str, Any] + properties: Dict[str, str] + + def __init__(self, name: str = '', thing_type: str = '', label: str = '', + channels: Optional[List[Dict[str, Any]]] = None, configuration: Optional[Dict[str, Any]] = None, + properties: Optional[Dict[str, str]] = None): super().__init__() + + # use name instead of uuid self.name: str = name - self.status: str = status + self.type: str = thing_type + + # optional entries + self.label: str = label + self.channels: List[Dict[str, Any]] = channels if channels is not None else [] + self.configuration: Dict[str, Any] = configuration if configuration is not None else {} + self.properties: Dict[str, str] = properties if properties is not None else {} @classmethod def from_dict(cls, topic: str, payload: dict): - # 'smarthome/things/zwave:device:controller:my_node/firmware/status' - return cls(name=topic[NAME_START:-16], status=payload['firmwareStatus']) + # 'openhab/things/astro:sun:0a94363608/added' + return cls( + name=payload['UID'], thing_type=payload['thingTypeUID'], label=payload['label'], + channels=payload.get('channels'), configuration=payload.get('configuration'), + properties=payload.get('properties'), + ) def __repr__(self): - return f'<{self.__class__.__name__} status: {self.status}>' + return f'<{self.__class__.__name__} name: {self.name}>' + + +class ThingAddedEvent(ThingRegistryBaseEvent): + pass + + +class ThingRemovedEvent(ThingRegistryBaseEvent): + pass + + +class ThingUpdatedEvent(ThingRegistryBaseEvent): + @classmethod + def from_dict(cls, topic: str, payload: List[Dict[str, Any]]): + + payload = payload[0] + return cls( + name=payload['UID'], thing_type=payload['thingTypeUID'], label=payload['label'], + channels=payload.get('channels'), configuration=payload.get('configuration'), + properties=payload.get('properties'), + ) diff --git a/src/HABApp/openhab/item_to_reg.py b/src/HABApp/openhab/item_to_reg.py index 4843ddde..7f14234f 100644 --- a/src/HABApp/openhab/item_to_reg.py +++ b/src/HABApp/openhab/item_to_reg.py @@ -1,12 +1,20 @@ import logging -from typing import Dict, Set, Tuple +from typing import Dict, Set, Tuple, TYPE_CHECKING + +from immutables import Map import HABApp -from HABApp.core import Items + +from HABApp.core.internals import uses_item_registry from HABApp.core.logger import log_warning +if TYPE_CHECKING: + import HABApp.openhab.definitions.rest + log = logging.getLogger('HABApp.openhab.items') +Items = uses_item_registry() + def add_to_registry(item: 'HABApp.openhab.items.OpenhabItem', set_value=False): name = item.name @@ -27,8 +35,10 @@ def add_to_registry(item: 'HABApp.openhab.items.OpenhabItem', set_value=False): MEMBERS.get(grp, set()).discard(name) # same type - it was only an item update (e.g. label)! - existing.tags = item.tags - existing.groups = item.groups + existing.label = item.label + existing.tags = item.tags + existing.groups = item.groups + existing.metadata = item.metadata return None log_warning(log, f'Item type changed from {existing.__class__} to {item.__class__}') @@ -63,5 +73,45 @@ def fresh_item_sync(): def get_members(group_name: str) -> Tuple['HABApp.openhab.items.OpenhabItem', ...]: ret = [] for name in MEMBERS.get(group_name, []): - ret.append(Items.get_item(name)) - return tuple(sorted(ret)) + item = Items.get_item(name) # type: HABApp.openhab.items.OpenhabItem + ret.append(item) + return tuple(sorted(ret, key=lambda x: x.name)) + + +# ---------------------------------------------------------------------------------------------------------------------- +# Thing handling +# ---------------------------------------------------------------------------------------------------------------------- +def add_thing_to_registry(thing: 'HABApp.openhab.definitions.rest.OpenhabThingDefinition'): + name = thing.uid + does_exist = Items.item_exists(name) + + # update existing + if does_exist: + existing = Items.get_item(name) # type: HABApp.openhab.items.Thing + if isinstance(existing, HABApp.openhab.items.Thing): + existing.status = thing.status.status + existing.status_detail = thing.status.detail + return None + + # create new Thing + new_thing = HABApp.openhab.items.Thing(name=name) + new_thing.status = thing.status.status + new_thing.status_detail = thing.status.detail + new_thing.label = thing.label + new_thing.configuration = Map(thing.configuration) + new_thing.properties = Map(thing.properties) + + if not does_exist: + Items.add_item(new_thing) + return None + + # Replace existing item with the updated definition + log_warning(log, f'Item type changed from {existing.__class__} to {thing.__class__}') + Items.pop_item(name) + Items.add_item(new_thing) + + +def remove_thing_from_registry(name: str): + if not Items.item_exists(name): + return None + return Items.pop_item(name) diff --git a/src/HABApp/openhab/items/__init__.py b/src/HABApp/openhab/items/__init__.py index 3fe20927..ee1ecf25 100644 --- a/src/HABApp/openhab/items/__init__.py +++ b/src/HABApp/openhab/items/__init__.py @@ -7,6 +7,7 @@ from .number_item import NumberItem from .datetime_item import DatetimeItem from .string_item import StringItem, LocationItem, PlayerItem +from .call_item import CallItem from .image_item import ImageItem from .group_item import GroupItem from .thing_item import Thing diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index b08c05e3..50c3ef15 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -1,10 +1,10 @@ import datetime -from typing import Any, FrozenSet, Mapping, NamedTuple, Optional +from typing import Any, FrozenSet, Mapping, NamedTuple, Optional, TypeVar, Type from immutables import Map from HABApp.core.const import MISSING -from HABApp.core.items.base_valueitem import BaseValueItem +from HABApp.core.items import BaseValueItem from HABApp.openhab.interface import get_persistence_data, post_update, send_command @@ -15,27 +15,42 @@ class MetaData(NamedTuple): class OpenhabItem(BaseValueItem): """Base class for items which exists in OpenHAB. + + :ivar str name: + :ivar Any value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: """ def __init__(self, name: str, initial_value=None, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): super().__init__(name, initial_value) + self.label: Optional[str] = label self.tags: FrozenSet[str] = tags self.groups: FrozenSet[str] = groups self.metadata: Mapping[str, MetaData] = metadata + @classmethod + def from_oh(cls, name: str, value=None, + label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()): + return cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) + def oh_send_command(self, value: Any = MISSING): """Send a command to the openHAB item - :param value: (optional) value to be sent. If not specified the item value will be used. + :param value: (optional) value to be sent. If not specified the current item value will be used. """ send_command(self.name, self.value if value is MISSING else value) def oh_post_update(self, value: Any = MISSING): """Post an update to the openHAB item - :param value: (optional) value to be posted. If not specified the item value will be used. + :param value: (optional) value to be posted. If not specified the current item value will be used. """ post_update(self.name, self.value if value is MISSING else value) @@ -52,3 +67,7 @@ def get_persistence_data(self, persistence: Optional[str] = None, return get_persistence_data( self.name, persistence, start_time, end_time ) + + +HINT_OPENHAB_ITEM = TypeVar('HINT_OPENHAB_ITEM', bound=OpenhabItem) +HINT_TYPE_OPENHAB_ITEM = Type[HINT_OPENHAB_ITEM] diff --git a/src/HABApp/openhab/items/call_item.py b/src/HABApp/openhab/items/call_item.py new file mode 100644 index 00000000..ab823e1d --- /dev/null +++ b/src/HABApp/openhab/items/call_item.py @@ -0,0 +1,30 @@ +from typing import FrozenSet, Mapping, Optional + +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData + + +class CallItem(OpenhabItem): + """CallItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar Tuple[str, ...] value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ + + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if value is not None: + value = tuple(value.split(',')) + return cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) + + def set_value(self, new_value) -> bool: + if isinstance(new_value, str): + new_value = tuple(new_value.split(',')) + return super().set_value(new_value) diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index 6a66993b..19e2ad1d 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -12,16 +12,37 @@ 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: + """ def __init__(self, name: str, h=0.0, s=0.0, b=0.0, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): - super().__init__(name=name, initial_value=(h, s, b), tags=tags, groups=groups, metadata=metadata) + super().__init__(name=name, initial_value=(h, s, b), label=label, tags=tags, groups=groups, metadata=metadata) self.hue: float = min(max(0.0, h), HUE_FACTOR) self.saturation: float = min(max(0.0, s), PERCENT_FACTOR) self.brightness: float = min(max(0.0, b), PERCENT_FACTOR) + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if value is None: + return cls(name, label=label, tags=tags, groups=groups, metadata=metadata) + return cls( + name, *[float(k) for k in value.split(',')], label=label, tags=tags, groups=groups, metadata=metadata) + def set_value(self, hue=0.0, saturation=0.0, brightness=0.0): """Set the color value diff --git a/src/HABApp/openhab/items/contact_item.py b/src/HABApp/openhab/items/contact_item.py index cb7bf712..271e3804 100644 --- a/src/HABApp/openhab/items/contact_item.py +++ b/src/HABApp/openhab/items/contact_item.py @@ -1,8 +1,22 @@ +from typing import Any + from HABApp.openhab.items.base_item import OpenhabItem from ..definitions import OpenClosedValue +from ...core.const import MISSING +from ..errors import SendCommandNotSupported class ContactItem(OpenhabItem): + """ContactItem + + :ivar str name: + :ivar str value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ def set_value(self, new_value) -> bool: @@ -21,6 +35,10 @@ def is_closed(self) -> bool: """Test value against closed-value""" return self.value == OpenClosedValue.CLOSED + def oh_send_command(self, value: Any = MISSING): + raise SendCommandNotSupported(f'{self.__class__.__name__} does not support send command! ' + 'See openHAB documentation for details.') + def __str__(self): return self.value diff --git a/src/HABApp/openhab/items/datetime_item.py b/src/HABApp/openhab/items/datetime_item.py index 957f120b..8bbc91ac 100644 --- a/src/HABApp/openhab/items/datetime_item.py +++ b/src/HABApp/openhab/items/datetime_item.py @@ -1,5 +1,30 @@ -from HABApp.openhab.items.base_item import OpenhabItem +from datetime import datetime +from typing import Optional, FrozenSet, Mapping + +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData class DatetimeItem(OpenhabItem): - """DateTimeItem which accepts and converts the data types from OpenHAB""" + """DateTimeItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar datetime value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ + + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if value is not None: + dt = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f%z') + # all datetime objs from openHAB have a timezone set so we can't easily compare them + # --> TypeError: can't compare offset-naive and offset-aware datetime + dt = dt.astimezone(tz=None) # Changes datetime object so it uses system timezone + value = dt.replace(tzinfo=None) # Removes timezone awareness + return cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) diff --git a/src/HABApp/openhab/items/dimmer_item.py b/src/HABApp/openhab/items/dimmer_item.py index 659b6a69..70963b20 100644 --- a/src/HABApp/openhab/items/dimmer_item.py +++ b/src/HABApp/openhab/items/dimmer_item.py @@ -1,9 +1,30 @@ -from HABApp.openhab.items.base_item import OpenhabItem +from typing import Optional, FrozenSet, Mapping + +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData from HABApp.openhab.items.commands import OnOffCommand, PercentCommand from ..definitions import OnOffValue, PercentValue class DimmerItem(OpenhabItem, OnOffCommand, PercentCommand): + """DimmerItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar Union[int, float] value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ + + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if value is not None: + value = float(value) + return cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) def set_value(self, new_value) -> bool: diff --git a/src/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py index 8d4b8be2..5cc4ccea 100644 --- a/src/HABApp/openhab/items/group_item.py +++ b/src/HABApp/openhab/items/group_item.py @@ -6,7 +6,16 @@ class GroupItem(OpenhabItem): - """GroupItem which accepts and converts the data types from OpenHAB""" + """GroupItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar str value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ def set_value(self, new_value) -> bool: diff --git a/src/HABApp/openhab/items/image_item.py b/src/HABApp/openhab/items/image_item.py index 5932c5ad..ab5ec2c5 100644 --- a/src/HABApp/openhab/items/image_item.py +++ b/src/HABApp/openhab/items/image_item.py @@ -22,16 +22,35 @@ def _convert_bytes(data: bytes, img_type: Optional[str]) -> str: class ImageItem(OpenhabItem): - """ImageItem which accepts and converts the data types from OpenHAB""" + """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: + """ def __init__(self, name: str, initial_value=None, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): - super().__init__(name, initial_value, tags, groups, metadata) + super().__init__(name, initial_value, label, tags, groups, metadata) # this item is unique because we also save the image type and thus have two states self.image_type: Optional[str] = None + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + + c = cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) + if value is not None: + c.set_value(RawValue(value)) + return c + def set_value(self, new_value) -> bool: assert isinstance(new_value, RawValue) or new_value is None, type(new_value) @@ -47,7 +66,7 @@ def set_value(self, new_value) -> bool: return super().set_value(new_value.value) def oh_post_update(self, data: bytes, img_type: Optional[str] = None): - """Post an update to an openhab image with new image data. Image type is automatically detected, + """Post an update to an openHAB image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. :param data: image data @@ -56,7 +75,7 @@ def oh_post_update(self, data: bytes, img_type: Optional[str] = None): return super().oh_post_update(_convert_bytes(data, img_type)) def oh_send_command(self, data: bytes, img_type: Optional[str] = None): - """Send a command to an openhab image with new image data. Image type is automatically detected, + """Send a command to an openHAB image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. :param data: image data diff --git a/src/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py index 34525c4a..2a31702c 100644 --- a/src/HABApp/openhab/items/number_item.py +++ b/src/HABApp/openhab/items/number_item.py @@ -1,9 +1,35 @@ -from HABApp.openhab.items.base_item import OpenhabItem +from typing import Optional, FrozenSet, Mapping + +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData from ..definitions import QuantityValue class NumberItem(OpenhabItem): - """NumberItem which accepts and converts the data types from OpenHAB""" + """NumberItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar Union[int, float] value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ + + + @classmethod + def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if value is None: + return cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) + + try: + value = int(value) + except ValueError: + value = float(value) + return cls(name, value, label, tags, groups, metadata) def set_value(self, new_value) -> bool: diff --git a/src/HABApp/openhab/items/rollershutter_item.py b/src/HABApp/openhab/items/rollershutter_item.py index 234b19bf..87a2d58f 100644 --- a/src/HABApp/openhab/items/rollershutter_item.py +++ b/src/HABApp/openhab/items/rollershutter_item.py @@ -1,9 +1,29 @@ -from HABApp.openhab.items.base_item import OpenhabItem +from typing import Optional, FrozenSet, Mapping + +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData from HABApp.openhab.items.commands import UpDownCommand, PercentCommand from ..definitions import UpDownValue, PercentValue class RollershutterItem(OpenhabItem, UpDownCommand, PercentCommand): + """RollershutterItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar Union[int, float] value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ + + def __init__(self, name: str, initial_value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), + groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + if initial_value is not None: + initial_value = float(initial_value) + super().__init__(name, initial_value, label, tags, groups, metadata) def set_value(self, new_value) -> bool: diff --git a/src/HABApp/openhab/items/string_item.py b/src/HABApp/openhab/items/string_item.py index 917cc000..f63520ba 100644 --- a/src/HABApp/openhab/items/string_item.py +++ b/src/HABApp/openhab/items/string_item.py @@ -1,28 +1,40 @@ from HABApp.openhab.items.base_item import OpenhabItem -from ..definitions import QuantityValue class StringItem(OpenhabItem): - """StringItem which accepts and converts the data types from OpenHAB""" + """StringItem which accepts and converts the data types from OpenHAB + :ivar str name: + :ivar str value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ -class LocationItem(OpenhabItem): - """LocationItem which accepts and converts the data types from OpenHAB""" - def set_value(self, new_value) -> bool: +class LocationItem(OpenhabItem): + """LocationItem which accepts and converts the data types from OpenHAB - if isinstance(new_value, QuantityValue): - return super().set_value(new_value.value) + :ivar str name: + :ivar str value: - return super().set_value(new_value) + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ class PlayerItem(OpenhabItem): - """PlayerItem which accepts and converts the data types from OpenHAB""" - - def set_value(self, new_value) -> bool: + """PlayerItem which accepts and converts the data types from OpenHAB - if isinstance(new_value, QuantityValue): - return super().set_value(new_value.value) + :ivar str name: + :ivar str value: - return super().set_value(new_value) + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ diff --git a/src/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py index 9eeadadd..0f54f02b 100644 --- a/src/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -4,6 +4,16 @@ class SwitchItem(OpenhabItem, OnOffCommand): + """SwitchItem which accepts and converts the data types from OpenHAB + + :ivar str name: + :ivar Tuple[str, ...] value: + + :ivar Optional[str] label: + :ivar FrozenSet[str] tags: + :ivar FrozenSet[str] groups: + :ivar Mapping[str, MetaData] metadata: + """ def set_value(self, new_value) -> bool: diff --git a/src/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py index 9aff08d4..551f87e3 100644 --- a/src/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -1,19 +1,33 @@ +from typing import Any +from typing import Mapping + +from immutables import Map from pendulum import UTC from pendulum import now as pd_now -from HABApp.core.items.base_item import BaseItem -from ..events import ThingStatusInfoEvent +from HABApp.core.items import BaseItem +from ..events import ThingStatusInfoEvent, ThingUpdatedEvent class Thing(BaseItem): """Base class for Things :ivar str status: Status of the thing (e.g. OFFLINE, ONLINE, ...) + :ivar str status_detail: Additional detail for the status + :ivar str label: Thing label + :ivar Mapping[str, Any] configuration: Thing configuration + :ivar Mapping[str, Any] properties: Thing properties """ def __init__(self, name: str): super().__init__(name) self.status: str = '' + self.status_detail: str = '' + + self.label: str = '' + + self.configuration: Mapping[str, Any] = Map() + self.properties: Mapping[str, Any] = Map() def __update_timestamps(self, changed: bool): _now = pd_now(UTC) @@ -26,7 +40,21 @@ def process_event(self, event): if isinstance(event, ThingStatusInfoEvent): old = self.status - self.status = event.status - self.__update_timestamps(old == self.status) + self.status = new = event.status + self.status_detail = event.detail + + self.__update_timestamps(old != new) + elif isinstance(event, ThingUpdatedEvent): + old_label = self.label + old_configuration = self.configuration + old_properties = self.properties + + self.label = event.label + self.configuration = Map(event.configuration) + self.properties = Map(event.properties) + + self.__update_timestamps( + old_label != self.label or old_configuration != self.configuration or old_properties != self.properties + ) return None diff --git a/src/HABApp/openhab/map_events.py b/src/HABApp/openhab/map_events.py index c96e4fa2..4cd2d472 100644 --- a/src/HABApp/openhab/map_events.py +++ b/src/HABApp/openhab/map_events.py @@ -1,11 +1,13 @@ -import typing +from typing import Dict, Type from HABApp.core.const.json import load_json from .events import OpenhabEvent, \ ItemStateEvent, ItemStateChangedEvent, ItemCommandEvent, ItemAddedEvent, \ ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent, \ - ChannelTriggeredEvent, \ - ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingConfigStatusInfoEvent, ThingFirmwareStatusInfoEvent + ChannelTriggeredEvent, ChannelDescriptionChangedEvent, \ + ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, \ + ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent + EVENT_LIST = [ # item events @@ -13,15 +15,15 @@ ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent, # channel events - ChannelTriggeredEvent, + ChannelTriggeredEvent, ChannelDescriptionChangedEvent, # thing events + ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, ThingStatusInfoEvent, ThingStatusInfoChangedEvent, ThingFirmwareStatusInfoEvent ] -__event_lookup: typing.Dict[str, typing.Type[OpenhabEvent]] = {k.__name__: k for k in EVENT_LIST} -__event_lookup['ConfigStatusInfoEvent'] = ThingConfigStatusInfoEvent # Naming from openhab is inconsistent here -__event_lookup['FirmwareStatusInfoEvent'] = ThingFirmwareStatusInfoEvent # Naming from openhab is inconsistent here +_events: Dict[str, Type[OpenhabEvent]] = {k.__name__: k for k in EVENT_LIST} +_events['FirmwareStatusInfoEvent'] = ThingFirmwareStatusInfoEvent # Naming from openHAB is inconsistent here def get_event(_in_dict: dict) -> OpenhabEvent: @@ -36,6 +38,6 @@ def get_event(_in_dict: dict) -> OpenhabEvent: # Find event from implemented events try: - return __event_lookup[event_type].from_dict(topic, payload) + return _events[event_type].from_dict(topic, payload) except KeyError: raise ValueError(f'Unknown Event: {event_type:s} for {_in_dict}') diff --git a/src/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py index 6648d31b..9caa00a1 100644 --- a/src/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -1,4 +1,3 @@ -import datetime import logging from typing import Any, Dict, FrozenSet, Optional @@ -6,16 +5,35 @@ import HABApp from HABApp.core.wrapper import process_exception -from HABApp.openhab.definitions.values import QuantityValue, RawValue +from HABApp.openhab.definitions.values import QuantityValue from HABApp.openhab.items import ColorItem, ContactItem, DatetimeItem, DimmerItem, GroupItem, ImageItem, LocationItem, \ - NumberItem, PlayerItem, RollershutterItem, StringItem, SwitchItem + NumberItem, PlayerItem, RollershutterItem, StringItem, SwitchItem, CallItem +from HABApp.openhab.items.base_item import HINT_TYPE_OPENHAB_ITEM from HABApp.openhab.items.base_item import MetaData log = logging.getLogger('HABApp.openhab') +_items: Dict[str, HINT_TYPE_OPENHAB_ITEM] = { + 'String': StringItem, + 'Number': NumberItem, + 'Switch': SwitchItem, + 'Contact': ContactItem, + 'Rollershutter': RollershutterItem, + 'Dimmer': DimmerItem, + 'DateTime': DatetimeItem, + 'Color': ColorItem, + 'Image': ImageItem, + 'Group': GroupItem, + 'Player': PlayerItem, + 'Location': LocationItem, + 'Call': CallItem, +} + + def map_item(name: str, type: str, value: Optional[str], - tags: FrozenSet[str], groups: FrozenSet[str], metadata: Optional[Dict[str, Dict[str, Any]]]) -> \ + label: Optional[str], tags: FrozenSet[str], + groups: FrozenSet[str], metadata: Optional[Dict[str, Dict[str, Any]]]) -> \ Optional['HABApp.openhab.items.OpenhabItem']: try: assert isinstance(type, str) @@ -38,75 +56,11 @@ def map_item(name: str, type: str, value: Optional[str], if value is not None: value, _ = QuantityValue.split_unit(value) - # Specific classes - if type == "Switch": - return SwitchItem(name, value, tags=tags, groups=groups, metadata=meta) - - if type == "String": - return StringItem(name, value, tags=tags, groups=groups, metadata=meta) - - if type == "Contact": - return ContactItem(name, value, tags=tags, groups=groups, metadata=meta) - - if type == "Rollershutter": - if value is None: - return RollershutterItem(name, value, tags=tags, groups=groups, metadata=meta) - return RollershutterItem(name, float(value), tags=tags, groups=groups, metadata=meta) - - if type == "Dimmer": - if value is None: - return DimmerItem(name, value, tags=tags, groups=groups, metadata=meta) - return DimmerItem(name, float(value), tags=tags, groups=groups, metadata=meta) - - if type == "Number": - if value is None: - return NumberItem(name, value, tags=tags, groups=groups, metadata=meta) - - # Number items can be int or float - try: - return NumberItem(name, int(value), tags=tags, groups=groups, metadata=meta) - except ValueError: - return NumberItem(name, float(value), tags=tags, groups=groups, metadata=meta) - - if type == "DateTime": - if value is None: - return DatetimeItem(name, value, tags=tags, groups=groups, metadata=meta) - # Todo: remove this once we go >= OH3.1 - # Previous OH versions used a datetime string like this: - # 2018-11-19T09:47:38.284+0100 - # OH 3.1 uses - # 2021-04-10T22:00:43.043996+0200 - if len(value) == 28: - value = value.replace('+', '000+') - dt = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f%z') - # all datetimes from openhab have a timezone set so we can't easily compare them - # --> TypeError: can't compare offset-naive and offset-aware datetimes - dt = dt.astimezone(tz=None) # Changes datetime object so it uses system timezone - dt = dt.replace(tzinfo=None) # Removes timezone awareness - return DatetimeItem(name, dt, tags=tags, groups=groups, metadata=meta) - - if type == "Color": - if value is None: - return ColorItem(name, tags=tags, groups=groups, metadata=meta) - return ColorItem(name, *(float(k) for k in value.split(',')), tags=tags, groups=groups, metadata=meta) - - if type == "Image": - img = ImageItem(name, tags=tags, groups=groups, metadata=meta) - if value is None: - return img - img.set_value(RawValue(value)) - return img - - if type == "Group": - return GroupItem(name, value, tags=tags, groups=groups, metadata=meta) - - if type == "Location": - return LocationItem(name, value, tags=tags, groups=groups, metadata=meta) - - if type == "Player": - return PlayerItem(name, value, tags=tags, groups=groups, metadata=meta) + cls = _items.get(type) + if cls is not None: + return cls.from_oh(name, value, label=label, tags=tags, groups=groups, metadata=meta) - raise ValueError(f'Unknown Openhab type: {type} for {name}') + raise ValueError(f'Unknown openHAB type: {type} for {name}') except Exception as e: process_exception('map_items', e, logger=log) diff --git a/src/HABApp/openhab/map_values.py b/src/HABApp/openhab/map_values.py index 88b81778..e1fce19c 100644 --- a/src/HABApp/openhab/map_values.py +++ b/src/HABApp/openhab/map_values.py @@ -26,15 +26,8 @@ def map_openhab_values(openhab_type: str, openhab_value: str): return float(openhab_value) if openhab_type == "DateTime": - # Todo: remove this once we go >= OH3.1 - # Previous OH versions used a datetime string like this: - # 2018-11-19T09:47:38.284+0100 - # OH 3.1 uses - # 2021-04-10T22:00:43.043996+0200 - if len(openhab_value) == 28: - openhab_value = openhab_value.replace('+', '000+') dt = datetime.datetime.strptime(openhab_value, '%Y-%m-%dT%H:%M:%S.%f%z') - # all datetimes from openhab have a timezone set so we can't easily compare them + # all datetimes from openHAB have a timezone set, so we can't easily compare them # --> TypeError: can't compare offset-naive and offset-aware datetimes dt = dt.astimezone(tz=None) # Changes datetime object so it uses system timezone dt = dt.replace(tzinfo=None) # Removes timezone awareness diff --git a/src/HABApp/parameters/parameter_files.py b/src/HABApp/parameters/parameter_files.py index c6cfa02a..e4f38dba 100644 --- a/src/HABApp/parameters/parameter_files.py +++ b/src/HABApp/parameters/parameter_files.py @@ -1,6 +1,5 @@ import logging import threading -import asyncio from pathlib import Path import HABApp @@ -34,7 +33,11 @@ async def unload_file(name: str, path: Path): def save_file(file: str): assert isinstance(file, str), type(file) - filename = HABApp.CONFIG.directories.param / (file + '.yml') + path = HABApp.CONFIG.directories.param + if path is None: + raise ValueError('Parameter files are disabled! Configure a folder to use them!') + + filename = path / (file + '.yml') with LOCK: # serialize to get proper error messages log.info(f'Updated {filename}') @@ -50,8 +53,7 @@ class HABAppParameterFile(HABAppFile): async def setup_param_files() -> bool: path = HABApp.CONFIG.directories.param - if not path.is_dir(): - log.info(f'Parameter files disabled: Folder {path} does not exist!') + if path is None: return False folder = add_habapp_folder(PARAM_PREFIX, path, 100) @@ -63,4 +65,4 @@ async def setup_param_files() -> bool: def reload_param_file(name: str): name = f'{PARAM_PREFIX}{name}.yml' path = HABApp.core.files.folders.get_path(name) - asyncio.run_coroutine_threadsafe(HABApp.core.files.manager.process_file(name, path), HABApp.core.const.loop) + HABApp.core.asyncio.create_task(HABApp.core.files.manager.process_file(name, path)) diff --git a/src/HABApp/rule/__init__.py b/src/HABApp/rule/__init__.py index 3dccd554..e7f9a4e2 100644 --- a/src/HABApp/rule/__init__.py +++ b/src/HABApp/rule/__init__.py @@ -1,3 +1,5 @@ -from .rule import Rule, get_parent_rule - from HABApp.rule.interfaces import FinishedProcessInfo + +# isort: split + +from .rule import Rule diff --git a/src/HABApp/rule/interfaces/_http.py b/src/HABApp/rule/interfaces/_http.py index 763477ea..a43d0c9e 100644 --- a/src/HABApp/rule/interfaces/_http.py +++ b/src/HABApp/rule/interfaces/_http.py @@ -2,7 +2,7 @@ import aiohttp -from HABApp.core.const import loop +import HABApp from HABApp.core.const.json import dump_json @@ -13,7 +13,7 @@ async def create_client(): global CLIENT assert CLIENT is None - CLIENT = aiohttp.ClientSession(json_serialize=dump_json, loop=loop) + CLIENT = aiohttp.ClientSession(json_serialize=dump_json, loop=HABApp.core.const.loop) from HABApp.runtime import shutdown shutdown.register_func(CLIENT.close, msg='Closing generic http connection') diff --git a/src/HABApp/rule/rule.py b/src/HABApp/rule/rule.py index f1b869d3..900688c6 100644 --- a/src/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -1,25 +1,23 @@ -import asyncio -import datetime import logging -import random import re -import sys -import traceback -import typing import warnings -import weakref -from typing import Iterable, Union +from typing import Iterable, Union, Any, Optional, Tuple, Pattern, List import HABApp import HABApp.core import HABApp.openhab import HABApp.rule_manager import HABApp.util -from HABApp.core.events import AllEvents -from HABApp.core.items.base_item import BaseItem, TYPE_ITEM, TYPE_ITEM_CLS +from HABApp.core.asyncio import create_task +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 +from HABApp.core.internals import wrap_func +from HABApp.core.items import BaseItem, HINT_ITEM_OBJ, HINT_TYPE_ITEM_OBJ, BaseValueItem from HABApp.rule import interfaces from HABApp.rule.scheduler import HABAppSchedulerView as _HABAppSchedulerView from .interfaces import async_subprocess_exec +from .rule_hook import get_rule_hook as _get_rule_hook log = logging.getLogger('HABApp.Rule') @@ -35,50 +33,25 @@ def send_warnings_to_log(message, category, filename, lineno, file=None, line=No warnings.showwarning = send_warnings_to_log -class Rule: - def __init__(self): - - # get the variables from the caller - depth = 1 - while True: - try: - __vars = sys._getframe(depth).f_globals - except ValueError: - raise RuntimeError('Rule files are not meant to be executed directly! ' - 'Put the file in the HABApp "rule" folder and HABApp will load it automatically.') - - depth += 1 - if '__HABAPP__RUNTIME__' in __vars: - __runtime__ = __vars['__HABAPP__RUNTIME__'] - __rule_file__ = __vars['__HABAPP__RULE_FILE__'] - break +post_event = uses_post_event() +item_registry = uses_item_registry() - # variable vor unittests - test = __vars.get('__UNITTEST__', False) - # this is a list which contains all rules of this file - __vars['__HABAPP__RULES'].append(self) - - assert isinstance(__runtime__, HABApp.runtime.Runtime) - self.__runtime: HABApp.runtime.Runtime = __runtime__ - - if not test: - assert isinstance(__rule_file__, HABApp.rule_manager.RuleFile) - self.__rule_file: HABApp.rule_manager.RuleFile = __rule_file__ +class Rule(ContextProvidingObj): + def __init__(self): + super().__init__(context=HABApp.rule_ctx.HABAppRuleContext(self)) - self.__event_listener: typing.List[HABApp.core.EventBusListener] = [] - self.__unload_functions: typing.List[typing.Callable[[], None]] = [] - self.__cancel_objs: weakref.WeakSet = weakref.WeakSet() + hook = _get_rule_hook() + hook.register_rule(self) - # schedule cleanup of this rule - self.register_on_unload(self.__cleanup_rule) - self.register_on_unload(self.__cleanup_objs) + self.__runtime: HABApp.runtime.Runtime = hook.runtime + assert isinstance(self.__runtime, HABApp.runtime.Runtime) # scheduler - self.run: _HABAppSchedulerView = _HABAppSchedulerView(self) + self.run: _HABAppSchedulerView = _HABAppSchedulerView(self._habapp_ctx) # suggest a rule name - self.rule_name: str = self.__rule_file.suggest_rule_name(self) + self.rule_name: str = hook.suggest_rule_name(self) # interfaces self.async_http = interfaces.http @@ -86,30 +59,31 @@ def __init__(self): self.oh: HABApp.openhab.interface = HABApp.openhab.interface self.openhab: HABApp.openhab.interface = self.oh - @HABApp.core.wrapper.log_exception - def __cleanup_objs(self): - while self.__cancel_objs: - # we log each error as warning - with HABApp.core.wrapper.ExceptionToHABApp(log, logging.WARNING): - obj = self.__cancel_objs.pop() - obj.cancel() - - @HABApp.core.wrapper.log_exception - def __cleanup_rule(self): - # Important: set the dicts to None so we don't schedule a future event during _cleanup. - # If dict is set to None we will crash instead but it is no problem because everything gets unloaded anyhow - event_listeners = self.__event_listener - self.__event_listener = None - - # Actually remove the listeners/events - for listener in event_listeners: - HABApp.core.EventBus.remove_listener(listener) - - # Unload the scheduler - self.run._scheduler.cancel_all() - return None - - def post_event(self, name, event): + def on_rule_loaded(self): + """Override this to implement logic that will be called when the rule and the file has been successfully loaded + """ + + def on_rule_removed(self): + """Override this to implement logic that will be called when the rule has been unloaded. + """ + + def __repr__(self): + # empty string, so we have a space if we have more than one entry + parts = [''] + + # rule name if it is different from the class + cls_name = self.__class__.__name__ + rule_name = str(self.rule_name) + if cls_name != rule_name: + parts.append(rule_name) + + # rule status + if self._habapp_ctx is None: + parts.append('(unloaded)') + + return f'<{cls_name}{" ".join(parts)}>' + + def post_event(self, name: Union[HINT_ITEM_OBJ, str], event: Any): """ Post an event to the event bus @@ -117,41 +91,39 @@ def post_event(self, name, event): :param event: Event class to be used (must be class instance) :return: """ - assert isinstance(name, (str, HABApp.core.items.BaseValueItem)), type(name) - return HABApp.core.EventBus.post_event( - name.name if isinstance(name, HABApp.core.items.BaseValueItem) else name, + assert isinstance(name, (str, BaseValueItem)), type(name) + return post_event( + name.name if isinstance(name, BaseValueItem) else name, event ) - def listen_event(self, name: Union[HABApp.core.items.BaseValueItem, str], - callback: typing.Callable[[typing.Any], typing.Any], - event_type: Union[typing.Type['HABApp.core.events.AllEvents'], - 'HABApp.core.events.EventFilter', typing.Any] = AllEvents - ) -> HABApp.core.EventBusListener: + def listen_event(self, name: Union[HINT_ITEM_OBJ, str], + callback: HINT_EVENT_CALLBACK, + event_filter: Optional[HINT_EVENT_FILTER_OBJ] = None + ) -> HINT_EVENT_BUS_LISTENER: """ Register an event listener - :param name: item or name to listen to. Use None to listen to all events + :param name: item or name to listen to :param callback: callback that accepts one parameter which will contain the event - :param event_type: Event filter. This is typically :class:`~HABApp.core.events.ValueUpdateEvent` or - :class:`~HABApp.core.events.ValueChangeEvent` which will also trigger on changes/update from openhab + :param event_filter: Event filter. This is typically :class:`~HABApp.core.events.ValueUpdateEventFilter` or + :class:`~HABApp.core.events.ValueChangeEventFilter` which will also trigger on changes/update from openhab or mqtt. Additionally it can be an instance of :class:`~HABApp.core.events.EventFilter` which additionally - filters on the values of the event. There are also templates for the most common filters, e.g. - :class:`~HABApp.core.events.ValueUpdateEventFilter` and :class:`~HABApp.core.events.ValueChangeEventFilter` + filters on the values of the event. It is also possible to group filters logically with, e.g. + :class:`~HABApp.core.events.AndFilterGroup` and :class:`~HABApp.core.events.OrFilterGroup` """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - name = name.name if isinstance(name, HABApp.core.items.BaseValueItem) else name + cb = wrap_func(callback, context=self._habapp_ctx) + name = name.name if isinstance(name, BaseItem) else name - if isinstance(event_type, HABApp.core.events.EventFilter): - listener = event_type.create_event_listener(name, cb) - else: - listener = HABApp.core.EventBusListener(name, cb, event_type) + if event_filter is None: + event_filter = HABApp.core.events.NoEventFilter() + if not isinstance(event_filter, EventFilterBase): + raise ValueError(f'Argument event_filter must be an instance of event filter (is {event_filter})') - self.__event_listener.append(listener) - HABApp.core.EventBus.add_listener(listener) - return listener + listener = ContextBoundEventBusListener(name, cb, event_filter, parent_ctx=self._habapp_ctx) + return self._habapp_ctx.add_event_listener(listener) - def execute_subprocess(self, callback, program, *args, capture_output=True): + def execute_subprocess(self, callback: HINT_EVENT_CALLBACK, program, *args, capture_output=True): """Run another program :param callback: |param_scheduled_cb| after process has finished. First parameter will @@ -163,43 +135,24 @@ def execute_subprocess(self, callback, program, *args, capture_output=True): """ assert isinstance(program, str), type(program) - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) + cb = wrap_func(callback, context=self._habapp_ctx) - asyncio.run_coroutine_threadsafe( + create_task( async_subprocess_exec(cb.run, program, *args, capture_output=capture_output), - HABApp.core.const.loop ) - def get_rule(self, rule_name: str) -> 'Union[Rule, typing.List[Rule]]': + def get_rule(self, rule_name: str) -> 'Union[Rule, List[Rule]]': assert rule_name is None or isinstance(rule_name, str), type(rule_name) return self.__runtime.rule_manager.get_rule(rule_name) - def register_on_unload(self, func: typing.Callable[[], typing.Any]): - """Register a function with no parameters which will be called when the rule is unloaded. - Use this for custom cleanup functions. - - :param func: function which will be called - """ - assert callable(func) - assert func not in self.__unload_functions, 'Function was already registered!' - self.__unload_functions.append(func) - - def register_cancel_obj(self, obj): - """Add a ``weakref`` to an obj which has a ``cancel`` function. - When the rule gets unloaded the cancel function will be called (if the obj was not already garbage collected) - - :param obj: - """ - self.__cancel_objs.add(obj) - @staticmethod - def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = None, - name: Union[str, typing.Pattern[str]] = None, + def get_items(type: Union[Tuple[HINT_TYPE_ITEM_OBJ, ...], HINT_TYPE_ITEM_OBJ] = None, + name: Union[str, Pattern[str]] = None, tags: Union[str, Iterable[str]] = None, groups: Union[str, Iterable[str]] = None, - metadata: Union[str, typing.Pattern[str]] = None, - metadata_value: Union[str, typing.Pattern[str]] = None, - ) -> Union[typing.List[TYPE_ITEM], typing.List[BaseItem]]: + metadata: Union[str, Pattern[str]] = None, + metadata_value: Union[str, Pattern[str]] = None, + ) -> Union[List[HINT_ITEM_OBJ], List[BaseItem]]: """Search the HABApp item registry and return the found items. :param type: item has to be an instance of this class @@ -233,7 +186,7 @@ def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = Non raise ValueError('Searching for tags, groups and metadata only works for OpenhabItem or its Subclasses') ret = [] - for item in HABApp.core.Items.get_all_items(): # type: HABApp.core.items.base_valueitem.BaseItem + for item in item_registry.get_items(): # type: HABApp.core.items.BaseItem if type is not None and not isinstance(item, type): continue @@ -255,130 +208,3 @@ def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = Non ret.append(item) return ret - - # ----------------------------------------------------------------------------------------------------------------- - # deprecated functions - # ----------------------------------------------------------------------------------------------------------------- - def run_every(self, time, interval: Union[int, datetime.timedelta], callback, *args, **kwargs): - warnings.warn('self.run_every is deprecated. Please use self.run.every', DeprecationWarning) - return self.run.every(time, interval, callback, *args, **kwargs) - - def run_on_sun(self, sun_event: str, callback, *args, **kwargs): - warnings.warn('self.run_on_sun is deprecated. Please use self.run.on_sunrise, self.run.on_sunset, ...', - DeprecationWarning) - func = {'sunset': self.run.on_sunset, 'sunrise': self.run.on_sunrise, - 'dusk': self.run.on_sun_dusk, 'dawn': self.run.on_sun_dawn} - return func[sun_event](callback, *args, **kwargs) - - def run_on_day_of_week(self, time: datetime.time, weekdays, callback, *args, **kwargs): - warnings.warn('self.run_on_day_of_week is deprecated. Please use self.run.on_day_of_week', DeprecationWarning) - return self.run.on_day_of_week(time, weekdays, callback, *args, **kwargs) - - def run_on_every_day(self, time: datetime.time, callback, *args, **kwargs): - warnings.warn('self.run_on_every_day is deprecated. Please use self.run.on_every_day', DeprecationWarning) - return self.run.on_every_day(time, callback, *args, **kwargs) - - def run_on_workdays(self, time: datetime.time, callback, *args, **kwargs): - warnings.warn('self.run_on_workdays is deprecated. Please use self.run.on_workdays', DeprecationWarning) - return self.run.on_workdays(time, callback, *args, **kwargs) - - def run_on_weekends(self, time: datetime.time, callback, *args, **kwargs): - warnings.warn('self.run_on_weekends is deprecated. Please use self.run.on_weekends', DeprecationWarning) - return self.run.on_weekends(time, callback, *args, **kwargs) - - def run_daily(self, callback, *args, **kwargs): - warnings.warn('self.run_hourly is deprecated. Please use self.run.every', DeprecationWarning) - start = datetime.timedelta(seconds=random.randint(0, 24 * 3600 - 1)) - return self.run.every(start, datetime.timedelta(days=1), callback, *args, **kwargs) - - def run_hourly(self, callback, *args, **kwargs) : - warnings.warn('self.run_hourly is deprecated. Please use self.run.every_hour', DeprecationWarning) - return self.run.every_hour(callback, *args, **kwargs) - - def run_minutely(self, callback, *args, **kwargs): - warnings.warn('self.run_minutely is deprecated. Please use self.run.every_minute', DeprecationWarning) - return self.run.every_minute(callback, *args, **kwargs) - - def run_at(self, date_time, callback, *args, **kwargs): - warnings.warn('self.run_at is deprecated. Please use self.run.at', DeprecationWarning) - return self.run.at(date_time, callback, *args, **kwargs) - - def run_in(self, seconds: Union[int, datetime.timedelta], callback, *args, **kwargs): - warnings.warn('self.run_in is deprecated. Please use self.run.at', DeprecationWarning) - return self.run.at(seconds, callback, *args, **kwargs) - - def run_soon(self, callback, *args, **kwargs): - warnings.warn('self.run_soon is deprecated. Please use self.run.soon', DeprecationWarning) - return self.run.soon(callback, *args, **kwargs) - - # ----------------------------------------------------------------------------------------------------------------- - # internal functions - # ----------------------------------------------------------------------------------------------------------------- - def _get_cb_name(self, callback): - return f'{self.rule_name}.{callback.__name__}' if self.rule_name else None - - def _add_event_listener(self, listener: HABApp.core.EventBusListener) -> HABApp.core.EventBusListener: - self.__event_listener.append(listener) - HABApp.core.EventBus.add_listener(listener) - return listener - - @HABApp.core.wrapper.log_exception - def _check_rule(self): - # We need items if we want to run the test - if HABApp.core.Items.get_all_items(): - - # Check if we have a valid item for all listeners - for listener in self.__event_listener: - - # Internal topics - don't warn there - if listener.topic in HABApp.core.const.topics.ALL: - continue - - # check if specific item exists - if not HABApp.core.Items.item_exists(listener.topic): - log.warning(f'Item "{listener.topic}" does not exist (yet)! ' - f'self.listen_event in "{self.rule_name}" may not work as intended.') - - # enable the scheduler - self.run._scheduler.resume() - - - @HABApp.core.wrapper.log_exception - def _unload(self): - - # unload all functions - for func in self.__unload_functions: - try: - func() - except Exception as e: - - # try getting function name - try: - name = f' in "{func.__name__}"' - except AttributeError: - name = '' - - log.error(f'Error{name} while unloading "{self.rule_name}": {e}') - - # log traceback - lines = traceback.format_exc().splitlines() - del lines[1:3] # see implementation in wrappedfunction.py why we do this - for line in lines: - log.error(line) - - -@HABApp.core.wrapper.log_exception -def get_parent_rule() -> Rule: - depth = 1 - while True: - try: - frm = sys._getframe(depth) - except ValueError: - raise RuntimeError('Could not find parent rule!') from None - - __vars = frm.f_locals - depth += 1 - if 'self' in __vars: - rule = __vars['self'] - if isinstance(rule, Rule): - return rule diff --git a/src/HABApp/rule/rule_hook.py b/src/HABApp/rule/rule_hook.py new file mode 100644 index 00000000..60606695 --- /dev/null +++ b/src/HABApp/rule/rule_hook.py @@ -0,0 +1,60 @@ +import sys +from typing import TYPE_CHECKING, Any, Callable, Final + + +if TYPE_CHECKING: + import HABApp + import types + import HABApp.rule_manager + +_NAME: Final = '__HABAPP__HOOK__' + + +class HABAppRuleHook: + + @classmethod + def in_dict(cls, obj: dict, + cb_register_rule: Callable[['HABApp.rule.Rule'], Any], + cb_suggest_name: Callable[['HABApp.rule.Rule'], str], + runtime: 'HABApp.runtime.Runtime', rule_file: 'HABApp.rule_manager.RuleFile') -> dict: + obj[_NAME] = cls(cb_register_rule, cb_suggest_name, runtime, rule_file) + return obj + + def __init__(self, + cb_register_rule: Callable[['HABApp.rule.Rule'], Any], + cb_suggest_name: Callable[['HABApp.rule.Rule'], str], + runtime: 'HABApp.runtime.Runtime', rule_file: 'HABApp.rule_manager.RuleFile'): + # callbacks + self._cb_register: Final = cb_register_rule + self._cb_suggest_name: Final = cb_suggest_name + + # runtime objs + self.runtime: Final = runtime + self.rule_file: Final = rule_file + + def register_rule(self, rule: 'HABApp.rule.Rule'): + return self._cb_register(rule) + + def suggest_rule_name(self, rule: 'HABApp.rule.Rule') -> str: + return self._cb_suggest_name(rule) + + +def get_rule_hook() -> HABAppRuleHook: + + depth = 0 + while True: + depth += 1 + try: + frame = sys._getframe(depth) # type: types.FrameType + except ValueError: + raise RuntimeError('Rule files are not meant to be executed directly! ' + 'Put the file in the HABApp "rule" folder and HABApp will load it automatically.') + + _globals = frame.f_globals + + hook = _globals.get(_NAME, None) + if hook is None: + continue + + assert isinstance(hook, HABAppRuleHook) + return hook diff --git a/src/HABApp/rule/scheduler/executor.py b/src/HABApp/rule/scheduler/executor.py index 89183060..91067a03 100644 --- a/src/HABApp/rule/scheduler/executor.py +++ b/src/HABApp/rule/scheduler/executor.py @@ -1,13 +1,12 @@ from typing import Callable +from HABApp.core.internals.wrapped_function import WrappedFunctionBase from eascheduler.executors import ExecutorBase -from HABApp.core import WrappedFunction - class WrappedFunctionExecutor(ExecutorBase): def __init__(self, func: Callable, *args, **kwargs): - assert isinstance(func, WrappedFunction), type(func) + assert isinstance(func, WrappedFunctionBase), type(func) super().__init__(func, *args, **kwargs) def execute(self): diff --git a/src/HABApp/rule/scheduler/habappschedulerview.py b/src/HABApp/rule/scheduler/habappschedulerview.py index be9e6557..661cde06 100644 --- a/src/HABApp/rule/scheduler/habappschedulerview.py +++ b/src/HABApp/rule/scheduler/habappschedulerview.py @@ -7,35 +7,42 @@ SunsetJob import HABApp -from HABApp.core import WrappedFunction +import HABApp.rule_ctx +from HABApp.core.const.hints import HINT_SCHEDULER_CALLBACK +from HABApp.core.internals import wrap_func from HABApp.rule.scheduler.executor import WrappedFunctionExecutor from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler +from HABApp.core.internals import ContextProvidingObj, HINT_CONTEXT_OBJ -class HABAppSchedulerView(SchedulerView): - def __init__(self, rule: 'HABApp.rule.Rule'): +class HABAppSchedulerView(SchedulerView, ContextProvidingObj): + def __init__(self, context: 'HABApp.rule_ctx.HABAppRuleContext'): super().__init__(_HABAppScheduler(), WrappedFunctionExecutor) - self._rule: 'HABApp.rule.Rule' = rule + self._habapp_rule_ctx: HINT_CONTEXT_OBJ = context - def at(self, time: Union[None, dt_datetime, dt_timedelta, dt_time, int], callback, *args, **kwargs) -> OneTimeJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def at(self, time: Union[None, dt_datetime, dt_timedelta, dt_time, int], + callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> OneTimeJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().at(time, callback, *args, **kwargs) - def countdown(self, expire_time: Union[dt_timedelta, float, int], callback, *args, **kwargs) -> CountdownJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def countdown(self, expire_time: Union[dt_timedelta, float, int], + callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> CountdownJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().countdown(expire_time, callback, *args, **kwargs) def every(self, start_time: Union[None, dt_datetime, dt_timedelta, dt_time, int], - interval: Union[int, float, dt_timedelta], callback, *args, **kwargs) -> ReoccurringJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + interval: Union[int, float, dt_timedelta], + callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().every(start_time, interval, callback, *args, **kwargs) def on_day_of_week(self, time: Union[dt_time, dt_datetime], weekdays: Union[str, Iterable[Union[str, int]]], - callback, *args, **kwargs) -> DayOfWeekJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DayOfWeekJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_day_of_week(time, weekdays, callback, *args, **kwargs) - def on_every_day(self, time: Union[dt_time, dt_datetime], callback, *args, **kwargs) -> DayOfWeekJob: + def on_every_day(self, time: Union[dt_time, dt_datetime], + callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DayOfWeekJob: """Create a job that will run at a certain time of day :param time: Time when the job will run @@ -43,26 +50,26 @@ def on_every_day(self, time: Union[dt_time, dt_datetime], callback, *args, **kwa :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_day_of_week(time, 'all', callback, *args, **kwargs) - def on_sunrise(self, callback, *args, **kwargs) -> SunriseJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def on_sunrise(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> SunriseJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sunrise(callback, *args, **kwargs) - def on_sunset(self, callback, *args, **kwargs) -> SunsetJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def on_sunset(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> SunsetJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sunset(callback, *args, **kwargs) - def on_sun_dawn(self, callback, *args, **kwargs) -> DawnJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def on_sun_dawn(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DawnJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sun_dawn(callback, *args, **kwargs) - def on_sun_dusk(self, callback, *args, **kwargs) -> DuskJob: - callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + def on_sun_dusk(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DuskJob: + callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sun_dusk(callback, *args, **kwargs) - def soon(self, callback, *args, **kwargs) -> OneTimeJob: + def soon(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> OneTimeJob: """ Run the callback as soon as possible. @@ -72,7 +79,7 @@ def soon(self, callback, *args, **kwargs) -> OneTimeJob: """ return self.at(None, callback, *args, **kwargs) - def every_minute(self, callback, *args, **kwargs) -> ReoccurringJob: + def every_minute(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: """Picks a random second and runs the callback every minute :param callback: |param_scheduled_cb| @@ -83,7 +90,7 @@ def every_minute(self, callback, *args, **kwargs) -> ReoccurringJob: interval = dt_timedelta(seconds=60) return self.every(start, interval, callback, *args, **kwargs) - def every_hour(self, callback, *args, **kwargs) -> ReoccurringJob: + def every_hour(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: """Picks a random minute and second and run the callback every hour :param callback: |param_scheduled_cb| diff --git a/src/HABApp/rule/scheduler/scheduler.py b/src/HABApp/rule/scheduler/scheduler.py index 0bc8c16b..4cc4765a 100644 --- a/src/HABApp/rule/scheduler/scheduler.py +++ b/src/HABApp/rule/scheduler/scheduler.py @@ -1,7 +1,7 @@ from asyncio import run_coroutine_threadsafe from HABApp.core.const import loop -from HABApp.core.context import async_context +from HABApp.core.asyncio import async_context from eascheduler.jobs.job_base import ScheduledJobBase from eascheduler.schedulers import AsyncScheduler diff --git a/src/HABApp/rule_ctx/__init__.py b/src/HABApp/rule_ctx/__init__.py new file mode 100644 index 00000000..6e8cc9b3 --- /dev/null +++ b/src/HABApp/rule_ctx/__init__.py @@ -0,0 +1 @@ +from .rule_ctx import HABAppRuleContext diff --git a/src/HABApp/rule_ctx/rule_ctx.py b/src/HABApp/rule_ctx/rule_ctx.py new file mode 100644 index 00000000..04fe2853 --- /dev/null +++ b/src/HABApp/rule_ctx/rule_ctx.py @@ -0,0 +1,77 @@ +import logging +from typing import Optional + +import HABApp +from HABApp.core.const.topics import ALL_TOPICS +from HABApp.core.internals import Context, uses_item_registry, HINT_EVENT_BUS_LISTENER +from HABApp.core.internals import uses_event_bus +from HABApp.core.internals.event_bus import EventBusBaseListener + +event_bus = uses_event_bus() +item_registry = uses_item_registry() + +log = logging.getLogger('HABApp.Rule') + + +class HABAppRuleContext(Context): + def __init__(self, rule: 'HABApp.rule.Rule'): + super().__init__() + self.rule: Optional['HABApp.rule.Rule'] = rule + + def get_callback_name(self, callback: callable) -> Optional[str]: + return f'{self.rule.rule_name}.{callback.__name__}' if self.rule.rule_name else None + + def add_event_listener(self, listener: HINT_EVENT_BUS_LISTENER) -> HINT_EVENT_BUS_LISTENER: + event_bus.add_listener(listener) + return listener + + def remove_event_listener(self, listener: HINT_EVENT_BUS_LISTENER) -> HINT_EVENT_BUS_LISTENER: + event_bus.remove_listener(listener) + return listener + + def unload_rule(self): + with HABApp.core.wrapper.ExceptionToHABApp(log): + rule = self.rule + + # Unload the scheduler + rule.run._scheduler.cancel_all() + rule.run._habapp_ctx = None + + # cancel things and set obj to None + while self.objs: + with HABApp.core.wrapper.ExceptionToHABApp(log): + to_cancel = next(iter(self.objs)) + to_cancel.cancel() + self.objs = None # Set to None so we crash if we want to schedule new stuff + + # clean references + self.rule = None + rule._habapp_rule_ctx = None + + # user implementation + rule.on_rule_removed() + + def check_rule(self): + with HABApp.core.wrapper.ExceptionToHABApp(log): + # We need items if we want to run the test + if item_registry.get_items(): + + # Check if we have a valid item for all listeners + for listener in self.objs: + if not isinstance(listener, EventBusBaseListener): + continue + + # Internal topics - don't warn there + if listener.topic in ALL_TOPICS: + continue + + # check if specific item exists + if not item_registry.item_exists(listener.topic): + log.warning(f'Item "{listener.topic}" does not exist (yet)! ' + f'self.listen_event in "{self.rule.rule_name}" may not work as intended.') + + # enable the scheduler + self.rule.run._scheduler.resume() + + # user implementation + self.rule.on_rule_loaded() diff --git a/src/HABApp/rule_manager/benchmark/bench_base.py b/src/HABApp/rule_manager/benchmark/bench_base.py index 76a1089e..b032fc9d 100644 --- a/src/HABApp/rule_manager/benchmark/bench_base.py +++ b/src/HABApp/rule_manager/benchmark/bench_base.py @@ -1,7 +1,7 @@ from typing import Optional import HABApp -from HABApp.core.const.topics import ERRORS +from HABApp.core.const.topics import TOPIC_ERRORS class BenchBaseRule(HABApp.Rule): @@ -29,7 +29,7 @@ def _err_event(self, event): def do_bench_start(self): self.errors.clear() - self.err_watcher = self.listen_event(ERRORS, self._err_event) + self.err_watcher = self.listen_event(TOPIC_ERRORS, self._err_event) self.run.at(1, self.do_bench_run) diff --git a/src/HABApp/rule_manager/benchmark/bench_file.py b/src/HABApp/rule_manager/benchmark/bench_file.py index 07f5b09f..99520d94 100644 --- a/src/HABApp/rule_manager/benchmark/bench_file.py +++ b/src/HABApp/rule_manager/benchmark/bench_file.py @@ -1,10 +1,11 @@ from pathlib import Path import HABApp +from HABApp.rule.rule_hook import HABAppRuleHook from HABApp.rule_manager import RuleFile from .bench_habapp import HABAppBenchRule -from .bench_oh import OpenhabBenchRule from .bench_mqtt import MqttBenchRule +from .bench_oh import OpenhabBenchRule class BenchFile(RuleFile): @@ -12,19 +13,12 @@ def __init__(self, rule_manager): super().__init__(rule_manager, 'BenchmarkFile', path=Path('BenchmarkFile')) def create_rules(self, created_rules: list): - glob = globals() - glob['__HABAPP__RUNTIME__'] = self.rule_manager.runtime - glob['__HABAPP__RULE_FILE__'] = self - glob['__HABAPP__RULES'] = created_rules + HABAppRuleHook.in_dict(globals(), created_rules.append, self.suggest_rule_name, self.rule_manager.runtime, self) rule_ha = rule = HABAppBenchRule() if HABApp.CONFIG.mqtt.connection.host: rule = rule.link_rule(MqttBenchRule()) - if HABApp.CONFIG.openhab.connection.host: + if HABApp.CONFIG.openhab.connection.url: rule = rule.link_rule(OpenhabBenchRule()) rule_ha.run.at(5, rule_ha.do_bench_start) - - glob.pop('__HABAPP__RUNTIME__') - glob.pop('__HABAPP__RULE_FILE__') - glob.pop('__HABAPP__RULES') diff --git a/src/HABApp/rule_manager/benchmark/bench_habapp.py b/src/HABApp/rule_manager/benchmark/bench_habapp.py index f16eb476..5f6ec634 100644 --- a/src/HABApp/rule_manager/benchmark/bench_habapp.py +++ b/src/HABApp/rule_manager/benchmark/bench_habapp.py @@ -73,7 +73,7 @@ def run_rtt(self, test_name, do_async=False): self.bench_times = self.bench_times_container.create(test_name) self.time_sent = time.time() - HABApp.core.EventBus.post_event(self.name, self.values[0]) + self.post_event(self.name, self.values[0]) self.run.soon(LOCK.acquire) time.sleep(1) @@ -103,7 +103,7 @@ def post_next_event_val(self, value): return None self.time_sent = time.time() - HABApp.core.EventBus.post_event(self.name, self.values[0]) + self.post_event(self.name, self.values[0]) async def a_post_next_event_val(self, event: ValueUpdateEvent): self.post_next_event_val(event) diff --git a/src/HABApp/rule_manager/benchmark/bench_mqtt.py b/src/HABApp/rule_manager/benchmark/bench_mqtt.py index ffc8ec4c..fd3c78b2 100644 --- a/src/HABApp/rule_manager/benchmark/bench_mqtt.py +++ b/src/HABApp/rule_manager/benchmark/bench_mqtt.py @@ -4,7 +4,7 @@ from threading import Lock import HABApp -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.events import ValueUpdateEvent, ValueUpdateEventFilter from .bench_base import BenchBaseRule from .bench_times import BenchContainer, BenchTime from HABApp.mqtt.interface import publish @@ -68,7 +68,9 @@ def run_rtt(self, test_name, do_async=False): self.values.append(random.randint(0, 99999999)) listener = self.listen_event( - self.name, self.post_next_event_val if not do_async else self.a_post_next_event_val, ValueUpdateEvent + self.name, + self.post_next_event_val if not do_async else self.a_post_next_event_val, + ValueUpdateEventFilter() ) self.bench_times = self.bench_times_container.create(test_name) diff --git a/src/HABApp/rule_manager/benchmark/bench_oh.py b/src/HABApp/rule_manager/benchmark/bench_oh.py index 0da4e214..c403135c 100644 --- a/src/HABApp/rule_manager/benchmark/bench_oh.py +++ b/src/HABApp/rule_manager/benchmark/bench_oh.py @@ -4,7 +4,7 @@ from threading import Lock import HABApp -from HABApp.core.events import ValueUpdateEvent +from HABApp.core.events import ValueUpdateEvent, ValueUpdateEventFilter from .bench_base import BenchBaseRule from .bench_times import BenchContainer, BenchTime @@ -34,7 +34,7 @@ def __init__(self): def cleanup(self): self.stop_load() - all_items = set(HABApp.core.Items.get_all_item_names()) + all_items = set(HABApp.core.Items.get_item_names()) to_rem = set(self.name_list) & all_items if not to_rem: @@ -127,7 +127,7 @@ def load_cb(event, item=self.name_list[i]): self.openhab.post_update(item, str(random.randint(0, 99999999))) self.openhab.create_item('String', self.name_list[i], label='MyLabel') - listener = self.listen_event(self.name_list[i], load_cb, ValueUpdateEvent) + listener = self.listen_event(self.name_list[i], load_cb, ValueUpdateEventFilter()) self.load_listener.append(listener) self.openhab.post_update(self.name_list[i], str(random.randint(0, 99999999))) @@ -145,7 +145,7 @@ def run_rtt(self, test_name, do_async=False): self.item_values.append(str(random.randint(0, 99999999))) listener = self.listen_event( - self.item_name, self.proceed_item_val if not do_async else self.a_proceed_item_val, ValueUpdateEvent + self.item_name, self.proceed_item_val if not do_async else self.a_proceed_item_val, ValueUpdateEventFilter() ) self.bench_times = self.bench_times_container.create(test_name) diff --git a/src/HABApp/rule_manager/rule_file.py b/src/HABApp/rule_manager/rule_file.py index 3f114e28..9f4df21c 100644 --- a/src/HABApp/rule_manager/rule_file.py +++ b/src/HABApp/rule_manager/rule_file.py @@ -5,11 +5,12 @@ from pathlib import Path import HABApp +from HABApp.rule.rule_hook import HABAppRuleHook +from HABApp.core.internals import get_current_context log = logging.getLogger('HABApp.Rules') - class RuleFile: def __init__(self, rule_manager, name: str, path: Path): from .rule_manager import RuleManager @@ -24,16 +25,13 @@ def __init__(self, rule_manager, name: str, path: Path): self.class_ctr: typing.Dict[str, int] = collections.defaultdict(lambda: 1) - def suggest_rule_name(self, obj) -> str: + def suggest_rule_name(self, obj: 'HABApp.Rule') -> str: # if there is already a name set we make no suggestion if getattr(obj, 'rule_name', '') != '': return obj.rule_name.replace('ü', 'ue').replace('ö', 'oe').replace('ä', 'ae') - # create unique name - # - parts = str(type(obj)).split('.') - name = parts[-1][:-2] + name = obj.__class__.__name__ found = self.class_ctr[name] self.class_ctr[name] += 1 @@ -41,7 +39,7 @@ def suggest_rule_name(self, obj) -> str: def check_all_rules(self): for rule in self.rules.values(): # type: HABApp.Rule - rule._check_rule() + get_current_context(rule).check_rule() def unload(self): @@ -51,7 +49,7 @@ def unload(self): # unload all registered callbacks for rule in self.rules.values(): # type: HABApp.Rule - rule._unload() + get_current_context(rule).unload_rule() log.debug(f'File {self.name} successfully unloaded!') return None @@ -61,13 +59,12 @@ def __process_tc(self, tb: list): return [line.replace('', self.path.name) for line in tb] def create_rules(self, created_rules: list): + init_globals = HABAppRuleHook.in_dict( + {}, created_rules.append, self.suggest_rule_name, self.rule_manager.runtime, self) + # It seems like python 3.8 doesn't allow path like objects any more: # https://github.com/spacemanspiff2007/HABApp/issues/111 - runpy.run_path(str(self.path), run_name=str(self.path), init_globals={ - '__HABAPP__RUNTIME__': self.rule_manager.runtime, - '__HABAPP__RULE_FILE__': self, - '__HABAPP__RULES': created_rules, - }) + runpy.run_path(str(self.path), run_name=str(self.path), init_globals=init_globals) def load(self) -> bool: @@ -84,7 +81,7 @@ def load(self) -> bool: # still listen to events and do stuff for rule in created_rules: with ign: - rule._unload() + get_current_context(rule).unload_rule() return False if not created_rules: @@ -108,7 +105,7 @@ def load(self) -> bool: # still listen to events and do stuff for rule in created_rules: with ign: - rule._unload() + get_current_context(rule).unload_rule() return False return True diff --git a/src/HABApp/rule_manager/rule_manager.py b/src/HABApp/rule_manager/rule_manager.py index ba7af956..66899464 100644 --- a/src/HABApp/rule_manager/rule_manager.py +++ b/src/HABApp/rule_manager/rule_manager.py @@ -14,9 +14,13 @@ from HABApp.core.wrapper import log_exception from HABApp.runtime import shutdown from .rule_file import RuleFile +from ..core.internals import uses_item_registry +from HABApp.core.internals.wrapped_function import run_function log = logging.getLogger('HABApp.Rules') +item_registry = uses_item_registry() + class RuleManager: @@ -43,7 +47,7 @@ async def setup(self): if cmd_args.DO_BENCH: from HABApp.rule_manager.benchmark import BenchFile self.files['bench'] = file = BenchFile(self) - ok = await HABApp.core.const.loop.run_in_executor(HABApp.core.WrappedFunction._WORKERS, file.load) + ok = await run_function(file.load) if not ok: log.error('Failed to load Benchmark!') HABApp.runtime.shutdown.request_shutdown() @@ -62,15 +66,15 @@ class HABAppRuleFile(HABAppFile): self.watcher = folder.add_watch('.py', True) # Initial loading of rules - HABApp.core.WrappedFunction(self.load_rules_on_startup, logger=log).run() + HABApp.core.internals.wrap_func(self.load_rules_on_startup, logger=log).run() async def load_rules_on_startup(self): - if HABApp.CONFIG.openhab.connection.host and HABApp.CONFIG.openhab.general.wait_for_openhab: + if HABApp.CONFIG.openhab.connection.url and HABApp.CONFIG.openhab.general.wait_for_openhab: items_found = False while not items_found: await sleep(3) - for item in HABApp.core.Items.get_all_items(): + for item in item_registry.get_items(): if isinstance(item, HABApp.openhab.items.OpenhabItem): items_found = True break @@ -124,7 +128,7 @@ async def request_file_unload(self, name: str, path: Path, request_lock=True): with self.__files_lock: rule = self.files.pop(path_str) - await HABApp.core.const.loop.run_in_executor(HABApp.core.WrappedFunction._WORKERS, rule.unload) + await run_function(rule.unload) finally: if request_lock: self.__load_lock.release() @@ -148,7 +152,7 @@ async def request_file_load(self, name: str, path: Path): with self.__files_lock: self.files[path_str] = file = RuleFile(self, name, path) - ok = await HABApp.core.const.loop.run_in_executor(HABApp.core.WrappedFunction._WORKERS, file.load) + ok = await run_function(file.load) if not ok: self.files.pop(path_str) log.warning(f'Failed to load {path_str}!') @@ -161,5 +165,4 @@ async def request_file_load(self, name: str, path: Path): def shutdown(self): for f in self.files.values(): - for rule in f.rules.values(): - rule._unload() + f.unload() diff --git a/src/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py index 09151e31..5e3b3192 100644 --- a/src/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -1,19 +1,21 @@ import asyncio from pathlib import Path +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 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 from HABApp.runtime import shutdown -from HABApp.core.context import async_context - -import HABApp.rule.interfaces._http class Runtime: @@ -24,9 +26,6 @@ def __init__(self): # Rule engine self.rule_manager: HABApp.rule_manager.RuleManager = None - # Async Workers & shutdown callback - shutdown.register_func(HABApp.core.WrappedFunction._WORKERS.shutdown, msg='Stopping workers') - async def start(self, config_folder: Path): try: token = async_context.set('HABApp startup') @@ -37,7 +36,17 @@ async def start(self, config_folder: Path): # Start Folder watcher! HABApp.core.files.watcher.start() - self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) + # Load config + HABApp.config.load_config(config_folder) + + # replace proxy objects + ir = HABApp.core.internals.ItemRegistry() + eb = HABApp.core.internals.EventBus() + setup_internals(ir, eb) + assert isinstance(HABApp.core.Items, ConstProxyObj) + HABApp.core.Items = ir + assert isinstance(HABApp.core.EventBus, ConstProxyObj) + HABApp.core.EventBus = eb await HABApp.core.files.setup() @@ -60,11 +69,10 @@ async def start(self, config_folder: Path): await openhab_connection.start() - shutdown.register_func(HABApp.core.const.loop.stop, msg='Stopping asyncio loop') - async_context.reset(token) - except asyncio.CancelledError: - pass + + except HABApp.config.InvalidConfigError: + shutdown.request_shutdown() except Exception as e: process_exception('Runtime.start', e) await asyncio.sleep(1) # Sleep so we can do a graceful shutdown diff --git a/src/HABApp/runtime/shutdown.py b/src/HABApp/runtime/shutdown.py index a02f7f2b..8e843040 100644 --- a/src/HABApp/runtime/shutdown.py +++ b/src/HABApp/runtime/shutdown.py @@ -3,13 +3,14 @@ import logging.handlers import traceback import typing -from asyncio import iscoroutinefunction, run_coroutine_threadsafe, sleep +import signal +from asyncio import iscoroutinefunction, sleep from dataclasses import dataclass from types import FunctionType, MethodType from typing import Callable, Coroutine, Union +from HABApp.core.asyncio import async_context, create_task from HABApp.core.const import loop -from HABApp.core.context import async_context @dataclass(frozen=True) @@ -32,8 +33,18 @@ def register_func(func, last=False, msg: str = ''): _FUNCS.append(ShutdownInfo(func, f'{func.__module__}.{func.__name__}' if not msg else msg, last)) +def register_signal_handler(): + def shutdown_handler(sig, frame): + print('Shutting down ...') + request_shutdown() + + # register shutdown helper + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + def request_shutdown(): - run_coroutine_threadsafe(_shutdown(), loop) + create_task(_shutdown()) async def _shutdown(): @@ -48,7 +59,10 @@ async def _shutdown(): requested = True for obj in itertools.chain(filter(lambda x: not x.last, _FUNCS), - filter(lambda x: x.last, _FUNCS)): + filter(lambda x: x.last, _FUNCS), + # shutdown of the event loop has to be the last thing that is done + # since stopping of the loop exits the program + [ShutdownInfo(loop.stop, 'Stopping asyncio loop', last=True)]): try: log.debug(f'{obj.msg}') if iscoroutinefunction(obj.func): diff --git a/src/HABApp/util/__init__.py b/src/HABApp/util/__init__.py index 1760de8f..2b09100d 100644 --- a/src/HABApp/util/__init__.py +++ b/src/HABApp/util/__init__.py @@ -1,10 +1,7 @@ -from . import functions -from .counter_item import CounterItem -from .period_counter import PeriodCounter from .threshold import Threshold from .statistics import Statistics -from . import multimode from .listener_groups import EventListenerGroup +from .fade import Fade -# 27.04.2020 - this can be removed in some time -from .multimode import MultiModeItem +from . import functions +from . import multimode diff --git a/src/HABApp/util/counter_item.py b/src/HABApp/util/counter_item.py deleted file mode 100644 index 884faa12..00000000 --- a/src/HABApp/util/counter_item.py +++ /dev/null @@ -1,58 +0,0 @@ -from threading import Lock - -import HABApp - - -class CounterItem(HABApp.core.items.Item): - """Implements a simple thread safe counter""" - - # todo: Max Value and events when counter is 0 or has max value - - def __init__(self, name: str, initial_value: int = 0): - """ - :param initial_value: Initial value of the counter - """ - - self.value: int = initial_value # this gets overwritten but we provide a type hint anyway - - super().__init__(name=name, initial_value=initial_value) - assert isinstance(initial_value, int), type(initial_value) - - self.__lock: Lock = Lock() - self.__initial_value = initial_value - - def set_value(self, new_value) -> bool: - assert isinstance(new_value, int), type(new_value) - return super().set_value(new_value) - - def post_value(self, new_value): - assert isinstance(new_value, int), type(new_value) - super().post_value(new_value) - - def reset(self): - """Reset value to initial value""" - with self.__lock: - self.post_value(self.__initial_value) - return self.__initial_value - - def increase(self, step=1) -> int: - """Increase value - - :param step: increase by this value, default = 1 - :return: value of the counter - """ - assert isinstance(step, int), type(step) - with self.__lock: - self.post_value(self.value + step) - return self.value - - def decrease(self, step=1) -> int: - """Decrease value - - :param step: decrease by this value, default = 1 - :return: value of the counter - """ - assert isinstance(step, int), type(step) - with self.__lock: - self.post_value(self.value - step) - return self.value diff --git a/src/HABApp/util/fade/__init__.py b/src/HABApp/util/fade/__init__.py new file mode 100644 index 00000000..abd6765b --- /dev/null +++ b/src/HABApp/util/fade/__init__.py @@ -0,0 +1 @@ +from .fade import Fade diff --git a/src/HABApp/util/fade/fade.py b/src/HABApp/util/fade/fade.py new file mode 100644 index 00000000..8ca6041a --- /dev/null +++ b/src/HABApp/util/fade/fade.py @@ -0,0 +1,146 @@ +from datetime import timedelta +from time import time +from typing import Union, Optional + +from HABApp.core.internals import wrap_func, AutoContextBoundObj + + +VAL_TYPE = Union[int, float] + + +class FadeWorker(AutoContextBoundObj): + + def __init__(self, parent: 'Fade', interval: float): + super().__init__() + self.parent: 'Fade' = parent + self.scheduler = self._parent_ctx.rule.run.every(None, interval, self.parent._scheduled_worker) + + def cancel(self): + self._ctx_unlink() + self.scheduler.cancel() + self.scheduler = None + + self.parent._fade_worker = None + self.parent = None + + +MIN_STEP_TIME = 0.2 + + +class Fade: + """Helper to easily fade values up/down + + :ivar min_value: minimum valid value for the fade value + :ivar max_value: maximum valid value for the fade value + :ivar callback: Function with one argument that will be automatically called with the new values when the scheduled + fade runs + """ + def __init__(self, callback=None, min_value: VAL_TYPE = 0, max_value: VAL_TYPE = 100): + self.min_value = min_value + self.max_value = max_value + + self._fade_start_time = 0 + self._fade_start_value = 0 + self._fade_stop_value = 0 + self._step_duration = 0 + self._fade_factor = 0 + self._fade_finished = True + + self._fade_worker: Optional[FadeWorker] = None + self.__callback = wrap_func(callback) if callback is not None else None + + self.value = 0 + + def setup(self, start_value: VAL_TYPE, stop_value: VAL_TYPE, duration: Union[int, float, timedelta], + min_step_duration: float = MIN_STEP_TIME, now: Optional[float] = None) -> 'Fade': + """Calculates everything that is needed to fade a value + + :param start_value: Start value + :param stop_value: Stop value + :param duration: How long shall the fade take + :param min_step_duration: minimum step duration (min 0.2 secs) + :param now: time.time() timestamp to sync multiple fades together + """ + if start_value < self.min_value or start_value > self.max_value: + raise ValueError('Start value is out of range') + if stop_value < self.min_value or stop_value > self.max_value: + raise ValueError('Stop value is out of range') + + if isinstance(duration, timedelta): + duration = duration.total_seconds() + assert isinstance(duration, (int, float)) and duration >= 1 + + diff = stop_value - start_value + if not diff: + raise ValueError('Start value must be different than stop value') + + # If we have a running fade we have to cancel it before changing the used values + self.stop_fade() + + self._fade_start_value = start_value + self._fade_stop_value = stop_value + + self._fade_factor = diff / duration + self._step_duration = max(MIN_STEP_TIME, min_step_duration, 1 / abs(self._fade_factor)) + + self._fade_start_time = time() if now is None else now + self._fade_finished = False + return self + + def get_value(self, now: Optional[float] = None) -> float: + """Returns the current value. If the fade is finished it will always return the stop value. + + :param now: time.time() timestamp for which the value shall be returned. Can be used to sync multiple fades + together. Not required. + :return: current value + """ + if self._fade_finished: + return self.value + + if now is None: + now = time() + + assert now > self._fade_start_time + dur = now - self._fade_start_time + value = self._fade_start_value + self._fade_factor * dur + + if self._fade_factor < 0: + if value <= self._fade_stop_value: + self._fade_finished = True + value = max(value, self._fade_stop_value) + else: + if value >= self._fade_stop_value: + self._fade_finished = True + value = min(value, self._fade_stop_value) + + self.value = value = round(value, 2) + return value + + @property + def is_finished(self) -> bool: + """True if the fade is finished""" + return self._fade_finished + + async def _scheduled_worker(self): + self.get_value() + if self._fade_finished: + self.stop_fade() + + if self.__callback is not None: + self.__callback.run(self.value) + + def schedule_fade(self) -> 'Fade': + """Automatically run the fade with the Scheduler. The callback can be used to set the current fade value + e.g. on an item. Calling this on a running fade will restart the fade + """ + if self._fade_worker is not None: + self._fade_worker.cancel() # This will also set this variable to None, so we don't have to do it + + self._fade_worker = FadeWorker(self, self._step_duration) + return self + + def stop_fade(self): + """Stop the scheduled fade. This can be called multiple times without error""" + if self._fade_worker is None: + return None + self._fade_worker.cancel() diff --git a/src/HABApp/util/listener_groups/__init__.py b/src/HABApp/util/listener_groups/__init__.py new file mode 100644 index 00000000..f7231ac8 --- /dev/null +++ b/src/HABApp/util/listener_groups/__init__.py @@ -0,0 +1 @@ +from .listener_groups import EventListenerGroup diff --git a/src/HABApp/util/listener_groups/listener_creator.py b/src/HABApp/util/listener_groups/listener_creator.py new file mode 100644 index 00000000..9eccc889 --- /dev/null +++ b/src/HABApp/util/listener_groups/listener_creator.py @@ -0,0 +1,59 @@ +from typing import Any, Callable, Optional, Union + +from HABApp.core.events import EventFilter +from HABApp.core.internals import EventBusListener +from HABApp.core.items import HINT_ITEM_OBJ + + +class ListenerCreatorBase: + def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any]): + self.item = item + self.callback = callback + + self.listener: Optional[EventBusListener] = None + self.active = True + + def create_listener(self) -> EventBusListener: + raise NotImplementedError() + + def listen(self): + if not self.active: + return None + + if self.listener is None: + self.listener = self.create_listener() + + def cancel(self): + if not self.active: + return None + + if self.listener is not None: + self.listener.cancel() + self.listener = None + + +class EventListenerCreator(ListenerCreatorBase): + def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], event_filter: EventFilter): + super(EventListenerCreator, self).__init__(item, callback) + self.event_filter = event_filter + + def create_listener(self) -> EventBusListener: + return self.item.listen_event(self.callback, self.event_filter) + + +class NoUpdateEventListenerCreator(ListenerCreatorBase): + def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], secs: Union[int, float]): + super(NoUpdateEventListenerCreator, self).__init__(item, callback) + self.secs = secs + + def create_listener(self) -> EventBusListener: + return self.item.watch_update(self.secs).listen_event(self.callback) + + +class NoChangeEventListenerCreator(ListenerCreatorBase): + def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], secs: Union[int, float]): + super(NoChangeEventListenerCreator, self).__init__(item, callback) + self.secs = secs + + def create_listener(self) -> EventBusListener: + return self.item.watch_change(self.secs).listen_event(self.callback) diff --git a/src/HABApp/util/listener_groups.py b/src/HABApp/util/listener_groups/listener_groups.py similarity index 63% rename from src/HABApp/util/listener_groups.py rename to src/HABApp/util/listener_groups/listener_groups.py index db433618..a7a7abf5 100644 --- a/src/HABApp/util/listener_groups.py +++ b/src/HABApp/util/listener_groups/listener_groups.py @@ -1,62 +1,11 @@ from typing import Any, Callable, Dict, Iterable, Optional, Union -from HABApp.core.event_bus_listener import EventBusListener -from HABApp.core.events import EventFilter -from HABApp.core.items.base_valueitem import BaseItem +from HABApp.core.internals import HINT_EVENT_FILTER_OBJ +from HABApp.core.items import BaseItem, HINT_ITEM_OBJ - -class ListenerCreatorBase: - def __init__(self, item: BaseItem, callback: Callable[[Any], Any]): - self.item = item - self.callback = callback - - self.listener: Optional[EventBusListener] = None - self.active = True - - def create_listener(self) -> EventBusListener: - raise NotImplementedError() - - def listen(self): - if not self.active: - return None - - if self.listener is None: - self.listener = self.create_listener() - - def cancel(self): - if not self.active: - return None - - if self.listener is not None: - self.listener.cancel() - self.listener = None - - -class EventListenerCreator(ListenerCreatorBase): - def __init__(self, item: BaseItem, callback: Callable[[Any], Any], event_filter: EventFilter): - super(EventListenerCreator, self).__init__(item, callback) - self.event_filter = event_filter - - def create_listener(self) -> EventBusListener: - return self.item.listen_event(self.callback, self.event_filter) - - -class NoUpdateEventListenerCreator(ListenerCreatorBase): - def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: Union[int, float]): - super(NoUpdateEventListenerCreator, self).__init__(item, callback) - self.secs = secs - - def create_listener(self) -> EventBusListener: - return self.item.watch_update(self.secs).listen_event(self.callback) - - -class NoChangeEventListenerCreator(ListenerCreatorBase): - def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: Union[int, float]): - super(NoChangeEventListenerCreator, self).__init__(item, callback) - self.secs = secs - - def create_listener(self) -> EventBusListener: - return self.item.watch_change(self.secs).listen_event(self.callback) +from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF +from .listener_creator import ListenerCreatorBase, EventListenerCreator, \ + NoChangeEventListenerCreator, NoUpdateEventListenerCreator class ListenerCreatorNotFoundError(Exception): @@ -131,12 +80,12 @@ def deactivate_listener(self, name: str, cancel_if_active=True): if not obj.active: return False - obj.active = False if cancel_if_active: obj.cancel() + obj.active = False return True - def __add_objs(self, cls, item: Union[BaseItem, Iterable[BaseItem]], callback: Callable[[Any], Any], + def __add_objs(self, cls, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], arg, alias: Optional[str] = None): # alias -> single param if alias is not None: @@ -152,8 +101,8 @@ def __add_objs(self, cls, item: Union[BaseItem, Iterable[BaseItem]], callback: C if self._is_active: obj.listen() - def add_listener(self, item: Union[BaseItem, Iterable[BaseItem]], callback: Callable[[Any], Any], - event_filter: EventFilter, alias: Optional[str] = None) -> 'EventListenerGroup': + def add_listener(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], + event_filter: HINT_EVENT_FILTER_OBJ, alias: Optional[str] = None) -> 'EventListenerGroup': """Add an event listener to the group :param item: Single or multiple items @@ -167,8 +116,9 @@ def add_listener(self, item: Union[BaseItem, Iterable[BaseItem]], callback: Call self.__add_objs(EventListenerCreator, item, callback, event_filter, alias) return self - def add_no_update_watcher(self, item: Union[BaseItem, Iterable[BaseItem]], callback: Callable[[Any], Any], - seconds: Union[int, float], alias: Optional[str] = None) -> 'EventListenerGroup': + def add_no_update_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], + seconds: TH_POSITIVE_TIME_DIFF, alias: Optional[str] = None + ) -> 'EventListenerGroup': """Add an no update watcher to the group. On ``listen`` this will create a no update watcher and the corresponding event listener that will trigger the callback @@ -182,8 +132,9 @@ def add_no_update_watcher(self, item: Union[BaseItem, Iterable[BaseItem]], callb self.__add_objs(NoUpdateEventListenerCreator, item, callback, seconds, alias) return self - def add_no_change_watcher(self, item: Union[BaseItem, Iterable[BaseItem]], callback: Callable[[Any], Any], - seconds: Union[int, float], alias: Optional[str] = None) -> 'EventListenerGroup': + def add_no_change_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], + seconds: TH_POSITIVE_TIME_DIFF, alias: Optional[str] = None + ) -> 'EventListenerGroup': """Add an no change watcher to the group. On ``listen`` this this will create a no change watcher and the corresponding event listener that will trigger the callback diff --git a/src/HABApp/util/multimode/item.py b/src/HABApp/util/multimode/item.py index d959552b..f9af7ee5 100644 --- a/src/HABApp/util/multimode/item.py +++ b/src/HABApp/util/multimode/item.py @@ -1,12 +1,10 @@ import datetime -import logging import typing import warnings from threading import Lock import HABApp from HABApp.core.items import Item -from HABApp.rule import get_parent_rule from .mode_base import BaseMode LOCK = Lock() @@ -14,8 +12,6 @@ class MultiModeItem(Item): """Prioritizer :class:`~HABApp.core.items.Item` - - :ivar logger: Assign a logger to get log messages about the different modes """ @classmethod @@ -61,7 +57,7 @@ def remove_mode(self, name: str) -> bool: """Remove mode if it exists :param name: name of the mode (case insensitive) - :return: True if something was removed, False if nothign was found + :return: True if something was removed, False if nothing was found """ assert isinstance(name, str), type(name) @@ -92,14 +88,6 @@ def add_mode(self, priority: int, mode: BaseMode) -> 'MultiModeItem': # resort self.__sort_modes() - - try: - get_parent_rule().register_cancel_obj(mode) - except RuntimeError: - HABApp.core.logger.log_warning( - logger=logging.getLogger('HABApp'), text='Parent rule not found! ' - f'Automatic unloading of the {self.__class__.__name__} {self.name} will not work!' - ) return self def all_modes(self) -> typing.List[typing.Tuple[int, BaseMode]]: @@ -152,3 +140,9 @@ def create_mode( self.add_mode(priority, m) return m + + def _on_item_removed(self): + for name, mode in self.all_modes(): + mode.cancel() + + super()._on_item_removed() diff --git a/src/HABApp/util/multimode/mode_base.py b/src/HABApp/util/multimode/mode_base.py index d0d27e78..ce49ad8f 100644 --- a/src/HABApp/util/multimode/mode_base.py +++ b/src/HABApp/util/multimode/mode_base.py @@ -1,9 +1,12 @@ import typing +from HABApp.core.internals import AutoContextBoundObj -class BaseMode: + +class BaseMode(AutoContextBoundObj): def __init__(self, name: str): + super(BaseMode, self).__init__() self.name: str = name self.__mode_lower_prio: typing.Optional[BaseMode] = None @@ -25,6 +28,7 @@ def calculate_value(self, lower_prio_value: typing.Any) -> typing.Any: def cancel(self): """Remove the mode from the parent ``MultiModeItem`` and stop processing it """ + self._ctx_unlink() self.parent.remove_mode(self.name) self.parent = None diff --git a/src/HABApp/util/multimode/mode_switch.py b/src/HABApp/util/multimode/mode_switch.py index b943c870..d0dec953 100644 --- a/src/HABApp/util/multimode/mode_switch.py +++ b/src/HABApp/util/multimode/mode_switch.py @@ -9,17 +9,17 @@ class SwitchItemValueMode(ValueMode): """SwitchItemMode, same as ValueMode but enabled/disabled of the mode is controlled by a OpenHAB :class:`~HABApp.openhab.items.SwitchItem` - :ivar datetime.datetime ~.last_update: Timestamp of the last update/enable of this value - :ivar typing.Optional[datetime.timedelta] ~.auto_disable_after: Automatically disable this mode after + :ivar datetime.datetime last_update: Timestamp of the last update/enable of this value + :ivar typing.Optional[datetime.timedelta] auto_disable_after: Automatically disable this mode after a given timedelta on the next recalculation - :vartype ~.auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] - :ivar ~.auto_disable_func: Function which can be used to disable this mode. Any function that accepts two - Arguments can be used. First arg is value with lower priority, - second argument is own value. Return ``True`` to disable this mode. - :vartype ~.calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] - :ivar ~.calc_value_func: Function to calculate the new value (e.g. ``min`` or ``max``). Any function that accepts - two Arguments can be used. First arg is value with lower priority, - second argument is own value. + :vartype auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] + :ivar auto_disable_func: Function which can be used to disable this mode. Any function that accepts two + Arguments can be used. First arg is value with lower priority, + second argument is own value. Return ``True`` to disable this mode. + :vartype calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] + :ivar calc_value_func: Function to calculate the new value (e.g. ``min`` or ``max``). Any function that accepts + two Arguments can be used. First arg is value with lower priority, + second argument is own value. """ def __init__(self, name: str, @@ -55,14 +55,14 @@ def __init__(self, name: str, calc_value_func=calc_value_func) # setup listener as the last thing - switch_item.listen_event(self.__switch_changed, HABApp.core.events.ValueChangeEvent) + switch_item.listen_event(self.__switch_changed, HABApp.core.events.ValueChangeEventFilter()) return # this is the original enabled method __set_enable = ValueMode.set_enabled # prevent direct calling - def set_enabled(self, value: bool): + def set_enabled(self, value: bool, only_on_change: bool = False): """""" # so it doesn't show in Sphinx raise PermissionError('Enabled is controlled through the switch item!') diff --git a/src/HABApp/util/multimode/mode_value.py b/src/HABApp/util/multimode/mode_value.py index 2460edf2..82881f60 100644 --- a/src/HABApp/util/multimode/mode_value.py +++ b/src/HABApp/util/multimode/mode_value.py @@ -8,15 +8,15 @@ class ValueMode(BaseMode): """ValueMode - :ivar datetime.datetime ~.last_update: Timestamp of the last update/enable of this value - :ivar typing.Optional[datetime.timedelta] ~.auto_disable_after: Automatically disable this mode after + :ivar datetime.datetime last_update: Timestamp of the last update/enable of this value + :ivar typing.Optional[datetime.timedelta] auto_disable_after: Automatically disable this mode after a given timedelta on the next recalculation - :vartype ~.auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] - :ivar ~.auto_disable_func: Function which can be used to disable this mode. Any function that accepts two + :vartype auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] + :ivar auto_disable_func: Function which can be used to disable this mode. Any function that accepts two Arguments can be used. First arg is value with lower priority, second argument is own value. Return ``True`` to disable this mode. - :vartype ~.calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] - :ivar ~.calc_value_func: Function to calculate the new value (e.g. ``min`` or ``max``). Any function that accepts + :vartype calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] + :ivar calc_value_func: Function to calculate the new value (e.g. ``min`` or ``max``). Any function that accepts two Arguments can be used. First arg is value with lower priority, second argument is own value. """ diff --git a/src/HABApp/util/period_counter.py b/src/HABApp/util/period_counter.py deleted file mode 100644 index 401a706d..00000000 --- a/src/HABApp/util/period_counter.py +++ /dev/null @@ -1,63 +0,0 @@ -import threading -import time - - -class PeriodCounter: - def __init__(self, period): - assert isinstance(period, int) - self.period = period - - # Thread save - self.__lock = threading.Lock() - # funcs which gets called when the counter changes - self.__on_change = set() - - self.__timestamps = [] - - def on_change(self, func, unregister=False): - assert callable(func) - if unregister: - self.__on_change.remove(func) - else: - self.__on_change.add(func) - - def __clean_timestamps(self, add=False): - now = time.time() - min_ts = now - self.period - self.__timestamps = [k for k in self.__timestamps if k >= min_ts] - if add: - self.__timestamps.append(now) - - def reset(self): - with self.__lock: - count_was = len(self.__timestamps) - self.__timestamps = [] - count_new = len(self.__timestamps) - - if count_was != count_new: - for func in self.__on_change: - func() - - def increase(self) -> int: - with self.__lock: - count_was = len(self.__timestamps) - self.__clean_timestamps(add=True) - count_new = len(self.__timestamps) - - if count_was != count_new: - for func in self.__on_change: - func(count_new) - - return count_new - - def get_count(self) -> int: - with self.__lock: - count_was = len(self.__timestamps) - self.__clean_timestamps(add=False) - count_new = len(self.__timestamps) - - if count_was != count_new: - for func in self.__on_change: - func(count_new) - - return count_new diff --git a/src/HABApp/util/statistics.py b/src/HABApp/util/statistics.py index 05402c20..95e8749b 100644 --- a/src/HABApp/util/statistics.py +++ b/src/HABApp/util/statistics.py @@ -6,13 +6,13 @@ class Statistics: """Calculate mathematical statistics of numerical values. - :ivar ~.sum: sum of all values - :ivar ~.min: minimum of all values - :ivar ~.max: maximum of all values - :ivar ~.mean: mean of all values - :ivar ~.median: median of all values - :ivar ~.last_value: last added value - :ivar ~.last_change: timestamp the last time a value was added + :ivar sum: sum of all values + :ivar min: minimum of all values + :ivar max: maximum of all values + :ivar mean: mean of all values + :ivar median: median of all values + :ivar last_value: last added value + :ivar last_change: timestamp the last time a value was added """ def __init__(self, max_age=None, max_samples=None): """ diff --git a/tests/conftest.py b/tests/conftest.py index 9befdcee..474e000f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,22 @@ +import asyncio import functools +import logging import typing -import asyncio import pytest import HABApp -from .helpers import params, parent_rule, sync_worker, event_bus, get_dummy_cfg - +import tests +from HABApp.core.asyncio import async_context +from HABApp.core.const.topics import TOPIC_ERRORS +from HABApp.core.internals import setup_internals, EventBus, ItemRegistry +from .helpers import params, parent_rule, sync_worker, eb, get_dummy_cfg if typing.TYPE_CHECKING: parent_rule = parent_rule params = params sync_worker = sync_worker - event_bus = event_bus + eb = eb def raise_err(func): @@ -37,7 +41,7 @@ def show_errors(monkeypatch): monkeypatch.setattr(HABApp.core.wrapper, 'log_exception', raise_err) -@pytest.yield_fixture(autouse=True, scope='function') +@pytest.fixture(autouse=True, scope='function') def use_dummy_cfg(monkeypatch): cfg = get_dummy_cfg() monkeypatch.setattr(HABApp, 'CONFIG', cfg) @@ -46,16 +50,51 @@ def use_dummy_cfg(monkeypatch): yield -@pytest.yield_fixture(autouse=True, scope='function') +@pytest.fixture(autouse=True, scope='session') def event_loop(): + token = async_context.set('pytest') + yield HABApp.core.const.loop + async_context.reset(token) -@pytest.yield_fixture(autouse=True, scope='function') -def cleanup_registry(): + +@pytest.fixture(scope='function') +def ir(): + ir = ItemRegistry() + yield ir + + +@pytest.fixture(autouse=True, scope='function') +def clean_objs(ir: ItemRegistry, eb: EventBus, request): + markers = request.node.own_markers + for marker in markers: + if marker.name == 'no_internals': + yield None + return None + + restore = setup_internals(ir, eb, final=False) + + yield + + for name in ir.get_item_names(): + ir.pop_item(name) + + for r in restore: + r.restore() + + +@pytest.fixture(scope='function') +def ensure_no_errors_in_log(caplog): yield - # Delete all existing items/listener from previous tests - HABApp.core.EventBus.remove_all_listeners() - for name in HABApp.core.Items.get_all_item_names(): - HABApp.core.Items.pop_item(name) + # Check if we have an error + for entry in caplog.records: + if entry.levelno >= logging.ERROR: + break + else: + return + + for entry in caplog.records: + print(entry) + assert False diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 99074423..187930bf 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,7 +1,7 @@ from .sync_worker import sync_worker from .parent_rule import parent_rule from .parameters import params -from .event_bus import event_bus, TmpEventBus +from .event_bus import eb, TestEventBus from .mock_file import MockFile from .module_helpers import get_module_classes, check_class_annotations from .habapp_config import get_dummy_cfg diff --git a/tests/helpers/docs.py b/tests/helpers/docs.py new file mode 100644 index 00000000..dbe9095a --- /dev/null +++ b/tests/helpers/docs.py @@ -0,0 +1,17 @@ +import re +from typing import Dict + +regex = re.compile(r':ivar\s+(.+?)\s+(\w+):') + + +def get_ivars(obj) -> Dict[str, str]: + ivars = {} + for line in obj.__doc__.splitlines(): + if m := regex.search(line): + name = m.group(2) + type = m.group(1) + assert name + assert type + assert name not in ivars + ivars[name] = type + return ivars diff --git a/tests/helpers/event_bus.py b/tests/helpers/event_bus.py index 623da661..7d1d9e5a 100644 --- a/tests/helpers/event_bus.py +++ b/tests/helpers/event_bus.py @@ -1,30 +1,41 @@ +from typing import Any import pytest -import typing -from HABApp.core import EventBus, EventBusListener -from HABApp.core import WrappedFunction +from HABApp.core.const.topics import TOPIC_ERRORS +from HABApp.core.events.habapp_events import HABAppException +from HABApp.core.internals import EventFilterBase, EventBusListener, EventBus, wrap_func -class TmpEventBus: - def __init__(self): - self.listener: typing.List[EventBusListener] = [] - - def listen_events(self, name: str, cb): - listener = EventBusListener(name, WrappedFunction(cb, name=f'TestFunc for {name}')) - self.listener.append(listener) - EventBus.add_listener(listener) - - def __enter__(self): - return self +class TestEventBus(EventBus): - def __exit__(self, exc_type, exc_val, exc_tb): - for listener in self.listener: - listener.cancel() - return False # do not suppress exception - - -@pytest.fixture(scope="function") -def event_bus(): - with TmpEventBus() as tb: - yield tb + def __init__(self): + super().__init__() + self.allow_errors = False + self.errors = [] + + def listen_events(self, name: str, cb, filter: EventFilterBase): + listener = EventBusListener(name, wrap_func(cb, name=f'TestFunc for {name}'), filter) + self.add_listener(listener) + + def post_event(self, topic: str, event: Any): + if not self.allow_errors: + if topic == TOPIC_ERRORS or isinstance(event, HABAppException): + self.errors.append(event) + super().post_event(topic, event) + + +@pytest.yield_fixture(scope='function') +def eb(): + eb = TestEventBus() + yield eb + eb.remove_all_listeners() + + for event in eb.errors: + if isinstance(event, HABAppException): + for line in event.to_str().splitlines(): + print(line) + else: + print(event) + + assert not eb.errors diff --git a/tests/helpers/habapp_config.py b/tests/helpers/habapp_config.py index 3d136a07..8bfd26d5 100644 --- a/tests/helpers/habapp_config.py +++ b/tests/helpers/habapp_config.py @@ -1,52 +1,11 @@ -from typing import Callable, List - -from EasyCo import ConfigEntry - import HABApp - - -class DummyConfigSection: - def __init__(self, key: str, notify: list): - self.dummy_key: str = key - self.dummy_notify: List[Callable] = notify.copy() - - def notify_change(self): - for k in self.dummy_notify: - k() - - -DUMMY_VALUES = { - 'location.latitude': 52.5185537, - 'location.longitude': 13.3758636, - 'location.elevation': 43, -} - - -def __copy_to_obj(obj, cfg, stack: tuple, used_dummies: set): - - for name in getattr(cfg, '_ConfigContainer__containers'): - child = DummyConfigSection(name, getattr(cfg, '_ConfigContainer__notify')) - setattr(obj, name, child) - new_stack = stack + (name,) - __copy_to_obj(child, getattr(cfg, name), new_stack, used_dummies) - - for name in getattr(cfg, '_ConfigContainer__entries'): - dummy_key = '.'.join(stack + (name,)) - value = DUMMY_VALUES.get(dummy_key) - if value is not None: - used_dummies.add(dummy_key) - else: - entry = getattr(cfg, name) - value = entry.default if isinstance(entry, ConfigEntry) else entry - setattr(obj, name, value) +import HABApp.config.models def get_dummy_cfg(): - obj = DummyConfigSection('root', getattr(HABApp.config.config.CONFIG, '_ConfigContainer__notify')) - used = set() - __copy_to_obj(obj, HABApp.config.config.CONFIG, tuple(), used) - - not_used = set(DUMMY_VALUES.keys()) - used - assert not not_used, not_used + cfg = HABApp.config.models.ApplicationConfig() + cfg.location.latitude = 52.5185537 + cfg.location.longitude = 13.3758636 + cfg.location.elevation = 43 - return obj + return cfg diff --git a/tests/helpers/module_helpers.py b/tests/helpers/module_helpers.py index 6d794695..deeed4f6 100644 --- a/tests/helpers/module_helpers.py +++ b/tests/helpers/module_helpers.py @@ -1,17 +1,43 @@ import importlib import inspect import sys -from typing import Iterable, Optional +from typing import Iterable, Optional, Union, Tuple, List, Callable +# Todo: Make this positional only if we go >= python 3.8 +# def get_module_classes(module_name: str, /, exclude: Optional[Iterable[Union[str, type]]] = None, +# include_imported=True, +# subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): -def get_module_classes(module_name: str, exclude: Optional[Iterable[str]] = None, skip_imports=True): - if exclude is None: - exclude = set() + +def get_module_classes(module_name: str, exclude: Optional[Iterable[Union[str, type]]] = None, include_imported=True, + subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): + + filters: List[Callable[[type], bool]] = [ + lambda x: inspect.isclass(x) + ] + + if not include_imported: + filters.append(lambda x: x.__module__ == module_name) + + if exclude is not None: + for exclude_obj in exclude: + if isinstance(exclude_obj, str): + filters.append(lambda x, obj=exclude_obj: x.__name__ != obj) + else: + filters.append(lambda x, obj=exclude_obj: x is not obj) + + if subclass is not None: + filters.append(lambda x: issubclass(x, subclass)) + + # Ensure that the class is not the subclass + if not include_subclass: + sub_cmp = subclass if isinstance(subclass, tuple) else tuple([subclass]) + filters.append(lambda x: all(map(lambda cls_obj: x is not cls_obj, sub_cmp))) importlib.import_module(module_name) return dict(inspect.getmembers( sys.modules[module_name], - lambda x: inspect.isclass(x) and (skip_imports or x.__module__ == module_name) and x.__name__ not in exclude + lambda x: all(map(lambda f: f(x), filters)) )) diff --git a/tests/helpers/parent_rule.py b/tests/helpers/parent_rule.py index 8da901a9..6b72fae5 100644 --- a/tests/helpers/parent_rule.py +++ b/tests/helpers/parent_rule.py @@ -1,31 +1,27 @@ from pytest import fixture import HABApp +import HABApp.core.items.base_item_watch +from HABApp.core.internals import ContextProvidingObj -class DummyRule: +class DummyRule(ContextProvidingObj): def __init__(self): + super().__init__(context=HABApp.rule_ctx.HABAppRuleContext(self)) self.rule_name = 'DummyRule' - self.__dict__['_Rule__event_listener'] = [] - - def register_cancel_obj(self, obj): - pass - - # copied funcs - _get_cb_name = HABApp.Rule._get_cb_name - _add_event_listener = HABApp.Rule._add_event_listener - @fixture def parent_rule(monkeypatch): rule = DummyRule() - # patch both imports imports - monkeypatch.setattr(HABApp.rule, 'get_parent_rule', lambda: rule, raising=True) - monkeypatch.setattr(HABApp.rule.rule, 'get_parent_rule', lambda: rule, raising=True) + def ret_dummy_rule_context(): + return rule._habapp_ctx + + # patch imports + monkeypatch.setattr(HABApp.core.internals, 'get_current_context', ret_dummy_rule_context) + monkeypatch.setattr(HABApp.core.internals.context.get_context, 'get_current_context', ret_dummy_rule_context) - # util imports - monkeypatch.setattr(HABApp.util.multimode.item, 'get_parent_rule', lambda: rule, raising=True) + monkeypatch.setattr(HABApp.core.items.base_item_watch, 'get_current_context', ret_dummy_rule_context) yield rule diff --git a/tests/helpers/sync_worker.py b/tests/helpers/sync_worker.py index 332407d8..ce6e0f7e 100644 --- a/tests/helpers/sync_worker.py +++ b/tests/helpers/sync_worker.py @@ -1,13 +1,15 @@ import pytest -from HABApp.core import WrappedFunction +from HABApp.core.internals.wrapped_function import wrapped_thread class SyncTestWorker: - def submit(self, callback, *args, **kwargs): + @staticmethod + def submit(callback, *args, **kwargs): callback(*args, **kwargs) @pytest.fixture(scope="function") def sync_worker(monkeypatch): - monkeypatch.setattr(WrappedFunction, '_WORKERS', SyncTestWorker()) + monkeypatch.setattr(wrapped_thread, 'WORKERS', SyncTestWorker()) + yield diff --git a/tests/helpers/traceback.py b/tests/helpers/traceback.py new file mode 100644 index 00000000..e568244e --- /dev/null +++ b/tests/helpers/traceback.py @@ -0,0 +1,15 @@ +import re +from pathlib import Path + + +def process_traceback(traceback: str) -> str: + + # object ids + traceback = re.sub(r' at 0x[0-9A-Fa-f]+', ' at 0x' + 'A' * 16, traceback) + + # File path + for m in re.finditer(r'File\s+"([^"]+)"', traceback): + fname = "/".join(Path(m.group(1)).parts[-3:]) + traceback = traceback.replace(m.group(0), f'File "{fname}"') + + return traceback diff --git a/tests/rule_runner/rule_runner.py b/tests/rule_runner/rule_runner.py index 8c2734b8..b048f970 100644 --- a/tests/rule_runner/rule_runner.py +++ b/tests/rule_runner/rule_runner.py @@ -1,26 +1,23 @@ -import sys +from typing import List +from pytest import MonkeyPatch + +import HABApp +import HABApp.core.lib.exceptions.format +import HABApp.rule.rule as rule_module import HABApp.rule.scheduler.habappschedulerview as ha_sched -from HABApp.core import WrappedFunction +from HABApp.core.asyncio import async_context +from HABApp.core.internals import setup_internals, ItemRegistry, EventBus +from HABApp.core.internals.proxy import ConstProxyObj +from HABApp.core.internals.wrapped_function import wrapped_thread, wrapper +from HABApp.core.internals.wrapped_function.wrapped_thread import WrappedThreadFunction +from HABApp.core.lib.exceptions.format import fallback_format +from HABApp.rule.rule_hook import HABAppRuleHook from HABApp.runtime import Runtime -from HABApp.core.context import async_context - - -def _get_topmost_globals() -> dict: - depth = 1 - while True: - try: - var = sys._getframe(depth).f_globals # noqa: F841 - depth += 1 - except ValueError: - break - return sys._getframe(depth - 1).f_globals -class TestRuleFile: - def suggest_rule_name(self, obj): - parts = str(type(obj)).split('.') - return parts[-1][:-2] +def suggest_rule_name(obj: object) -> str: + return f'TestRule.{obj.__class__.__name__}' class SyncScheduler: @@ -40,57 +37,68 @@ def cancel_all(self): self.jobs.clear() -class SimpleRuleRunner: +class DummyRuntime(Runtime): def __init__(self): - self.vars: dict = _get_topmost_globals() - self.loaded_rules = [] - self.original_scheduler = None + pass - self._patched_objs = [] - def patch_obj(self, obj, name, new_value): - assert hasattr(obj, name) - self._patched_objs.append((obj, name, getattr(obj, name))) - setattr(obj, name, new_value) +def raising_fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: + traceback = fallback_format(e, existing_traceback) + traceback = traceback + raise - def restore(self): - for obj, name, original in self._patched_objs: - assert hasattr(obj, name) - setattr(obj, name, original) + +class SimpleRuleRunner: + def __init__(self): + self.loaded_rules = [] + + self.monkeypatch = MonkeyPatch() + self.restore = [] def submit(self, callback, *args, **kwargs): - # submit never raises and exception, so we don't do it here, too - try: - callback(*args, **kwargs) - except Exception as e: # noqa: F841 - pass + # This executes the callback so we can not ignore exceptions + callback(*args, **kwargs) def set_up(self): - self.vars['__UNITTEST__'] = True - self.vars['__HABAPP__RUNTIME__'] = Runtime() - self.vars['__HABAPP__RULE_FILE__'] = TestRuleFile() - self.vars['__HABAPP__RULES'] = self.loaded_rules = [] + # ensure that we call setup only once! + assert isinstance(HABApp.core.Items, ConstProxyObj) + assert isinstance(HABApp.core.EventBus, ConstProxyObj) + + ir = ItemRegistry() + eb = EventBus() + self.restore = setup_internals(ir, eb, final=False) + + # Overwrite + self.monkeypatch.setattr(HABApp.core, 'EventBus', eb) + self.monkeypatch.setattr(HABApp.core, 'Items', ir) + + # Patch the hook so we can instantiate the rules + hook = HABAppRuleHook(self.loaded_rules.append, suggest_rule_name, DummyRuntime(), None) + self.monkeypatch.setattr(rule_module, '_get_rule_hook', lambda: hook) # patch worker with a synchronous worker - self.patch_obj(WrappedFunction, '_WORKERS', self) + self.monkeypatch.setattr(wrapped_thread, 'WORKERS', self) + self.monkeypatch.setattr(wrapper, 'SYNC_CLS', WrappedThreadFunction, raising=False) - # patch scheduler, so we run synchronous - self.patch_obj(ha_sched, '_HABAppScheduler', SyncScheduler) + # raise exceptions during error formatting + self.monkeypatch.setattr(HABApp.core.lib.exceptions.format, 'fallback_format', raising_fallback_format) + # patch scheduler, so we run synchronous + self.monkeypatch.setattr(ha_sched, '_HABAppScheduler', SyncScheduler) def tear_down(self): - async_context.set('Tear down test') + ctx = async_context.set('Tear down test') - self.vars.pop('__UNITTEST__') - self.vars.pop('__HABAPP__RUNTIME__') - self.vars.pop('__HABAPP__RULE_FILE__') - loaded_rules = self.vars.pop('__HABAPP__RULES') - for rule in loaded_rules: - rule._unload() - loaded_rules.clear() + for rule in self.loaded_rules: + rule._habapp_ctx.unload_rule() + self.loaded_rules.clear() # restore patched - self.restore() + self.monkeypatch.undo() + async_context.reset(ctx) + + for r in self.restore: + r.restore() def process_events(self): for s in SyncScheduler.ALL: @@ -102,6 +110,5 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.tear_down() - # do not supress exception return False diff --git a/tests/rule_runner/test_rule_runner.py b/tests/rule_runner/test_rule_runner.py index 951efd29..fc2f30dc 100644 --- a/tests/rule_runner/test_rule_runner.py +++ b/tests/rule_runner/test_rule_runner.py @@ -1,3 +1,8 @@ +import pytest +import HABApp.core.lib.exceptions.format + + +@pytest.mark.no_internals def test_doc_run(): calls = [] @@ -24,3 +29,34 @@ def say_something(self): runner.tear_down() assert len(calls) == 4 + + +@pytest.mark.no_internals +def test_doc_run_exception(monkeypatch): + """Check that the RuleRunner propagates exceptions which happen during exception formatting""" + + class MyException(Exception): + pass + + def err(*args, **kwargs): + raise MyException() + + monkeypatch.setattr(HABApp.core.lib.exceptions.format, 'format_frame_info', err) + + from tests import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + + class MyFirstRule(HABApp.Rule): + def __init__(self): + super().__init__() + self.run.soon(self.say_something) + + def say_something(self): + 1 / 0 + + MyFirstRule() + + with pytest.raises(MyException): + runner.process_events() + runner.tear_down() diff --git a/tests/test_all/__init__.py b/tests/test_all/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_all/test_items.py b/tests/test_all/test_items.py new file mode 100644 index 00000000..1f836abf --- /dev/null +++ b/tests/test_all/test_items.py @@ -0,0 +1,51 @@ +import pytest + +from HABApp.core.items import BaseValueItem, HINT_TYPE_ITEM_OBJ +from HABApp.mqtt.items import MqttBaseItem +from tests.helpers import get_module_classes + + +def get_item_classes(skip=tuple()): + classes = [] + for module_name in ('core', 'openhab', 'mqtt'): + if module_name in skip: + continue + + for name, cls in get_module_classes(f'HABApp.{module_name}.items', exclude=[MqttBaseItem], + subclass=BaseValueItem, include_subclass=False).items(): + if name in skip: + continue + + default = None + if name == 'ColorItem': + default = (0.0, 0.0, 0.0) + classes.append(pytest.param(cls, default, id=f'{module_name}.{cls.__name__}')) + return classes + + +@pytest.mark.parametrize('item_cls, default', get_item_classes()) +def test_create_item(item_cls: HINT_TYPE_ITEM_OBJ, default): + + # test normal create + item = item_cls('item_name') + assert item.name == 'item_name' + assert (item.value is None if default is None else item.value == default) + + # test create positional + item = item_cls(name='item_name') + assert item.name == 'item_name' + assert (item.value is None if default is None else item.value == default) + + +@pytest.mark.parametrize( + 'item_cls, default', get_item_classes(skip=('openhab', 'MqttPairItem'),)) +def test_get_create_item(item_cls: HINT_TYPE_ITEM_OBJ, default): + + # test normal create + item = item_cls.get_create_item('item_name') + assert item.name == 'item_name' + assert (item.value is None if default is None else item.value == default) + + # test create positional + item2 = item_cls.get_create_item(name='item_name') + assert item2 is item diff --git a/tests/test_config/test_platform.py b/tests/test_config/test_platform.py index 5bbb2e97..81e232d9 100644 --- a/tests/test_config/test_platform.py +++ b/tests/test_config/test_platform.py @@ -1,6 +1,8 @@ from pathlib import Path -from HABApp.config import config_loader, default_logfile +from HABApp.config.logging.config import _yaml_safe +from HABApp.config.logging import default_logfile + from HABApp.config.platform_defaults import get_log_folder @@ -23,12 +25,12 @@ def ensure_key(key, obj): monkeypatch.setattr(default_logfile, 'get_log_folder', lambda: Path('/platfrom/log/folder')) default = default_logfile.get_default_logfile() - ensure_key('root', config_loader._yaml_param.load(default)) + ensure_key('root', _yaml_safe.load(default)) monkeypatch.setattr(default_logfile, 'is_openhabian', lambda: False) default = default_logfile.get_default_logfile() - ensure_key('root', config_loader._yaml_param.load(default)) + ensure_key('root', _yaml_safe.load(default)) monkeypatch.setattr(default_logfile, 'get_log_folder', lambda: None) default = default_logfile.get_default_logfile() - ensure_key('root', config_loader._yaml_param.load(default)) + ensure_key('root', _yaml_safe.load(default)) diff --git a/tests/test_core/test_all_items.py b/tests/test_core/test_all_items.py deleted file mode 100644 index f0031215..00000000 --- a/tests/test_core/test_all_items.py +++ /dev/null @@ -1,34 +0,0 @@ -import unittest - -from HABApp.core.items import Item -from HABApp.core import Items - - -class TestCasesItem(unittest.TestCase): - - def tearDown(self) -> None: - for name in Items.get_all_item_names(): - Items.pop_item(name) - - def setUp(self) -> None: - for name in Items.get_all_item_names(): - Items.pop_item(name) - - def test_item(self): - - NAME = 'test' - created_item = Item(NAME) - Items.add_item(created_item) - - self.assertTrue(Items.item_exists(NAME)) - self.assertIs(created_item, Items.get_item(NAME)) - - self.assertEqual(Items.get_all_item_names(), [NAME]) - self.assertEqual(Items.get_all_items(), [created_item]) - - self.assertIs(created_item, Items.pop_item(NAME)) - self.assertEqual(Items.get_all_items(), []) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_core/test_context.py b/tests/test_core/test_context.py index aebdfa2a..42e93da4 100644 --- a/tests/test_core/test_context.py +++ b/tests/test_core/test_context.py @@ -1,9 +1,8 @@ import pytest -from HABApp.core.context import async_context, AsyncContextError +from HABApp.core.asyncio import async_context, AsyncContextError -@pytest.mark.asyncio async def test_error_msg(): def my_sync_func(): diff --git a/tests/test_core/test_event_bus.py b/tests/test_core/test_event_bus.py index 3f2054da..341654cb 100644 --- a/tests/test_core/test_event_bus.py +++ b/tests/test_core/test_event_bus.py @@ -1,81 +1,70 @@ from unittest.mock import MagicMock -from pytest import fixture - -from HABApp.core import EventBus, EventBusListener, wrappedfunction from HABApp.core.events import ComplexEventValue, ValueChangeEvent, ValueUpdateEvent -from HABApp.core.items import Item +from HABApp.core.events.filter import NoEventFilter, EventFilter, OrFilterGroup +from HABApp.core.internals import EventBus, EventBusListener, wrap_func class TestEvent: pass -@fixture -def clean_event_bus(): - EventBus.remove_all_listeners() - yield EventBus - EventBus.remove_all_listeners() - - -def test_repr(clean_event_bus: EventBus, sync_worker): - f = wrappedfunction.WrappedFunction(lambda x: x) +def test_repr(sync_worker): + f = wrap_func(lambda x: x) - listener = EventBusListener('test_name', f) - assert listener.desc() == '"test_name" (type AllEvents)' + listener = EventBusListener('test_name', f, NoEventFilter()) + assert listener.describe() == '"test_name" (filter=NoEventFilter())' - listener = EventBusListener('test_name', f, attr_name1='test1', attr_value1='value1') - assert listener.desc() == '"test_name" (type AllEvents, test1==value1)' + listener = EventBusListener('test_name', f, EventFilter(ValueUpdateEvent, value='test1')) + assert listener.describe() == '"test_name" (filter=EventFilter(type=ValueUpdateEvent, value=test1))' - listener = EventBusListener('test_name', f, attr_name2='test2', attr_value2='value2') - assert listener.desc() == '"test_name" (type AllEvents, test2==value2)' - listener = EventBusListener('test_name', f, attr_name1='test1', attr_value1='value1', - attr_name2='test2', attr_value2='value2') - assert listener.desc() == '"test_name" (type AllEvents, test1==value1, test2==value2)' - - -def test_str_event(clean_event_bus: EventBus, sync_worker): +def test_str_event(sync_worker): event_history = [] + eb = EventBus() def append_event(event): event_history.append(event) - func = wrappedfunction.WrappedFunction(append_event) + func = wrap_func(append_event) - listener = EventBusListener('str_test', func) - EventBus.add_listener(listener) + listener = EventBusListener('str_test', func, NoEventFilter()) + eb.add_listener(listener) - EventBus.post_event('str_test', 'str_event') + eb.post_event('str_test', 'str_event') assert event_history == ['str_event'] -def test_multiple_events(clean_event_bus: EventBus, sync_worker): +def test_multiple_events(sync_worker): event_history = [] + eb = EventBus() target = ['str_event', TestEvent(), 'str_event2'] def append_event(event): event_history.append(event) - listener = EventBusListener('test', wrappedfunction.WrappedFunction(append_event), (str, TestEvent)) - EventBus.add_listener(listener) + listener = EventBusListener( + 'test', wrap_func(append_event), + OrFilterGroup(EventFilter(str), EventFilter(TestEvent))) + eb.add_listener(listener) for k in target: - EventBus.post_event('test', k) + eb.post_event('test', k) assert event_history == target -def test_complex_event_unpack(clean_event_bus: EventBus, sync_worker): +def test_complex_event_unpack(sync_worker): """Test that the ComplexEventValue get properly unpacked""" m = MagicMock() assert not m.called + eb = EventBus() - item = Item.get_create_item('test_complex') - listener = EventBusListener(item.name, wrappedfunction.WrappedFunction(m, name='test')) - EventBus.add_listener(listener) + listener = EventBusListener('test_complex', wrap_func(m, name='test'), NoEventFilter()) + eb.add_listener(listener) - item.post_value(ComplexEventValue('ValOld')) - item.post_value(ComplexEventValue('ValNew')) + eb.post_event('test_complex', ValueUpdateEvent('test_complex', ComplexEventValue('ValOld'))) + eb.post_event('test_complex', + ValueChangeEvent('test_complex', ComplexEventValue('ValNew'), ComplexEventValue('ValOld'))) # assert that we have been called with exactly one arg for k in m.call_args_list: @@ -83,71 +72,7 @@ def test_complex_event_unpack(clean_event_bus: EventBus, sync_worker): arg0 = m.call_args_list[0][0][0] arg1 = m.call_args_list[1][0][0] - arg2 = m.call_args_list[2][0][0] - arg3 = m.call_args_list[3][0][0] # Events for first post_value - assert vars(arg0) == vars(ValueUpdateEvent(item.name, 'ValOld')) - assert vars(arg1) == vars(ValueChangeEvent(item.name, 'ValOld', None)) - - # Events for second post_value - assert vars(arg2) == vars(ValueUpdateEvent(item.name, 'ValNew')) - assert vars(arg3) == vars(ValueChangeEvent(item.name, 'ValNew', 'ValOld')) - - -def test_event_filter(clean_event_bus: EventBus, sync_worker): - events_all, events_filtered1, events_filtered2 , events_filtered3 = [], [], [], [] - - def append_all(event): - events_all.append(event) - - def append_filter1(event): - events_filtered1.append(event) - - def append_filter2(event): - events_filtered2.append(event) - - def append_filter3(event): - events_filtered3.append(event) - - name = 'test_filter' - func1 = wrappedfunction.WrappedFunction(append_filter1) - func2 = wrappedfunction.WrappedFunction(append_filter2) - func3 = wrappedfunction.WrappedFunction(append_filter3) - - # listener to all events - EventBus.add_listener( - EventBusListener(name, wrappedfunction.WrappedFunction(append_all)) - ) - - listener = EventBusListener(name, func1, ValueUpdateEvent, 'value', 'test_value') - EventBus.add_listener(listener) - listener = EventBusListener(name, func2, ValueUpdateEvent, None, None, 'value', 1) - EventBus.add_listener(listener) - listener = EventBusListener(name, func3, ValueChangeEvent, 'old_value', None, 'value', 1) - EventBus.add_listener(listener) - - event0 = ValueUpdateEvent(name, None) - event1 = ValueUpdateEvent(name, 'test_value') - event2 = ValueUpdateEvent(name, 1) - event3 = ValueChangeEvent(name, 1, None) - - EventBus.post_event(name, event0) - EventBus.post_event(name, event1) - EventBus.post_event(name, event2) - EventBus.post_event(name, event3) - - assert len(events_all) == 4 - assert vars(events_all[0]) == vars(event0) - assert vars(events_all[1]) == vars(event1) - assert vars(events_all[2]) == vars(event2) - assert vars(events_all[3]) == vars(event3) - - assert len(events_filtered1) == 1 - assert vars(events_filtered1[0]) == vars(event1) - - assert len(events_filtered2) == 1 - assert vars(events_filtered2[0]) == vars(event2) - - assert len(events_filtered3) == 1 - assert vars(events_filtered3[0]) == vars(event3) + assert vars(arg0) == vars(ValueUpdateEvent('test_complex', 'ValOld')) + assert vars(arg1) == vars(ValueChangeEvent('test_complex', 'ValNew', 'ValOld')) diff --git a/tests/test_core/test_events/test_core_filters.py b/tests/test_core/test_events/test_core_filters.py index 7fd530a6..ddfa376d 100644 --- a/tests/test_core/test_events/test_core_filters.py +++ b/tests/test_core/test_events/test_core_filters.py @@ -1,8 +1,8 @@ import pytest -from HABApp.core.event_bus_listener import WrappedFunction -from HABApp.core.events import EventFilter, ValueChangeEvent, ValueChangeEventFilter, ValueUpdateEvent, \ - ValueUpdateEventFilter +from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.core.events.filter import EventFilter, ValueChangeEventFilter, ValueUpdateEventFilter, NoEventFilter, \ + OrFilterGroup, AndFilterGroup from tests.helpers import check_class_annotations @@ -13,20 +13,28 @@ def test_class_annotations(): def test_repr(): + assert NoEventFilter().describe() == 'NoEventFilter()' + f = EventFilter(ValueUpdateEvent, value=1) - assert str(f) == 'EventFilter(event_type=ValueUpdateEvent, value=1)' + assert f.describe() == 'EventFilter(type=ValueUpdateEvent, value=1)' f = ValueUpdateEventFilter(value='asd') - assert str(f) == 'ValueUpdateEventFilter(value=asd)' + assert f.describe() == 'ValueUpdateEventFilter(value=asd)' f = ValueChangeEventFilter(value=1.5) - assert str(f) == 'ValueChangeEventFilter(value=1.5)' + assert f.describe() == 'ValueChangeEventFilter(value=1.5)' f = ValueChangeEventFilter(old_value=3) - assert str(f) == 'ValueChangeEventFilter(old_value=3)' + assert f.describe() == 'ValueChangeEventFilter(old_value=3)' f = ValueChangeEventFilter(value=1.5, old_value=3) - assert str(f) == 'ValueChangeEventFilter(value=1.5, old_value=3)' + assert f.describe() == 'ValueChangeEventFilter(value=1.5, old_value=3)' + + f = AndFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) + assert f.describe() == '(ValueChangeEventFilter(old_value=1) and ValueChangeEventFilter(value=2))' + + f = OrFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) + assert f.describe() == '(ValueChangeEventFilter(old_value=1) or ValueChangeEventFilter(value=2))' def test_exception_missing(): @@ -36,18 +44,47 @@ def test_exception_missing(): assert str(e.value) == 'Filter attribute "asdf" does not exist for "ValueUpdateEvent"' -def test_create_listener(): +def test_all_events(): + assert NoEventFilter().trigger(None) is True + assert NoEventFilter().trigger('') is True + assert NoEventFilter().trigger(False) is True + assert NoEventFilter().trigger(True) is True + assert NoEventFilter().trigger('AnyStr') is True + + +def test_value_change_event_filter(): + + f = ValueChangeEventFilter() + assert f.trigger(ValueUpdateEvent()) is False + assert f.trigger(ValueChangeEvent()) is True + + f = ValueChangeEventFilter(value=1) + assert f.trigger(ValueUpdateEvent()) is False + assert f.trigger(ValueChangeEvent(value=1)) is True + assert f.trigger(ValueChangeEvent(value=2)) is False + + f = ValueChangeEventFilter(old_value=1) + assert f.trigger(ValueUpdateEvent()) is False + assert f.trigger(ValueChangeEvent(value=1)) is False + assert f.trigger(ValueChangeEvent(old_value=2)) is False + assert f.trigger(ValueChangeEvent(old_value=1)) is True - f = EventFilter(ValueUpdateEvent, value=1) - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - assert e.event_filter is ValueUpdateEvent - assert e.attr_name1 == 'value' - assert e.attr_value1 == 1 +def test_filter_groups_and(): + f = AndFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) + assert f.trigger(ValueUpdateEvent()) is False + assert f.trigger(ValueChangeEvent()) is False + assert f.trigger(ValueChangeEvent(value=1)) is False + assert f.trigger(ValueChangeEvent(value=2)) is False + assert f.trigger(ValueChangeEvent(value=2, old_value=3)) is False + assert f.trigger(ValueChangeEvent(value=2, old_value=1)) is True - f = ValueChangeEventFilter(old_value='asdf') - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - assert e.event_filter is ValueChangeEvent - assert e.attr_name1 == 'old_value' - assert e.attr_value1 == 'asdf' +def test_filter_groups_or(): + f = OrFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) + assert f.trigger(ValueUpdateEvent()) is False + assert f.trigger(ValueChangeEvent()) is False + assert f.trigger(ValueChangeEvent(value=1)) is False + assert f.trigger(ValueChangeEvent(value=2)) is True + assert f.trigger(ValueChangeEvent(value=1, old_value=3)) is False + assert f.trigger(ValueChangeEvent(value=1, old_value=1)) is True diff --git a/tests/test_core/test_files/test_file_dependencies.py b/tests/test_core/test_files/test_file_dependencies.py index f9e5b3d3..f1765296 100644 --- a/tests/test_core/test_files/test_file_dependencies.py +++ b/tests/test_core/test_files/test_file_dependencies.py @@ -83,7 +83,7 @@ def cfg(monkeypatch): # FILE_PROPS['params/param1'] = FileProperties(depends_on=[], reloads_on=['params/param2']) # FILE_PROPS['params/param2'] = FileProperties() # -# event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) +# event_bus.listen_events(HABApp.core.const.topics.TOPIC_FILES, process_event) # # process([MockFile('param2'), MockFile('param1')]) # @@ -106,7 +106,6 @@ def cfg(monkeypatch): # order.clear() -@pytest.mark.asyncio async def test_reload_dep(cfg: CfgObj, caplog): cfg.properties['params/param1'] = FileProperties(depends_on=['params/param2'], reloads_on=['params/param2']) cfg.properties['params/param2'] = FileProperties() @@ -134,7 +133,6 @@ async def test_reload_dep(cfg: CfgObj, caplog): cfg.operation.clear() -@pytest.mark.asyncio async def test_missing_dependencies(cfg: CfgObj, caplog): cfg.properties['params/param1'] = FileProperties(depends_on=['params/param4', 'params/param5']) cfg.properties['params/param2'] = FileProperties(depends_on=['params/param4']) @@ -169,7 +167,7 @@ async def test_missing_dependencies(cfg: CfgObj, caplog): # FILE_PROPS['params/param1'] = FileProperties(reloads_on=['params/param4', 'params/param5']) # FILE_PROPS['params/param2'] = FileProperties(reloads_on=['params/param4']) # -# event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) +# event_bus.listen_events(HABApp.core.const.topics.TOPIC_FILES, process_event) # # process([MockFile('param1'), MockFile('param2')]) # @@ -200,7 +198,7 @@ async def test_missing_dependencies(cfg: CfgObj, caplog): # FILE_PROPS['params/p1'] = FileProperties(depends_on=['params/p2'], reloads_on=[]) # FILE_PROPS['params/p2'] = FileProperties() # -# event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) +# event_bus.listen_events(HABApp.core.const.topics.TOPIC_FILES, process_event) # # process([MockFile('p1')]) # diff --git a/tests/test_core/test_files/test_watcher.py b/tests/test_core/test_files/test_watcher.py index b43e14bb..7f972ee8 100644 --- a/tests/test_core/test_files/test_watcher.py +++ b/tests/test_core/test_files/test_watcher.py @@ -2,24 +2,21 @@ import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from unittest.mock import Mock +from unittest.mock import AsyncMock -import pytest from watchdog.events import FileSystemEvent import HABApp.core.files.watcher.file_watcher from HABApp.core.files.watcher import AggregatingAsyncEventHandler from HABApp.core.files.watcher.base_watcher import FileEndingFilter -from ...helpers import TmpEventBus -@pytest.mark.asyncio -async def test_file_events(monkeypatch, event_bus: TmpEventBus, sync_worker): +async def test_file_events(monkeypatch, sync_worker): wait_time = 0.1 monkeypatch.setattr(HABApp.core.files.watcher.file_watcher, 'DEBOUNCE_TIME', wait_time) - m = Mock() + m = AsyncMock() handler = AggregatingAsyncEventHandler(Path('folder'), m, FileEndingFilter('.tmp'), False) loop = asyncio.get_event_loop() diff --git a/tests/test_core/test_item_registry.py b/tests/test_core/test_item_registry.py new file mode 100644 index 00000000..cf56aaa7 --- /dev/null +++ b/tests/test_core/test_item_registry.py @@ -0,0 +1,19 @@ +from HABApp.core.items import Item +from HABApp.core.internals import ItemRegistry + + +def test_basics(): + item_name = 'test' + + ir = ItemRegistry() + created_item = Item(item_name) + ir.add_item(created_item) + + assert ir.item_exists(item_name) + assert created_item is ir.get_item(item_name) + + assert ir.get_item_names() == (item_name, ) + assert ir.get_items() == (created_item, ) + + assert created_item == ir.pop_item(item_name) + assert ir.get_items() == tuple() diff --git a/tests/test_core/test_item_watch.py b/tests/test_core/test_item_watch.py index 98f7e491..dcc26bb3 100644 --- a/tests/test_core/test_item_watch.py +++ b/tests/test_core/test_item_watch.py @@ -1,16 +1,14 @@ import asyncio -from unittest.mock import MagicMock from datetime import timedelta +from unittest.mock import MagicMock import pytest from HABApp.core.events import ItemNoUpdateEvent, ItemNoChangeEvent from HABApp.core.items import Item from tests.helpers.parent_rule import DummyRule -from ..helpers import TmpEventBus -@pytest.mark.asyncio async def test_multiple_add(parent_rule: DummyRule): i = Item('test') @@ -24,26 +22,27 @@ async def test_multiple_add(parent_rule: DummyRule): assert w1 is not w2 -@pytest.mark.asyncio -async def test_watch_update(parent_rule: DummyRule, event_bus: TmpEventBus, sync_worker, caplog): +@pytest.mark.parametrize('method', ('watch_update', 'watch_change')) +async def test_watch_update(parent_rule: DummyRule, sync_worker, caplog, method): + caplog.set_level(0) + cb = MagicMock() + cb.__name__ = 'MockName' - for meth in ('watch_update', 'watch_change'): + secs = 0.2 - cb = MagicMock() - cb.__name__ = 'MockName' - - secs = 0.2 + i = Item('test') + func = getattr(i, method) + func(secs / 2) + w = func(timedelta(seconds=secs)) + w.listen_event(cb) - i = Item('test') - func = getattr(i, meth) - func(secs / 2) - w = func(timedelta(seconds=secs)) - w.listen_event(cb) + i.post_value(1) + await asyncio.sleep(0.3) - i.post_value(1) - await asyncio.sleep(0.3) + for c in caplog.records: + print(c) - cb.assert_called_once() - assert isinstance(cb.call_args[0][0], ItemNoUpdateEvent if meth == 'watch_update' else ItemNoChangeEvent) - assert cb.call_args[0][0].name == 'test' - assert cb.call_args[0][0].seconds == secs + cb.assert_called_once() + assert isinstance(cb.call_args[0][0], ItemNoUpdateEvent if method == 'watch_update' else ItemNoChangeEvent) + assert cb.call_args[0][0].name == 'test' + assert cb.call_args[0][0].seconds == secs diff --git a/tests/test_core/test_items/test_item_aggregation.py b/tests/test_core/test_items/test_item_aggregation.py index ec939a78..cfad08a9 100644 --- a/tests/test_core/test_items/test_item_aggregation.py +++ b/tests/test_core/test_items/test_item_aggregation.py @@ -1,14 +1,11 @@ import asyncio -import pytest +from HABApp.core.items import AggregationItem, Item -import HABApp - -@pytest.mark.asyncio async def test_aggregation_item(): - agg = HABApp.core.items.AggregationItem.get_create_item('MyAggregation') - src = HABApp.core.items.Item.get_create_item('MySource') + agg = AggregationItem.get_create_item('MyAggregation') + src = Item.get_create_item('MySource') INTERVAL = 0.2 @@ -51,10 +48,9 @@ async def post_val(t, v): assert agg.value == (2, [2]) -@pytest.mark.asyncio async def test_aggregation_item_cleanup(): - agg = HABApp.core.items.AggregationItem.get_create_item('MyTestAggregation') - src = HABApp.core.items.Item.get_create_item('MyTestSource') + agg = AggregationItem.get_create_item('MyTestAggregation') + src = Item.get_create_item('MyTestSource') INTERVAL = 0.2 diff --git a/tests/test_core/test_items/test_item_color.py b/tests/test_core/test_items/test_item_color.py index a1539f52..79ce69fc 100644 --- a/tests/test_core/test_items/test_item_color.py +++ b/tests/test_core/test_items/test_item_color.py @@ -2,9 +2,9 @@ import pytest -from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent, NoEventFilter from HABApp.core.items import ColorItem -from ...helpers import TmpEventBus +from tests.helpers import TestEventBus def test_repr(): @@ -79,11 +79,11 @@ def test_hsv_to_rgb(): assert i.get_rgb() == (168, 123, 94) -def test_post_update(sync_worker, event_bus: TmpEventBus): +def test_post_update(sync_worker, eb: TestEventBus): i = ColorItem('test', 23, 44, 66) mock = MagicMock() - event_bus.listen_events(i.name, mock) + eb.listen_events(i.name, mock, NoEventFilter()) mock.assert_not_called() i.post_value(1, 2, 3) diff --git a/tests/test_core/test_items/test_item_interface.py b/tests/test_core/test_items/test_item_interface.py index e15beb9d..cad96bc4 100644 --- a/tests/test_core/test_items/test_item_interface.py +++ b/tests/test_core/test_items/test_item_interface.py @@ -1,36 +1,32 @@ import pytest -from HABApp.core import Items +from HABApp.core.errors import ItemNotFoundException, ItemAlreadyExistsError +from HABApp.core.internals import ItemRegistry from HABApp.core.items import Item -@pytest.fixture -def clean_reg(): - Items._ALL_ITEMS.clear() - yield - Items._ALL_ITEMS.clear() +def test_pop(): + ir = ItemRegistry() + ir.add_item(Item('test')) + assert ir.item_exists('test') + with pytest.raises(ItemNotFoundException): + ir.pop_item('asdfadsf') -def test_pop(clean_reg): - Items.add_item(Item('test')) - assert Items.item_exists('test') + ir.pop_item('test') + assert not ir.item_exists('test') - with pytest.raises(Items.ItemNotFoundException): - Items.pop_item('asdfadsf') - Items.pop_item('test') - assert not Items.item_exists('test') - - -def test_add(clean_reg): +def test_add(): + ir = ItemRegistry() added = Item('test') - Items.add_item(added) - assert Items.item_exists('test') + ir.add_item(added) + assert ir.item_exists('test') # adding the same item multiple times will not cause an exception - Items.add_item(added) - Items.add_item(added) + ir.add_item(added) + ir.add_item(added) # adding a new item -> exception - with pytest.raises(Items.ItemAlreadyExistsError): - Items.add_item(Item('test')) + with pytest.raises(ItemAlreadyExistsError): + ir.add_item(Item('test')) diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index c16c37ab..6979140b 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -1,5 +1,4 @@ import asyncio -from datetime import timedelta from unittest.mock import MagicMock import pytest @@ -8,8 +7,11 @@ import HABApp import HABApp.core.items.tmp_data +from HABApp.core.events import NoEventFilter from HABApp.core.items.base_item import ChangedTime, UpdatedTime -from ...helpers import TmpEventBus +from tests.helpers import TestEventBus +from HABApp.core.internals import wrap_func, HINT_ITEM_REGISTRY, HINT_EVENT_BUS +from HABApp.core.items import Item @pytest.fixture(scope="function") @@ -21,8 +23,10 @@ def u(): yield a # cancel the rest of the running tasks - w1.cancel() - w2.cancel() + if w1._parent_ctx is not None: + w1.cancel() + if w2._parent_ctx is not None: + w2.cancel() @pytest.fixture(scope="function") @@ -34,36 +38,33 @@ def c(): yield a # cancel the rest of the running tasks - w1.cancel() - w2.cancel() + if w1._parent_ctx is not None: + w1.cancel() + if w2._parent_ctx is not None: + w2.cancel() -def test_sec_timedelta(): +def test_sec_timedelta(parent_rule): a = UpdatedTime('test', pd_now(UTC)) w1 = a.add_watch(1) # We return the same object because it is the same time - assert w1 is a.add_watch(timedelta(seconds=1)) + assert w1 is a.add_watch(1) + assert w1 is a.add_watch(1.0) - w2 = a.add_watch(timedelta(seconds=3)) + w2 = a.add_watch(3) assert w2.fut.secs == 3 - w3 = a.add_watch(timedelta(minutes=3)) - assert w3.fut.secs == 3 * 60 - w1.cancel() w2.cancel() - w3.cancel() -@pytest.mark.asyncio -async def test_rem(u: UpdatedTime): +async def test_rem(parent_rule, u: UpdatedTime): for t in u.tasks: t.cancel() -@pytest.mark.asyncio -async def test_cancel_running(u: UpdatedTime): +async def test_cancel_running(parent_rule, u: UpdatedTime): u.set(pd_now(UTC)) w1 = u.tasks[0] @@ -81,12 +82,11 @@ async def test_cancel_running(u: UpdatedTime): assert w2 not in u.tasks -@pytest.mark.asyncio -async def test_event_update(u: UpdatedTime): +async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: HINT_EVENT_BUS): m = MagicMock() u.set(pd_now(UTC)) - list = HABApp.core.EventBusListener('test', HABApp.core.WrappedFunction(m, name='MockFunc')) - HABApp.core.EventBus.add_listener(list) + list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) + eb.add_listener(list) u.set(pd_now(UTC)) await asyncio.sleep(1) @@ -111,12 +111,11 @@ async def test_event_update(u: UpdatedTime): list.cancel() -@pytest.mark.asyncio -async def test_event_change(c: ChangedTime): +async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: HINT_EVENT_BUS): m = MagicMock() c.set(pd_now(UTC)) - list = HABApp.core.EventBusListener('test', HABApp.core.WrappedFunction(m, name='MockFunc')) - HABApp.core.EventBus.add_listener(list) + list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) + eb.add_listener(list) c.set(pd_now(UTC)) await asyncio.sleep(1) @@ -141,48 +140,46 @@ async def test_event_change(c: ChangedTime): list.cancel() -@pytest.mark.asyncio -async def test_watcher_change_restore(parent_rule): +async def test_watcher_change_restore(parent_rule, ir: HINT_ITEM_REGISTRY): name = 'test_save_restore' - item_a = HABApp.core.items.Item(name) - HABApp.core.Items.add_item(item_a) + item_a = Item(name) + ir.add_item(item_a) watcher = item_a.watch_change(1) # remove item assert name not in HABApp.core.items.tmp_data.TMP_DATA - HABApp.core.Items.pop_item(name) + ir.pop_item(name) assert name in HABApp.core.items.tmp_data.TMP_DATA - item_b = HABApp.core.items.Item(name) - HABApp.core.Items.add_item(item_b) + item_b = Item(name) + ir.add_item(item_b) assert item_b._last_change.tasks == [watcher] - HABApp.core.Items.pop_item(name) + ir.pop_item(name) -@pytest.mark.asyncio -async def test_watcher_update_restore(parent_rule): +async def test_watcher_update_restore(parent_rule, ir: HINT_ITEM_REGISTRY): name = 'test_save_restore' - item_a = HABApp.core.items.Item(name) - HABApp.core.Items.add_item(item_a) + item_a = Item(name) + ir.add_item(item_a) watcher = item_a.watch_update(1) # remove item assert name not in HABApp.core.items.tmp_data.TMP_DATA - HABApp.core.Items.pop_item(name) + ir.pop_item(name) assert name in HABApp.core.items.tmp_data.TMP_DATA - item_b = HABApp.core.items.Item(name) - HABApp.core.Items.add_item(item_b) + item_b = Item(name) + ir.add_item(item_b) assert item_b._last_update.tasks == [watcher] - HABApp.core.Items.pop_item(name) + ir.pop_item(name) -@pytest.mark.asyncio -async def test_watcher_update_cleanup(monkeypatch, parent_rule, c: ChangedTime, sync_worker, event_bus: TmpEventBus): +async def test_watcher_update_cleanup(monkeypatch, parent_rule, c: ChangedTime, + sync_worker, eb: TestEventBus, ir: HINT_ITEM_REGISTRY): monkeypatch.setattr(HABApp.core.items.tmp_data.CLEANUP, 'secs', 0.7) text_warning = '' @@ -191,16 +188,16 @@ def get_log(event): nonlocal text_warning text_warning = event - event_bus.listen_events(HABApp.core.const.topics.WARNINGS, get_log) + eb.listen_events(HABApp.core.const.topics.TOPIC_WARNINGS, get_log, NoEventFilter()) name = 'test_save_restore' item_a = HABApp.core.items.Item(name) - HABApp.core.Items.add_item(item_a) + ir.add_item(item_a) item_a.watch_update(1) # remove item assert name not in HABApp.core.items.tmp_data.TMP_DATA - HABApp.core.Items.pop_item(name) + ir.pop_item(name) assert name in HABApp.core.items.tmp_data.TMP_DATA # ensure that the tmp data gets deleted diff --git a/tests/test_core/test_items/tests_all_items.py b/tests/test_core/test_items/tests_all_items.py index af320b5b..e19d1cc7 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 import Items +from HABApp.core.internals import HINT_ITEM_REGISTRY from HABApp.core.items import Item @@ -17,19 +17,18 @@ def test_test_params(self): assert self.CLS is not None assert self.TEST_VALUES, type(self) - def test_factories(self): + def test_factories(self, ir: HINT_ITEM_REGISTRY): cls = self.CLS ITEM_NAME = 'testitem' - if Items.item_exists(ITEM_NAME): - Items.pop_item(ITEM_NAME) + if ir.item_exists(ITEM_NAME): + ir.pop_item(ITEM_NAME) c = cls.get_create_item(name=ITEM_NAME, **self.TEST_CREATE_ITEM) assert isinstance(c, cls) assert isinstance(cls.get_item(name=ITEM_NAME), cls) - def test_var_names(self): item = self.CLS('test') # assert item.value is None, f'{item.value} ({type(item.value)})' diff --git a/tests/test_core/test_lib/__init__.py b/tests/test_core/test_lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_lib/test_format_traceback.py b/tests/test_core/test_lib/test_format_traceback.py new file mode 100644 index 00000000..bb0006de --- /dev/null +++ b/tests/test_core/test_lib/test_format_traceback.py @@ -0,0 +1,170 @@ +import logging +from typing import Optional, Union + +from pydantic import BaseModel + +import HABApp +from HABApp.core.const.json import load_json, dump_json +from HABApp.core.lib import format_exception +from easyconfig import create_app_config +from tests.helpers.traceback import process_traceback +from HABApp.core.lib.exceptions.format_frame import SUPPRESSED_HABAPP_PATHS, skip_file +from pathlib import Path + +log = logging.getLogger('TestLogger') + + +def exec_func(func) -> str: + try: + func() + except Exception as e: + msg = '\n' + '\n'.join(format_exception(e)) + + msg = process_traceback(msg) + return msg + + +def func_obj_def_multilines(): + item = HABApp.core.items.Item # noqa: F841 + a = [ # noqa: F841 + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + 1 / 0 + + +# def test_exception_format_traceback_compact_lines(): +# +# msg = exec_func(func_obj_def_multilines) +# assert msg == r''' +# File "test_core/test_lib/test_format_traceback.py", line 17 in exec_func +# -------------------------------------------------------------------------------- +# 15 | def exec_func(func) -> str: +# 16 | try: +# --> 17 | func() +# 18 | except Exception as e: +# ------------------------------------------------------------ +# e = ZeroDivisionError('division by zero') +# func = +# ------------------------------------------------------------ +# +# File "test_core/test_lib/test_format_traceback.py", line 37 in func_obj_def_multilines +# -------------------------------------------------------------------------------- +# 25 | def func_obj_def_multilines(): +# 26 | item = HABApp.core.items.Item # noqa: F841 +# 27 | a = [ # noqa: F841 +# 28 | 1, +# (...) +# 35 | 8 +# 36 | ] +# --> 37 | 1 / 0 +# ------------------------------------------------------------ +# item = +# a = [1, 2, 3, 4, 5, 6, 7, 8] +# ------------------------------------------------------------ +# +# -------------------------------------------------------------------------------- +# Traceback (most recent call last): +# File "test_core/test_lib/test_format_traceback.py", line 17, in exec_func +# func() +# File "test_core/test_lib/test_format_traceback.py", line 37, in func_obj_def_multilines +# 1 / 0 +# ZeroDivisionError: division by zero''' + + +class DummyModel(BaseModel): + a: int = 3 + b: str = 'asdf' + + +CONFIG = create_app_config(DummyModel()) + + +def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: Union[str, int] = 3): + assert isinstance(a, str) or a is None, type(a) + assert isinstance(b, str) or b is None, type(b) + assert isinstance(c, (str, int)), type(c) + CONFIGURATION = '3' + my_dict = {'key_a': 'val_a'} + 1 / 0 + log.error('Error message') + dump_json(load_json('a')) + if CONFIG.a > 2: + print('test') + print(my_dict['key_a']) + print(CONFIGURATION) + + +def test_exception_expression_remove(): + log.setLevel(logging.WARNING) + msg = exec_func(func_test_assert_none) + assert msg == r''' +File "test_core/test_lib/test_format_traceback.py", line 19 in exec_func +-------------------------------------------------------------------------------- + 17 | def exec_func(func) -> str: + 18 | try: +--> 19 | func() + 20 | except Exception as e: + ------------------------------------------------------------ + e = ZeroDivisionError('division by zero') + func = + ------------------------------------------------------------ + +File "test_core/test_lib/test_format_traceback.py", line 95 in func_test_assert_none +-------------------------------------------------------------------------------- + 89 | def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: Union[str, int] = 3): + (...) + 92 | assert isinstance(c, (str, int)), type(c) + 93 | CONFIGURATION = '3' + 94 | my_dict = {'key_a': 'val_a'} +--> 95 | 1 / 0 + 96 | log.error('Error message') + ------------------------------------------------------------ + CONFIG.a = 3 + a = None + b = None + c = 3 + CONFIGURATION = '3' + log = + my_dict = {'key_a': 'val_a'} + my_dict['key_a'] = 'val_a' + CONFIG.a > 2 = True + ------------------------------------------------------------ + +-------------------------------------------------------------------------------- +Traceback (most recent call last): + File "test_core/test_lib/test_format_traceback.py", line 19, in exec_func + func() + File "test_core/test_lib/test_format_traceback.py", line 95, in func_test_assert_none + 1 / 0 +ZeroDivisionError: division by zero''' + + +def test_habapp_regex(pytestconfig): + + files = tuple(str(f) for f in (Path(pytestconfig.rootpath) / 'src' / 'HABApp').glob('**/*')) + + for regex in SUPPRESSED_HABAPP_PATHS: + for file in files: + if regex.search(file): + break + else: + raise ValueError(f'Nothing matched for {regex}') + + +def test_regex(pytestconfig): + + assert not skip_file('/lib/habapp/asdf') + assert not skip_file('/lib/HABApp/asdf') + assert not skip_file('/HABApp/core/lib/asdf') + assert not skip_file('/HABApp/core/lib/asdf/asdf') + + assert skip_file(r'\Python310\lib\runpy.py') + assert skip_file(r'/usr/lib/python3.10/runpy.py') + assert skip_file(r'\Python310\lib\asyncio\tasks.py') diff --git a/tests/test_core/test_lib/test_single_task.py b/tests/test_core/test_lib/test_single_task.py new file mode 100644 index 00000000..0d09130e --- /dev/null +++ b/tests/test_core/test_lib/test_single_task.py @@ -0,0 +1,33 @@ +import asyncio + +from HABApp.core.lib import SingleTask +from unittest.mock import Mock + + +async def test_single_task_start(): + + m = Mock() + + async def cb(): + m() + + st = SingleTask(cb) + + for _ in range(10): + st.start() + + await asyncio.sleep(0.05) + m.assert_called_once_with() + assert st.task is None + + m.reset_mock() + + for _ in range(10): + st.start() + st.cancel() + + st.start() + + await asyncio.sleep(0.05) + m.assert_called_once_with() + assert st.task is None diff --git a/tests/test_core/test_logger.py b/tests/test_core/test_logger.py index ad9d0bcd..0c48aac5 100644 --- a/tests/test_core/test_logger.py +++ b/tests/test_core/test_logger.py @@ -1,6 +1,8 @@ from HABApp.core.logger import HABAppLogger, HABAppError, HABAppInfo, HABAppWarning from logging import getLogger +from tests.helpers import TestEventBus + def test_exception(): e = Exception('Exception test') @@ -13,11 +15,16 @@ def test_exception_multiline(): def test_exception_traceback(): - e = Exception('Line1\nLine2\nLine3') - assert HABAppLogger(None).add_exception(e, add_traceback=True).lines == ['Exception: Line1', 'Line2', 'Line3'] + try: + raise Exception('Line1\nLine2\nLine3') + except Exception as e: + e = HABAppLogger(None).add_exception(e, add_traceback=True) + assert e.lines + +def test_bool(eb: TestEventBus): + eb.allow_errors = True -def test_bool(): for cls in (HABAppError, HABAppInfo, HABAppWarning): i = cls(getLogger('test')).add('') assert i diff --git a/tests/test_core/test_utilities.py b/tests/test_core/test_utilities.py index 1a914e6d..f0e44412 100644 --- a/tests/test_core/test_utilities.py +++ b/tests/test_core/test_utilities.py @@ -5,7 +5,7 @@ from HABApp.core.lib import PendingFuture -@pytest.mark.asyncio +@pytest.mark.uses_rule_runner async def test_pending_future(): a = 0 @@ -31,7 +31,6 @@ async def b(): assert a == 1 -@pytest.mark.asyncio async def test_pending_future_cancel(): exception = None diff --git a/tests/test_core/test_wrapped_func.py b/tests/test_core/test_wrapped_func.py index 8c294618..854efcb5 100644 --- a/tests/test_core/test_wrapped_func.py +++ b/tests/test_core/test_wrapped_func.py @@ -1,121 +1,65 @@ import asyncio -import sys -import typing -import unittest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock +from unittest.mock import Mock import pytest import HABApp -from HABApp.core import WrappedFunction -from HABApp.core.const.topics import ERRORS as TOPIC_ERRORS +from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS +from HABApp.core.events import NoEventFilter +from HABApp.core.internals import EventBusListener +from HABApp.core.internals import wrap_func +from tests.helpers import TestEventBus -if sys.version_info < (3, 8): - from mock import AsyncMock -else: - from unittest.mock import AsyncMock - -class TestCases(unittest.TestCase): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.func_called = False - self.last_args: typing.Optional[typing.List] = None - self.last_kwargs: typing.Optional[typing.Dict] = None - - self.err_func: MagicMock = None - - def setUp(self): - self.func_called = False - self.last_args = None - self.last_kwargs = None - - self.worker = WrappedFunction._WORKERS - - self.err_func: MagicMock = MagicMock() - self.err_listener = HABApp.core.EventBusListener( - TOPIC_ERRORS, WrappedFunction(self.err_func, name='ErrMock') - ) - HABApp.core.EventBus.add_listener(self.err_listener) - - class CExecutor: - def submit(self, callback, *args, **kwargs): - callback(*args, **kwargs) - WrappedFunction._WORKERS = CExecutor() - - def tearDown(self): - WrappedFunction._WORKERS = self.worker - HABApp.core.EventBus.remove_listener(self.err_listener) - - def func_call(self, *args, **kwargs): - self.func_called = True - self.last_args = args - self.last_kwargs = kwargs - - async def async_func_call(self, *args, **kwargs): - self.func_called = True - self.last_args = args - self.last_kwargs = kwargs - - def test_sync_run(self): - f = WrappedFunction(self.func_call) - f.run() - self.assertTrue(self.func_called) - - def test_sync_args(self): - f = WrappedFunction(self.func_call) - f.run('sarg1', 'sarg2', skw1='skw1') - self.assertTrue(self.func_called) - self.assertEqual(self.last_args, ('sarg1', 'sarg2')) - self.assertEqual(self.last_kwargs, {'skw1': 'skw1'}) - - def test_exception1(self): - def tmp(): - 1 / 0 - - f = WrappedFunction(tmp) - self.assertFalse(self.err_func.called) - - f.run() - - self.assertTrue(self.err_func.called) - err = self.err_func.call_args[0][0] - assert isinstance(err, HABApp.core.events.habapp_events.HABAppException) - assert err.func_name == 'tmp' - assert isinstance(err.exception, ZeroDivisionError) - assert err.traceback.startswith('File ') +def test_sync_run(sync_worker): + func = Mock() + f = wrap_func(func, name='mock') + f.run() + func.assert_called_once_with() -@pytest.mark.asyncio async def test_async_run(): coro = AsyncMock() - f = WrappedFunction(coro, name='coro_mock') + f = wrap_func(coro, name='coro_mock') f.run() await asyncio.sleep(0.05) coro.assert_awaited_once() -@pytest.mark.asyncio +def test_sync_args(sync_worker): + func = Mock() + f = wrap_func(func, name='mock') + f.run('arg1', 'arg2', kw1='kw1') + func.assert_called_once_with('arg1', 'arg2', kw1='kw1') + + async def test_async_args(): coro = AsyncMock() - f = WrappedFunction(coro, name='coro_mock') + f = wrap_func(coro, name='coro_mock') f.run('arg1', 'arg2', kw1='kw1') await asyncio.sleep(0.05) coro.assert_awaited_once_with('arg1', 'arg2', kw1='kw1') -@pytest.mark.asyncio -async def test_async_error_wrapper(): - async def tmp(): - 1 / 0 +def func_div_error(): + 1 / 0 + + +async def async_func_div_error(): + 1 / 0 + + +@pytest.mark.parametrize( + 'func, name', ((func_div_error, 'func_div_error'), (async_func_div_error, 'async_func_div_error'))) +async def test_async_error_wrapper(eb: TestEventBus, name, func, sync_worker): + eb.allow_errors = True - f = WrappedFunction(tmp) + f = wrap_func(func) err_func = AsyncMock() - err_listener = HABApp.core.EventBusListener(TOPIC_ERRORS, WrappedFunction(err_func, name='ErrMock')) - HABApp.core.EventBus.add_listener(err_listener) + err_listener = EventBusListener(TOPIC_ERRORS, wrap_func(err_func, name='ErrMock'), NoEventFilter()) + eb.add_listener(err_listener) f.run() await asyncio.sleep(0.05) @@ -123,6 +67,6 @@ async def tmp(): assert err_func.called err = err_func.call_args[0][0] assert isinstance(err, HABApp.core.events.habapp_events.HABAppException) - assert err.func_name == 'tmp' + assert err.func_name == name assert isinstance(err.exception, ZeroDivisionError) assert err.traceback.startswith('File ') diff --git a/tests/test_core/test_wrapper.py b/tests/test_core/test_wrapper.py index d6bc2031..042a21f7 100644 --- a/tests/test_core/test_wrapper.py +++ b/tests/test_core/test_wrapper.py @@ -1,6 +1,6 @@ import asyncio import logging -from unittest.mock import MagicMock +from unittest.mock import Mock import aiohttp import pytest @@ -12,11 +12,10 @@ @pytest.fixture -def p_mock(): - post_event = HABApp.core.EventBus.post_event - HABApp.core.EventBus.post_event = m = MagicMock() +def p_mock(monkeypatch): + m = Mock() + monkeypatch.setattr(HABApp.core.wrapper, 'post_event', m) yield m - HABApp.core.EventBus.post_event = post_event def test_error_catch(p_mock): @@ -30,21 +29,21 @@ def test_error_catch(p_mock): with ExceptionToHABApp(log, logging.WARNING): 1 / 0 p_mock.assert_called_once() - assert p_mock.call_args[0][0] == HABApp.core.const.topics.WARNINGS + assert p_mock.call_args[0][0] == HABApp.core.const.topics.TOPIC_WARNINGS def test_error_level(p_mock): with ExceptionToHABApp(log, logging.WARNING): 1 / 0 p_mock.assert_called_once() - assert p_mock.call_args[0][0] == HABApp.core.const.topics.WARNINGS + assert p_mock.call_args[0][0] == HABApp.core.const.topics.TOPIC_WARNINGS p_mock.reset_mock() with ExceptionToHABApp(log): 1 / 0 p_mock.assert_called_once() - assert p_mock.call_args[0][0] == HABApp.core.const.topics.ERRORS + assert p_mock.call_args[0][0] == HABApp.core.const.topics.TOPIC_ERRORS @ignore_exception @@ -57,11 +56,12 @@ def test_func_wrapper(p_mock): @pytest.mark.skip(reason="Behavior still unclear") -def test_exception_format(p_mock): +def test_exception_format_included_files(p_mock): async def test(): async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(0.01)) as session: async with session.get('http://localhost:12345'): pass + await asyncio.sleep(0) with ExceptionToHABApp(log): asyncio.get_event_loop().run_until_complete(test()) diff --git a/tests/test_debug_info.py b/tests/test_debug_info.py index e9582e62..822c8c7a 100644 --- a/tests/test_debug_info.py +++ b/tests/test_debug_info.py @@ -1,4 +1,4 @@ -from HABApp.__main__ import get_debug_info +from HABApp.__debug_info__ import get_debug_info def test_debug_info(): diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..7831197e --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,86 @@ +from inspect import getmembers, isclass +from pathlib import Path + +from pydantic import BaseModel + +import HABApp.config.models +from HABApp.config import CONFIG +from easyconfig import yaml + + +def test_sample_yaml(pytestconfig): + file = pytestconfig.rootpath / 'docs' / 'configuration.rst' + + all_cfgs = [] + + lines = [] + add = False + indent = 0 + + for line in file.read_text().splitlines(): + line = line + stripped = line.strip() + + if add: + if not indent and stripped: + while line[indent] == ' ': + indent += 1 + + if stripped and line[0] != ' ': + all_cfgs.append(lines) + add = False + continue + + lines.append(line[indent:]) + + if stripped.startswith('.. code-block:: yaml') or stripped.startswith('.. code-block:: yml'): + add = True + lines = [] + + if add: + all_cfgs.append(lines) + + for cfg_lines in all_cfgs: + sample_cfg = '\n'.join(cfg_lines) + + map = yaml.yaml_rt.load(sample_cfg) + CONFIG.load_config_dict(map) + + +def test_config_documentation_complete(pytestconfig): + cfg_docs: Path = pytestconfig.rootpath / 'docs' / 'configuration.rst' + cfg_model_dir: Path = pytestconfig.rootpath / 'src' / 'HABApp' / 'config' / 'models' + assert cfg_model_dir.is_dir() + + documented_objs = set() + + # documented config + current_module = '' + for line in map(lambda x: x.strip().replace(' ', ''), cfg_docs.read_text().splitlines()): # type: str + if line.startswith('.. py:currentmodule::'): + current_module = line[21:].strip() + continue + + if line.startswith('.. autopydantic_model::'): + obj_name = line[23:].strip() + if current_module: + obj_name = f'{current_module}.{obj_name}' + assert obj_name not in documented_objs + documented_objs.add(obj_name) + + # config implementation + existing_objs = set() + for module_name in [f.stem for f in cfg_model_dir.glob('**/*.py')]: + module = getattr(HABApp.config.models, module_name) + cfg_objs = [x[1] for x in getmembers(module, lambda x: isclass(x) and issubclass(x, BaseModel))] + cfg_names = { + f'{obj.__module__}.{obj.__qualname__}' for obj in cfg_objs if not obj.__module__.startswith('easyconfig.') + } + existing_objs.update(cfg_names) + + # we check this here to get the module with the error message + missing = cfg_names - documented_objs + assert not missing, module.__name__ + + # ensure that everything that is implemented is documented + assert existing_objs == documented_objs diff --git a/tests/test_mqtt/test_mqtt_connect.py b/tests/test_mqtt/test_mqtt_connect.py index 303593c6..61e3d6fb 100644 --- a/tests/test_mqtt/test_mqtt_connect.py +++ b/tests/test_mqtt/test_mqtt_connect.py @@ -2,11 +2,12 @@ import HABApp from HABApp.mqtt.mqtt_connection import connect, STATUS +from pathlib import Path def test_connect(caplog): HABApp.CONFIG.mqtt.connection.host = 'localhost' - HABApp.CONFIG.mqtt.connection.tls_ca_cert = 'invalid_file_path' + HABApp.CONFIG.mqtt.connection.tls.ca_cert = Path('invalid_file_path') connect() diff --git a/tests/test_mqtt/test_mqtt_filters.py b/tests/test_mqtt/test_mqtt_filters.py index 9ad93dba..aab6e744 100644 --- a/tests/test_mqtt/test_mqtt_filters.py +++ b/tests/test_mqtt/test_mqtt_filters.py @@ -1,4 +1,3 @@ -from HABApp.core.wrappedfunction import WrappedFunction from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueChangeEventFilter, MqttValueUpdateEvent, \ MqttValueUpdateEventFilter from tests.helpers import check_class_annotations @@ -11,26 +10,20 @@ def test_class_annotations(): check_class_annotations('HABApp.mqtt.events', exclude=exclude, skip_imports=False) -def test_create_listener(): +def test_mqtt_filter(): f = MqttValueUpdateEventFilter(value=1) - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - - assert e.event_filter is MqttValueUpdateEvent - assert e.attr_name1 == 'value' - assert e.attr_value1 == 1 + assert f.event_class is MqttValueUpdateEvent + assert f.attr_name1 == 'value' + assert f.attr_value1 == 1 f = MqttValueChangeEventFilter(old_value='asdf') - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - - assert e.event_filter is MqttValueChangeEvent - assert e.attr_name1 == 'old_value' - assert e.attr_value1 == 'asdf' + assert f.event_class is MqttValueChangeEvent + assert f.attr_name1 == 'old_value' + assert f.attr_value1 == 'asdf' f = MqttValueChangeEventFilter(old_value='asdf', value=1) - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - - assert e.event_filter is MqttValueChangeEvent - assert e.attr_name1 == 'value' - assert e.attr_value1 == 1 - assert e.attr_name2 == 'old_value' - assert e.attr_value2 == 'asdf' + assert f.event_class is MqttValueChangeEvent + assert f.attr_name1 == 'value' + assert f.attr_value1 == 1 + assert f.attr_name2 == 'old_value' + assert f.attr_value2 == 'asdf' diff --git a/tests/test_mqtt/test_retain.py b/tests/test_mqtt/test_retain.py index a798292c..4da039fb 100644 --- a/tests/test_mqtt/test_retain.py +++ b/tests/test_mqtt/test_retain.py @@ -1,5 +1,5 @@ -from HABApp.core import Items -from HABApp.mqtt.mqtt_connection import process_msg +from HABApp.core.internals import HINT_ITEM_REGISTRY +from HABApp.mqtt.mqtt_connection import send_event_async class MqttDummyMsg: @@ -11,16 +11,14 @@ def __init__(self, topic='', payload='', retain=False): self.qos = 0 -def test_retain_create(): +async def test_retain_create(ir: HINT_ITEM_REGISTRY): topic = '/test/creation' - assert not Items.item_exists(topic) - process_msg(None, None, MqttDummyMsg(topic, 'aaa', retain=False)) - assert not Items.item_exists(topic) + assert not ir.item_exists(topic) + await send_event_async(topic, 'aaa', retain=False) + assert not ir.item_exists(topic) # Retain True will create the item - process_msg(None, None, MqttDummyMsg(topic, 'adsf123', retain=True)) - assert Items.item_exists(topic) - assert Items.get_item(topic).value == 'adsf123' - - Items.pop_item(topic) + await send_event_async(topic, 'adsf123', retain=True) + assert ir.item_exists(topic) + assert ir.get_item(topic).value == 'adsf123' diff --git a/tests/test_openhab/test_connection/test_connection_waiter.py b/tests/test_openhab/test_connection/test_connection_waiter.py index fee3097a..361c70b5 100644 --- a/tests/test_openhab/test_connection/test_connection_waiter.py +++ b/tests/test_openhab/test_connection/test_connection_waiter.py @@ -1,7 +1,5 @@ import asyncio -import pytest - from HABApp.openhab.connection_handler.http_connection_waiter import WaitBetweenConnects waited = -1 @@ -12,7 +10,6 @@ async def sleep(time): waited = time -@pytest.mark.asyncio async def test_aggregation_item(monkeypatch): monkeypatch.setattr(asyncio, "sleep", sleep) diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index 5996e9c6..1bfbeec0 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -2,8 +2,9 @@ import pytest from HABApp.openhab.events import ChannelTriggeredEvent, GroupItemStateChangedEvent, ItemAddedEvent, ItemCommandEvent, \ - ItemStateChangedEvent, ItemStateEvent, ItemStatePredictedEvent, ItemUpdatedEvent, ThingConfigStatusInfoEvent, \ - ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent + ItemStateChangedEvent, ItemStateEvent, ItemStatePredictedEvent, ItemUpdatedEvent, \ + ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent, ChannelDescriptionChangedEvent, \ + ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent from HABApp.openhab.map_events import get_event, EVENT_LIST @@ -30,12 +31,15 @@ def test_ItemCommandEvent(): def test_ItemAddedEvent1(): - event = get_event({'topic': 'openhab/items/TestString/added', - 'payload': '{"type":"String","name":"TestString","tags":[],"groupNames":["TestGroup"]}', - 'type': 'ItemAddedEvent'}) + event = get_event({ + 'topic': 'openhab/items/TestString/added', + 'payload': '{"type":"String","name":"TestString","label":"MyLabel","category":"","tags":[],"groupNames":[]}', + 'type': 'ItemAddedEvent'} + ) assert isinstance(event, ItemAddedEvent) assert event.name == 'TestString' assert event.type == 'String' + assert event.label == 'MyLabel' def test_ItemAddedEvent2(): @@ -143,7 +147,7 @@ def test_GroupItemStateChangedEvent(): assert event.old_value == 15 -def test_ChannelTriggeredEvent(): +def test_channel_ChannelTriggeredEvent(): d = { "topic": "openhab/channels/mihome:sensor_switch:00000000000000:button/triggered", "payload": "{\"event\":\"SHORT_PRESSED\",\"channel\":\"mihome:sensor_switch:11111111111111:button\"}", @@ -157,7 +161,21 @@ def test_ChannelTriggeredEvent(): assert event.event == 'SHORT_PRESSED' -def test_thing_info_events(): +def test_channel_ChannelDescriptionChangedEvent(): + data = { + 'topic': 'openhab/channels/lgwebos:WebOSTV:**********************:channel/descriptionchanged', + 'payload': '{"field":"STATE_OPTIONS","channelUID":"lgwebos:WebOSTV:**********************:channel",' + '"linkedItemNames":[],"value":"{\\"options\\":[]}"}', + 'type': 'ChannelDescriptionChangedEvent' + } + event = get_event(data) + assert isinstance(event, ChannelDescriptionChangedEvent) + assert event.name == 'lgwebos:WebOSTV:**********************:channel' + assert event.field == 'STATE_OPTIONS' + assert event.value == '{"options":[]}' + + +def test_thing_ThingStatusInfoEvent(): data = { 'topic': 'openhab/things/samsungtv:tv:mysamsungtv/status', 'payload': '{"status":"ONLINE","statusDetail":"MyStatusDetail"}', @@ -181,7 +199,7 @@ def test_thing_info_events(): assert event.detail is None -def test_thing_info_changed_events(): +def test_thing_ThingStatusInfoChangedEvent(): data = { 'topic': 'openhab/things/samsungtv:tv:mysamsungtv/statuschanged', 'payload': '[{"status":"OFFLINE","statusDetail":"NONE"},{"status":"ONLINE","statusDetail":"NONE"}]', @@ -196,18 +214,6 @@ def test_thing_info_changed_events(): assert event.old_detail is None -def test_thing_ConfigStatusInfoEvent(): - data = { - 'topic': 'openhab/things/zwave:device:controller:my_node/config/status', - 'payload': '{"configStatusMessages":[{"parameterName":"switchall_mode","type":"PENDING"}]}', - 'type': 'ConfigStatusInfoEvent' - } - event = get_event(data) - assert isinstance(event, ThingConfigStatusInfoEvent) - assert event.name == 'zwave:device:controller:my_node' - assert event.messages == [{"parameterName": "switchall_mode", "type": "PENDING"}] - - def test_thing_FirmwareStatusEvent(): data = { 'topic': 'openhab/things/zigbee:device:12345678:9abcdefghijklmno/firmware/status', @@ -220,6 +226,51 @@ def test_thing_FirmwareStatusEvent(): assert event.status == 'UNKNOWN' +def test_thing_ThingAddedEvent(): + data = { + 'topic': 'openhab/things/astro:sun:0a94363608/added', + 'payload': '{"channels":[{"uid":"astro:sun:0a94363608:rise#start","id":"rise#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#end","id":"rise#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#duration","id":"rise#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:rise#event","id":"rise#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#start","id":"set#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#end","id":"set#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#duration","id":"set#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:set#event","id":"set#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#start","id":"noon#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#end","id":"noon#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#duration","id":"noon#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:noon#event","id":"noon#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#start","id":"night#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#end","id":"night#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#duration","id":"night#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:night#event","id":"night#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#start","id":"morningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#end","id":"morningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#duration","id":"morningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:morningNight#event","id":"morningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#start","id":"astroDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#end","id":"astroDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#duration","id":"astroDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDawn#event","id":"astroDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#start","id":"nauticDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#end","id":"nauticDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#duration","id":"nauticDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDawn#event","id":"nauticDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#start","id":"civilDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#end","id":"civilDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#duration","id":"civilDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDawn#event","id":"civilDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#start","id":"astroDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#end","id":"astroDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#duration","id":"astroDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDusk#event","id":"astroDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#start","id":"nauticDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#end","id":"nauticDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#duration","id":"nauticDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDusk#event","id":"nauticDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#start","id":"civilDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#end","id":"civilDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#duration","id":"civilDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDusk#event","id":"civilDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#start","id":"eveningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#end","id":"eveningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#duration","id":"eveningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eveningNight#event","id":"eveningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#start","id":"daylight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#end","id":"daylight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#duration","id":"daylight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:daylight#event","id":"daylight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:position#azimuth","id":"position#azimuth","channelTypeUID":"astro:azimuth","itemType":"Number:Angle","kind":"STATE","label":"Azimut","description":"Das Azimut des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#elevation","id":"position#elevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#shadeLength","id":"position#shadeLength","channelTypeUID":"astro:shadeLength","itemType":"Number","kind":"STATE","label":"Schattenlängenverhältnis","description":"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#direct","id":"radiation#direct","channelTypeUID":"astro:directRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Direkte Strahlung","description":"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#diffuse","id":"radiation#diffuse","channelTypeUID":"astro:diffuseRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Diffuse Strahlung","description":"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#total","id":"radiation#total","channelTypeUID":"astro:totalRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Gesamtstrahlung","description":"Gesamtmenge der Strahlung auf dem Boden","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:zodiac#start","id":"zodiac#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#end","id":"zodiac#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#sign","id":"zodiac#sign","channelTypeUID":"astro:sign","itemType":"String","kind":"STATE","label":"Sternzeichen","description":"Das Sternzeichen","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#name","id":"season#name","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Jahreszeit","description":"Der Name der aktuellen Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#spring","id":"season#spring","channelTypeUID":"astro:spring","itemType":"DateTime","kind":"STATE","label":"Frühling","description":"Frühlingsanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#summer","id":"season#summer","channelTypeUID":"astro:summer","itemType":"DateTime","kind":"STATE","label":"Sommer","description":"Sommeranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#autumn","id":"season#autumn","channelTypeUID":"astro:autumn","itemType":"DateTime","kind":"STATE","label":"Herbst","description":"Herbstanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#winter","id":"season#winter","channelTypeUID":"astro:winter","itemType":"DateTime","kind":"STATE","label":"Winter","description":"Winteranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#nextName","id":"season#nextName","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Nächste Jahreszeit","description":"Der Name der nächsten Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#timeLeft","id":"season#timeLeft","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Verbleibende Zeit","description":"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#total","id":"eclipse#total","channelTypeUID":"astro:total","itemType":"DateTime","kind":"STATE","label":"Totale Sonnenfinsternis","description":"Zeitpunkt der nächsten totalen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#totalElevation","id":"eclipse#totalElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partial","id":"eclipse#partial","channelTypeUID":"astro:partial","itemType":"DateTime","kind":"STATE","label":"Partielle Sonnenfinsternis","description":"Zeitpunkt der nächsten partiellen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partialElevation","id":"eclipse#partialElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ring","id":"eclipse#ring","channelTypeUID":"astro:ring","itemType":"DateTime","kind":"STATE","label":"Ringförmige Sonnenfinsternis","description":"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ringElevation","id":"eclipse#ringElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#event","id":"eclipse#event","channelTypeUID":"astro:sunEclipseEvent","kind":"TRIGGER","label":"Sonnenfinsternisereignis","description":"Sonnenfinsternisereignis","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:phase#name","id":"phase#name","channelTypeUID":"astro:sunPhaseName","itemType":"String","kind":"STATE","label":"Sonnenphase","description":"Der Name der aktuellen Sonnenphase","defaultTags":[],"properties":{},"configuration":{}}],"label":"Astronomische Sonnendaten","configuration":{"useMeteorologicalSeason":false,"interval":300,"geolocation":"50.65294336725709,8.349609375000002"},"properties":{},"UID":"astro:sun:0a94363608","thingTypeUID":"astro:sun"}', # noqa: E501 + 'type': 'ThingAddedEvent' + } + + event = get_event(data) + assert isinstance(event, ThingAddedEvent) + assert event.name == 'astro:sun:0a94363608' + assert event.type == 'astro:sun' + assert event.label == 'Astronomische Sonnendaten' + assert event.configuration == { + 'useMeteorologicalSeason': False, 'interval': 300, 'geolocation': '50.65294336725709,8.349609375000002'} + assert event.properties == {} + + +def test_thing_ThingRemovedEvent(): + data = { + 'topic': 'openhab/things/astro:sun:0a94363608/removed', + 'payload': '{"channels":[{"uid":"astro:sun:0a94363608:rise#start","id":"rise#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#end","id":"rise#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#duration","id":"rise#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:rise#event","id":"rise#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#start","id":"set#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#end","id":"set#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#duration","id":"set#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:set#event","id":"set#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#start","id":"noon#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#end","id":"noon#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#duration","id":"noon#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:noon#event","id":"noon#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#start","id":"night#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#end","id":"night#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#duration","id":"night#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:night#event","id":"night#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#start","id":"morningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#end","id":"morningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#duration","id":"morningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:morningNight#event","id":"morningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#start","id":"astroDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#end","id":"astroDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#duration","id":"astroDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDawn#event","id":"astroDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#start","id":"nauticDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#end","id":"nauticDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#duration","id":"nauticDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDawn#event","id":"nauticDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#start","id":"civilDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#end","id":"civilDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#duration","id":"civilDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDawn#event","id":"civilDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#start","id":"astroDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#end","id":"astroDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#duration","id":"astroDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDusk#event","id":"astroDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#start","id":"nauticDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#end","id":"nauticDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#duration","id":"nauticDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDusk#event","id":"nauticDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#start","id":"civilDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#end","id":"civilDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#duration","id":"civilDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDusk#event","id":"civilDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#start","id":"eveningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#end","id":"eveningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#duration","id":"eveningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eveningNight#event","id":"eveningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#start","id":"daylight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#end","id":"daylight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#duration","id":"daylight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:daylight#event","id":"daylight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:position#azimuth","id":"position#azimuth","channelTypeUID":"astro:azimuth","itemType":"Number:Angle","kind":"STATE","label":"Azimut","description":"Das Azimut des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#elevation","id":"position#elevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#shadeLength","id":"position#shadeLength","channelTypeUID":"astro:shadeLength","itemType":"Number","kind":"STATE","label":"Schattenlängenverhältnis","description":"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#direct","id":"radiation#direct","channelTypeUID":"astro:directRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Direkte Strahlung","description":"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#diffuse","id":"radiation#diffuse","channelTypeUID":"astro:diffuseRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Diffuse Strahlung","description":"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#total","id":"radiation#total","channelTypeUID":"astro:totalRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Gesamtstrahlung","description":"Gesamtmenge der Strahlung auf dem Boden","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:zodiac#start","id":"zodiac#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#end","id":"zodiac#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#sign","id":"zodiac#sign","channelTypeUID":"astro:sign","itemType":"String","kind":"STATE","label":"Sternzeichen","description":"Das Sternzeichen","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#name","id":"season#name","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Jahreszeit","description":"Der Name der aktuellen Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#spring","id":"season#spring","channelTypeUID":"astro:spring","itemType":"DateTime","kind":"STATE","label":"Frühling","description":"Frühlingsanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#summer","id":"season#summer","channelTypeUID":"astro:summer","itemType":"DateTime","kind":"STATE","label":"Sommer","description":"Sommeranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#autumn","id":"season#autumn","channelTypeUID":"astro:autumn","itemType":"DateTime","kind":"STATE","label":"Herbst","description":"Herbstanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#winter","id":"season#winter","channelTypeUID":"astro:winter","itemType":"DateTime","kind":"STATE","label":"Winter","description":"Winteranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#nextName","id":"season#nextName","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Nächste Jahreszeit","description":"Der Name der nächsten Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#timeLeft","id":"season#timeLeft","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Verbleibende Zeit","description":"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#total","id":"eclipse#total","channelTypeUID":"astro:total","itemType":"DateTime","kind":"STATE","label":"Totale Sonnenfinsternis","description":"Zeitpunkt der nächsten totalen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#totalElevation","id":"eclipse#totalElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partial","id":"eclipse#partial","channelTypeUID":"astro:partial","itemType":"DateTime","kind":"STATE","label":"Partielle Sonnenfinsternis","description":"Zeitpunkt der nächsten partiellen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partialElevation","id":"eclipse#partialElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ring","id":"eclipse#ring","channelTypeUID":"astro:ring","itemType":"DateTime","kind":"STATE","label":"Ringförmige Sonnenfinsternis","description":"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ringElevation","id":"eclipse#ringElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#event","id":"eclipse#event","channelTypeUID":"astro:sunEclipseEvent","kind":"TRIGGER","label":"Sonnenfinsternisereignis","description":"Sonnenfinsternisereignis","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:phase#name","id":"phase#name","channelTypeUID":"astro:sunPhaseName","itemType":"String","kind":"STATE","label":"Sonnenphase","description":"Der Name der aktuellen Sonnenphase","defaultTags":[],"properties":{},"configuration":{}}],"label":"Astronomische Sonnendaten","configuration":{"useMeteorologicalSeason":false,"interval":300,"geolocation":"50.65294336725709,8.349609375000002"},"properties":{},"UID":"astro:sun:0a94363608","thingTypeUID":"astro:sun"}', # noqa: E501 + 'type': 'ThingRemovedEvent' + } + + event = get_event(data) + assert isinstance(event, ThingRemovedEvent) + assert event.name == 'astro:sun:0a94363608' + assert event.type == 'astro:sun' + assert event.label == 'Astronomische Sonnendaten' + assert event.configuration == { + 'useMeteorologicalSeason': False, 'interval': 300, 'geolocation': '50.65294336725709,8.349609375000002'} + assert event.properties == {} + + +def test_thing_ThingUpdatedEvent(): + data = { + "topic": "openhab/things/astro:sun:local/updated", + "payload": "[{\"channels\":[{\"uid\":\"astro:sun:local:rise#start\",\"id\":\"rise#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#end\",\"id\":\"rise#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#duration\",\"id\":\"rise#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:rise#event\",\"id\":\"rise#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#start\",\"id\":\"set#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#end\",\"id\":\"set#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#duration\",\"id\":\"set#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:set#event\",\"id\":\"set#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#start\",\"id\":\"noon#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#end\",\"id\":\"noon#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#duration\",\"id\":\"noon#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:noon#event\",\"id\":\"noon#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#start\",\"id\":\"night#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#end\",\"id\":\"night#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#duration\",\"id\":\"night#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:night#event\",\"id\":\"night#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#start\",\"id\":\"morningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#end\",\"id\":\"morningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#duration\",\"id\":\"morningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:morningNight#event\",\"id\":\"morningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#start\",\"id\":\"astroDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#end\",\"id\":\"astroDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#duration\",\"id\":\"astroDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDawn#event\",\"id\":\"astroDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#start\",\"id\":\"nauticDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#end\",\"id\":\"nauticDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#duration\",\"id\":\"nauticDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDawn#event\",\"id\":\"nauticDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#start\",\"id\":\"civilDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#end\",\"id\":\"civilDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#duration\",\"id\":\"civilDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDawn#event\",\"id\":\"civilDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#start\",\"id\":\"astroDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#end\",\"id\":\"astroDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#duration\",\"id\":\"astroDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDusk#event\",\"id\":\"astroDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#start\",\"id\":\"nauticDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#end\",\"id\":\"nauticDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#duration\",\"id\":\"nauticDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDusk#event\",\"id\":\"nauticDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#start\",\"id\":\"civilDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#end\",\"id\":\"civilDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#duration\",\"id\":\"civilDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDusk#event\",\"id\":\"civilDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#start\",\"id\":\"eveningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#end\",\"id\":\"eveningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#duration\",\"id\":\"eveningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eveningNight#event\",\"id\":\"eveningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#start\",\"id\":\"daylight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#end\",\"id\":\"daylight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#duration\",\"id\":\"daylight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:daylight#event\",\"id\":\"daylight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:position#azimuth\",\"id\":\"position#azimuth\",\"channelTypeUID\":\"astro:azimuth\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Azimut\",\"description\":\"Das Azimut des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#elevation\",\"id\":\"position#elevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#shadeLength\",\"id\":\"position#shadeLength\",\"channelTypeUID\":\"astro:shadeLength\",\"itemType\":\"Number\",\"kind\":\"STATE\",\"label\":\"Schattenlängenverhältnis\",\"description\":\"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#direct\",\"id\":\"radiation#direct\",\"channelTypeUID\":\"astro:directRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Direkte Strahlung\",\"description\":\"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#diffuse\",\"id\":\"radiation#diffuse\",\"channelTypeUID\":\"astro:diffuseRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Diffuse Strahlung\",\"description\":\"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#total\",\"id\":\"radiation#total\",\"channelTypeUID\":\"astro:totalRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Gesamtstrahlung\",\"description\":\"Gesamtmenge der Strahlung auf dem Boden\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:zodiac#start\",\"id\":\"zodiac#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#end\",\"id\":\"zodiac#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#sign\",\"id\":\"zodiac#sign\",\"channelTypeUID\":\"astro:sign\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sternzeichen\",\"description\":\"Das Sternzeichen\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#name\",\"id\":\"season#name\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Jahreszeit\",\"description\":\"Der Name der aktuellen Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#spring\",\"id\":\"season#spring\",\"channelTypeUID\":\"astro:spring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Frühling\",\"description\":\"Frühlingsanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#summer\",\"id\":\"season#summer\",\"channelTypeUID\":\"astro:summer\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Sommer\",\"description\":\"Sommeranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#autumn\",\"id\":\"season#autumn\",\"channelTypeUID\":\"astro:autumn\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Herbst\",\"description\":\"Herbstanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#winter\",\"id\":\"season#winter\",\"channelTypeUID\":\"astro:winter\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Winter\",\"description\":\"Winteranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#nextName\",\"id\":\"season#nextName\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Nächste Jahreszeit\",\"description\":\"Der Name der nächsten Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#timeLeft\",\"id\":\"season#timeLeft\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Verbleibende Zeit\",\"description\":\"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#total\",\"id\":\"eclipse#total\",\"channelTypeUID\":\"astro:total\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Totale Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten totalen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#totalElevation\",\"id\":\"eclipse#totalElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partial\",\"id\":\"eclipse#partial\",\"channelTypeUID\":\"astro:partial\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Partielle Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten partiellen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partialElevation\",\"id\":\"eclipse#partialElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ring\",\"id\":\"eclipse#ring\",\"channelTypeUID\":\"astro:ring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Ringförmige Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ringElevation\",\"id\":\"eclipse#ringElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#event\",\"id\":\"eclipse#event\",\"channelTypeUID\":\"astro:sunEclipseEvent\",\"kind\":\"TRIGGER\",\"label\":\"Sonnenfinsternisereignis\",\"description\":\"Sonnenfinsternisereignis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:phase#name\",\"id\":\"phase#name\",\"channelTypeUID\":\"astro:sunPhaseName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sonnenphase\",\"description\":\"Der Name der aktuellen Sonnenphase\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}}],\"label\":\"Lokale Sonnendaten\",\"configuration\":{\"useMeteorologicalSeason\":false,\"interval\":300,\"geolocation\":\"52.175458141853085,12.015266418457033\"},\"properties\":{},\"UID\":\"astro:sun:local\",\"thingTypeUID\":\"astro:sun\"},{\"channels\":[{\"uid\":\"astro:sun:local:rise#start\",\"id\":\"rise#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#end\",\"id\":\"rise#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#duration\",\"id\":\"rise#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:rise#event\",\"id\":\"rise#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#start\",\"id\":\"set#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#end\",\"id\":\"set#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#duration\",\"id\":\"set#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:set#event\",\"id\":\"set#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#start\",\"id\":\"noon#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#end\",\"id\":\"noon#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#duration\",\"id\":\"noon#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:noon#event\",\"id\":\"noon#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#start\",\"id\":\"night#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#end\",\"id\":\"night#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#duration\",\"id\":\"night#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:night#event\",\"id\":\"night#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#start\",\"id\":\"morningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#end\",\"id\":\"morningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#duration\",\"id\":\"morningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:morningNight#event\",\"id\":\"morningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#start\",\"id\":\"astroDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#end\",\"id\":\"astroDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#duration\",\"id\":\"astroDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDawn#event\",\"id\":\"astroDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#start\",\"id\":\"nauticDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#end\",\"id\":\"nauticDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#duration\",\"id\":\"nauticDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDawn#event\",\"id\":\"nauticDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#start\",\"id\":\"civilDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#end\",\"id\":\"civilDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#duration\",\"id\":\"civilDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDawn#event\",\"id\":\"civilDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#start\",\"id\":\"astroDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#end\",\"id\":\"astroDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#duration\",\"id\":\"astroDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDusk#event\",\"id\":\"astroDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#start\",\"id\":\"nauticDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#end\",\"id\":\"nauticDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#duration\",\"id\":\"nauticDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDusk#event\",\"id\":\"nauticDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#start\",\"id\":\"civilDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#end\",\"id\":\"civilDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#duration\",\"id\":\"civilDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDusk#event\",\"id\":\"civilDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#start\",\"id\":\"eveningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#end\",\"id\":\"eveningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#duration\",\"id\":\"eveningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eveningNight#event\",\"id\":\"eveningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#start\",\"id\":\"daylight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#end\",\"id\":\"daylight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#duration\",\"id\":\"daylight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:daylight#event\",\"id\":\"daylight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:position#azimuth\",\"id\":\"position#azimuth\",\"channelTypeUID\":\"astro:azimuth\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Azimut\",\"description\":\"Das Azimut des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#elevation\",\"id\":\"position#elevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#shadeLength\",\"id\":\"position#shadeLength\",\"channelTypeUID\":\"astro:shadeLength\",\"itemType\":\"Number\",\"kind\":\"STATE\",\"label\":\"Schattenlängenverhältnis\",\"description\":\"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#direct\",\"id\":\"radiation#direct\",\"channelTypeUID\":\"astro:directRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Direkte Strahlung\",\"description\":\"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#diffuse\",\"id\":\"radiation#diffuse\",\"channelTypeUID\":\"astro:diffuseRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Diffuse Strahlung\",\"description\":\"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#total\",\"id\":\"radiation#total\",\"channelTypeUID\":\"astro:totalRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Gesamtstrahlung\",\"description\":\"Gesamtmenge der Strahlung auf dem Boden\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:zodiac#start\",\"id\":\"zodiac#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#end\",\"id\":\"zodiac#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#sign\",\"id\":\"zodiac#sign\",\"channelTypeUID\":\"astro:sign\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sternzeichen\",\"description\":\"Das Sternzeichen\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#name\",\"id\":\"season#name\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Jahreszeit\",\"description\":\"Der Name der aktuellen Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#spring\",\"id\":\"season#spring\",\"channelTypeUID\":\"astro:spring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Frühling\",\"description\":\"Frühlingsanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#summer\",\"id\":\"season#summer\",\"channelTypeUID\":\"astro:summer\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Sommer\",\"description\":\"Sommeranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#autumn\",\"id\":\"season#autumn\",\"channelTypeUID\":\"astro:autumn\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Herbst\",\"description\":\"Herbstanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#winter\",\"id\":\"season#winter\",\"channelTypeUID\":\"astro:winter\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Winter\",\"description\":\"Winteranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#nextName\",\"id\":\"season#nextName\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Nächste Jahreszeit\",\"description\":\"Der Name der nächsten Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#timeLeft\",\"id\":\"season#timeLeft\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Verbleibende Zeit\",\"description\":\"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#total\",\"id\":\"eclipse#total\",\"channelTypeUID\":\"astro:total\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Totale Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten totalen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#totalElevation\",\"id\":\"eclipse#totalElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partial\",\"id\":\"eclipse#partial\",\"channelTypeUID\":\"astro:partial\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Partielle Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten partiellen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partialElevation\",\"id\":\"eclipse#partialElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ring\",\"id\":\"eclipse#ring\",\"channelTypeUID\":\"astro:ring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Ringförmige Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ringElevation\",\"id\":\"eclipse#ringElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#event\",\"id\":\"eclipse#event\",\"channelTypeUID\":\"astro:sunEclipseEvent\",\"kind\":\"TRIGGER\",\"label\":\"Sonnenfinsternisereignis\",\"description\":\"Sonnenfinsternisereignis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:phase#name\",\"id\":\"phase#name\",\"channelTypeUID\":\"astro:sunPhaseName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sonnenphase\",\"description\":\"Der Name der aktuellen Sonnenphase\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}}],\"label\":\"Lokale Sonnendaten\",\"configuration\":{\"useMeteorologicalSeason\":false,\"interval\":300,\"geolocation\":\"52.17651083497927,12.022132873535158\"},\"properties\":{},\"UID\":\"astro:sun:local\",\"thingTypeUID\":\"astro:sun\"}]", # noqa: E501 + "type": "ThingUpdatedEvent" + } + + event = get_event(data) + assert isinstance(event, ThingUpdatedEvent) + + @pytest.mark.parametrize('cls', [*EVENT_LIST]) def test_event_has_name(cls): # this test ensure that alle events have a name argument diff --git a/tests/test_openhab/test_events/test_from_dict_oh2.py b/tests/test_openhab/test_events/test_from_dict_oh2.py deleted file mode 100644 index 93fc8137..00000000 --- a/tests/test_openhab/test_events/test_from_dict_oh2.py +++ /dev/null @@ -1,189 +0,0 @@ -import datetime -import pytest - -from HABApp.openhab.connection_handler.http_connection import patch_for_oh2 -from HABApp.openhab.events import ChannelTriggeredEvent, GroupItemStateChangedEvent, ItemAddedEvent, ItemCommandEvent, \ - ItemStateChangedEvent, ItemStateEvent, ItemStatePredictedEvent, ItemUpdatedEvent, ThingConfigStatusInfoEvent, \ - ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent -from HABApp.openhab.map_events import get_event - - -@pytest.fixture -def oh2_event(): - patch_for_oh2() - yield None - patch_for_oh2(reverse=True) - - -def test_ItemStateEvent(oh2_event): - event = get_event({'topic': 'smarthome/items/Ping/state', 'payload': '{"type":"String","value":"1"}', - 'type': 'ItemStateEvent'}) - assert isinstance(event, ItemStateEvent) - assert event.name == 'Ping' - assert event.value == '1' - - -def test_ItemCommandEvent(oh2_event): - event = get_event({'topic': 'smarthome/items/Ping/command', 'payload': '{"type":"String","value":"1"}', - 'type': 'ItemCommandEvent'}) - assert isinstance(event, ItemCommandEvent) - assert event.name == 'Ping' - assert event.value == '1' - - -def test_ItemAddedEvent1(oh2_event): - event = get_event({'topic': 'smarthome/items/TestString/added', - 'payload': '{"type":"String","name":"TestString","tags":[],"groupNames":["TestGroup"]}', - 'type': 'ItemAddedEvent'}) - assert isinstance(event, ItemAddedEvent) - assert event.name == 'TestString' - assert event.type == 'String' - - -def test_ItemAddedEvent2(oh2_event): - event = get_event({ - 'topic': 'smarthome/items/TestColor_OFF/added', - 'payload': '{"type":"Color","name":"TestColor_OFF","tags":[],"groupNames":["TestGroup"]}', - 'type': 'ItemAddedEvent' - }) - assert isinstance(event, ItemAddedEvent) - assert event.name == 'TestColor_OFF' - assert event.type == 'Color' - - -def test_ItemUpdatedEvent(oh2_event): - event = get_event({ - 'topic': 'smarthome/items/NameUpdated/updated', - 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]},' - '{"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', - 'type': 'ItemUpdatedEvent' - }) - assert isinstance(event, ItemUpdatedEvent) - assert event.name == 'NameUpdated' - assert event.type == 'Switch' - - -def test_ItemStateChangedEvent1(oh2_event): - event = get_event({'topic': 'smarthome/items/Ping/statechanged', - 'payload': '{"type":"String","value":"1","oldType":"UnDef","oldValue":"NULL"}', - 'type': 'ItemStateChangedEvent'}) - assert isinstance(event, ItemStateChangedEvent) - assert event.name == 'Ping' - assert event.value == '1' - assert event.old_value is None - - -def test_ItemStatePredictedEvent(oh2_event): - event = get_event({'topic': 'smarthome/items/Buero_Lampe_Vorne_W/statepredicted', - 'payload': '{"predictedType":"Percent","predictedValue":"10","isConfirmation":false}', - 'type': 'ItemStatePredictedEvent'}) - assert isinstance(event, ItemStatePredictedEvent) - assert event.name == 'Buero_Lampe_Vorne_W' - assert event.value.value == 10.0 - - -def test_ItemStateChangedEvent2(oh2_event): - UTC_OFFSET = datetime.datetime.now().astimezone(None).strftime('%z') - - _in = { - 'topic': 'smarthome/items/TestDateTimeTOGGLE/statechanged', - 'payload': f'{{"type":"DateTime","value":"2018-06-21T19:47:08.000{UTC_OFFSET}",' - f'"oldType":"DateTime","oldValue":"2017-10-20T17:46:07.000{UTC_OFFSET}"}}', - 'type': 'ItemStateChangedEvent'} - - event = get_event(_in) - - assert isinstance(event, ItemStateChangedEvent) - assert event.name == 'TestDateTimeTOGGLE' - assert datetime.datetime(2018, 6, 21, 19, 47, 8), event.value - - -def test_GroupItemStateChangedEvent(oh2_event): - d = { - 'topic': 'smarthome/items/TestGroupAVG/TestNumber1/statechanged', - 'payload': '{"type":"Decimal","value":"16","oldType":"Decimal","oldValue":"15"}', - 'type': 'GroupItemStateChangedEvent' - } - event = get_event(d) - assert isinstance(event, GroupItemStateChangedEvent) - assert event.name == 'TestGroupAVG' - assert event.item == 'TestNumber1' - assert event.value == 16 - assert event.old_value == 15 - - -def test_ChannelTriggeredEvent(oh2_event): - d = { - "topic": "smarthome/channels/mihome:sensor_switch:00000000000000:button/triggered", - "payload": "{\"event\":\"SHORT_PRESSED\",\"channel\":\"mihome:sensor_switch:11111111111111:button\"}", - "type": "ChannelTriggeredEvent" - } - - event = get_event(d) - assert isinstance(event, ChannelTriggeredEvent) - assert event.name == 'mihome:sensor_switch:00000000000000:button' - assert event.channel == 'mihome:sensor_switch:11111111111111:button' - assert event.event == 'SHORT_PRESSED' - - -def test_thing_info_events(oh2_event): - data = { - 'topic': 'smarthome/things/samsungtv:tv:mysamsungtv/status', - 'payload': '{"status":"ONLINE","statusDetail":"MyStatusDetail"}', - 'type': 'ThingStatusInfoEvent' - } - event = get_event(data) - assert isinstance(event, ThingStatusInfoEvent) - assert event.name == 'samsungtv:tv:mysamsungtv' - assert event.status == 'ONLINE' - assert event.detail == 'MyStatusDetail' - - data = { - 'topic': 'smarthome/things/chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/status', - 'payload': '{"status":"ONLINE","statusDetail":"NONE"}', - 'type': 'ThingStatusInfoEvent' - } - event = get_event(data) - assert isinstance(event, ThingStatusInfoEvent) - assert event.name == 'chromecast:chromecast:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - assert event.status == 'ONLINE' - assert event.detail is None - - -def test_thing_info_changed_events(oh2_event): - data = { - 'topic': 'smarthome/things/samsungtv:tv:mysamsungtv/statuschanged', - 'payload': '[{"status":"OFFLINE","statusDetail":"NONE"},{"status":"ONLINE","statusDetail":"NONE"}]', - 'type': 'ThingStatusInfoChangedEvent' - } - event = get_event(data) - assert isinstance(event, ThingStatusInfoChangedEvent) - assert event.name == 'samsungtv:tv:mysamsungtv' - assert event.status == 'OFFLINE' - assert event.detail is None - assert event.old_status == 'ONLINE' - assert event.old_detail is None - - -def test_thing_ConfigStatusInfoEvent(oh2_event): - data = { - 'topic': 'smarthome/things/zwave:device:controller:my_node/config/status', - 'payload': '{"configStatusMessages":[{"parameterName":"switchall_mode","type":"PENDING"}]}', - 'type': 'ConfigStatusInfoEvent' - } - event = get_event(data) - assert isinstance(event, ThingConfigStatusInfoEvent) - assert event.name == 'zwave:device:controller:my_node' - assert event.messages == [{"parameterName": "switchall_mode", "type": "PENDING"}] - - -def test_thing_FirmwareStatusEvent(oh2_event): - data = { - 'topic': 'smarthome/things/zigbee:device:12345678:9abcdefghijklmno/firmware/status', - 'payload': - '{"thingUID":{"segments":["zigbee","device","12345678","9abcdefghijklmno"]},"firmwareStatus":"UNKNOWN"}', - 'type': 'FirmwareStatusInfoEvent' - } - event = get_event(data) - assert isinstance(event, ThingFirmwareStatusInfoEvent) - assert event.status == 'UNKNOWN' diff --git a/tests/test_openhab/test_events/test_oh_filters.py b/tests/test_openhab/test_events/test_oh_filters.py index d66feaee..5f34e6a5 100644 --- a/tests/test_openhab/test_events/test_oh_filters.py +++ b/tests/test_openhab/test_events/test_oh_filters.py @@ -1,37 +1,35 @@ -from HABApp.core.wrappedfunction import WrappedFunction from HABApp.openhab.events import ItemStateChangedEvent, ItemStateChangedEventFilter, ItemStateEvent, \ - ItemStateEventFilter + ItemStateEventFilter, ItemCommandEventFilter, ItemCommandEvent from tests.helpers import check_class_annotations def test_class_annotations(): """EventFilter relies on the class annotations so we test that every event has those""" - exclude = ['OpenhabEvent', 'ItemStateChangedEventFilter', 'ItemStateEventFilter'] + exclude = ['OpenhabEvent', 'ItemStateChangedEventFilter', 'ItemStateEventFilter', 'ItemCommandEventFilter'] check_class_annotations('HABApp.openhab.events', exclude=exclude, skip_imports=False) -def test_create_listener(): +def test_oh_filters(): f = ItemStateEventFilter(value=1) - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) + assert f.event_class is ItemStateEvent + assert f.attr_name1 == 'value' + assert f.attr_value1 == 1 - assert e.event_filter is ItemStateEvent - assert e.attr_name1 == 'value' - assert e.attr_value1 == 1 + f = ItemCommandEventFilter(value=1) + assert f.event_class is ItemCommandEvent + assert f.attr_name1 == 'value' + assert f.attr_value1 == 1 f = ItemStateChangedEventFilter(old_value='asdf') - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - - assert e.event_filter is ItemStateChangedEvent - assert e.attr_name1 == 'old_value' - assert e.attr_value1 == 'asdf' + assert f.event_class is ItemStateChangedEvent + assert f.attr_name1 == 'old_value' + assert f.attr_value1 == 'asdf' f = ItemStateChangedEventFilter(old_value='asdf', value=1) - e = f.create_event_listener('asdf', WrappedFunction(lambda x: x)) - - assert e.event_filter is ItemStateChangedEvent - assert e.attr_name1 == 'value' - assert e.attr_value1 == 1 - assert e.attr_name2 == 'old_value' - assert e.attr_value2 == 'asdf' + assert f.event_class is ItemStateChangedEvent + assert f.attr_name1 == 'value' + assert f.attr_value1 == 1 + assert f.attr_name2 == 'old_value' + assert f.attr_value2 == 'asdf' diff --git a/tests/test_openhab/test_interface_sync.py b/tests/test_openhab/test_interface_sync.py index 0a48b326..6ecaa53f 100644 --- a/tests/test_openhab/test_interface_sync.py +++ b/tests/test_openhab/test_interface_sync.py @@ -3,7 +3,7 @@ import pytest import HABApp.openhab.interface -from HABApp.core.context import async_context, AsyncContextError +from HABApp.core.asyncio import async_context, AsyncContextError from HABApp.openhab.interface import \ post_update, send_command, \ get_item, item_exists, remove_item, create_item, \ @@ -35,7 +35,6 @@ def test_all_imported(func: Callable): (remove_channel_link, ('channel', 'item')), (create_channel_link, ('channel', 'item', {})), )) -@pytest.mark.asyncio async def test_item_has_name(func, args): async_context.set('Test') diff --git a/tests/test_openhab/test_item_to_reg.py b/tests/test_openhab/test_item_to_reg.py new file mode 100644 index 00000000..33edd422 --- /dev/null +++ b/tests/test_openhab/test_item_to_reg.py @@ -0,0 +1,13 @@ +from HABApp.core.internals import ItemRegistry +from HABApp.openhab.item_to_reg import MEMBERS, get_members +from HABApp.openhab.items import StringItem + + +def test_get_members(monkeypatch, clean_objs, ir: ItemRegistry): + a = ir.add_item(StringItem('a', 1)) + b = ir.add_item(StringItem('b', 'asdf')) + c = ir.add_item(StringItem('c', (1, 2))) + d = ir.add_item(StringItem('d')) + monkeypatch.setitem(MEMBERS, 'test_grp', {d.name, c.name, b.name, a.name}) + + assert get_members('test_grp') == (a, b, c, d) diff --git a/tests/test_openhab/test_items/test_all.py b/tests/test_openhab/test_items/test_all.py new file mode 100644 index 00000000..1304970b --- /dev/null +++ b/tests/test_openhab/test_items/test_all.py @@ -0,0 +1,53 @@ +import inspect + +import pytest + +from HABApp.openhab.items import Thing, ColorItem, ImageItem +from HABApp.openhab.items.base_item import OpenhabItem +from HABApp.openhab.map_items import _items as item_dict +from tests.helpers.docs import get_ivars + + +@pytest.mark.parametrize('cls', (c for c in item_dict.values())) +def test_argspec_from_oh(cls): + target_spec = inspect.getfullargspec(OpenhabItem.from_oh) + current_spec = inspect.getfullargspec(cls.from_oh) + assert current_spec == target_spec + + +@pytest.mark.parametrize('cls', tuple(c for c in item_dict.values()) + (Thing, )) +def test_set_name(cls): + + # test that we can set the name properly + c = cls('asdf') + assert c.name == 'asdf' + + # this test ensures that all openHAB items inherit from OpenhabItem + if cls is not Thing: + assert isinstance(c, OpenhabItem) + + +@pytest.mark.parametrize('cls', (c for c in item_dict.values())) +def test_doc_ivar(cls): + + class_vars = get_ivars(cls) + + # test that the class has the corresponding attribute + obj = cls(name='test') + for name in class_vars: + assert hasattr(obj, name) + + if cls is ColorItem: + class_vars.pop('hue') + class_vars.pop('saturation') + class_vars.pop('brightness') + + if cls is ImageItem: + class_vars.pop('image_type') + + class_vars.pop('value') + + # compare with base class so we have a consistent signature + target_vars = get_ivars(OpenhabItem) + target_vars.pop('value') + assert target_vars == class_vars diff --git a/tests/test_openhab/test_items/test_call.py b/tests/test_openhab/test_items/test_call.py new file mode 100644 index 00000000..7e9f8fc0 --- /dev/null +++ b/tests/test_openhab/test_items/test_call.py @@ -0,0 +1,37 @@ +from immutables import Map + +from HABApp.openhab.items import CallItem +from HABApp.openhab.map_items import map_item + + +def test_call_set_value(): + + call = CallItem('my_call_item') + call.set_value('03018,2722720') + assert call.value == ('03018', '2722720') + + call = CallItem('my_call_item') + call.set_value(('a', 'b')) + assert call.value == ('a', 'b') + + +def test_call_map(): + call = map_item( + 'my_call_item', 'Call', 'my_value', label='l', tags=frozenset(), groups=frozenset(), metadata=None,) + assert isinstance(call, CallItem) + assert call.value == ('my_value', ) + + assert call.label == 'l' + assert call.tags == frozenset() + assert call.groups == frozenset() + assert call.metadata == Map() + + i = map_item( + 'my_call_item', 'Call', '03018,2722720', label='l', tags=frozenset(), groups=frozenset(), metadata=None,) + assert isinstance(i, CallItem) + assert i.value == ('03018', '2722720') + + assert call.label == 'l' + assert call.tags == frozenset() + assert call.groups == frozenset() + assert call.metadata == Map() diff --git a/tests/test_openhab/test_items/test_commands.py b/tests/test_openhab/test_items/test_commands.py index 1e60ba0e..599517ca 100644 --- a/tests/test_openhab/test_items/test_commands.py +++ b/tests/test_openhab/test_items/test_commands.py @@ -1,18 +1,14 @@ -import pytest import typing -from HABApp.openhab.items import ContactItem, DimmerItem, RollershutterItem, SwitchItem, ColorItem, \ - DatetimeItem, GroupItem, LocationItem, NumberItem, PlayerItem, StringItem, ImageItem +import pytest + from HABApp.openhab.definitions import OnOffValue, UpDownValue, OpenClosedValue +from HABApp.openhab.items import ContactItem from HABApp.openhab.items.commands import UpDownCommand, OnOffCommand - -ALL_ITEMS = [ - ContactItem, DimmerItem, RollershutterItem, SwitchItem, ColorItem, ImageItem, - DatetimeItem, GroupItem, LocationItem, NumberItem, PlayerItem, StringItem -] +from HABApp.openhab.map_items import _items as item_dict -@pytest.mark.parametrize("cls", [cls for cls in ALL_ITEMS if issubclass(cls, OnOffCommand)]) +@pytest.mark.parametrize("cls", [cls for cls in item_dict.values() if issubclass(cls, OnOffCommand)]) def test_OnOff(cls): c = cls('item_name') c.set_value(OnOffValue('ON')) @@ -25,7 +21,7 @@ def test_OnOff(cls): assert not c.is_on() -@pytest.mark.parametrize("cls", [cls for cls in ALL_ITEMS if issubclass(cls, UpDownCommand)]) +@pytest.mark.parametrize("cls", [cls for cls in item_dict.values() if issubclass(cls, UpDownCommand)]) def test_UpDown(cls): c = cls('item_name') c.set_value(UpDownValue('UP')) diff --git a/tests/test_openhab/test_items/test_contact.py b/tests/test_openhab/test_items/test_contact.py new file mode 100644 index 00000000..f551a4f8 --- /dev/null +++ b/tests/test_openhab/test_items/test_contact.py @@ -0,0 +1,13 @@ +import pytest + +from HABApp.openhab.errors import SendCommandNotSupported +from HABApp.openhab.items import ContactItem + + +def test_send_command(): + c = ContactItem('item_name') + + with pytest.raises(SendCommandNotSupported) as e: + c.oh_send_command('asdf') + + assert str(e.value) == 'ContactItem does not support send command! See openHAB documentation for details.' diff --git a/tests/test_openhab/test_items/test_group_handling.py b/tests/test_openhab/test_items/test_group_handling.py new file mode 100644 index 00000000..bfedeb02 --- /dev/null +++ b/tests/test_openhab/test_items/test_group_handling.py @@ -0,0 +1,16 @@ +from HABApp.openhab.item_to_reg import add_to_registry +from HABApp.openhab.items import GroupItem, StringItem + + +def test_item_group_members_sorted(): + add_to_registry(StringItem('d_str', initial_value='val_9', groups=frozenset(['grp_1']))) + add_to_registry(StringItem('a_str', groups=frozenset(['grp_1']))) + add_to_registry(StringItem('b_str', groups=frozenset(['grp_1']))) + add_to_registry(GroupItem('grp_1')) + + grp = GroupItem.get_item('grp_1') + assert grp.members == ( + StringItem.get_item('a_str'), + StringItem.get_item('b_str'), + StringItem.get_item('d_str'), + ) diff --git a/tests/test_openhab/test_items/test_image.py b/tests/test_openhab/test_items/test_image.py index 0cea7411..9bcd07cb 100644 --- a/tests/test_openhab/test_items/test_image.py +++ b/tests/test_openhab/test_items/test_image.py @@ -8,6 +8,7 @@ def test_image_load(): 'localCurrentConditionIcon', 'Image', "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAD8CAYAAABTq8lnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFIGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTgtMDgtMTdUMTQ6MTc6NTAtMDQ6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjJiNzE4NDBmLTE2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", # noqa: E501 + label='', tags=frozenset(), groups=frozenset(), metadata=None, diff --git a/tests/test_openhab/test_items/test_items.py b/tests/test_openhab/test_items/test_items.py deleted file mode 100644 index b7338ad0..00000000 --- a/tests/test_openhab/test_items/test_items.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - -import HABApp.openhab.items - - -@pytest.mark.parametrize('cls', [ - getattr(HABApp.openhab.items, i) for i in dir(HABApp.openhab.items) if i[0] != '_' and i[0].isupper() -]) -def test_item_has_name(cls): - # this test ensure that all openhab items inherit from OpenhabItem - c = cls('asdf') - assert c.name == 'asdf' - if cls is not HABApp.openhab.items.Thing: - assert isinstance(c, HABApp.openhab.items.OpenhabItem) diff --git a/tests/test_openhab/test_items/test_mapping.py b/tests/test_openhab/test_items/test_mapping.py index 2b410ad7..7d301dea 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -5,14 +5,16 @@ from HABApp.openhab.items import DatetimeItem, NumberItem from HABApp.openhab.map_items import map_item +from tests.helpers import TestEventBus -def test_exception(): - assert map_item('test', 'Number', 'asdf', frozenset(), frozenset(), {}) is None +def test_exception(eb: TestEventBus): + eb.allow_errors = True + assert map_item('test', 'Number', 'asdf', 'my_label', frozenset(), frozenset(), {}) is None def test_metadata(): - make_number = partial(map_item, 'test', 'Number', None, frozenset(), frozenset()) + make_number = partial(map_item, 'test', 'Number', None, 'my_label', frozenset(), frozenset()) item = make_number({'ns1': {'value': 'v1'}}) assert isinstance(item.metadata, Map) @@ -33,7 +35,7 @@ def test_metadata(): def test_number_unit_of_measurement(): - make_item = partial(map_item, tags=frozenset(), groups=frozenset(), metadata={}) + make_item = partial(map_item, label='l', tags=frozenset(), groups=frozenset(), metadata={}) assert make_item('test1', 'Number:Length', '1.0 m', ) == NumberItem('test', 1) assert make_item('test2', 'Number:Temperature', '2.0 °C', ) == NumberItem('test', 2) assert make_item('test3', 'Number:Pressure', '3.0 hPa', ) == NumberItem('test', 3) @@ -46,11 +48,11 @@ 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(), {}) == \ + 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(), {}) == \ + 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)) diff --git a/tests/test_openhab/test_items/test_thing.py b/tests/test_openhab/test_items/test_thing.py index eae7a165..3313655b 100644 --- a/tests/test_openhab/test_items/test_thing.py +++ b/tests/test_openhab/test_items/test_thing.py @@ -1,44 +1,130 @@ import pytest +from immutables import Map import HABApp -from HABApp.openhab.events import ThingStatusInfoEvent +from HABApp.core.internals import HINT_ITEM_REGISTRY +from HABApp.openhab.events import ThingStatusInfoEvent, ThingUpdatedEvent from HABApp.openhab.items import Thing - from HABApp.openhab.map_events import get_event +from pendulum import set_test_now, DateTime, UTC @pytest.fixture(scope="function") -def test_thing(): +def test_thing(ir: HINT_ITEM_REGISTRY): + set_test_now(DateTime(2000, 1, 1, tzinfo=UTC)) thing = HABApp.openhab.items.Thing('test_thing') - HABApp.core.Items.add_item(thing) yield thing - HABApp.core.Items.pop_item('test_thing') + set_test_now() -def get_dict(status: str): - return { +def get_status_event(status: str) -> ThingStatusInfoEvent: + data = { 'topic': 'smarthome/things/test_thing/status', 'payload': f'{{"status":"{status}","statusDetail":"NONE"}}', 'type': 'ThingStatusInfoEvent' } + event = get_event(data) + assert isinstance(event, ThingStatusInfoEvent) + return event def test_thing_status_events(test_thing: Thing): assert test_thing.status == '' - e = get_event(get_dict('ONLINE')) - assert isinstance(e, ThingStatusInfoEvent) - test_thing.process_event(e) + + # initial set -> update and change + set_test_now(DateTime(2000, 1, 1, 1, tzinfo=UTC)) + test_thing.process_event(get_status_event('ONLINE')) assert test_thing.status == 'ONLINE' + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) - e = get_event(get_dict('asdf')) - assert isinstance(e, ThingStatusInfoEvent) - test_thing.process_event(e) + # second set -> update + set_test_now(DateTime(2000, 1, 1, 2, tzinfo=UTC)) + test_thing.process_event(get_status_event('ONLINE')) + assert test_thing.status == 'ONLINE' + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 2, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + + # third set -> update & change + set_test_now(DateTime(2000, 1, 1, 3, tzinfo=UTC)) + test_thing.process_event(get_status_event('asdf')) assert test_thing.status == 'asdf' + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) - e = get_event(get_dict('NONE')) - assert isinstance(e, ThingStatusInfoEvent) - test_thing.process_event(e) + test_thing.process_event(get_status_event('NONE')) assert test_thing.status is None + + +def get_config_event(status) -> ThingStatusInfoEvent: + data = { + 'topic': 'smarthome/things/test_thing/status', + 'payload': f'{{"status":"{status}","statusDetail":"NONE"}}', + 'type': 'ThingStatusInfoEvent' + } + event = get_event(data) + assert isinstance(event, ThingStatusInfoEvent) + return event + + +def test_thing_updated_event(test_thing: Thing): + + assert test_thing.properties == Map() + assert test_thing.configuration == Map() + + # initial set of configuration -> update and change + set_test_now(DateTime(2000, 1, 1, 1, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(configuration={'a': 'b'})) + assert test_thing.label == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map() + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + + # second set of configuration -> update + set_test_now(DateTime(2000, 1, 1, 2, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(configuration={'a': 'b'})) + assert test_thing.label == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map() + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 2, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + + # initial set of properties-> update and change + set_test_now(DateTime(2000, 1, 1, 3, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + + # second set of properties-> update + set_test_now(DateTime(2000, 1, 1, 4, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 4, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + + # initial set of label-> update and change + set_test_now(DateTime(2000, 1, 1, 5, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == 'l1' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) + + # second set of label-> update + set_test_now(DateTime(2000, 1, 1, 6, tzinfo=UTC)) + test_thing.process_event(ThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == 'l1' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.dt == DateTime(2000, 1, 1, 6, tzinfo=UTC) + assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) diff --git a/tests/test_openhab/test_plugins/test_load_items.py b/tests/test_openhab/test_plugins/test_load_items.py deleted file mode 100644 index 9b05362c..00000000 --- a/tests/test_openhab/test_plugins/test_load_items.py +++ /dev/null @@ -1,98 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, List -from unittest.mock import Mock - -import pytest - -from HABApp.openhab.connection_handler import http_connection -from HABApp.openhab.connection_logic.plugin_load_items import LoadAllOpenhabItems - - -class FalseFuture: - @staticmethod - def done(): - return True - - -@dataclass -class SimpleReqInfo: - method: str - url: str - - -class DummyResp: - def __init__(self, url: str, status: int, json=None): - self.url = url - self.status = status - self._json = json - self.request_info = None - - async def json(self, *args, **kwargs): - return self._json - - -RESPONSES: Dict[str, List[DummyResp]] = {} - - -class MySession: - _PREFIX = 'TEST/rest/' - - @staticmethod - async def _request(meth: str, url: str, **kwargs): - assert meth == 'GET' - if url.startswith(MySession._PREFIX): - url = url[len(MySession._PREFIX):] - - return RESPONSES[url].pop(0) - - @staticmethod - def add_resp(obj: DummyResp): - http_connection.IS_ONLINE = True - obj.request_info = SimpleReqInfo('GET', MySession._PREFIX + obj.url) - RESPONSES.setdefault(obj.url, []).append(obj) - - -@pytest.mark.asyncio -async def test_disconnect(monkeypatch, caplog): - disconnect_cb = Mock() - - # Disable automatic reconnect - monkeypatch.setattr(http_connection, 'try_uuid', lambda: 1) - monkeypatch.setattr(http_connection, 'FUT_UUID', FalseFuture) - monkeypatch.setattr(http_connection.asyncio, 'run_coroutine_threadsafe', lambda x, y: FalseFuture) - - # Use dummy session - monkeypatch.setattr(http_connection, 'ON_DISCONNECTED', disconnect_cb) - monkeypatch.setattr(http_connection, 'HTTP_PREFIX', 'TEST') - monkeypatch.setattr(http_connection, 'HTTP_SESSION', MySession) - - # 404 on item request - disconnect_cb.assert_not_called() - - MySession.add_resp(DummyResp('items/', 404)) - p = LoadAllOpenhabItems() - await p.on_connect_function() - - disconnect_cb.assert_called_once() - disconnect_cb.reset_mock() - - # 404 on thing request - disconnect_cb.assert_not_called() - - MySession.add_resp(DummyResp('items/', 200, json=[])) - MySession.add_resp(DummyResp('things/', 404)) - p = LoadAllOpenhabItems() - await p.on_connect_function() - - disconnect_cb.assert_called_once() - disconnect_cb.reset_mock() - - # everything works - disconnect_cb.assert_not_called() - - MySession.add_resp(DummyResp('items/', 200, json=[])) - MySession.add_resp(DummyResp('things/', 200, json=[])) - p = LoadAllOpenhabItems() - await p.on_connect_function() - - disconnect_cb.assert_not_called() 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 00ef3511..51dce445 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,11 @@ import time -import pytest - from HABApp.openhab.connection_logic.plugin_things.plugin_things import ManualThingConfig -from tests.helpers import MockFile +from tests.helpers import MockFile, TestEventBus -@pytest.mark.asyncio -async def test_errors(caplog): +async def test_errors(caplog, eb: TestEventBus): + eb.allow_errors = True cfg = ManualThingConfig() @@ -68,3 +66,7 @@ async def test_errors(caplog): errors = [rec.message for rec in caplog.records if rec.levelno >= 30] assert errors[0] == '"â_ß_{_)" is not a valid name for an item!' + + cfg.do_cleanup.cancel() + + caplog.clear() 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 3f66caa4..240e605d 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,4 +1,5 @@ from HABApp.openhab.connection_logic.plugin_things.cfg_validator import validate_cfg, UserItemCfg +from tests.helpers import TestEventBus def test_cfg_optional(): @@ -20,7 +21,8 @@ def test_thing_cfg_types(): }) -def test_cfg_err(): +def test_cfg_err(eb: TestEventBus): + eb.allow_errors = True assert None is validate_cfg({'test': True, 'filter1': {}}, 'filename') assert None is validate_cfg({'test': True, 'filter1': {}}) diff --git a/tests/test_openhab/test_rest/test_items.py b/tests/test_openhab/test_rest/test_items.py index 010009aa..b6f48c92 100644 --- a/tests/test_openhab/test_rest/test_items.py +++ b/tests/test_openhab/test_rest/test_items.py @@ -81,7 +81,7 @@ def test_group_item(): "category": "my_category", "tags": [], "groupNames": [ - "ALL" + "ALL_TOPICS" ] } item = OpenhabItemDefinition.parse_obj(_in) # type: OpenhabItemDefinition diff --git a/tests/test_packages.py b/tests/test_packages.py new file mode 100644 index 00000000..b7ef551e --- /dev/null +++ b/tests/test_packages.py @@ -0,0 +1,21 @@ +import re +from pathlib import Path + +import HABApp.__check_dependency_packages__ + + +def test_installation_check(): + re_name = re.compile(r'^([A-Za-z_-]{3,})') + requirements = Path(__file__).parent.parent / 'requirements_setup.txt' + assert requirements.is_file() + + found = set() + for line in requirements.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + found.add(re_name.search(line).group(1)) + + coded = set(HABApp.__check_dependency_packages__.get_dependencies()) + + assert coded == found diff --git a/tests/test_rule/test_item_search.py b/tests/test_rule/test_item_search.py index 75e0df65..c82ef52a 100644 --- a/tests/test_rule/test_item_search.py +++ b/tests/test_rule/test_item_search.py @@ -1,20 +1,20 @@ import pytest from HABApp import Rule -from HABApp.core import Items +from HABApp.core.internals import HINT_ITEM_REGISTRY 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(): +def test_search_type(ir: HINT_ITEM_REGISTRY): item1 = BaseValueItem('item_1') item2 = Item('item_2') assert Rule.get_items() == [] - Items.add_item(item1) - Items.add_item(item2) + ir.add_item(item1) + ir.add_item(item2) assert Rule.get_items() == [item1, item2] assert Rule.get_items(type=BaseValueItem) == [item1, item2] @@ -23,7 +23,7 @@ def test_search_type(): assert Rule.get_items(type=Item) == [item2] -def test_search_oh(): +def test_search_oh(ir: HINT_ITEM_REGISTRY): 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']), @@ -32,9 +32,9 @@ def test_search_oh(): assert Rule.get_items() == [] - Items.add_item(item1) - Items.add_item(item2) - Items.add_item(item3) + ir.add_item(item1) + ir.add_item(item2) + ir.add_item(item3) assert Rule.get_items() == [item1, item2, item3] assert Rule.get_items(tags='tag2') == [item1, item2] @@ -61,14 +61,14 @@ def test_classcheck(): Rule.get_items(Item, tags='asdf') -def test_search_name(): +def test_search_name(ir: HINT_ITEM_REGISTRY): item1 = BaseValueItem('item_1a') item2 = Item('item_2a') assert Rule.get_items() == [] - Items.add_item(item1) - Items.add_item(item2) + ir.add_item(item1) + ir.add_item(item2) assert Rule.get_items() == [item1, item2] assert Rule.get_items(name=r'\da') == [item1, item2] diff --git a/tests/test_rule/test_process.py b/tests/test_rule/test_process.py index 6fc4f213..4fe5dc3b 100644 --- a/tests/test_rule/test_process.py +++ b/tests/test_rule/test_process.py @@ -28,7 +28,7 @@ def rule(): runner.tear_down() -@pytest.mark.asyncio +@pytest.mark.no_internals async def test_run_func(rule): rule.execute_subprocess( rule.set_ret, sys.executable, '-c', 'import datetime; print("OK", end="")', capture_output=True @@ -40,7 +40,7 @@ async def test_run_func(rule): assert rule.ret.stderr == '' -@pytest.mark.asyncio +@pytest.mark.no_internals async def test_run_func_no_cap(rule): rule.execute_subprocess( rule.set_ret, sys.executable, '-c', 'import datetime; print("OK", end="")', capture_output=False @@ -52,7 +52,7 @@ async def test_run_func_no_cap(rule): assert rule.ret.stderr is None -@pytest.mark.asyncio +@pytest.mark.no_internals async def test_invalid_program(rule): rule.execute_subprocess(rule.set_ret, 'ProgramThatDoesNotExist', capture_output=True) diff --git a/tests/test_rule/test_rule_funcs.py b/tests/test_rule/test_rule_funcs.py index c7c28ee3..9af83451 100644 --- a/tests/test_rule/test_rule_funcs.py +++ b/tests/test_rule/test_rule_funcs.py @@ -1,39 +1,47 @@ -import unittest from unittest.mock import MagicMock +import pytest + from HABApp import Rule +from tests.helpers import TestEventBus from ..rule_runner import SimpleRuleRunner +@pytest.mark.no_internals def test_unload_function(): with SimpleRuleRunner(): r = Rule() m = MagicMock() + r.on_rule_removed = m assert not m.called - r.register_on_unload(m) assert m.called -def test_unload_function_exception(): +@pytest.mark.no_internals +def test_unload_function_exception(eb: TestEventBus): + eb.allow_errors = True with SimpleRuleRunner(): r = Rule() - m = MagicMock() - m_exception = MagicMock(side_effect=ValueError) + m = MagicMock(side_effect=ValueError) + r.on_rule_removed = m assert not m.called - assert not m_exception.called - r.register_on_unload(lambda : 1 / 0) - def asdf(): - 1 / 0 - - r.register_on_unload(asdf) - r.register_on_unload(m_exception) - r.register_on_unload(m) assert m.called - assert m.m_exception -if __name__ == '__main__': - unittest.main() +@pytest.mark.no_internals +def test_repr(): + class Abc(Rule): + + def __init__(self): + super().__init__() + self.rule_name = 'Abc' + + with SimpleRuleRunner(): + rule = Abc() + assert repr(rule) == '' + + rule.rule_name = 'MyName' + assert repr(rule) == '' diff --git a/tests/test_utils/test_counter.py b/tests/test_utils/test_counter.py deleted file mode 100644 index 63ab53a6..00000000 --- a/tests/test_utils/test_counter.py +++ /dev/null @@ -1,17 +0,0 @@ -from HABApp.util import CounterItem - -from ..test_core import ItemTests - - -class TestCounterItem(ItemTests): - CLS = CounterItem - TEST_VALUES = [5, -10, 10] - TEST_CREATE_ITEM = {'initial_value': 10} - - -def test_reset(): - c = CounterItem('TestItem', initial_value=10) - c.increase() - assert c.value == 11 - c.reset() - assert c.value == 10 diff --git a/tests/test_utils/test_fade.py b/tests/test_utils/test_fade.py new file mode 100644 index 00000000..9d83f717 --- /dev/null +++ b/tests/test_utils/test_fade.py @@ -0,0 +1,46 @@ +from HABApp.util.fade.fade import Fade + + +def test_setup(): + f = Fade().setup(0, 10, 10) + assert f._fade_factor == 1 + assert f._step_duration == 1 + + f = Fade().setup(0, 10, 20) + assert f._fade_factor == 0.5 + assert f._step_duration == 2 + + f = Fade().setup(0, 10, 5) + assert f._fade_factor == 2 + assert f._step_duration == 0.5 + + f = Fade().setup(0, 10, 2) + assert f._fade_factor == 5 + assert f._step_duration == 0.2 + + f = Fade().setup(0, 20, 1) + assert f._fade_factor == 20 + assert f._step_duration == 0.2 + + f = Fade().setup(0, 100, 10) + assert f._fade_factor == 10 + assert f._step_duration == 0.2 + + f = Fade().setup(0, 100, 10, min_step_duration=2) + assert f._fade_factor == 10 + assert f._step_duration == 2 + + +def test_values(): + f = Fade().setup(0, 10, 5, now=10) + assert f.get_value(11) == 2 + assert f.get_value(12) == 4 + assert f.get_value(13) == 6 + assert f.get_value(14) == 8 + assert not f.is_finished + f.get_value(14.99) + assert not f.is_finished + + for i in range(15, 30): + assert f.get_value(i) == 10 + assert f.is_finished diff --git a/tests/test_utils/test_listener_groups.py b/tests/test_utils/test_listener_groups.py index 47b20ecb..5b52f848 100644 --- a/tests/test_utils/test_listener_groups.py +++ b/tests/test_utils/test_listener_groups.py @@ -3,9 +3,9 @@ import pytest import HABApp.util.listener_groups -from HABApp.core.items.base_valueitem import BaseItem +from HABApp.core.items import BaseItem from HABApp.util import EventListenerGroup -from HABApp.util.listener_groups import EventListenerCreator, ListenerCreatorNotFoundError +from HABApp.util.listener_groups.listener_groups import EventListenerCreator, ListenerCreatorNotFoundError class PatchedBaseItem(BaseItem): @@ -119,7 +119,8 @@ def test_activate(): def test_listen_add(monkeypatch): m = Mock() - monkeypatch.setattr(HABApp.util.listener_groups, EventListenerCreator.__name__, Mock(return_value=m)) + monkeypatch.setattr( + HABApp.util.listener_groups.listener_groups, EventListenerCreator.__name__, Mock(return_value=m)) item = patched_item() cb = object() diff --git a/tox.ini b/tox.ini index e9e083cb..62522de8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,29 +1,29 @@ # content of: tox.ini , put in same dir as setup.py [tox] envlist = - py37 py38 py39 + py310 flake docs [gh-actions] python = - 3.7: py37 - 3.8: py38, flake, docs - 3.9: py39 + 3.8: py38 + 3.9: py39, flake, docs + 3.10: py310 [testenv] deps = -r{toxinidir}/requirements.txt commands = - python -m pytest --ignore=conf --ignore=conf_testing + python -m pytest [testenv:flake] deps = {[testenv]deps} - # pydocstyle + flake8 commands = flake8 -v # pydocstyle @@ -33,10 +33,21 @@ description = invoke sphinx-build to build the HTML docs deps = {[testenv]deps} - -r{toxinidir}/_doc/requirements.txt + -r{toxinidir}/docs/requirements.txt commands = - sphinx-build -d "{toxworkdir}/docs_doctree" _doc "{toxworkdir}/docs_out" --color -bhtml {posargs} + sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" --color -bhtml {posargs} [pydocstyle] ignore = D107, D100, D101, D104, D102 + + +[pytest] +asyncio_mode = auto +norecursedirs = run docs +markers = + no_internals: Does not set up the item registry and event bus + +log_file_level = 0 +log_file_format = [%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s +log_file= ./run/conf_testing/log/pytest.log