From 744b928af1951f5451d07d11dac21bfe3d352723 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Sun, 2 May 2021 13:58:35 +0200 Subject: [PATCH] 0.30.0 (#215) - Switched to Apache2.0 License - Fix DateTime string parsing for OH 3.1 (#214) - State of Groupitem gets set correctly - About ~50% performance increase for async calls in rules - 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. It is made for functions that do something after a certain period of time (e.g. switch a light off after movement) Example: https://habapp.readthedocs.io/en/develop/rule_examples.html#turn-something-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 - Renamed HABAppError to HABAppException - Some Doc improvements --- .flake8 | 4 +- .github/workflows/run_tox.yml | 2 +- .readthedocs.yml | 4 +- .travis.yml | 58 -- HABApp/__version__.py | 1 - HABApp/config/_conf_location.py | 34 - HABApp/core/const/const.py | 6 - HABApp/core/files/__init__.py | 5 - HABApp/core/files/all.py | 119 --- HABApp/core/files/event_listener.py | 59 -- HABApp/core/files/file.py | 107 --- HABApp/core/files/file_name.py | 42 - HABApp/core/lib/__init__.py | 2 - HABApp/core/lib/funcs.py | 8 - HABApp/parameters/parameter_files.py | 70 -- HABApp/rule/__init__.py | 4 - HABApp/rule/scheduler/__init__.py | 4 - HABApp/rule/scheduler/base.py | 194 ---- HABApp/rule/scheduler/one_time_cb.py | 13 - HABApp/rule/scheduler/reoccurring_cb.py | 72 -- HABApp/rule/scheduler/sun_cb.py | 31 - HABApp/util/functions/__init__.py | 1 - LICENSE | 875 ++++-------------- _doc/_plugins/sphinx_execute_code.py | 19 +- _doc/advanced_usage.rst | 8 +- _doc/class_reference.rst | 69 ++ _doc/conf.py | 11 +- _doc/getting_started.rst | 8 +- _doc/images/openhab_api_config.png | Bin 0 -> 10613 bytes _doc/installation.rst | 75 +- _doc/interface_openhab.rst | 31 + _doc/parameters.rst | 9 +- _doc/requirements.txt | 2 + _doc/rule.rst | 46 +- _doc/rule_examples.rst | 58 +- _doc/tips.rst | 2 +- _doc/util.rst | 28 + conf/rules/async_rule.py | 2 +- conf/rules/mqtt_rule.py | 5 +- conf/rules/openhab_to_mqtt_rule.py | 4 +- conf/rules/time_rule.py | 24 +- conf_testing/lib/HABAppTests/_rest_patcher.py | 12 +- conf_testing/lib/HABAppTests/test_base.py | 12 +- conf_testing/rules/openhab_bugs.py | 3 + conf_testing/rules/test_habapp.py | 2 +- conf_testing/rules/test_openhab_groups.py | 44 + conf_testing/rules/test_openhab_item_funcs.py | 2 - conf_testing/rules/test_scheduler.py | 8 +- mypy.ini | 3 - readme.md | 31 +- requirements.txt | 16 +- requirements_setup.txt | 16 + setup.py | 11 +- {HABApp => src/HABApp}/__cmd_args__.py | 0 {HABApp => src/HABApp}/__do_setup__.py | 6 - {HABApp => src/HABApp}/__init__.py | 46 +- {HABApp => src/HABApp}/__main__.py | 104 +-- src/HABApp/__version__.py | 1 + {HABApp => src/HABApp}/config/__init__.py | 4 +- src/HABApp/config/_conf_location.py | 20 + {HABApp => src/HABApp}/config/_conf_mqtt.py | 130 +-- .../HABApp}/config/_conf_openhab.py | 64 +- {HABApp => src/HABApp}/config/config.py | 111 ++- .../HABApp}/config/config_loader.py | 5 +- .../HABApp}/config/default_logfile.py | 184 ++-- .../HABApp}/config/platform_defaults.py | 0 {HABApp => src/HABApp}/core/EventBus.py | 0 {HABApp => src/HABApp}/core/Items.py | 0 {HABApp => src/HABApp}/core/__init__.py | 30 +- {HABApp => src/HABApp}/core/const/__init__.py | 0 src/HABApp/core/const/const.py | 14 + {HABApp => src/HABApp}/core/const/json.py | 0 {HABApp => src/HABApp}/core/const/loop.py | 3 +- {HABApp => src/HABApp}/core/const/topics.py | 0 {HABApp => src/HABApp}/core/const/yml.py | 0 .../HABApp}/core/event_bus_listener.py | 0 .../HABApp}/core/events/__init__.py | 0 .../HABApp}/core/events/event_filters.py | 0 {HABApp => src/HABApp}/core/events/events.py | 0 .../HABApp}/core/events/habapp_events.py | 18 +- src/HABApp/core/files/__init__.py | 8 + src/HABApp/core/files/errors.py | 21 + src/HABApp/core/files/file/__init__.py | 3 + src/HABApp/core/files/file/file.py | 134 +++ src/HABApp/core/files/file/file_state.py | 17 + src/HABApp/core/files/file/file_types.py | 31 + .../HABApp/core/files/file/properties.py | 2 +- src/HABApp/core/files/folders/__init__.py | 2 + src/HABApp/core/files/folders/folders.py | 65 ++ src/HABApp/core/files/manager/__init__.py | 3 + src/HABApp/core/files/manager/files.py | 12 + .../core/files/manager/listen_events.py | 23 + src/HABApp/core/files/manager/worker.py | 85 ++ src/HABApp/core/files/setup.py | 5 + .../HABApp}/core/files/watcher/__init__.py | 1 + .../core/files/watcher/base_watcher.py | 37 +- .../core/files/watcher/file_watcher.py | 28 +- .../core/files/watcher/folder_watcher.py | 13 +- {HABApp => src/HABApp}/core/items/__init__.py | 0 .../HABApp}/core/items/base_item.py | 15 +- .../HABApp}/core/items/base_item_times.py | 8 +- .../HABApp}/core/items/base_item_watch.py | 0 .../HABApp}/core/items/base_valueitem.py | 10 +- {HABApp => src/HABApp}/core/items/item.py | 0 .../HABApp}/core/items/item_aggregation.py | 3 +- .../HABApp}/core/items/item_color.py | 20 +- {HABApp => src/HABApp}/core/items/tmp_data.py | 0 src/HABApp/core/lib/__init__.py | 3 + src/HABApp/core/lib/funcs.py | 16 + {HABApp => src/HABApp}/core/lib/handler.py | 0 .../HABApp}/core/lib/pending_future.py | 5 +- src/HABApp/core/lib/rgb_hsv.py | 40 + {HABApp => src/HABApp}/core/logger.py | 0 .../HABApp}/core/wrappedfunction.py | 28 +- {HABApp => src/HABApp}/core/wrapper.py | 4 +- {HABApp => src/HABApp}/mqtt/__init__.py | 0 .../HABApp}/mqtt/events/__init__.py | 0 .../HABApp}/mqtt/events/mqtt_events.py | 0 .../HABApp}/mqtt/events/mqtt_filters.py | 0 {HABApp => src/HABApp}/mqtt/interface.py | 0 {HABApp => src/HABApp}/mqtt/items/__init__.py | 0 .../HABApp}/mqtt/items/mqtt_item.py | 0 .../HABApp}/mqtt/items/mqtt_pair_item.py | 0 .../HABApp}/mqtt/mqtt_connection.py | 0 {HABApp => src/HABApp}/mqtt/mqtt_interface.py | 0 {HABApp => src/HABApp}/openhab/__init__.py | 18 +- .../openhab/connection_handler/__init__.py | 0 .../openhab/connection_handler/func_async.py | 0 .../openhab/connection_handler/func_sync.py | 0 .../connection_handler/http_connection.py | 6 +- .../http_connection_waiter.py | 0 .../openhab/connection_logic/__init__.py | 0 .../openhab/connection_logic/_plugin.py | 4 +- .../openhab/connection_logic/connection.py | 10 +- .../connection_logic/plugin_load_items.py | 0 .../openhab/connection_logic/plugin_ping.py | 2 +- .../connection_logic/plugin_thing_overview.py | 0 .../plugin_things/__init__.py | 0 .../connection_logic/plugin_things/_log.py | 0 .../plugin_things/cfg_validator.py | 0 .../connection_logic/plugin_things/filters.py | 0 .../plugin_things/item_worker.py | 0 .../plugin_things/items_file.py | 0 .../plugin_things/plugin_things.py | 76 +- .../plugin_things/str_builder.py | 0 .../plugin_things/thing_config.py | 16 +- .../plugin_things/thing_worker.py | 0 .../HABApp}/openhab/definitions/__init__.py | 0 .../openhab/definitions/definitions.py | 0 .../openhab/definitions/helpers/__init__.py | 0 .../openhab/definitions/helpers/log_table.py | 0 .../definitions/helpers/persistence_data.py | 0 .../openhab/definitions/rest/__init__.py | 0 .../HABApp}/openhab/definitions/rest/base.py | 0 .../openhab/definitions/rest/habapp_data.py | 0 .../HABApp}/openhab/definitions/rest/items.py | 0 .../HABApp}/openhab/definitions/rest/links.py | 0 .../openhab/definitions/rest/things.py | 0 .../HABApp}/openhab/definitions/values.py | 0 .../HABApp}/openhab/events/__init__.py | 14 +- .../HABApp}/openhab/events/base_event.py | 12 +- .../HABApp}/openhab/events/channel_events.py | 0 .../HABApp}/openhab/events/event_filters.py | 0 .../HABApp}/openhab/events/item_events.py | 436 ++++----- .../HABApp}/openhab/events/thing_events.py | 0 {HABApp => src/HABApp}/openhab/exceptions.py | 0 {HABApp => src/HABApp}/openhab/interface.py | 0 .../HABApp}/openhab/interface_async.py | 0 .../HABApp}/openhab/items/__init__.py | 24 +- .../HABApp}/openhab/items/base_item.py | 0 .../HABApp}/openhab/items/color_item.py | 25 +- .../HABApp}/openhab/items/commands.py | 0 .../HABApp}/openhab/items/contact_item.py | 78 +- .../HABApp}/openhab/items/datetime_item.py | 0 .../HABApp}/openhab/items/dimmer_item.py | 0 .../HABApp}/openhab/items/group_item.py | 0 .../HABApp}/openhab/items/image_item.py | 0 .../HABApp}/openhab/items/number_item.py | 0 .../openhab/items/rollershutter_item.py | 0 .../HABApp}/openhab/items/string_item.py | 0 .../HABApp}/openhab/items/switch_item.py | 86 +- .../HABApp}/openhab/items/thing_item.py | 7 +- {HABApp => src/HABApp}/openhab/map_events.py | 0 {HABApp => src/HABApp}/openhab/map_items.py | 9 +- {HABApp => src/HABApp}/openhab/map_values.py | 8 +- {HABApp => src/HABApp}/parameters/__init__.py | 0 .../HABApp}/parameters/parameter.py | 0 src/HABApp/parameters/parameter_files.py | 66 ++ .../HABApp}/parameters/parameters.py | 8 +- src/HABApp/rule/__init__.py | 3 + src/HABApp/rule/habappscheduler.py | 105 +++ .../HABApp}/rule/interfaces/__init__.py | 0 .../HABApp}/rule/interfaces/http.py | 0 .../rule/interfaces/rule_subprocess.py | 0 {HABApp => src/HABApp}/rule/rule.py | 794 +++++++--------- .../HABApp}/rule_manager/__init__.py | 4 +- .../rule_manager/benchmark/__init__.py | 0 .../rule_manager/benchmark/bench_base.py | 10 +- .../rule_manager/benchmark/bench_file.py | 4 +- .../rule_manager/benchmark/bench_habapp.py | 2 +- .../rule_manager/benchmark/bench_mqtt.py | 2 +- .../rule_manager/benchmark/bench_oh.py | 4 +- .../rule_manager/benchmark/bench_times.py | 0 .../HABApp}/rule_manager/rule_file.py | 227 ++--- .../HABApp}/rule_manager/rule_manager.py | 380 ++++---- {HABApp => src/HABApp}/runtime/__init__.py | 2 +- {HABApp => src/HABApp}/runtime/runtime.py | 119 +-- {HABApp => src/HABApp}/runtime/shutdown.py | 0 {HABApp => src/HABApp}/util/__init__.py | 16 +- {HABApp => src/HABApp}/util/counter_item.py | 0 src/HABApp/util/functions/__init__.py | 2 + .../HABApp}/util/functions/min_max.py | 0 .../HABApp}/util/multimode/__init__.py | 0 {HABApp => src/HABApp}/util/multimode/item.py | 0 .../HABApp}/util/multimode/mode_base.py | 0 .../HABApp}/util/multimode/mode_switch.py | 0 .../HABApp}/util/multimode/mode_value.py | 0 {HABApp => src/HABApp}/util/period_counter.py | 126 +-- {HABApp => src/HABApp}/util/statistics.py | 0 {HABApp => src/HABApp}/util/threshold.py | 120 +-- tests/conftest.py | 6 + tests/rule_runner/rule_runner.py | 34 +- .../test_files/test_file_dependencies.py | 328 +++---- .../test_files/test_file_properties.py | 45 +- tests/test_core/test_files/test_rel_name.py | 47 +- tests/test_core/test_files/test_watcher.py | 3 +- tests/test_core/test_items/test_item.py | 21 +- .../test_items/test_item_aggregation.py | 20 +- tests/test_core/test_items/test_item_color.py | 2 +- tests/test_core/test_items/test_item_times.py | 23 +- tests/test_core/test_items/tests_all_items.py | 21 +- tests/test_core/test_wrapped_func.py | 4 +- tests/test_mqtt/test_retain.py | 1 + tests/test_openhab/test_items/test_items.py | 2 +- tests/test_openhab/test_items/test_mapping.py | 16 +- .../test_plugins/test_thing/test_errors.py | 17 +- .../test_plugins/test_thing/test_thing_cfg.py | 6 +- tests/test_rule/test_rule_funcs.py | 54 -- tests/test_rule/test_scheduler.py | 152 --- tests/test_utils/test_functions.py | 25 +- tox.ini | 7 - 241 files changed, 3362 insertions(+), 3955 deletions(-) delete mode 100644 .travis.yml delete mode 100644 HABApp/__version__.py delete mode 100644 HABApp/config/_conf_location.py delete mode 100644 HABApp/core/const/const.py delete mode 100644 HABApp/core/files/__init__.py delete mode 100644 HABApp/core/files/all.py delete mode 100644 HABApp/core/files/event_listener.py delete mode 100644 HABApp/core/files/file.py delete mode 100644 HABApp/core/files/file_name.py delete mode 100644 HABApp/core/lib/__init__.py delete mode 100644 HABApp/core/lib/funcs.py delete mode 100644 HABApp/parameters/parameter_files.py delete mode 100644 HABApp/rule/__init__.py delete mode 100644 HABApp/rule/scheduler/__init__.py delete mode 100644 HABApp/rule/scheduler/base.py delete mode 100644 HABApp/rule/scheduler/one_time_cb.py delete mode 100644 HABApp/rule/scheduler/reoccurring_cb.py delete mode 100644 HABApp/rule/scheduler/sun_cb.py delete mode 100644 HABApp/util/functions/__init__.py create mode 100644 _doc/images/openhab_api_config.png create mode 100644 conf_testing/rules/test_openhab_groups.py create mode 100644 requirements_setup.txt rename {HABApp => src/HABApp}/__cmd_args__.py (100%) rename {HABApp => src/HABApp}/__do_setup__.py (72%) rename {HABApp => src/HABApp}/__init__.py (94%) rename {HABApp => src/HABApp}/__main__.py (88%) create mode 100644 src/HABApp/__version__.py rename {HABApp => src/HABApp}/config/__init__.py (98%) create mode 100644 src/HABApp/config/_conf_location.py rename {HABApp => src/HABApp}/config/_conf_mqtt.py (96%) rename {HABApp => src/HABApp}/config/_conf_openhab.py (97%) rename {HABApp => src/HABApp}/config/config.py (76%) rename {HABApp => src/HABApp}/config/config_loader.py (96%) rename {HABApp => src/HABApp}/config/default_logfile.py (96%) rename {HABApp => src/HABApp}/config/platform_defaults.py (100%) rename {HABApp => src/HABApp}/core/EventBus.py (100%) rename {HABApp => src/HABApp}/core/Items.py (100%) rename {HABApp => src/HABApp}/core/__init__.py (95%) rename {HABApp => src/HABApp}/core/const/__init__.py (100%) create mode 100644 src/HABApp/core/const/const.py rename {HABApp => src/HABApp}/core/const/json.py (100%) rename {HABApp => src/HABApp}/core/const/loop.py (99%) rename {HABApp => src/HABApp}/core/const/topics.py (100%) rename {HABApp => src/HABApp}/core/const/yml.py (100%) rename {HABApp => src/HABApp}/core/event_bus_listener.py (100%) rename {HABApp => src/HABApp}/core/events/__init__.py (100%) rename {HABApp => src/HABApp}/core/events/event_filters.py (100%) rename {HABApp => src/HABApp}/core/events/events.py (100%) rename {HABApp => src/HABApp}/core/events/habapp_events.py (71%) create mode 100644 src/HABApp/core/files/__init__.py create mode 100644 src/HABApp/core/files/errors.py create mode 100644 src/HABApp/core/files/file/__init__.py create mode 100644 src/HABApp/core/files/file/file.py create mode 100644 src/HABApp/core/files/file/file_state.py create mode 100644 src/HABApp/core/files/file/file_types.py rename HABApp/core/files/file_props.py => src/HABApp/core/files/file/properties.py (96%) create mode 100644 src/HABApp/core/files/folders/__init__.py create mode 100644 src/HABApp/core/files/folders/folders.py create mode 100644 src/HABApp/core/files/manager/__init__.py create mode 100644 src/HABApp/core/files/manager/files.py create mode 100644 src/HABApp/core/files/manager/listen_events.py create mode 100644 src/HABApp/core/files/manager/worker.py create mode 100644 src/HABApp/core/files/setup.py rename {HABApp => src/HABApp}/core/files/watcher/__init__.py (74%) rename {HABApp => src/HABApp}/core/files/watcher/base_watcher.py (52%) rename {HABApp => src/HABApp}/core/files/watcher/file_watcher.py (56%) rename {HABApp => src/HABApp}/core/files/watcher/folder_watcher.py (75%) rename {HABApp => src/HABApp}/core/items/__init__.py (100%) rename {HABApp => src/HABApp}/core/items/base_item.py (92%) rename {HABApp => src/HABApp}/core/items/base_item_times.py (91%) rename {HABApp => src/HABApp}/core/items/base_item_watch.py (100%) rename {HABApp => src/HABApp}/core/items/base_valueitem.py (97%) rename {HABApp => src/HABApp}/core/items/item.py (100%) rename {HABApp => src/HABApp}/core/items/item_aggregation.py (97%) rename {HABApp => src/HABApp}/core/items/item_color.py (84%) rename {HABApp => src/HABApp}/core/items/tmp_data.py (100%) create mode 100644 src/HABApp/core/lib/__init__.py create mode 100644 src/HABApp/core/lib/funcs.py rename {HABApp => src/HABApp}/core/lib/handler.py (100%) rename {HABApp => src/HABApp}/core/lib/pending_future.py (87%) create mode 100644 src/HABApp/core/lib/rgb_hsv.py rename {HABApp => src/HABApp}/core/logger.py (100%) rename {HABApp => src/HABApp}/core/wrappedfunction.py (81%) rename {HABApp => src/HABApp}/core/wrapper.py (98%) rename {HABApp => src/HABApp}/mqtt/__init__.py (100%) rename {HABApp => src/HABApp}/mqtt/events/__init__.py (100%) rename {HABApp => src/HABApp}/mqtt/events/mqtt_events.py (100%) rename {HABApp => src/HABApp}/mqtt/events/mqtt_filters.py (100%) rename {HABApp => src/HABApp}/mqtt/interface.py (100%) rename {HABApp => src/HABApp}/mqtt/items/__init__.py (100%) rename {HABApp => src/HABApp}/mqtt/items/mqtt_item.py (100%) rename {HABApp => src/HABApp}/mqtt/items/mqtt_pair_item.py (100%) rename {HABApp => src/HABApp}/mqtt/mqtt_connection.py (100%) rename {HABApp => src/HABApp}/mqtt/mqtt_interface.py (100%) rename {HABApp => src/HABApp}/openhab/__init__.py (96%) rename {HABApp => src/HABApp}/openhab/connection_handler/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/connection_handler/func_async.py (100%) rename {HABApp => src/HABApp}/openhab/connection_handler/func_sync.py (100%) rename {HABApp => src/HABApp}/openhab/connection_handler/http_connection.py (98%) rename {HABApp => src/HABApp}/openhab/connection_handler/http_connection_waiter.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/_plugin.py (93%) rename {HABApp => src/HABApp}/openhab/connection_logic/connection.py (87%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_load_items.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_ping.py (96%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_thing_overview.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/_log.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/cfg_validator.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/filters.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/item_worker.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/items_file.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/plugin_things.py (78%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/str_builder.py (100%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/thing_config.py (95%) rename {HABApp => src/HABApp}/openhab/connection_logic/plugin_things/thing_worker.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/definitions.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/helpers/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/helpers/log_table.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/helpers/persistence_data.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/__init__.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/base.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/habapp_data.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/items.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/links.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/rest/things.py (100%) rename {HABApp => src/HABApp}/openhab/definitions/values.py (100%) rename {HABApp => src/HABApp}/openhab/events/__init__.py (98%) rename {HABApp => src/HABApp}/openhab/events/base_event.py (95%) rename {HABApp => src/HABApp}/openhab/events/channel_events.py (100%) rename {HABApp => src/HABApp}/openhab/events/event_filters.py (100%) rename {HABApp => src/HABApp}/openhab/events/item_events.py (96%) rename {HABApp => src/HABApp}/openhab/events/thing_events.py (100%) rename {HABApp => src/HABApp}/openhab/exceptions.py (100%) rename {HABApp => src/HABApp}/openhab/interface.py (100%) rename {HABApp => src/HABApp}/openhab/interface_async.py (100%) rename {HABApp => src/HABApp}/openhab/items/__init__.py (97%) rename {HABApp => src/HABApp}/openhab/items/base_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/color_item.py (84%) rename {HABApp => src/HABApp}/openhab/items/commands.py (100%) rename {HABApp => src/HABApp}/openhab/items/contact_item.py (96%) rename {HABApp => src/HABApp}/openhab/items/datetime_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/dimmer_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/group_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/image_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/number_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/rollershutter_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/string_item.py (100%) rename {HABApp => src/HABApp}/openhab/items/switch_item.py (96%) rename {HABApp => src/HABApp}/openhab/items/thing_item.py (89%) rename {HABApp => src/HABApp}/openhab/map_events.py (100%) rename {HABApp => src/HABApp}/openhab/map_items.py (89%) rename {HABApp => src/HABApp}/openhab/map_values.py (81%) rename {HABApp => src/HABApp}/parameters/__init__.py (100%) rename {HABApp => src/HABApp}/parameters/parameter.py (100%) create mode 100644 src/HABApp/parameters/parameter_files.py rename {HABApp => src/HABApp}/parameters/parameters.py (90%) create mode 100644 src/HABApp/rule/__init__.py create mode 100644 src/HABApp/rule/habappscheduler.py rename {HABApp => src/HABApp}/rule/interfaces/__init__.py (100%) rename {HABApp => src/HABApp}/rule/interfaces/http.py (100%) rename {HABApp => src/HABApp}/rule/interfaces/rule_subprocess.py (100%) rename {HABApp => src/HABApp}/rule/rule.py (51%) rename {HABApp => src/HABApp}/rule_manager/__init__.py (97%) rename {HABApp => src/HABApp}/rule_manager/benchmark/__init__.py (100%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_base.py (88%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_file.py (86%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_habapp.py (99%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_mqtt.py (99%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_oh.py (98%) rename {HABApp => src/HABApp}/rule_manager/benchmark/bench_times.py (100%) rename {HABApp => src/HABApp}/rule_manager/rule_file.py (93%) rename {HABApp => src/HABApp}/rule_manager/rule_manager.py (50%) rename {HABApp => src/HABApp}/runtime/__init__.py (98%) rename {HABApp => src/HABApp}/runtime/runtime.py (64%) rename {HABApp => src/HABApp}/runtime/shutdown.py (100%) rename {HABApp => src/HABApp}/util/__init__.py (97%) rename {HABApp => src/HABApp}/util/counter_item.py (100%) create mode 100644 src/HABApp/util/functions/__init__.py rename {HABApp => src/HABApp}/util/functions/min_max.py (100%) rename {HABApp => src/HABApp}/util/multimode/__init__.py (100%) rename {HABApp => src/HABApp}/util/multimode/item.py (100%) rename {HABApp => src/HABApp}/util/multimode/mode_base.py (100%) rename {HABApp => src/HABApp}/util/multimode/mode_switch.py (100%) rename {HABApp => src/HABApp}/util/multimode/mode_value.py (100%) rename {HABApp => src/HABApp}/util/period_counter.py (96%) rename {HABApp => src/HABApp}/util/statistics.py (100%) rename {HABApp => src/HABApp}/util/threshold.py (96%) delete mode 100644 tests/test_rule/test_scheduler.py diff --git a/.flake8 b/.flake8 index fa5a54d3..f3965ae1 100644 --- a/.flake8 +++ b/.flake8 @@ -25,5 +25,5 @@ exclude = tests/conftest.py, # the interfaces will throw unused imports - HABApp/openhab/interface.py, - HABApp/openhab/interface_async.py, + src/HABApp/openhab/interface.py, + src/HABApp/openhab/interface_async.py, diff --git a/.github/workflows/run_tox.yml b/.github/workflows/run_tox.yml index 35e324a3..7829f8f1 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.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v1 diff --git a/.readthedocs.yml b/.readthedocs.yml index 089f5096..a5f92def 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -18,8 +18,8 @@ formats: all # Optionally set the version of Python and requirements required to build your docs python: - version: 3.7 + version: 3.8 install: - requirements: _doc/requirements.txt - method: setuptools - path: . \ No newline at end of file + path: . diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d3f1039b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -language: python -os: linux -stages: -- unit tests -- docker -- docs - -jobs: - include: - - &python_38 - stage: unit tests - python: 3.8 - script: tox - install: pip install tox - env: TOXENV=py38 - - - <<: *python_38 - python: 3.6 - env: TOXENV=py36 - - - <<: *python_38 - python: 3.7 - env: TOXENV=py37 - - - <<: *python_38 - python: 3.9 - env: TOXENV=py39 - - - <<: *python_38 - stage: docs - env: TOXENV=flake - - - <<: *python_38 - stage: docs - python: 3.8 - env: TOXENV=docs - - - &docker - stage: docker - language: shell - install: - # test docker build - - docker build -t habapp . - - docker run -d --name habapp habapp - script: - # Allow the container to start properly - - sleep 5 - # output stdout to travis in case we can not start the container - - docker logs habapp - # test if container is still running - # -q means quiet and will return 0 if a match is found - - docker ps | grep -q habapp - # Show logs from HABApp - - docker exec habapp tail -n +1 /config/log/HABApp.log - - # Docker arm build (e.g. raspberry pi) - - <<: *docker - arch: arm64 diff --git a/HABApp/__version__.py b/HABApp/__version__.py deleted file mode 100644 index e015511f..00000000 --- a/HABApp/__version__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.20.2' diff --git a/HABApp/config/_conf_location.py b/HABApp/config/_conf_location.py deleted file mode 100644 index 5128a9e5..00000000 --- a/HABApp/config/_conf_location.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging.config - -import astral as _astral -import tzlocal -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__() - - self._astral_location: _astral.LocationInfo - self.astral_observer: _astral.Observer - - def on_all_values_set(self): - tz = tzlocal.get_localzone() - tz_name = str(tz) - log.debug(f'Local Timezone: {tz_name}') - - # unsure why we need the location in 2.1 - self._astral_location = _astral.LocationInfo(name='HABApp', ) - self._astral_location.latitude = self.latitude - self._astral_location.longitude = self.longitude - self._astral_location.timezone = tz_name - - self.astral_observer = self._astral_location.observer - self.astral_observer.elevation = self.elevation diff --git a/HABApp/core/const/const.py b/HABApp/core/const/const.py deleted file mode 100644 index b32d01e2..00000000 --- a/HABApp/core/const/const.py +++ /dev/null @@ -1,6 +0,0 @@ -class _MissingType: - def __repr__(self): - return '' - - -MISSING = _MissingType() diff --git a/HABApp/core/files/__init__.py b/HABApp/core/files/__init__.py deleted file mode 100644 index 6fc9e052..00000000 --- a/HABApp/core/files/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import watcher -from .file_name import path_from_name, name_from_path -from .file import HABAppFile -from .all import watch_folder, file_load_failed, file_load_ok -from .event_listener import add_event_bus_listener diff --git a/HABApp/core/files/all.py b/HABApp/core/files/all.py deleted file mode 100644 index 79d0b0da..00000000 --- a/HABApp/core/files/all.py +++ /dev/null @@ -1,119 +0,0 @@ -import logging -import typing -from itertools import chain -from pathlib import Path -from threading import Lock - -import HABApp -from HABApp.core.logger import HABAppError -from HABApp.core.wrapper import ignore_exception -from . import name_from_path -from .file import CircularReferenceError, HABAppFile -from .watcher import AggregatingAsyncEventHandler - -log = logging.getLogger('HABApp.files') - -LOCK = Lock() - -ALL: typing.Dict[str, HABAppFile] = {} -LOAD_RUNNING = False - - -@ignore_exception -def process(files: typing.List[Path], load_next: bool = True): - global LOAD_RUNNING - - for file in files: - name = name_from_path(file) - - # unload - if not file.is_file(): - with LOCK: - existing = ALL.pop(name, None) - if existing is not None: - existing.unload() - continue - - try: - # reload/initial load - obj = HABAppFile.from_path(name, file) - except Exception as e: - HABAppError(log).add_exception(e, add_traceback=False).dump() - # If we can not load the HABApp properties we skip the file - continue - - with LOCK: - ALL[name] = obj - - # find all which have this file as dependency and are not valid so it can be checked again - for _f in filter(lambda x: not x.is_valid and name in x.properties.depends_on, ALL.values()): - _f.is_checked = False - - if not load_next: - return None - - # Start loading only once - with LOCK: - if LOAD_RUNNING: - return None - LOAD_RUNNING = True - - _load_next() - - -@ignore_exception -def file_load_ok(name: str): - with LOCK: - file = ALL.get(name) - file.load_ok() - - # reload files with the property "reloads_on" - reload = [k.path for k in filter(lambda f: f.is_loaded and name in f.properties.reloads_on, ALL.values())] - if reload: - process(reload, load_next=False) - - _load_next() - - -@ignore_exception -def file_load_failed(name: str): - with LOCK: - f = ALL.get(name) - f.load_failed() - - _load_next() - - -def _load_next(): - global LOAD_RUNNING - - # check files for dependencies etc. - for file in list(filter(lambda x: not x.is_checked, ALL.values())): - try: - file.check_properties() - except Exception as e: - if not isinstance(e, (CircularReferenceError, FileNotFoundError)): - HABApp.core.wrapper.process_exception(file.check_properties, e, logger=log) - - # Load order is parameters -> openhab config files-> rules - f_n = HABApp.core.files.file_name - _all = sorted(ALL.keys()) - files = chain(filter(f_n.is_param, _all), filter(f_n.is_config, _all), filter(f_n.is_rule, _all)) - - for name in files: - file = ALL[name] - if file.is_loaded or file.is_failed: - continue - - if file.can_be_loaded(): - file.load() - return None - - with LOCK: - LOAD_RUNNING = False - - -def watch_folder(folder: Path, file_ending: str, watch_subfolders: bool = False) -> AggregatingAsyncEventHandler: - handler = AggregatingAsyncEventHandler(folder, process, file_ending, watch_subfolders) - HABApp.core.files.watcher.add_folder_watch(handler) - return handler diff --git a/HABApp/core/files/event_listener.py b/HABApp/core/files/event_listener.py deleted file mode 100644 index 9e7296cc..00000000 --- a/HABApp/core/files/event_listener.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -from pathlib import Path -from typing import Any, Callable, Optional - -import HABApp -from HABApp.core.logger import log_error - - -def add_event_bus_listener( - file_type: str, - func_load: Optional[Callable[[str, Path], Any]], - func_unload: Optional[Callable[[str, Path], Any]], - logger: logging.Logger): - - func = { - 'config': HABApp.core.files.file_name.is_config, - 'rule': HABApp.core.files.file_name.is_rule, - 'param': HABApp.core.files.file_name.is_param, - }[file_type] - - def filter_func_load(event: HABApp.core.events.habapp_events.RequestFileLoadEvent): - if not func(event.name): - return None - - name = event.name - path = event.get_path() - - # Only load existing files - if not path.is_file(): - log_error(logger, f'{file_type} file "{path}" does not exist and can not be loaded!') - return None - - func_load(name, path) - - def filter_func_unload(event: HABApp.core.events.habapp_events.RequestFileUnloadEvent): - if not func(event.name): - return None - - name = event.name - path = event.get_path() - func_unload(name, path) - - if func_unload is not None: - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - HABApp.core.const.topics.FILES, - HABApp.core.WrappedFunction(filter_func_unload), - HABApp.core.events.habapp_events.RequestFileUnloadEvent - ) - ) - - if func_load is not None: - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - HABApp.core.const.topics.FILES, - HABApp.core.WrappedFunction(filter_func_load), - HABApp.core.events.habapp_events.RequestFileLoadEvent - ) - ) diff --git a/HABApp/core/files/file.py b/HABApp/core/files/file.py deleted file mode 100644 index 2dce728c..00000000 --- a/HABApp/core/files/file.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -import typing -from pathlib import Path - -import HABApp -from HABApp.core.const.topics import FILES as T_FILES -from HABApp.core.events.habapp_events import RequestFileLoadEvent, RequestFileUnloadEvent -from .file_props import FileProperties, get_props - -log = logging.getLogger('HABApp.files') - - -class CircularReferenceError(Exception): - pass - - -class HABAppFile: - - @classmethod - def from_path(cls, name: str, path: Path) -> 'HABAppFile': - with path.open('r', encoding='utf-8') as f: - txt = f.read(10 * 1024) - return cls(name, path, get_props(txt)) - - def __init__(self, name: str, path: Path, properties: FileProperties): - self.name: str = name - self.path: Path = path - self.properties: FileProperties = properties - - # file checks - self.is_checked = False - self.is_valid = False - - # file loaded - self.is_loaded = False - self.is_failed = False - - def _check_refs(self, stack, prop: str): - c: typing.List[str] = getattr(self.properties, prop) - for f in c: - _stack = stack + (f, ) - if f in stack: - log.error(f'Circular reference: {" -> ".join(_stack)}') - raise CircularReferenceError(" -> ".join(_stack)) - - next_file = ALL.get(f) - if next_file is not None: - next_file._check_refs(_stack, prop) - - def check_properties(self): - self.is_checked = True - - # check dependencies - mis = set(filter(lambda x: x not in ALL, self.properties.depends_on)) - if mis: - one = len(mis) == 1 - msg = f'File {self.path} depends on file{"" if one else "s"} that ' \ - f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}' - log.error(msg) - raise FileNotFoundError(msg) - - # check reload - mis = set(filter(lambda x: x not in ALL, self.properties.reloads_on)) - if mis: - one = len(mis) == 1 - log.warning(f'File {self.path} reloads on file{"" if one else "s"} that ' - f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}') - - # check for circular references - self._check_refs((self.name, ), 'depends_on') - self._check_refs((self.name, ), 'reloads_on') - - self.is_valid = True - - def can_be_loaded(self) -> bool: - if not self.is_valid: - return False - - for name in self.properties.depends_on: - f = ALL.get(name, None) - if f is None: - return False - - if not f.is_loaded: - return False - return True - - def load(self): - self.is_loaded = False - - HABApp.core.EventBus.post_event( - T_FILES, RequestFileLoadEvent(self.name) - ) - - def unload(self): - HABApp.core.EventBus.post_event( - T_FILES, RequestFileUnloadEvent(self.name) - ) - - def load_ok(self): - self.is_loaded = True - - def load_failed(self): - self.is_failed = True - - -from .all import ALL # noqa F401 diff --git a/HABApp/core/files/file_name.py b/HABApp/core/files/file_name.py deleted file mode 100644 index d28b9228..00000000 --- a/HABApp/core/files/file_name.py +++ /dev/null @@ -1,42 +0,0 @@ -import HABApp -from pathlib import Path - - -PREFIX_CONFIGS = 'configs' -PREFIX_PARAMS = 'params' -PREFIX_RULES = 'rules' - - -def name_from_path(path: Path) -> str: - _path = path.as_posix() - d = HABApp.config.CONFIG.directories - folders = {PREFIX_CONFIGS: d.config.as_posix(), PREFIX_PARAMS: d.param.as_posix(), PREFIX_RULES: d.rules.as_posix()} - - for prefix, folder in folders.items(): - if _path.startswith(folder): - return prefix + '/' + _path[len(folder) + 1:] - - raise ValueError(f'Path "{path}" is not part of the configured folders!') - - -def path_from_name(name: str) -> Path: - d = HABApp.config.CONFIG.directories - folders = {PREFIX_CONFIGS: d.config.as_posix(), PREFIX_PARAMS: d.param.as_posix(), PREFIX_RULES: d.rules.as_posix()} - - for prefix, folder in folders.items(): - if name.startswith(prefix): - return Path(folder + '/' + name[len(prefix):]) - - raise ValueError(f'Prefix not found for "{name}"!') - - -def is_config(name: str): - return name.startswith(PREFIX_CONFIGS) - - -def is_param(name: str): - return name.startswith(PREFIX_PARAMS) - - -def is_rule(name: str): - return name.startswith(PREFIX_RULES) diff --git a/HABApp/core/lib/__init__.py b/HABApp/core/lib/__init__.py deleted file mode 100644 index 615e5aa1..00000000 --- a/HABApp/core/lib/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .funcs import list_files -from .pending_future import PendingFuture diff --git a/HABApp/core/lib/funcs.py b/HABApp/core/lib/funcs.py deleted file mode 100644 index 2a6540c3..00000000 --- a/HABApp/core/lib/funcs.py +++ /dev/null @@ -1,8 +0,0 @@ -import typing -from pathlib import Path - - -def list_files(folder: Path, file_ending: str = '', recursive: bool = False) -> typing.List[Path]: - # glob is much quicker than iter_dir() - files = folder.glob(f'**/*{file_ending}' if recursive else f'*{file_ending}') - return sorted(files, key=lambda x: x.relative_to(folder)) diff --git a/HABApp/parameters/parameter_files.py b/HABApp/parameters/parameter_files.py deleted file mode 100644 index 8dfe8ede..00000000 --- a/HABApp/parameters/parameter_files.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -import threading -from pathlib import Path - -import HABApp -from HABApp.core.files import file_load_failed, file_load_ok -from .parameters import get_parameter_file, remove_parameter_file, set_parameter_file - -log = logging.getLogger('HABApp.RuleParameters') - -LOCK = threading.Lock() - - -def setup_param_files() -> bool: - if not HABApp.CONFIG.directories.param.is_dir(): - log.info(f'Parameter files disabled: Folder {HABApp.CONFIG.directories.param} does not exist!') - return False - - # Add event bus listener - HABApp.core.files.add_event_bus_listener('param', load_file, unload_file, log) - - # watch folder and load all files - watcher = HABApp.core.files.watch_folder(HABApp.CONFIG.directories.param, '.yml', True) - watcher.trigger_all() - return True - - -def load_file(name: str, path: Path): - with LOCK: # serialize to get proper error messages - try: - with path.open(mode='r', encoding='utf-8') as file: - data = HABApp.core.const.yml.load(file) - if data is None: - data = {} - set_parameter_file(path.stem, data) - except Exception as exc: - e = HABApp.core.logger.HABAppError(log) - e.add(f"Could not load parameters for {name} ({path})!") - e.add_exception(exc, add_traceback=True) - e.dump() - - file_load_failed(name) - return None - - log.debug(f'Loaded params from {path.name}!') - file_load_ok(name) - - -def unload_file(name: str, path: Path): - with LOCK: # serialize to get proper error messages - try: - remove_parameter_file(path.stem) - except Exception as exc: - e = HABApp.core.logger.HABAppError(log) - e.add(f"Could not remove parameters for {name} ({path})!") - e.add_exception(exc, add_traceback=True) - e.dump() - return None - - log.debug(f'Removed params from {path.name}!') - - -def save_file(file: str): - assert isinstance(file, str), type(file) - filename = HABApp.CONFIG.directories.param / (file + '.yml') - - with LOCK: # serialize to get proper error messages - log.info(f'Updated {filename}') - with filename.open('w', encoding='utf-8') as outfile: - HABApp.core.const.yml.dump(get_parameter_file(file), outfile) diff --git a/HABApp/rule/__init__.py b/HABApp/rule/__init__.py deleted file mode 100644 index c53397ba..00000000 --- a/HABApp/rule/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .scheduler import ReoccurringScheduledCallback, DayOfWeekScheduledCallback, OneTimeCallback -from .rule import Rule, get_parent_rule - -from HABApp.rule.interfaces import FinishedProcessInfo diff --git a/HABApp/rule/scheduler/__init__.py b/HABApp/rule/scheduler/__init__.py deleted file mode 100644 index 7327c9df..00000000 --- a/HABApp/rule/scheduler/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .base import TYPING_DATE_TIME -from .one_time_cb import OneTimeCallback -from .reoccurring_cb import DayOfWeekScheduledCallback, ReoccurringScheduledCallback -from .sun_cb import SunScheduledCallback \ No newline at end of file diff --git a/HABApp/rule/scheduler/base.py b/HABApp/rule/scheduler/base.py deleted file mode 100644 index e71592b0..00000000 --- a/HABApp/rule/scheduler/base.py +++ /dev/null @@ -1,194 +0,0 @@ -import random -import typing -import tzlocal -from datetime import datetime, timedelta, time - -from pytz import utc - -from HABApp.core import WrappedFunction - -TYPING_DATE_TIME = typing.Union[None, datetime, timedelta, time] - -local_tz = tzlocal.get_localzone() - - -def replace_time(dt_obj: datetime, t_obj: typing.Union[datetime, time]) -> datetime: - return dt_obj.replace(hour=t_obj.hour, minute=t_obj.minute, second=t_obj.second, microsecond=0) - - -class ScheduledCallbackBase: - - def __init__(self, callback: WrappedFunction, *args, **kwargs): - - self._callback: WrappedFunction = callback - self._args = args - self._kwargs = kwargs - - # boundaries - self._earliest: typing.Optional[time] = None - self._latest: typing.Optional[time] = None - self._offset: typing.Optional[timedelta] = None - self._jitter: typing.Optional[int] = None - self._boundary_func: typing.Optional[typing.Callable[[datetime], datetime]] = None - - # times when we run - self._next_base: datetime - self._next_call: datetime - - # properties - self.is_due = False - self.is_finished = False - self.run_counter = 0 - - def _set_next_base(self, next_time: TYPING_DATE_TIME) -> 'ScheduledCallbackBase': - # initializer method for self._next_base - - __now = datetime.now() - if next_time is None: - # If we don't specify a datetime we start it now - base_time = __now - elif isinstance(next_time, timedelta): - # if it is a timedelta add it to now to easily speciy points in the future - base_time = __now + next_time - elif isinstance(next_time, time): - # if it is a time object it specifies a time of day. - base_time = __now.replace(hour=next_time.hour, minute=next_time.minute, second=next_time.second) - if base_time < __now: - base_time += timedelta(days=1) - else: - base_time = next_time - - assert isinstance(base_time, datetime), type(base_time) - - # convert to utc - base_time = local_tz.localize(base_time) - self._next_base = base_time.astimezone(utc) - return self - - def _calculate_next_call(self): - """Get next calc time""" - raise NotImplementedError() - - def earliest(self, time_obj: typing.Optional[time]) -> 'ScheduledCallbackBase': - """Set earliest boundary as time of day. ``None`` will disable boundary. - - :param time_obj: time obj, scheduler will not run earlier - """ - assert isinstance(time_obj, time) or time_obj is None, type(time_obj) - changed = self._earliest != time_obj - self._earliest = time_obj - if changed: - self._update_run_time() - return self - - def latest(self, time_obj: typing.Optional[time]) -> 'ScheduledCallbackBase': - """Set earliest boundary as time of day. ``None`` will disable boundary. - - :param time_obj: time obj, scheduler will not run later - """ - assert isinstance(time_obj, time) or time_obj is None, type(time_obj) - changed = self._latest != time_obj - self._latest = time_obj - if changed: - self._update_run_time() - return self - - def offset(self, timedelta_obj: typing.Optional[timedelta]) -> 'ScheduledCallbackBase': - """Set a constant offset to the calculation of the next run. ``None`` will disable the offset. - - :param timedelta_obj: constant offset - """ - assert isinstance(timedelta_obj, timedelta) or timedelta_obj is None, type(timedelta_obj) - changed = self._offset != timedelta_obj - self._offset = timedelta_obj - if changed: - self._update_run_time() - return self - - def jitter(self, secs: typing.Optional[int]) -> 'ScheduledCallbackBase': - """Add a random jitter per call in the intervall [(-1) * secs ... secs] to the next run. - ``None`` will disable jitter. - - :param secs: jitter in secs - """ - assert isinstance(secs, int) or secs is None, type(secs) - changed = self._jitter != secs - self._jitter = secs - if changed: - self._update_run_time() - return self - - def boundary_func(self, func: typing.Optional[typing.Callable[[datetime], datetime]]): - """Add a function which will be called when the datetime changes. Use this to implement custom boundaries. - Use ``None`` to disable the boundary function. - - :param func: Function which returns a datetime obj, arg is a datetime with the next call time - """ - changed = self._boundary_func != func - self._boundary_func = func - if changed: - self._update_run_time() - return self - - def _update_run_time(self) -> 'ScheduledCallbackBase': - # Starting point is always the next call - self._next_call = self._next_base - - # custom boundaries first - if self._boundary_func is not None: - self._next_call = self._boundary_func(self._next_call.astimezone(local_tz)).astimezone(utc) - - if self._offset is not None: - self._next_call += self._offset # offset doesn't have to be localized - - if self._jitter is not None: - self._next_call += timedelta(seconds=random.randint(-1 * self._jitter, self._jitter)) - - if self._earliest is not None: - earliest = replace_time(self._next_call.astimezone(local_tz), self._earliest) - earliest = earliest.astimezone(utc) - if self._next_call < earliest: - self._next_call = earliest - - if self._latest is not None: - latest = replace_time(self._next_call.astimezone(local_tz), self._latest) - latest = latest.astimezone(utc) - if self._next_call > latest: - self._next_call = latest - - return self - - def get_next_call(self): - """Return the next execution timestamp""" - return self._next_call.astimezone(local_tz).replace(tzinfo=None) - - def check_due(self, now: datetime): - """Check whether the callback is due for execution - - :param now: - :return: - """ - - self.is_due = True if self._next_call <= now else False - if self.is_finished: - self.is_due = False - - return self.is_due - - def execute(self) -> bool: - """Try to execute callback. If the callback is not due yet or execution has already finished nothing will happen - - :return: True if callback has been executed else False - """ - if not self.is_due or self.is_finished: - return False - - self.run_counter += 1 - self._calculate_next_call() - self._callback.run(*self._args, **self._kwargs) - return True - - def cancel(self): - """Cancel execution - """ - self.is_finished = True diff --git a/HABApp/rule/scheduler/one_time_cb.py b/HABApp/rule/scheduler/one_time_cb.py deleted file mode 100644 index 734fe7dc..00000000 --- a/HABApp/rule/scheduler/one_time_cb.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import ScheduledCallbackBase, TYPING_DATE_TIME - - -class OneTimeCallback(ScheduledCallbackBase): - - def _calculate_next_call(self): - if self.run_counter: - self.is_finished = True - - def set_run_time(self, next_time: TYPING_DATE_TIME) -> 'OneTimeCallback': - self._set_next_base(next_time) - self._update_run_time() - return self diff --git a/HABApp/rule/scheduler/reoccurring_cb.py b/HABApp/rule/scheduler/reoccurring_cb.py deleted file mode 100644 index d19ded22..00000000 --- a/HABApp/rule/scheduler/reoccurring_cb.py +++ /dev/null @@ -1,72 +0,0 @@ -import typing -from datetime import datetime, time, timedelta - -from pytz import utc - -from .base import ScheduledCallbackBase, local_tz - - -class ReoccurringScheduledCallback(ScheduledCallbackBase): - - def __init__(self, callback, *args, **kwargs): - super().__init__(callback, *args, **kwargs) - self._interval: timedelta - - def _calculate_next_call(self): - self._next_base += self._interval - self._update_run_time() - - def interval(self, interval: typing.Union[int, timedelta]) -> 'ReoccurringScheduledCallback': - if isinstance(interval, int): - interval = timedelta(seconds=interval) - assert isinstance(interval, timedelta), type(interval) - assert interval.total_seconds() > 0 - - self._interval = interval - super()._set_next_base(interval) - - self._update_run_time() - return self - - -class DayOfWeekScheduledCallback(ScheduledCallbackBase): - - def __init__(self, callback, *args, **kwargs): - super().__init__(callback, *args, **kwargs) - self._time: time - self._weekdays: typing.Set[int] - - def time(self, _time: typing.Union[time, datetime]) -> 'DayOfWeekScheduledCallback': - super()._set_next_base(_time) - - self._time = _time if isinstance(_time, time) else _time.time() - - # it is possible that the current day is not in the weekdays -> find next correct day - # it's also why we don't have to call _update_run_time here - self._calculate_next_call(add_day=False) - return self - - def weekdays(self, weekdays) -> 'DayOfWeekScheduledCallback': - if weekdays == 'weekend': - weekdays = [6, 7] - elif weekdays == 'workday': - weekdays = [1, 2, 3, 4, 5] - elif weekdays == 'all': - weekdays = [1, 2, 3, 4, 5, 6, 7] - for k in weekdays: - assert 1 <= k <= 7, k - self._weekdays = weekdays - return self - - def _calculate_next_call(self, add_day=True): - - # we have to do it like this so the dst-change works, - # otherwise we have the wrong hour after the change - next_utc = self._next_base + timedelta(days=1) if add_day else self._next_base - loc = datetime.combine(next_utc.astimezone(local_tz).date(), self._time) - - while not loc.isoweekday() in self._weekdays: - loc += timedelta(days=1) - - self._next_base = loc.astimezone(utc) - self._update_run_time() diff --git a/HABApp/rule/scheduler/sun_cb.py b/HABApp/rule/scheduler/sun_cb.py deleted file mode 100644 index 0f312cd0..00000000 --- a/HABApp/rule/scheduler/sun_cb.py +++ /dev/null @@ -1,31 +0,0 @@ -from datetime import datetime, timedelta - -from astral import sun -from pytz import utc - -import HABApp -from .base import ScheduledCallbackBase - - -class SunScheduledCallback(ScheduledCallbackBase): - - def __init__(self, callback, *args, **kwargs): - super().__init__(callback, *args, **kwargs) - self._method: str = None - - def sun_trigger(self, trig): - assert trig in ('sunrise', 'sunset', 'dusk', 'dawn'), trig - self._method = trig - - def _calculate_next_call(self): - func = getattr(sun, self._method) - observer = HABApp.config.CONFIG.location.astral_observer - - dt = datetime.now().date() - # the datetime from astral has the proper timezone set so we don't have to do anything here - self._next_base: datetime = func(observer=observer, date=dt).replace(microsecond=0) - self._update_run_time() - - if self._next_call < datetime.now(tz=utc): - self._next_base: datetime = func(observer=observer, date=dt + timedelta(days=1)).replace(microsecond=0) - self._update_run_time() diff --git a/HABApp/util/functions/__init__.py b/HABApp/util/functions/__init__.py deleted file mode 100644 index ac756c05..00000000 --- a/HABApp/util/functions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .min_max import min, max diff --git a/LICENSE b/LICENSE index f288702d..261eeb9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,201 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/_doc/_plugins/sphinx_execute_code.py b/_doc/_plugins/sphinx_execute_code.py index fff2fab4..d832cdef 100644 --- a/_doc/_plugins/sphinx_execute_code.py +++ b/_doc/_plugins/sphinx_execute_code.py @@ -18,6 +18,7 @@ print 'Execute this python code' """ import functools +import os import re import subprocess import sys @@ -32,6 +33,8 @@ log = logging.getLogger(__name__) +ADDITIONAL_PATH = Path(__file__).parent.parent.parent + def PrintException( func): @@ -47,7 +50,7 @@ def f(*args, **kwargs): return f -re_line = re.compile(r'line (\d+),') +re_line = re.compile(r'File "", line (\d+),') class CodeException(Exception): @@ -168,7 +171,17 @@ def run(self): def execute_code(code, ignore_stderr) -> str: - run = subprocess.run([sys.executable, '-c', code], capture_output=True, cwd=WORKING_DIR) + env = os.environ.copy() + + # Add additional PATH so we find the "tests" folder + try: + paths = env['PYTHONPATH'].split(os.pathsep) + paths.insert(0, str(ADDITIONAL_PATH)) + env['PYTHONPATH'] = os.pathsep.join(paths) + except KeyError: + env['PYTHONPATH'] = str(ADDITIONAL_PATH) + + run = subprocess.run([sys.executable, '-c', code], capture_output=True, cwd=WORKING_DIR, env=env) if run.returncode != 0: # print('') # print(f'stdout: {run.stdout.decode()}') @@ -216,6 +229,8 @@ def builder_ready(app): def setup(app): """ Register sphinx_execute_code directive with Sphinx """ + assert (ADDITIONAL_PATH / 'tests').is_dir(), ADDITIONAL_PATH + app.add_config_value('execute_code_working_dir', None, 'env') app.connect('builder-inited', builder_ready) diff --git a/_doc/advanced_usage.rst b/_doc/advanced_usage.rst index 948d36d0..5714b3f0 100644 --- a/_doc/advanced_usage.rst +++ b/_doc/advanced_usage.rst @@ -24,13 +24,13 @@ An example would be dynamically reloading files or an own notifier in case there - ``str`` * - HABApp.Warnings - - All warnings in functions and rules of HABApp create an according event - - ``str`` + - All warnings in functions (e.g. caught exceptions) and rules of HABApp create an according event + - :class:`~HABApp.core.events.habapp_events.HABAppException` or ``str`` * - HABApp.Errors - All errors in functions and rules of HABApp create an according event. Use this topic to create an own notifier in case of errors (e.g. Pushover). - - :class:`~HABApp.core.events.habapp_events.HABAppError` or ``str`` + - :class:`~HABApp.core.events.habapp_events.HABAppException` or ``str`` @@ -40,7 +40,7 @@ An example would be dynamically reloading files or an own notifier in case there .. autoclass:: HABApp.core.events.habapp_events.RequestFileUnloadEvent :members: -.. autoclass:: HABApp.core.events.habapp_events.HABAppError +.. autoclass:: HABApp.core.events.habapp_events.HABAppException :members: File properties diff --git a/_doc/class_reference.rst b/_doc/class_reference.rst index 9c38fdcb..72401d56 100644 --- a/_doc/class_reference.rst +++ b/_doc/class_reference.rst @@ -25,3 +25,72 @@ ItemNoChangeWatch :inherited-members: :member-order: groupwise + +Scheduler +====================================== + + +OneTimeJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.OneTimeJob + :members: + :inherited-members: + :member-order: groupwise + +CountdownJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.CountdownJob + :members: + :inherited-members: + :member-order: groupwise + +ReoccurringJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.ReoccurringJob + :members: + :inherited-members: + :member-order: groupwise + +DayOfWeekJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.DayOfWeekJob + :members: + :inherited-members: + :member-order: groupwise + +DawnJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.DawnJob + :members: + :inherited-members: + :member-order: groupwise + +SunriseJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.SunriseJob + :members: + :inherited-members: + :member-order: groupwise + +SunsetJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.SunsetJob + :members: + :inherited-members: + :member-order: groupwise + +DuskJob +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: eascheduler.jobs.DuskJob + :members: + :inherited-members: + :member-order: groupwise + diff --git a/_doc/conf.py b/_doc/conf.py index 8d617f03..3d5aedb5 100644 --- a/_doc/conf.py +++ b/_doc/conf.py @@ -18,14 +18,14 @@ import sys # required for autodoc -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.join(os.path.abspath('..'), 'src')) sys.path.insert(0, os.path.abspath('./_plugins')) # -- Project information ----------------------------------------------------- project = 'HABApp' -copyright = '2020, spacemanspiff2007' +copyright = '2021, spacemanspiff2007' author = 'spacemanspiff2007' # The short X.Y version @@ -35,7 +35,7 @@ try: from HABApp import __version__ version = __version__ - print( f'Building doc for {version}') + print(f'Building doc for {version}') except Exception as e: print('Exception', e) version = 'dev' @@ -53,8 +53,6 @@ 'sphinx.ext.autodoc', 'sphinx_autodoc_typehints', 'sphinx_execute_code', - 'sphinx_execute_code', - 'sphinx.ext.inheritance_diagram', ] @@ -206,7 +204,8 @@ # -- Extension configuration ------------------------------------------------- -execute_code_working_dir = pathlib.Path(__file__).parent.parent +execute_code_working_dir = pathlib.Path(__file__).parent.parent / 'src' +assert execute_code_working_dir.is_dir(), execute_code_working_dir autodoc_member_order = 'bysource' autoclass_content = 'both' diff --git a/_doc/getting_started.rst b/_doc/getting_started.rst index 8ccf77bb..4175a52a 100644 --- a/_doc/getting_started.rst +++ b/_doc/getting_started.rst @@ -32,9 +32,9 @@ rules in the HABApp rule engine. So lets write a small rule which prints somethi def __init__(self): super().__init__() - # Use run_soon to schedule things directly after instantiation, + # Use run.at to schedule things directly after instantiation, # don't do blocking things in __init__ - self.run_soon(self.say_something) + self.run.soon(self.say_something) def say_something(self): print('That was easy!') @@ -65,7 +65,7 @@ This often comes in handy if there is some logic that shall be applied to differ super().__init__() self.param = my_parameter - self.run_soon(self.say_something) + self.run.soon(self.say_something) def say_something(self): print(f'Param {self.param}') @@ -131,7 +131,7 @@ To access items from openhab use the correct openhab item type (see :ref:`the op # Get the item or create it if it does not exist self.my_item = Item.get_create_item('Item_Name') - self.run_soon(self.say_something) + self.run.soon(self.say_something) def say_something(self): # Post updates to the item through the internal event bus diff --git a/_doc/images/openhab_api_config.png b/_doc/images/openhab_api_config.png new file mode 100644 index 0000000000000000000000000000000000000000..f408d4eedd83082ea30c9392fee93970906538d0 GIT binary patch literal 10613 zcmch7by!qUxAy=dQlg}^h;&MWgn)Dp-J*1NmxM@4%>YBk07Lgs0}9e5F;db!l+w)| z-}ips^Zjw}eeV0!ALlu9_ME-<+Iz3{Tfeo|iPltCAjG4>0{{SoN{VlR002fW`r>_r ziEb$vlJ`g7Fg$xG3B6v?Nu@~oK zz%Wq0NIP=kYtP8X$oPVvaZFOz^C>D$nfNmB_t0j^ZZG_fV4^^ zx})8H*^$HuHP3T!(9v;QJSmIrbj@7Sq;pU*TpfIy;*E=*X;D}d1_cNEpU+e51C!}P zcy@h!IxkMjy}hynk1iJ$GT~CFlf)f? zP};yuF~ixC>-IXx7-Td46!G9KS#!I0>_+HP!n(#foj~kPx1QBM;U^Dgmw?kJ+FZNk zW0L0fu~Wjg+Ec0wMEcE2LH8b4HCu<%WgbvoC*W`L%Z}ZN9HxQ z?|s~j>!&JHoQ}9&+rkFEwwNpa0B-dcvjnC608N1V=<^juEX-ACrw&t`lO?!LeWk2m z{vm~9>m~i2Q7mfwUN%Wm&ZTWq*c8Y08cVt^vV2>J^~1{FxW{%SW_CWuIbf=7!@a?) zfPs!riPaU*Wu)iAF`34J)V(-1Lp<5?i^R>&JIKia^9s^D5Ym_Up(~0;d}nI;!DX{t z32J0;ME1k^%|m*_L51qc2LizQu(-^_sngg)@vuqD;A z>2mlou{^Wrq@$E2J+aQ$u2j?3>%?sVXNRfm{_~K*=sCCUO9{Y6KZ>OGixJXLKzrd? zcG7ESu}GRh_^95JIbFYa{!kH1mh<35^2CDUHq!3kNPbvl_2MFsy66l-+<051*Tm)Z zJ6)FxY3#lzF*XsvjZ86%4v84*L2jk0&o2=FB+;2lE)sH%)Z2A1byg&C$_{7r!4S!0CqU=8mCH0LzMNzoy`y^cXn6NAP1@4OYBI6g6 zOEDdHLS06)vp}ULPu&jCWfA181eo1p$0exvE$VZXm@&k8gW3?;k`;FXvMq5i%6VkE zblfmS8d2PAtp4=8$oOzl!62Yu@&S<}ILzS38tD_Dkmk6m-i4vV@i2ZBHJ0 zZMoM^q?g617oitZMqNIpXlzfGqVcvWG1x&4xXO^hc{4m!Vo&8m#!k5{b@^+7c=1zp z#R6__AfzgouLg%N1SD?g&-1F3aSGd$D^q{zR%ZT{Wp;#y*a87kbUta~TKlf54TVAP zqeR}yeHw0Vsy)hdZh;iG=NaW|u5c?+Ub{0hWngL4NuJO(?{rE#%I0de8Qb+rDd4mY zF0&cWu5cE~;hinZMV2IjMJ z^>4XMBUF&{!vbvmt80WiP?`?~;$7bHi$Q00$kB_$pnAqXPa&Bxkr4MFtsBDS>gw-G zf{7!oV5;dQ7E=y1N)aEFnRfuF#zwVY+(QZFJXfY0FeIBh#de>#o{6Z z0E$0?(XMRr|Mu%&;wUs8M@n5>2s)UB+~DxF;v5Kyl92uGyr1lJxij!&wPb!=F0F#S z{Oa4S4t4+Tm)s%-BYR^&<50*Bl`Z4!8ydZEFy2?ju(`gK0sIVBTbQ(pDcfod6HM(c5#EREf1hE zDU1j_UJCX)KiwC$V*lsdZVVD7F0utH){NanJfFnJ>1-ZGh>{zx_4GlF$Ll<~W8b=p z5jtSZFl)#^)g!(66U&}8qE=#`8jxdUIU>92(EtE+3fqkUz z4;1&)V&3rje0&6^Lh__f6tbbLvd8_kvFx()hfV012dyL;z;JU-*`V8>cHb=o(MKZ= zY_4Db$#J&+t6jd9iZ%W$TyK9)F`{|%| zUp2y0I1f8iq~akLS!f^LdD)a|FRikXLGSPp>-hv)ibz(Sr?$W=DA6u8P2?){y;8(e z6Q;P)z*5y}hO+e9cyHD7rjzOrm2RFpqqMru-S%i*@qL@>k~z3`MAjo6rA$5-=vxr~ z+3AsBy;9_^9yd9>L3!WAZhwGew>11w)_9=fCS%Ig+EjMX#V`f;W!zc}9LWko`ZSZk3LrW~pO_ZujZ02Yo3~E$Sly3`JcW=IJToqSav0HN zE}J3AD9Men7ymdVK)a7XP)cXaT+m>y+&{De;CpC$wHfwLVF8DjcjZ#I$lRMY^Wcl_%`b8;DMTTP z(yXM|sXti$_HO1YJ$GYq5?dq4uVE3=L#y^z!9Ud@$7yQxzZlGKFQe|y&pO+$bMMy< zd=3?D-)I9jb(I+gn$VU3NFRnTEe72q;IXz&Mc1SwZJxxoYPm+rN?2%m>Lwj6Q*&`K9NunsQcD~IRYolZucm*m`-fhGn5 z@mU(CcydBqMZ^&x!__kwt0yl;%*MrtmONjY2M!ggR97W!W9{eeowRrsr~C7m{fs{65Lp`p zQ#_b#v5WN?UkXW1Pd#Mv&-Z=oq=QSFGE!rR;w{b_4Z6XJ{W^Pg!Wex;xg04 zaASy?a{Kxg8uQ#J0bHjqj-o;?0_=xW(ZN9u|6x%5gzj6P~8QXoxZ^h|X-)jlT#$k2C3ZMp^ zZ+yfBZ~HF{8-e=O{B)$ zdgBb!7K3e_jNR&YXpOjN`2_ydaCC;{Oj5VIW%-&jLAi$9s>YhU;c|=cLtx*7uLx6)t-zi8)L(>bn%UJ zQ+^)*@Jz{6Q4bfX#QA%3qrTqJW`b>>`_SYXGiOD2GoUu;8ECIdzu$VgC##J^98o}^ zy*C(;I9;h(XCo{;7ARRM!Wl#&*|jsS9%a9sSE z=pt2P;{Pnj*w=Ea9prkAIGWO)n+0CJ=m<=?=V9mAIN{i0%%a=(?4mkA^c!ODon<%f z28be#YrS6Y?td8Si5mX`CqL_d1YhFNkbnkpHJWsw_~i8y2rg0xnLA?(8;`uJgzAO; zig;@HG7!lk;E@-?E<~mV)IL9D&pWKk`|9|)pAxD(t54t;t?rR{RJV*cno&(w1JX%B zEzf`^DTbsrnmHLUR{3s@M2U*!x@OR5l2&Rj!i>9Tuhq(JH-jQn+1n&^^(F$kMg_+x znhI+X_K`v3MI;qOU-CDKIIE*|T2wvvK$Jh<22iHH$K!HN@9*O7p?ub$lY}EoEISnT zkiE8i5*2yPb((S-q!j+~+QuiZ>?^{tHz3-|)~z#{v%dM_jkvUS zn%*X~?{f9+R!n@PwbBnU8F2E{z)AOJn~96a zvf~43(#BW@U4Iv#fGGuH#Ow}Kq@x@X6(>w2>`j8$u&>nH^VlPl%| zG=~{(L#Ro$J=_CvHzrFy8Z#;mrK}D|Dbe5i?X6h}OuZXUq6J4hn=AJo$WCj8l&8mN z*XdKH7<~rnGwFKi3rLq?deX2jh|FxFL@fLoSS!3 zlZ~s!mrVhO$L*=HqKbslzQ4LIdyr{)(;BM=jw#+<_yR`Jn0SVXPk!&4awGZKo?aM@ zSU*^DPC1ft`HXJ+_oI9p!J{#?}XG zt7Ohap@s%Mr5`mS)nEzPnKf3wocV>Q`*q;+f8lCVxbeCxqo0Bu{w2q$4ZfNau0 zP9;O%;Ag?iK0wLHbvgdj$)?Ozk{G&u??^wt7uQYesu2$8;Se3~Tcnsuk zpp`E!1UE*z&s4DrTp+%n0*jV%n(rYkI2Bnrsulj2{N`H`PS9t=W< zO}v;~r$;6M3UVsg?;aVk&k+3YgiLD)dl=D5l*K2(A2PQoz7?#N6t|U6GKD_UV_+`3 z)2w@85uH7iFq)Aq4b;T)8bZN2o>~u7>U=_6>+Vs7o?|ym;gd{%rS;k}w|w0SD}0D{ z2aZm#lR)w#8`wsQR>3_spS6?2rlx0hv97?eBxY?fuM_uxNL;qloYXj(wPh>ervvi5 zdr%s#k^0~fcd-37QGIf=dbORj#bhI#(Y$ZFO01H#=rFK+C=hmLKJ06bmBD5 z!R8*}gU$my_>VajMxPS5(Fa^v4(L%ZaQqg&j@vZ9D;Jn3Y2Y)2D%H$2f~_GUM$Zt% z2S7R4hl}S(kwM!7fkALm8@E4H8uE#z9{y71yZ{E5#kcGFV{IlpdDMPN zR7Q0Ke9eABm0;-bfbaCmy0w7!m8*A96|-XFjP;b){M&7HELbSvZn{>o;9|)?9C~ z9VV{iIWbYH>BE@LW_|XZ0DxCGe&{T&E8qXu2>gG<_ih)&e|f@VpTDHxza8vg&%j_aSkb%Gi8ZIW4EG%afo@76U+s+h)WroKlJC_Nf(g zY+U;Oec7gx1mJ45XA3hIzt3}dQPkk$P@qzowsMwfH0Gg2;3)1?J|Sv@!X$TTT>Um# zLSJO4P0fXC1u?BlGk&UO&@|D!^@>O&z&$CiNm|R(_#wbVV&2$(MvD+dF~Q#KArSOZ zV?n0&Ae=csdcjsxyzV2RSCLxfVd$bhg_HO*Y^&G9Q}MH@3b2fNjZ2Zl>51)rQA|Kp zk{=gnUQvEN27atfn@OYvFYnD(B2;cH3>|qc&7W^hXbJ}9SXH~eJE5flxFYv1zMHp& zcY6kr3|#E=_mXfWjn4gWe*?Ggw)OflQNP#73jm1AoZTFgb{ad*VG73lLfJldhzGea zi9LQncJ(vGWBq>1j$RIt6|lUbx8Uoi^9A(dokdNJ72qqfz647A>lq$QT60KIJje*1 zUQv`#EWxxpG=sU(R6@G+z_6SsAT{(dH&nTB*vxMaXv?Z^tOr%HG_>ogC8pveAa5c7 zH2b6T%Q&HjR%o{P{GS;d6PLfF5eMf#M5}i5o7>p5PGgLW=x*}A#PN`#1$vh&_YV$9 zHpQ`o2sn>lE0ns!rzGnK{LKy}q!^>7Bc(3ubFGv7F#opbNh6MgJ1m2aZ%FXmMA zh%H?Q!2ab@p&aXU$H~t z=9r}#oWDV{en-IhfjP-Y=YCU@)C?kQ>AC?4iM__0pl#CWI}}!SfRq$*Z-_M6DHfF3?vWbat$Y0tLFNykq3Psk}qmXr*R#s*8jv zy4rzWyez^U`uU$b^)IB)9-E^pLFwP6gU`^3wXXyJVhRqu{%YsH{M-#95pXHbY*ndu zV5y%d%#*98xWI3vz>?}S*ioE2fF-pKCh6kdKT$>I47XH`Y6;Qx8=?ZAP*`2J>989I zu;<1)g<0E)TYWeW7V8Mrj+xyLIAmFF`N}=ouvBTGKbH%eQxlJ%N_7xVteS`y<6Wo{lTK0Q-!G-ECoMk<3ygS*h=O?yj$5%ID!kqKqrDQF`9jgvDU>wX9=S%$6+h@a@{Oyh%~jgByhopPe7o_8 z! zk-nPLn-b}G!R_Ib{!M7a-=suaw`66NJiKtawUpFmbCr|!E8gRyR@_1Lf#Z8^j8v)n zPR?tYNfD>%cYXcCr2+y;3loYpHKX;uwPxa&kmtJpps8kUg-I^@bX<52v`T1330(=A z7yPIi@m%GFs&u&HCS4kHs?VNAoB(Q@iyFLZNZBM@&W94wDbCl-gw~)mU%4}cy(hH+ zGuw@Gc$pZIcOmz?=q!+pQwEJ+0_g9`* zXDZI;onIt_if@>7D`)f%g=a}AGbqipv$zz?@@DFds-|UHWuCmOc&XFsmiXQ)YthF_ zw~^6q@@1U>J0Xtk!M>zdHq+CR{>MFBcpJ3WHJeH&APWCJT8p&s84)0)`qP=U-R(Ba zpTcAeXmB)N$|_P|9&Y zjga}!3H$zP$Zjk2V2~QRY#=PW@xg(#%&kEwOmH?s0wT9&E8)=f$y)L=#7i03bN${H z3ZZw)ciVSqW;$yO>Svp8B`PhQsP+bz)f}a&lRZ0kwh*Hdu#SQ6(2^xQr-t*}nlza? zFeR)J@1u);P8w4nS%FBf+g$C}UM&8Pf;&S4Jy~oFzD%_ph}QCX51a_%JIYJ$$1umZXvx)Icm`+f3?19n(YzLL zc6_))4$p9Wm=GiPEPY%RtZ(KZYvN%cT_8cTP8J%4v9N(GJYRe&b+c@Qc11)n=N*Lw zP&3iLscqIDz-nrAV)9=uJoIAO!49`H;`ZhQ@7<$R{E(V0glwx;)e8ynd$ZS8LS9Ln z{M-nWYkZpMr?d(CRz2ftvlrfvkNn3>HXrBoup#}IJPxl^|76yr4Y*?j_6}Kg-q4*( zd!by+nhi#VqUjFC^PvaJsrHD&7N4Nv3$>Xc%NBs-npK^7@R#f6n}H|)cxT@hb`va? zBIyc6R9^C%eNa1%eH{6l{joc4J0y7AGNzN=G#z?=>cPj6`jyi7Nf3SeAGJYf;Us~h zVH2}OHfYAs6W8pA$^JpvMIbwF`ODtO_YJBOS<2~KLGxPkv0JWH0}F(WyMo2aYQF10q&NU=QWtAck5u(bDe zLU!cm@6QH`;}@S*BM=%J&#QOykFM4Rt>&1#shgxqkPa__Vkvd(A_3I(u>@RcUFC8c zztSc|`Kf(6ifrK(W)fOi`4eO>L>+Z+Q4baMVrtC9bM6cCKNT{rKT#=SsJ^*()Wp=I z(8n3=Bt)*!A}x8F{2g1mubCCK@IvH^1ldhwqeY1_yLWKJISb?9hRp{lqW!!67r-6n zM{uaXihoMHbeX31C9*`z&XZ#MCgrz9aYc{BUO zyy!&27uE597^%Yh;Zm|p26*-8X#HUAW&E6ILho>#-q1yV@r}52a9FFb(Ca#pBj6yg zBJfSlO9cA@N{0MJkE&;aLYS|L0(H%sr^#WN3;i{?*ojwpsWP{DOSt)B+q!BQvYP#=K2%^X#{fi(j}+z`YXzi z%oEK4$jw=DuW2#rh7_i$hxrIo!?U(W*Ya z^qX+cJMFtn>f3b*RjS9Wc``1)(4w7z2aWq@uvN?xg~+=5^!w+`v}IaCc-(DYTwhy- zn_yNOC4zRsIJ2wgQnLz5*;yq`mVvHYDzaQN@mv87M!m!=-7$H9T08g3SaS}Ec*&Fa zQFFVr)kgwGLbhw4vBuZ6)1=kTD{00g7?S##|n zJsP3t2>;g~xs%4|Iw=6-FB$yr*GboYah-m<6X}!X*o*KlGZz|n<=jg?#Ily1jb(Wg z`=F8s$0XCaTW&Z*Vt_w}cZy-QqDCJ{>6;1V$*V6;&McS^D{r ztH0}8;cUyIb5)1{sYbUyS*HPAr*FN^7}&Fi-SnEV(x>@$?@$Ob%e^WM=7;%NmCVTr zL{{Jvv+aDomKsO4dcKNWA5|a7Q`1*1goJl?S_?dOT%uQ@zu%TdzE%^onR!Y1(%d+a zpml0%v{ahvWQ(l`S?3m$w=&Gf8BiIk*JHNxJ?Gx$7JD%Q%(g8~*bD!5@oqp%acBtR zxU5CdY6U#Gg;*P;hHMDX-VwG5nZHn1s1B>JdqO+4#_L_osU|iosh>Xop4kZxDdmrE zwM2qGF@THi;MVQ-?2hEf^ZjXBXG0Cf!vYb0iasWwuP%6m*YnRSP=zI9?YXs{MKzlo z4$~=n6a0x*Lsqtgl~sVrGvl(cPY0HpI`vnz|; z;8Y=VPG^Y3U+AqLuMOv-qONx#t-)XIQ+wk-5{%^ij`PwMMN~N|8MASct@MZRAr^E` zE}tn{^qSYNpAk4EN0DW9s;T)s}T&coc^ zCPiu~y?oRsUt%GGXH0douc4%AGu}1hDBv5aICd9T3`(if@rmbFajSAWiY!yoj?2B@ z%d#H*^yw$#DF!>F&?rW?_z9_=sX_v`;Hz2tlj-8UH>L<%dk{@}NN2;wy$EEm zLc#18^?i#ro&9Dd<6OMRhW^ULHli;ZP~Wh)=o^a>KWF-UN zxW6Jb!F3ijW}}>7vo*GpudF0EkS|tDQ$^H0z2l))h81d0p~fGU=A5-iQoomtnvuCR z(7ZpD-Vp(ojpF^WCaBao-U#irMYah`mYXD)aB>S@h0EzCLi;@)dpFysL>N19oG0YS zFMS_d`w4IR45|8+i28Qz5>}(Ur)X2|^*B7un%;Vi!}A(%;nw#Vif(osxM54O^66Op z*!|*kFEH9ZEv&iKA$^+*(MGMnt*a_+njB7z=Y*l>an+YnV4I}#*2pXJY0%C4j;lOJ z&MJ`fpN6Ow&S=4Loyra|+g>!NQde^XEE5~?3Am_OPdPrxSaAPUi4*Wvsc^M}hS$(& z1DRYneho_)3N?$odkbVM<_4Ym$k17n-$g)6W;DcxN8#TOiPJz9H$8QS(c$f3x)-~6 z+!)yx-M&B}4+JP&`;E%N10$4G{`qr#|HWxzQ4zw1DYZPPS=a^0kRf@pnV&^*Sjtx!DLVDK*L6ciMRtXD0J^;u|l8b*Sm?5=4d~ z42wN7INwR)_yw^Jx27EICbYjZJ9r9BL-<|t9l(Z8Bg!4%d6z*9*m!lYk@G!~ft05D n&qRDo%Uawi*u$G`hj&u6`p)g$x=dMU`_. - -.. code-block:: none - - Running setup.py install for ujson ... error - ERROR: Complete output from command 'C:\Users\User\Desktop\HABapp\habapp\Scripts\python.exe' -u -c 'import setuptools, tokenize;__file__='"'"'C:\\Users\\User\\AppData\\Local\\Temp\\pip-install-4y0tobjp\\ujson\\setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record 'C:\Users\User\AppData\Local\Temp\pip-record-6t2yo712\install-record.txt' --single-version-externally-managed --compile --install-headers 'C:\Users\User\Desktop\HABapp\habapp\include\site\python3.7\ujson': - ERROR: Warning: 'classifiers' should be a list, got type 'filter' - running install - running build - running build_ext - building 'ujson' extension - error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools": https://visualstudio.microsoft.com/downloads/ - ---------------------------------------- + Windows:: -Error message while installing ruamel.yaml -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Scripts\activate -.. code-block:: none +#. Run the following command in your activated virtual environment:: - _ruamel_yaml.c:4:10: fatal error: Python.h: No such file or directory + python3 -m pip install --upgrade habapp -Run the follwing command to fix it:: +#. Start HABApp - sudo apt install python3-dev +#. Observe the logs for errors in case there were changes Autostart after reboot ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -146,6 +133,36 @@ It is now possible to start, stop, restart and check the status of HABApp with:: sudo systemctl restart habapp.service sudo systemctl status habapp.service + +Error message while installing ujson +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Under windows the installation of ujson may throw the following error but the download link is not working. +Several working alternatives can be found `here `_. + +.. code-block:: none + + Running setup.py install for ujson ... error + ERROR: Complete output from command 'C:\Users\User\Desktop\HABapp\habapp\Scripts\python.exe' -u -c 'import setuptools, tokenize;__file__='"'"'C:\\Users\\User\\AppData\\Local\\Temp\\pip-install-4y0tobjp\\ujson\\setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record 'C:\Users\User\AppData\Local\Temp\pip-record-6t2yo712\install-record.txt' --single-version-externally-managed --compile --install-headers 'C:\Users\User\Desktop\HABapp\habapp\include\site\python3.7\ujson': + ERROR: Warning: 'classifiers' should be a list, got type 'filter' + running install + running build + running build_ext + building 'ujson' extension + error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools": https://visualstudio.microsoft.com/downloads/ + ---------------------------------------- + +Error message while installing ruamel.yaml +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: none + + _ruamel_yaml.c:4:10: fatal error: Python.h: No such file or directory + +Run the follwing command to fix it:: + + sudo apt install python3-dev + ---------------------------------- Docker ---------------------------------- diff --git a/_doc/interface_openhab.rst b/_doc/interface_openhab.rst index a20fab80..29b8b924 100644 --- a/_doc/interface_openhab.rst +++ b/_doc/interface_openhab.rst @@ -4,6 +4,37 @@ openHAB ###################################### +************************************** +Additional configuration +************************************** + +openHAB 2 +====================================== +For openHAB2 there is no additional configuration needed. + +openHAB 3 +====================================== +For optimal performance it is recommended to use Basic Auth (available from openHAB 3.1 M3 on). +It can be enabled through GUI or through textual configuration. + +Textual configuration +-------------------------------------- +The settings are in the ``runtime.cfg``. +Remove the ``#`` before the entry to activate it. + +.. code-block:: text + + ################ REST API ################### + org.openhab.restauth:allowBasicAuth=true + + +GUI +-------------------------------------- +It can be enabled through the gui in ``settings`` -> ``API Security`` -> ``Allow Basic Authentication``. + +.. image:: /images/openhab_api_config.png + + ************************************** Interaction with a openHAB diff --git a/_doc/parameters.rst b/_doc/parameters.rst index 11e510ff..14fafef5 100644 --- a/_doc/parameters.rst +++ b/_doc/parameters.rst @@ -89,13 +89,12 @@ Example from pathlib import Path import HABApp + from HABApp.core.files.folders import add_folder from HABApp.parameters.parameters import _PARAMETERS - _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} + from HABApp.parameters.parameter_files import PARAM_PREFIX - # Patch values so we don't get errors - HABApp.config.CONFIG.directories.rules = Path('/my_rules/') - HABApp.config.CONFIG.directories.config = Path('/my_config/') - HABApp.config.CONFIG.directories.param = Path('/my_param/') + add_folder(PARAM_PREFIX, Path('/params'), 0) + _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} # hide import HABApp diff --git a/_doc/requirements.txt b/_doc/requirements.txt index 4c5b9cbf..c51cd0e0 100644 --- a/_doc/requirements.txt +++ b/_doc/requirements.txt @@ -1,2 +1,4 @@ +# Packages required to build the documentation +sphinx sphinx-autodoc-typehints sphinx_rtd_theme diff --git a/_doc/rule.rst b/_doc/rule.rst index 9515213a..373c10b2 100644 --- a/_doc/rule.rst +++ b/_doc/rule.rst @@ -161,7 +161,8 @@ There are convenience Filters (e.g. :class:`~HABApp.core.events.ValueUpdateEvent Scheduler ------------------------------ With the scheduler it is easy to call functions in the future or periodically. -Do not use `time.sleep` but rather :meth:`~HABApp.Rule.run_in`. +Do not use `time.sleep` but rather `self.run.at`. +Another very useful function is `self.run.countdown` as it can simplify many rules! .. list-table:: :widths: auto @@ -170,45 +171,52 @@ Do not use `time.sleep` but rather :meth:`~HABApp.Rule.run_in`. * - Function - Description - * - :meth:`~HABApp.Rule.run_soon` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.soon` - Run the callback as soon as possible (typically in the next second). - * - :meth:`~HABApp.Rule.run_in` - - Run the callback in x seconds. + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.at` + - Run the callback in x seconds or at a specified time. - * - :meth:`~HABApp.Rule.run_at` - - Run a function at a specified date_time + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.countdown` + - Run a function after a time has run down - * - :meth:`~HABApp.Rule.run_every` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.every` - Run a function periodically - * - :meth:`~HABApp.Rule.run_minutely` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.every_minute` - Run a function every minute - * - :meth:`~HABApp.Rule.run_hourly` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.every_hour` - Run a function every hour - * - :meth:`~HABApp.Rule.run_daily` - - Run a function every day - - * - :meth:`~HABApp.Rule.run_on_every_day` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_every_day` - Run a function at a specific time every day - * - :meth:`~HABApp.Rule.run_on_workdays` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_workdays` - Run a function at a specific time on workdays - * - :meth:`~HABApp.Rule.run_on_weekends` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_weekends` - Run a function at a specific time on weekends - * - :meth:`~HABApp.Rule.run_on_day_of_week` + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_day_of_week` - Run a function at a specific time on specific days of the week - * - :meth:`~HABApp.Rule.run_on_sun` - - Run a function in relation to the sun (e.g. Sunrise, Sunset) + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_sun_dawn` + - Run a function on dawn + + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_sunrise` + - Run a function on sunrise + + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_sunset` + - Run a function on sunset + + * - :meth:`~HABApp.rule.habappscheduler.HABAppScheduler.on_sun_dusk` + - Run a function on dusk + All functions return an instance of ScheduledCallbackBase -.. autoclass:: HABApp.rule.scheduler.base.ScheduledCallbackBase +.. autoclass:: HABApp.rule.habappscheduler.HABAppScheduler :members: diff --git a/_doc/rule_examples.rst b/_doc/rule_examples.rst index 58e91c8d..9bfdb4b4 100644 --- a/_doc/rule_examples.rst +++ b/_doc/rule_examples.rst @@ -55,6 +55,54 @@ Get an even when the item is constant for 5 and for 10 seconds. # hide +Turn something off after movement +------------------------------------------ +Turn a device off 30 seconds after one of the movement sensors in a room signals movement. + + +.. execute_code:: + :hide_output: + + # hide + import time, HABApp + from tests 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) + # hide + import HABApp + from HABApp.core.items import Item + from HABApp.core.events import ValueUpdateEvent + + class MyCountdownRule(HABApp.Rule): + def __init__(self): + super().__init__() + + self.countdown = self.run.countdown(30, self.switch_off) + self.device = Item.get_item('my_device') + + self.movement1 = Item.get_item('movement_sensor1') + self.movement1.listen_event(self.movement, ValueUpdateEvent) + + self.movement2 = Item.get_item('movement_sensor2') + self.movement2.listen_event(self.movement, ValueUpdateEvent) + + def movement(self, event: ValueUpdateEvent): + if self.device != 'ON': + self.device.post_value('ON') + + self.countdown.reset() + + def switch_off(self): + self.device.post_value('OFF') + + MyCountdownRule() + # hide + runner.tear_down() + # hide + 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. @@ -72,17 +120,17 @@ to the mobile device (see :doc:`Avanced Usage ` for more informa # hide import HABApp - from HABApp.core.events.habapp_events import HABAppError + from HABApp.core.events.habapp_events import HABAppException class NotifyOnError(HABApp.Rule): def __init__(self): super().__init__() # Listen to all errors - self.listen_event('HABApp.Errors', self.on_error, HABAppError) + self.listen_event('HABApp.Errors', self.on_error, HABAppException) - def on_error(self, error_event: HABAppError): - msg = event.to_str() if isinstance(event, HABAppError) else event + def on_error(self, error_event: HABAppException): + msg = event.to_str() if isinstance(event, HABAppException) else event print(msg) NotifyOnError() @@ -92,7 +140,7 @@ to the mobile device (see :doc:`Avanced Usage ` for more informa class FaultyRule(HABApp.Rule): def __init__(self): super().__init__() - self.run_soon(self.faulty_function) + self.run.soon(self.faulty_function) def faulty_function(self): 1 / 0 diff --git a/_doc/tips.rst b/_doc/tips.rst index 19c5fcb2..935e6587 100644 --- a/_doc/tips.rst +++ b/_doc/tips.rst @@ -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:`_ref_textual_thing_config_metadata`. +It's also possible with textual thing configuration to add it as :ref:`metadata<_ref_textual_thing_config_metadata>`. diff --git a/_doc/util.rst b/_doc/util.rst index f1f320bb..a9a59cbe 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -45,6 +45,34 @@ It behaves like the standard python function except that it will ignore ``None`` .. autofunction:: HABApp.util.functions.max +rgb_to_hsb +^^^^^^^^^^^^^^^^^^ + +Converts a rgb value to hsb color space + +.. execute_code:: + :hide_output: + + from HABApp.util.functions import rgb_to_hsb + + print(rgb_to_hsb(224, 201, 219)) + +.. autofunction:: HABApp.util.functions.rgb_to_hsb + + +hsb_to_rgb +^^^^^^^^^^^^^^^^^^ + +Converts a hsb value to the rgb color space + +.. execute_code:: + :hide_output: + + from HABApp.util.functions import hsb_to_rgb + + print(hsb_to_rgb(150, 40, 100)) + +.. autofunction:: HABApp.util.functions.hsb_to_rgb CounterItem diff --git a/conf/rules/async_rule.py b/conf/rules/async_rule.py index c8c367b0..59d68b57 100644 --- a/conf/rules/async_rule.py +++ b/conf/rules/async_rule.py @@ -8,7 +8,7 @@ class AsyncRule(HABApp.Rule): def __init__(self): super().__init__() - self.run_soon(self.async_func) + self.run.soon(self.async_func) async def async_func(self): await asyncio.sleep(2) diff --git a/conf/rules/mqtt_rule.py b/conf/rules/mqtt_rule.py index 0ac95022..8b42868b 100644 --- a/conf/rules/mqtt_rule.py +++ b/conf/rules/mqtt_rule.py @@ -5,12 +5,13 @@ from HABApp.core.events import ValueUpdateEvent from HABApp.mqtt.items import MqttItem + class ExampleMqttTestRule(HABApp.Rule): def __init__(self): super().__init__() - self.run_every( - time=datetime.timedelta(seconds=10), + self.run.every( + start_time=datetime.timedelta(seconds=10), interval=datetime.timedelta(seconds=20), callback=self.publish_rand_value ) diff --git a/conf/rules/openhab_to_mqtt_rule.py b/conf/rules/openhab_to_mqtt_rule.py index 4c3c6390..1aa447ce 100644 --- a/conf/rules/openhab_to_mqtt_rule.py +++ b/conf/rules/openhab_to_mqtt_rule.py @@ -18,8 +18,8 @@ def __init__(self): def process_update(self, event): assert isinstance(event, ItemStateEvent) - print( f'/openhab/{event.name} <- {event.value}') - self.mqtt.publish( f'/openhab/{event.name}', str(event.value)) + print(f'/openhab/{event.name} <- {event.value}') + self.mqtt.publish(f'/openhab/{event.name}', str(event.value)) ExampleOpenhabToMQTTRule() diff --git a/conf/rules/time_rule.py b/conf/rules/time_rule.py index 5848692c..5914c6c8 100644 --- a/conf/rules/time_rule.py +++ b/conf/rules/time_rule.py @@ -1,27 +1,21 @@ -import datetime -import time +from datetime import time, timedelta, datetime +from HABApp import Rule -import HABApp - -class MyRule(HABApp.Rule): +class MyRule(Rule): def __init__(self): super().__init__() - self.run_on_day_of_week( - datetime.time(14, 34, 20), - weekdays=['Mo'], - callback=self.run_mondays - ) + self.run.on_day_of_week(time=time(14, 34, 20), weekdays=['Mo'], callback=self.run_mondays) - self.run_every(datetime.timedelta(seconds=5), 3, self.run_every_3s, 'arg 1', asdf='kwarg 1') + self.run.every(timedelta(seconds=5), 3, self.run_every_3s, 'arg 1', asdf='kwarg 1') - self.run_on_workdays(datetime.time(15, 00), self.run_workdays) - self.run_on_weekends(datetime.time(15, 00), self.run_weekends) + self.run.on_workdays(time(15, 00), self.run_workdays) + self.run.on_weekends(time(15, 00), self.run_weekends) def run_every_3s(self, arg, asdf = None): - print( f'run_ever_3s: {time.time():.3f} : {arg}, {asdf}') + print(f'run_ever_3s: {datetime.now().replace(microsecond=0)} : {arg}, {asdf}') def run_mondays(self): print('Today is monday!') @@ -30,7 +24,7 @@ def run_workdays(self): print('Today is a workday!') def run_weekends(self): - print('Today is weekend!') + print('Finally weekend!') MyRule() diff --git a/conf_testing/lib/HABAppTests/_rest_patcher.py b/conf_testing/lib/HABAppTests/_rest_patcher.py index e2f54409..5dc4d516 100644 --- a/conf_testing/lib/HABAppTests/_rest_patcher.py +++ b/conf_testing/lib/HABAppTests/_rest_patcher.py @@ -16,10 +16,10 @@ def shorten_url(url: str): class RestPatcher: - def __init__(self, name): + def __init__(self, name: str): + self.name = name + self.logged_name = False self.log = logging.getLogger('HABApp.Rest') - self.log.debug('') - self.log.debug(f'{name}:') def wrap(self, to_call): async def resp_wrap(*args, **kwargs): @@ -32,6 +32,12 @@ async def resp_wrap(*args, **kwargs): if kwargs.get('data') is not None: out = f' "{kwargs["data"]}"' + # Log name when we log the first message + if not self.logged_name: + self.logged_name = True + self.log.debug('') + self.log.debug(f'{self.name}:') + self.log.debug( f'{resp.request_info.method:^6s} {shorten_url(resp.request_info.url)} ({resp.status}){out}' ) diff --git a/conf_testing/lib/HABAppTests/test_base.py b/conf_testing/lib/HABAppTests/test_base.py index 245ca36e..146b9782 100644 --- a/conf_testing/lib/HABAppTests/test_base.py +++ b/conf_testing/lib/HABAppTests/test_base.py @@ -3,7 +3,7 @@ import typing import HABApp -from HABApp.core.events.habapp_events import HABAppError +from HABApp.core.events.habapp_events import HABAppException from ._rest_patcher import RestPatcher log = logging.getLogger('HABApp.Tests') @@ -68,7 +68,7 @@ def __init__(self): self.config = TestConfig() # we have to chain the rules later, because we register the rules only once we loaded successfully. - self.run_in(2, self.__execute_run) + self.run.at(2, self.__execute_run) # collect warnings and infos self.listen_event(HABApp.core.const.topics.WARNINGS, self.__warning) @@ -83,7 +83,7 @@ def __warning(self, event: str): def __error(self, event): self.__errors += 1 - msg = event.to_str() if isinstance(event, HABAppError) else event + msg = event.to_str() if isinstance(event, HABAppException) else event for line in msg.splitlines(): log.error(line) @@ -120,7 +120,8 @@ def run_tests(self, result: TestResult): self.tests_started = True try: - self.set_up() + with RestPatcher(self.__class__.__name__ + '.' + 'set_up'): + self.set_up() except Exception as e: log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') for line in HABApp.core.wrapper.format_exception(e): @@ -136,7 +137,8 @@ def run_tests(self, result: TestResult): # TEAR DOWN try: - self.tear_down() + with RestPatcher(self.__class__.__name__ + '.' + 'tear_down'): + self.tear_down() except Exception as e: log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') for line in HABApp.core.wrapper.format_exception(e): diff --git a/conf_testing/rules/openhab_bugs.py b/conf_testing/rules/openhab_bugs.py index fe256629..50100c07 100644 --- a/conf_testing/rules/openhab_bugs.py +++ b/conf_testing/rules/openhab_bugs.py @@ -1,3 +1,6 @@ +# HABApp: +# depends on: ['asdf'] + import time from HABApp.openhab.connection_handler.func_async import async_create_item, async_get_item, ItemNotFoundError, \ diff --git a/conf_testing/rules/test_habapp.py b/conf_testing/rules/test_habapp.py index bfc7a652..1b73bb3d 100644 --- a/conf_testing/rules/test_habapp.py +++ b/conf_testing/rules/test_habapp.py @@ -74,7 +74,7 @@ def trigger_event(self): self.watch_item = Item.get_create_item(get_random_name()) listener = self.watch_item.listen_event(self.check_event, ValueUpdateEvent) - self.run_in( + self.run.at( 1, HABApp.core.EventBus.post_event, self.watch_item.name, ValueUpdateEvent(self.watch_item.name, 123) ) diff --git a/conf_testing/rules/test_openhab_groups.py b/conf_testing/rules/test_openhab_groups.py new file mode 100644 index 00000000..55220195 --- /dev/null +++ b/conf_testing/rules/test_openhab_groups.py @@ -0,0 +1,44 @@ +from HABApp.openhab.items import SwitchItem, GroupItem +from HABAppTests import ItemWaiter, TestBaseRule, get_random_name + + +class TestOpenhabGroupFunction(TestBaseRule): + + def __init__(self): + super().__init__() + + self.group = get_random_name() + self.item1 = get_random_name() + self.item2 = get_random_name() + + self.add_test('Group Update', self.test_group_update) + + def set_up(self): + self.oh.create_item('Switch', self.item1) + self.oh.create_item('Switch', self.item2) + self.oh.create_item('Group', self.group, group_type='Switch', group_function='OR') + + def tear_down(self): + self.oh.remove_item(self.item1) + self.oh.remove_item(self.item2) + self.oh.remove_item(self.group) + + def test_group_update(self): + item1 = SwitchItem.get_item(self.item1) + item2 = SwitchItem.get_item(self.item2) + group = GroupItem(self.group) + + with ItemWaiter(group) as waiter: + waiter.wait_for_state(None) + + item1.post_value('ON') + waiter.wait_for_state('ON') + + item1.post_value('OFF') + waiter.wait_for_state('OFF') + + item2.post_value('ON') + waiter.wait_for_state('ON') + + +TestOpenhabGroupFunction() diff --git a/conf_testing/rules/test_openhab_item_funcs.py b/conf_testing/rules/test_openhab_item_funcs.py index 87cd06f5..f3c3b4d2 100644 --- a/conf_testing/rules/test_openhab_item_funcs.py +++ b/conf_testing/rules/test_openhab_item_funcs.py @@ -53,12 +53,10 @@ def __init__(self): } ) - def add_func_test(self, cls, params: set): # -> SwitchItem self.add_test(str(cls).split('.')[-1][:-2], self.test_func, cls, params) - def test_func(self, item_type, test_params): # create a nice name for the tmp item diff --git a/conf_testing/rules/test_scheduler.py b/conf_testing/rules/test_scheduler.py index fc386de7..2dcb65b9 100644 --- a/conf_testing/rules/test_scheduler.py +++ b/conf_testing/rules/test_scheduler.py @@ -11,10 +11,10 @@ class TestScheduler(TestBaseRule): def __init__(self): super().__init__() - f = self.run_on_sun('sunrise', self.sunrise_func) - print(f'Sunrise: {f.get_next_call()}') - f = self.run_on_sun('sunset', self.sunset_func) - print(f'Sunset: {f.get_next_call()}') + f = self.run.on_sunrise(self.sunrise_func) + print(f'Sunrise: {f.get_next_run()}') + f = self.run.on_sunset(self.sunset_func) + print(f'Sunset : {f.get_next_run()}') def sunrise_func(self): print('sunrise!') diff --git a/mypy.ini b/mypy.ini index de525e2f..7d211204 100644 --- a/mypy.ini +++ b/mypy.ini @@ -27,9 +27,6 @@ ignore_missing_imports = True [mypy-bidict] ignore_missing_imports = True -[mypy-astral] -ignore_missing_imports = True - [mypy-voluptuous] ignore_missing_imports = True diff --git a/readme.md b/readme.md index 3236ad2d..427c63de 100644 --- a/readme.md +++ b/readme.md @@ -38,7 +38,7 @@ class ExampleMqttTestRule(HABApp.Rule): def __init__(self): super().__init__() - self.run_every( + self.run.every( time=datetime.timedelta(seconds=60), interval=datetime.timedelta(seconds=30), callback=self.publish_rand_value @@ -102,6 +102,35 @@ MyOpenhabRule() ``` # Changelog +#### 0.30.0 (02.05.2021) + +Attention: +- No more support for python 3.6! +- Migration of rules is needed! + +Changelog +- Switched to Apache2.0 License +- Fix DateTime string parsing for OH 3.1 (#214) +- State of Groupitem gets set correctly +- About ~50% performance increase for async calls in rules +- 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. + 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 +- Renamed HABAppError to HABAppException +- Some Doc improvements + +Migration of rules: +- Search for ``self.run_`` and replace with ``self.run.`` +- Search for ``self.run.in`` and replace with ``self.run.at`` +- Search for ``.get_next_call()`` and replace with ``.get_next_run()`` (But make sure it's a scheduled job) +- Search for ``HABAppError`` and replace with ``HABAppException`` + + #### 0.20.2 (07.04.2021) - Added HABApp.util.functions with min/max - Reworked small parts of the file watcher diff --git a/requirements.txt b/requirements.txt index 7fae3485..6322129d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,22 @@ -aiohttp-sse-client==0.2.0 +# Packages required for HABApp +aiohttp-sse-client==0.2.1 aiohttp==3.7.4 -astral==2.2 bidict==0.21.2 +eascheduler==0.1.1 easyco==0.2.3 paho-mqtt==1.5.1 +pendulum==2.1.2 pydantic==1.8.1 -pytz==2020.5 stackprinter==0.2.5 tzlocal==2.1 voluptuous==0.12.1 watchdog==2.0.2 ujson==4.0.2 -# Backports -dataclasses==0.8;python_version<"3.7" +# Packages to run source tests +pytest==6.2.3 +pytest-asyncio==0.15.1 +mock;python_version<"3.8" + +# Packages for tox tests +flake8==3.9.1 diff --git a/requirements_setup.txt b/requirements_setup.txt new file mode 100644 index 00000000..6b283f1e --- /dev/null +++ b/requirements_setup.txt @@ -0,0 +1,16 @@ +aiohttp-sse-client==0.2.1 +aiohttp==3.7.4 +bidict==0.21.2 +eascheduler==0.1.1 +easyco==0.2.3 +paho-mqtt==1.5.1 +pendulum==2.1.2 +pydantic==1.8.1 +stackprinter==0.2.5 +tzlocal==2.1 +voluptuous==0.12.1 +watchdog==2.0.2 +ujson==4.0.2 + +# Backports +dataclasses==0.8;python_version<"3.7" diff --git a/setup.py b/setup.py index de05cf14..421e27a4 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,13 @@ import typing from pathlib import Path -import setuptools # type: ignore +from setuptools import find_packages, setup # Load version number without importing HABApp def load_version() -> str: version: typing.Dict[str, str] = {} - with open("HABApp/__version__.py") as fp: + with open("src/HABApp/__version__.py") as fp: exec(fp.read(), version) assert version['__version__'], version return version['__version__'] @@ -15,7 +15,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.txt') + req_file = Path(__file__).with_name('requirements_setup.txt') if not req_file.is_file(): return [''] @@ -35,7 +35,7 @@ def load_req() -> typing.List[str]: with readme.open("r", encoding='utf-8') as fh: long_description = fh.read() -setuptools.setup( +setup( name="HABApp", version=__version__, author="spaceman_spiff", @@ -54,7 +54,8 @@ def load_req() -> typing.List[str]: 'Documentation': 'https://habapp.readthedocs.io/', 'GitHub': 'https://github.com/spacemanspiff2007/HABApp', }, - packages=setuptools.find_packages(exclude=['tests*']), + package_dir={'': 'src'}, + packages=find_packages('src', exclude=['tests*']), install_requires=load_req(), classifiers=[ "Development Status :: 4 - Beta", diff --git a/HABApp/__cmd_args__.py b/src/HABApp/__cmd_args__.py similarity index 100% rename from HABApp/__cmd_args__.py rename to src/HABApp/__cmd_args__.py diff --git a/HABApp/__do_setup__.py b/src/HABApp/__do_setup__.py similarity index 72% rename from HABApp/__do_setup__.py rename to src/HABApp/__do_setup__.py index 345e4b86..66361a39 100644 --- a/HABApp/__do_setup__.py +++ b/src/HABApp/__do_setup__.py @@ -1,9 +1,3 @@ -# ----------------------------------------------------------------------------- -# setup astral -# ----------------------------------------------------------------------------- -import astral -astral._LOCATION_INFO = '' # Remove City list because we don't need it and it is rather big - # ----------------------------------------------------------------------------- # setup pydantic # ----------------------------------------------------------------------------- diff --git a/HABApp/__init__.py b/src/HABApp/__init__.py similarity index 94% rename from HABApp/__init__.py rename to src/HABApp/__init__.py index 753e22da..aff36d8e 100644 --- a/HABApp/__init__.py +++ b/src/HABApp/__init__.py @@ -1,23 +1,23 @@ -# 1. Static stuff -from .__version__ import __version__ - -# 2. Setup used libraries -import HABApp.__do_setup__ - -# 3. User configuration -import HABApp.config - -# 4. Core features -import HABApp.core - -# 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 - -from HABApp.config import CONFIG +# 1. Static stuff +from .__version__ import __version__ + +# 2. Setup used libraries +import HABApp.__do_setup__ + +# 3. User configuration +import HABApp.config + +# 4. Core features +import HABApp.core + +# 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 + +from HABApp.config import CONFIG diff --git a/HABApp/__main__.py b/src/HABApp/__main__.py similarity index 88% rename from HABApp/__main__.py rename to src/HABApp/__main__.py index 9ac23541..43f97ddf 100644 --- a/HABApp/__main__.py +++ b/src/HABApp/__main__.py @@ -1,52 +1,52 @@ -import asyncio -import logging -import signal -import sys -import traceback -import typing - -import HABApp -from HABApp.__cmd_args__ import parse_args - - -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() - - log = logging.getLogger('HABApp') - - try: - app = HABApp.runtime.Runtime() - app.startup(config_folder=cfg_folder) - - 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) - - # start workers - try: - HABApp.core.const.loop.run_until_complete(app.get_async()) - except asyncio.CancelledError: - pass - except HABApp.config.InvalidConfigException: - pass - except Exception as e: - for line in traceback.format_exc().splitlines(): - log.error(line) - print(e) - return str(e) - finally: - # Sleep to allow underlying connections of aiohttp to close - # https://aiohttp.readthedocs.io/en/stable/client_advanced.html#graceful-shutdown - HABApp.core.const.loop.run_until_complete(asyncio.sleep(1)) - HABApp.core.const.loop.close() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +import asyncio +import logging +import signal +import sys +import traceback +import typing + +import HABApp +from HABApp.__cmd_args__ import parse_args + + +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() + + log = logging.getLogger('HABApp') + + try: + app = HABApp.runtime.Runtime() + + 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) + + # start workers + try: + asyncio.ensure_future(app.start(cfg_folder)) + HABApp.core.const.loop.run_forever() + except asyncio.CancelledError: + pass + except HABApp.config.InvalidConfigException: + pass + except Exception as e: + for line in traceback.format_exc().splitlines(): + log.error(line) + print(e) + return str(e) + finally: + # Sleep to allow underlying connections of aiohttp to close + # https://aiohttp.readthedocs.io/en/stable/client_advanced.html#graceful-shutdown + HABApp.core.const.loop.run_until_complete(asyncio.sleep(1)) + HABApp.core.const.loop.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py new file mode 100644 index 00000000..e187e0aa --- /dev/null +++ b/src/HABApp/__version__.py @@ -0,0 +1 @@ +__version__ = '0.30.0' diff --git a/HABApp/config/__init__.py b/src/HABApp/config/__init__.py similarity index 98% rename from HABApp/config/__init__.py rename to src/HABApp/config/__init__.py index 0598134b..6192a012 100644 --- a/HABApp/config/__init__.py +++ b/src/HABApp/config/__init__.py @@ -1,3 +1,3 @@ -from .config import Openhab, Mqtt -from .config import CONFIG +from .config import Openhab, Mqtt +from .config import CONFIG from .config_loader import HABAppConfigLoader, InvalidConfigException \ No newline at end of file diff --git a/src/HABApp/config/_conf_location.py b/src/HABApp/config/_conf_location.py new file mode 100644 index 00000000..8720466e --- /dev/null +++ b/src/HABApp/config/_conf_location.py @@ -0,0 +1,20 @@ +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.longitude, self.longitude, self.elevation) diff --git a/HABApp/config/_conf_mqtt.py b/src/HABApp/config/_conf_mqtt.py similarity index 96% rename from HABApp/config/_conf_mqtt.py rename to src/HABApp/config/_conf_mqtt.py index 339e0419..22aba73a 100644 --- a/HABApp/config/_conf_mqtt.py +++ b/src/HABApp/config/_conf_mqtt.py @@ -1,65 +1,65 @@ -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_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() +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_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/HABApp/config/_conf_openhab.py b/src/HABApp/config/_conf_openhab.py similarity index 97% rename from HABApp/config/_conf_openhab.py rename to src/HABApp/config/_conf_openhab.py index eeb7d251..26adf9a8 100644 --- a/HABApp/config/_conf_openhab.py +++ b/src/HABApp/config/_conf_openhab.py @@ -1,32 +1,32 @@ -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() +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/HABApp/config/config.py b/src/HABApp/config/config.py similarity index 76% rename from HABApp/config/config.py rename to src/HABApp/config/config.py index 27ef6da5..493e4d45 100644 --- a/HABApp/config/config.py +++ b/src/HABApp/config/config.py @@ -1,51 +1,60 @@ -import logging -import sys -from pathlib import Path - -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 will be loaded') - lib: Path = ConfigEntry(Path('lib'), description='Folder where additional libraries can be placed') - - def on_all_values_set(self): - 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() +import logging +import sys +from pathlib import Path + +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() diff --git a/HABApp/config/config_loader.py b/src/HABApp/config/config_loader.py similarity index 96% rename from HABApp/config/config_loader.py rename to src/HABApp/config/config_loader.py index 40e2082a..7f6109bf 100644 --- a/HABApp/config/config_loader.py +++ b/src/HABApp/config/config_loader.py @@ -68,12 +68,13 @@ def __init__(self, config_folder: Path): 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, file_ending='.yml', watch_subfolders=False + self.folder_conf, self.files_changed, filter, watch_subfolders=False ) HABApp.core.files.watcher.add_folder_watch(watcher) - def files_changed(self, paths: List[Path]): + async def files_changed(self, paths: List[Path]): for path in paths: if path.name == 'config.yml': self.load_cfg() diff --git a/HABApp/config/default_logfile.py b/src/HABApp/config/default_logfile.py similarity index 96% rename from HABApp/config/default_logfile.py rename to src/HABApp/config/default_logfile.py index ce9c10d9..0d195551 100644 --- a/HABApp/config/default_logfile.py +++ b/src/HABApp/config/default_logfile.py @@ -1,92 +1,92 @@ -from string import Template -from .platform_defaults import get_log_folder, is_openhabian - - -def get_default_logfile() -> str: - template = Template(""" -${LOG_LEVELS}formatters: - HABApp_format: - format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' -${FRONTAIL_FORMAT} - -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 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_FILE}' - maxBytes: 1_048_576 - backupCount: 3 - - formatter: ${HABAPP_FILE_FORMAT} - level: DEBUG - - EventFile: - class: HABApp.core.lib.handler.MidnightRotatingFileHandler - filename: '${EVENT_FILE}' - 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 - -""") - - # Default values are relative - subs = { - 'EVENT_FILE': 'events.log', - 'HABAPP_FILE': 'HABApp.log', - 'HABAPP_FILE_FORMAT': 'HABApp_format', - 'FRONTAIL_FORMAT': '', - 'LOG_LEVELS': '', - } - - # 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 - subs['HABAPP_FILE'] = (log_folder / subs['HABAPP_FILE']).as_posix() - - # Keep this relative so it is easier to read in the file - subs['EVENT_FILE'] = 'HABApp_events.log' - - # With openhabian we typically use frontail - if is_openhabian(): - subs['FRONTAIL_FORMAT'] = '\n'\ - ' Frontail_format:\n' \ - " format: '%(asctime)s.%(msecs)03d [%(levelname)-5s] [%(name)-36s] - %(message)s'\n" \ - " datefmt: '%Y-%m-%d %H:%M:%S'" - - subs['HABAPP_FILE_FORMAT'] = 'Frontail_format' - - # frontail expects WARN instead of WARNING - subs['LOG_LEVELS'] = 'levels:\n WARNING: WARN\n\n' - - return template.substitute(**subs) +from string import Template +from .platform_defaults import get_log_folder, is_openhabian + + +def get_default_logfile() -> str: + template = Template(""" +${LOG_LEVELS}formatters: + HABApp_format: + format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' +${FRONTAIL_FORMAT} + +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 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_FILE}' + maxBytes: 1_048_576 + backupCount: 3 + + formatter: ${HABAPP_FILE_FORMAT} + level: DEBUG + + EventFile: + class: HABApp.core.lib.handler.MidnightRotatingFileHandler + filename: '${EVENT_FILE}' + 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 + +""") + + # Default values are relative + subs = { + 'EVENT_FILE': 'events.log', + 'HABAPP_FILE': 'HABApp.log', + 'HABAPP_FILE_FORMAT': 'HABApp_format', + 'FRONTAIL_FORMAT': '', + 'LOG_LEVELS': '', + } + + # 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 + subs['HABAPP_FILE'] = (log_folder / subs['HABAPP_FILE']).as_posix() + + # Keep this relative so it is easier to read in the file + subs['EVENT_FILE'] = 'HABApp_events.log' + + # With openhabian we typically use frontail + if is_openhabian(): + subs['FRONTAIL_FORMAT'] = '\n'\ + ' Frontail_format:\n' \ + " format: '%(asctime)s.%(msecs)03d [%(levelname)-5s] [%(name)-36s] - %(message)s'\n" \ + " datefmt: '%Y-%m-%d %H:%M:%S'" + + subs['HABAPP_FILE_FORMAT'] = 'Frontail_format' + + # frontail expects WARN instead of WARNING + subs['LOG_LEVELS'] = 'levels:\n WARNING: WARN\n\n' + + return template.substitute(**subs) diff --git a/HABApp/config/platform_defaults.py b/src/HABApp/config/platform_defaults.py similarity index 100% rename from HABApp/config/platform_defaults.py rename to src/HABApp/config/platform_defaults.py diff --git a/HABApp/core/EventBus.py b/src/HABApp/core/EventBus.py similarity index 100% rename from HABApp/core/EventBus.py rename to src/HABApp/core/EventBus.py diff --git a/HABApp/core/Items.py b/src/HABApp/core/Items.py similarity index 100% rename from HABApp/core/Items.py rename to src/HABApp/core/Items.py diff --git a/HABApp/core/__init__.py b/src/HABApp/core/__init__.py similarity index 95% rename from HABApp/core/__init__.py rename to src/HABApp/core/__init__.py index 839d3b0c..02a3aa1a 100644 --- a/HABApp/core/__init__.py +++ b/src/HABApp/core/__init__.py @@ -1,15 +1,15 @@ -from . import const -from . import lib -from . import wrapper -from . import logger - -from .wrappedfunction import WrappedFunction - -from .event_bus_listener import EventBusListener - -import HABApp.core.events -import HABApp.core.files -import HABApp.core.items - -import HABApp.core.EventBus -import HABApp.core.Items +from . import const +from . import lib +from . import wrapper +from . import logger + +from .wrappedfunction import WrappedFunction + +from .event_bus_listener import EventBusListener + +import HABApp.core.events +import HABApp.core.files +import HABApp.core.items + +import HABApp.core.EventBus +import HABApp.core.Items diff --git a/HABApp/core/const/__init__.py b/src/HABApp/core/const/__init__.py similarity index 100% rename from HABApp/core/const/__init__.py rename to src/HABApp/core/const/__init__.py diff --git a/src/HABApp/core/const/const.py b/src/HABApp/core/const/const.py new file mode 100644 index 00000000..f391af43 --- /dev/null +++ b/src/HABApp/core/const/const.py @@ -0,0 +1,14 @@ +import time +from enum import Enum + + +class _MissingType(Enum): + MISSING = object() + + def __str__(self): + return '' + + +# todo: add type final if we go >= python 3.8 +MISSING = _MissingType.MISSING +STARTUP = time.time() diff --git a/HABApp/core/const/json.py b/src/HABApp/core/const/json.py similarity index 100% rename from HABApp/core/const/json.py rename to src/HABApp/core/const/json.py diff --git a/HABApp/core/const/loop.py b/src/HABApp/core/const/loop.py similarity index 99% rename from HABApp/core/const/loop.py rename to src/HABApp/core/const/loop.py index 76eed5a0..b6e3bc58 100644 --- a/HABApp/core/const/loop.py +++ b/src/HABApp/core/const/loop.py @@ -1,6 +1,5 @@ -import sys import asyncio - +import sys # setup everything so we can create a subprocess, only required for older versions if sys.version_info < (3, 8): diff --git a/HABApp/core/const/topics.py b/src/HABApp/core/const/topics.py similarity index 100% rename from HABApp/core/const/topics.py rename to src/HABApp/core/const/topics.py diff --git a/HABApp/core/const/yml.py b/src/HABApp/core/const/yml.py similarity index 100% rename from HABApp/core/const/yml.py rename to src/HABApp/core/const/yml.py diff --git a/HABApp/core/event_bus_listener.py b/src/HABApp/core/event_bus_listener.py similarity index 100% rename from HABApp/core/event_bus_listener.py rename to src/HABApp/core/event_bus_listener.py diff --git a/HABApp/core/events/__init__.py b/src/HABApp/core/events/__init__.py similarity index 100% rename from HABApp/core/events/__init__.py rename to src/HABApp/core/events/__init__.py diff --git a/HABApp/core/events/event_filters.py b/src/HABApp/core/events/event_filters.py similarity index 100% rename from HABApp/core/events/event_filters.py rename to src/HABApp/core/events/event_filters.py diff --git a/HABApp/core/events/events.py b/src/HABApp/core/events/events.py similarity index 100% rename from HABApp/core/events/events.py rename to src/HABApp/core/events/events.py diff --git a/HABApp/core/events/habapp_events.py b/src/HABApp/core/events/habapp_events.py similarity index 71% rename from HABApp/core/events/habapp_events.py rename to src/HABApp/core/events/habapp_events.py index bbe062ad..ac968308 100644 --- a/HABApp/core/events/habapp_events.py +++ b/src/HABApp/core/events/habapp_events.py @@ -1,19 +1,7 @@ -import HABApp -from pathlib import Path - - class __FileEventBase: - - @classmethod - def from_path(cls, path: Path) -> '__FileEventBase': - return cls(HABApp.core.files.name_from_path(path)) - def __init__(self, name: str): self.name: str = name - def get_path(self) -> Path: - return HABApp.core.files.path_from_name(self.name) - def __repr__(self): return f'<{self.__class__.__name__} filename: {self.name}>' @@ -32,8 +20,8 @@ class RequestFileUnloadEvent(__FileEventBase): """ -class HABAppError: - """Contains information about an error in a function +class HABAppException: + """Contains information about an Exception that has occurred in HABApp :ivar str func_name: name of the function where the error occurred :ivar str traceback: traceback @@ -49,4 +37,4 @@ def __repr__(self): def to_str(self) -> str: """Create a readable str with all information""" - return f'Error in {self.func_name}: {self.exception}\n{self.traceback}' + return f'Exception in {self.func_name}: {self.exception}\n{self.traceback}' diff --git a/src/HABApp/core/files/__init__.py b/src/HABApp/core/files/__init__.py new file mode 100644 index 00000000..020ab40c --- /dev/null +++ b/src/HABApp/core/files/__init__.py @@ -0,0 +1,8 @@ +from . import errors + +from . import watcher +from . import file +from . import folders +from . import manager + +from .setup import setup diff --git a/src/HABApp/core/files/errors.py b/src/HABApp/core/files/errors.py new file mode 100644 index 00000000..5b33450c --- /dev/null +++ b/src/HABApp/core/files/errors.py @@ -0,0 +1,21 @@ +from typing import Iterable as _Iterable + + +class CircularReferenceError(Exception): + def __init__(self, stack: _Iterable[str]): + self.stack = stack + + def __repr__(self): + return f'<{self.__class__.__name__} {" -> ".join(self.stack)}>' + + +class DependencyDoesNotExitError(Exception): + def __init__(self, msg: str): + self.msg = msg + + def __repr__(self): + return f'<{self.__class__.__name__} {self.msg}>' + + +class AlreadyHandledFileError(Exception): + pass diff --git a/src/HABApp/core/files/file/__init__.py b/src/HABApp/core/files/file/__init__.py new file mode 100644 index 00000000..e4229245 --- /dev/null +++ b/src/HABApp/core/files/file/__init__.py @@ -0,0 +1,3 @@ +from .file_state import FileState +from .file import HABAppFile +from .file_types import create_file, register_file_type diff --git a/src/HABApp/core/files/file/file.py b/src/HABApp/core/files/file/file.py new file mode 100644 index 00000000..aa7f70ce --- /dev/null +++ b/src/HABApp/core/files/file/file.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +import typing +from pathlib import Path +from typing import Callable, Awaitable, Any + +from HABApp.core.files.errors import CircularReferenceError, DependencyDoesNotExitError, AlreadyHandledFileError +from HABApp.core.files.file.properties import FileProperties +from HABApp.core.files.manager.files import FILES, file_state_changed +from HABApp.core.wrapper import process_exception +from . import FileState + +log = logging.getLogger('HABApp.files') + + +class HABAppFile: + LOGGER: logging.Logger + LOAD_FUNC: Callable[[str, Path], Awaitable[Any]] + UNLOAD_FUNC: Callable[[str, Path], Awaitable[Any]] + + def __init__(self, name: str, path: Path, properties: FileProperties): + self.name: str = name + self.path: Path = path + + self.state: FileState = FileState.PENDING + self.properties: FileProperties = properties + log.debug(f'{self.name} added') + + def __repr__(self): + return f'<{self.__class__.__name__} {self.name} state: {self.state}>' + + def set_state(self, new_state: FileState): + if self.state is new_state: + return None + + self.state = new_state + log.debug(f'{self.name} changed to {self.state}') + file_state_changed(self) + + def _check_circ_refs(self, stack, prop: str): + c: typing.List[str] = getattr(self.properties, prop) + for f in c: + _stack = stack + (f, ) + if f in stack: + raise CircularReferenceError(_stack) + + next_file = FILES.get(f) + if next_file is not None: + next_file._check_circ_refs(_stack, prop) + + def _check_properties(self): + # check dependencies + mis = set(filter(lambda x: x not in FILES, self.properties.depends_on)) + if mis: + one = len(mis) == 1 + msg = f'File {self.path} depends on file{"" if one else "s"} that ' \ + f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}' + + raise DependencyDoesNotExitError(msg) + + # check reload + mis = set(filter(lambda x: x not in FILES, self.properties.reloads_on)) + if mis: + one = len(mis) == 1 + log.warning(f'File {self.path} reloads on file{"" if one else "s"} that ' + f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}') + + def check_properties(self, log_msg: bool = False): + if self.state is not FileState.PENDING and self.state is not FileState.PROPERTIES_ERROR: + return None + + try: + self._check_properties() + except DependencyDoesNotExitError as e: + if log_msg: + log.error(e.msg) + return self.set_state(FileState.PROPERTIES_ERROR) + + try: + # check for circular references + self._check_circ_refs((self.name, ), 'depends_on') + self._check_circ_refs((self.name, ), 'reloads_on') + except CircularReferenceError as e: + log.error(f'Circular reference: {" -> ".join(e.stack)}') + return self.set_state(FileState.PROPERTIES_ERROR) + + # Check if we can already load it + self.set_state(FileState.DEPENDENCIES_OK if not self.properties.depends_on else FileState.DEPENDENCIES_MISSING) + + def check_dependencies(self): + if self.state is not FileState.DEPENDENCIES_MISSING: + return None + + for name in self.properties.depends_on: + f = FILES.get(name, None) + if f is None: + return None + if f.state is not FileState.LOADED: + return None + + self.set_state(FileState.DEPENDENCIES_OK) + return None + + async def load(self): + assert self.state is FileState.DEPENDENCIES_OK, self.state + + try: + await self.__class__.LOAD_FUNC(self.name, self.path) + except Exception as e: + if not isinstance(e, AlreadyHandledFileError): + process_exception(self.__class__.LOAD_FUNC, e, logger=self.LOGGER) + self.set_state(FileState.FAILED) + return None + + self.set_state(FileState.LOADED) + return None + + async def unload(self): + try: + await self.__class__.UNLOAD_FUNC(self.name, self.path) + except Exception as e: + if not isinstance(e, AlreadyHandledFileError): + process_exception(self.__class__.UNLOAD_FUNC, e, logger=self.LOGGER) + self.set_state(FileState.FAILED) + return None + + self.set_state(FileState.PENDING) + return None + + def file_changed(self, file: HABAppFile): + name = file.name + if name in self.properties.reloads_on: + self.set_state(FileState.PENDING) diff --git a/src/HABApp/core/files/file/file_state.py b/src/HABApp/core/files/file/file_state.py new file mode 100644 index 00000000..cd4a77bb --- /dev/null +++ b/src/HABApp/core/files/file/file_state.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from enum import Enum, auto + + +class FileState(Enum): + LOADED = auto() + FAILED = auto() + + DEPENDENCIES_OK = auto() + DEPENDENCIES_MISSING = auto() + PROPERTIES_ERROR = auto() + + PENDING = auto() + + def __str__(self): + return str(self.name) diff --git a/src/HABApp/core/files/file/file_types.py b/src/HABApp/core/files/file/file_types.py new file mode 100644 index 00000000..90b440b8 --- /dev/null +++ b/src/HABApp/core/files/file/file_types.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Type, Dict + +from HABApp.core.files.file import HABAppFile +from HABApp.core.files.file.properties import get_properties + +FILE_TYPES: Dict[str, Type[HABAppFile]] = {} + + +def register_file_type(prefix: str, cls: Type[HABAppFile]): + assert prefix not in FILE_TYPES + + assert cls.LOGGER + assert cls.LOAD_FUNC + assert cls.UNLOAD_FUNC + + FILE_TYPES[prefix] = cls + + +def create_file(name: str, path: Path) -> HABAppFile: + for prefix, cls in FILE_TYPES.items(): + if name.startswith(prefix): + break + else: + raise ValueError(f'Unknown file type for "{name}"!') + + with path.open('r', encoding='utf-8') as f: + txt = f.read(10 * 1024) + return cls(name, path, get_properties(txt)) diff --git a/HABApp/core/files/file_props.py b/src/HABApp/core/files/file/properties.py similarity index 96% rename from HABApp/core/files/file_props.py rename to src/HABApp/core/files/file/properties.py index e80a7751..659c8c3d 100644 --- a/HABApp/core/files/file_props.py +++ b/src/HABApp/core/files/file/properties.py @@ -18,7 +18,7 @@ class Config: RE_START = re.compile(r'^#(\s*)HABApp\s*:', re.IGNORECASE) -def get_props(_str: str) -> FileProperties: +def get_properties(_str: str) -> FileProperties: cfg = [] cut = 0 diff --git a/src/HABApp/core/files/folders/__init__.py b/src/HABApp/core/files/folders/__init__.py new file mode 100644 index 00000000..93d8b389 --- /dev/null +++ b/src/HABApp/core/files/folders/__init__.py @@ -0,0 +1,2 @@ +from .folders import add_folder, get_name, get_path, get_prefixes + diff --git a/src/HABApp/core/files/folders/folders.py b/src/HABApp/core/files/folders/folders.py new file mode 100644 index 00000000..f3435555 --- /dev/null +++ b/src/HABApp/core/files/folders/folders.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Dict +from typing import List, Type + +import HABApp +from HABApp.core.const.topics import FILES as T_FILES +from HABApp.core.events.habapp_events import RequestFileUnloadEvent, RequestFileLoadEvent +from ..watcher import AggregatingAsyncEventHandler + + +FOLDERS: Dict[str, 'ConfiguredFolder'] = {} + + +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) + ) + + +class ConfiguredFolder: + def __init__(self, prefix: str, folder: Path, priority: int): + self.prefix = prefix + self.folder = folder + self.priority: int = priority + + def add_watch(self, file_ending: str, watch_subfolders: bool = True) -> AggregatingAsyncEventHandler: + filter = HABApp.core.files.watcher.FileEndingFilter(file_ending) + handler = AggregatingAsyncEventHandler(self.folder, _generate_file_events, filter, watch_subfolders) + HABApp.core.files.watcher.add_folder_watch(handler) + return handler + + def add_file_type(self, cls: Type['HABApp.core.files.file.HABAppFile']): + HABApp.core.files.file.register_file_type(self.prefix, cls) + + +def get_prefixes() -> List[str]: + return list(map(lambda x: x.prefix, sorted(FOLDERS.values(), key=lambda x: x.priority, reverse=True))) + + +def add_folder(prefix: str, folder: Path, priority: int) -> ConfiguredFolder: + assert prefix and prefix.endswith('/') + for obj in FOLDERS.values(): + assert obj.priority != priority + FOLDERS[prefix] = c = ConfiguredFolder(prefix, folder, priority) + return c + + +def get_name(path: Path) -> str: + path = path.as_posix() + for prefix, cfg in sorted(FOLDERS.items(), key=lambda x: len(x[0]), reverse=True): + folder = cfg.folder.as_posix() + if path.startswith(folder): + return prefix + path[len(folder) + 1:] + + raise ValueError(f'Path "{path}" is not part of the configured folders!') + + +def get_path(name: str) -> Path: + for prefix, obj in FOLDERS.items(): + if name.startswith(prefix): + return obj.folder / name[len(prefix):] + + raise ValueError(f'Prefix not found for "{name}"!') diff --git a/src/HABApp/core/files/manager/__init__.py b/src/HABApp/core/files/manager/__init__.py new file mode 100644 index 00000000..95f70830 --- /dev/null +++ b/src/HABApp/core/files/manager/__init__.py @@ -0,0 +1,3 @@ +from .files import FILES, file_state_changed +from .listen_events import setup_file_manager +from .worker import process_file diff --git a/src/HABApp/core/files/manager/files.py b/src/HABApp/core/files/manager/files.py new file mode 100644 index 00000000..70c9c35d --- /dev/null +++ b/src/HABApp/core/files/manager/files.py @@ -0,0 +1,12 @@ +from typing import Dict, TYPE_CHECKING + +if TYPE_CHECKING: + import HABApp + +FILES: Dict[str, 'HABApp.core.files.file.HABAppFile'] = {} + + +def file_state_changed(file: 'HABApp.core.files.file.HABAppFile'): + for f in FILES.values(): + if f is not file: + f.file_changed(file) diff --git a/src/HABApp/core/files/manager/listen_events.py b/src/HABApp/core/files/manager/listen_events.py new file mode 100644 index 00000000..2f317a6d --- /dev/null +++ b/src/HABApp/core/files/manager/listen_events.py @@ -0,0 +1,23 @@ +import logging +from typing import Union + +import HABApp +from HABApp.core.const.topics import FILES as T_FILES +from HABApp.core.events.habapp_events import RequestFileUnloadEvent, RequestFileLoadEvent + +log = logging.getLogger('HABApp.Files') + + +async def _process_event(event: Union[RequestFileUnloadEvent, RequestFileLoadEvent]): + name = event.name + await HABApp.core.files.manager.process_file(name, HABApp.core.files.folders.get_path(name)) + + +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) + ) + HABApp.core.EventBus.add_listener( + HABApp.core.EventBusListener(T_FILES, HABApp.core.WrappedFunction(_process_event), RequestFileLoadEvent) + ) diff --git a/src/HABApp/core/files/manager/worker.py b/src/HABApp/core/files/manager/worker.py new file mode 100644 index 00000000..73715195 --- /dev/null +++ b/src/HABApp/core/files/manager/worker.py @@ -0,0 +1,85 @@ + +import logging +import time +from asyncio import Future, create_task, sleep +from pathlib import Path +from typing import Optional + +import HABApp +from HABApp.core.files.file import FileState +from HABApp.core.files.folders import get_prefixes +from . import FILES + +log = logging.getLogger('HABApp.files') + + +TASK: Optional[Future] = None +TASK_SLEEP: float = 0.3 +TASK_DURATION: float = 15 + + +async def process_file(name: str, file: Path): + global TASK + + # unload + if not file.is_file(): + existing = FILES.pop(name, None) + if existing is not None: + await existing.unload() + return None + + FILES[name] = HABApp.core.files.file.create_file(name, file) + if TASK is None: + TASK = create_task(_process()) + + +async def _process(): + global TASK + + prefixes = get_prefixes() + + ct = -1 + log_msg = False + last_process = time.time() + + try: + while True: + + # wait until files are stable + while ct != len(FILES): + ct = len(FILES) + await sleep(TASK_SLEEP) + + # check files for dependencies etc. + for file in FILES.values(): + file.check_properties(log_msg) + + # Load order + for prefix in prefixes: + file_loaded = False + for name in filter(lambda x: x.startswith(prefix), sorted(FILES.keys())): + file = FILES[name] + file.check_dependencies() + + if file.state is FileState.DEPENDENCIES_OK: + await file.load() + last_process = time.time() + file_loaded = True + break + if file_loaded: + break + + # if we don't have any files left to load we sleep! + if not any(map(lambda x: x.state is FileState.DEPENDENCIES_OK, FILES.values())): + await sleep(TASK_SLEEP) + + # Emit an error message during the last run + if log_msg: + break + log_msg = time.time() - last_process > TASK_DURATION + + except Exception as e: + HABApp.core.wrapper.process_exception('file load worker', e, logger=log) + finally: + TASK = None + log.debug('Worker done!') diff --git a/src/HABApp/core/files/setup.py b/src/HABApp/core/files/setup.py new file mode 100644 index 00000000..01a761a0 --- /dev/null +++ b/src/HABApp/core/files/setup.py @@ -0,0 +1,5 @@ +from .manager import setup_file_manager + + +async def setup(): + await setup_file_manager() diff --git a/HABApp/core/files/watcher/__init__.py b/src/HABApp/core/files/watcher/__init__.py similarity index 74% rename from HABApp/core/files/watcher/__init__.py rename to src/HABApp/core/files/watcher/__init__.py index 7b620c09..bbb27003 100644 --- a/HABApp/core/files/watcher/__init__.py +++ b/src/HABApp/core/files/watcher/__init__.py @@ -1,2 +1,3 @@ from .folder_watcher import start, remove_folder_watch, add_folder_watch from .file_watcher import AggregatingAsyncEventHandler +from .base_watcher import FileEndingFilter diff --git a/HABApp/core/files/watcher/base_watcher.py b/src/HABApp/core/files/watcher/base_watcher.py similarity index 52% rename from HABApp/core/files/watcher/base_watcher.py rename to src/HABApp/core/files/watcher/base_watcher.py index 99f9377c..b5e4f09a 100644 --- a/HABApp/core/files/watcher/base_watcher.py +++ b/src/HABApp/core/files/watcher/base_watcher.py @@ -1,38 +1,53 @@ import logging from pathlib import Path -from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.events import FileSystemEvent -log = logging.getLogger('HABApp.file_events') +log = logging.getLogger('HABApp.file.events') log.setLevel(logging.INFO) -class BaseWatcher(FileSystemEventHandler): - def __init__(self, folder: Path, file_ending: str, watch_subfolders: bool = False): +class EventFilterBase: + def notify(self, path: str) -> bool: + raise NotImplementedError() + + +class FileEndingFilter(EventFilterBase): + def __init__(self, ending: str): + self.ending: str = ending + + def notify(self, path: str) -> bool: + return path.endswith(self.ending) + + def __repr__(self): + return f'<{self.__class__.__name__} ending: {self.ending}>' + + +class FileSystemEventHandler: + def __init__(self, folder: Path, filter: EventFilterBase, watch_subfolders: bool = False): assert isinstance(folder, Path), type(folder) - assert isinstance(file_ending, str), type(file_ending) assert watch_subfolders is True or watch_subfolders is False self.folder: Path = folder - self.file_ending: str = file_ending self.watch_subfolders: bool = watch_subfolders - def dispatch(self, event: FileSystemEvent): - - log.debug(event) + self.filter: EventFilterBase = filter + def dispatch(self, event: FileSystemEvent): # we don't process directory events if event.is_directory: return None + log.debug(event) + src = event.src_path - if src.endswith(self.file_ending): + if self.filter.notify(src): self.file_changed(src) # moved events have a dst, so we process it, too if hasattr(event, 'dest_path'): dst = event.dest_path - if dst.endswith(self.file_ending): + if self.filter.notify(dst): self.file_changed(dst) return None diff --git a/HABApp/core/files/watcher/file_watcher.py b/src/HABApp/core/files/watcher/file_watcher.py similarity index 56% rename from HABApp/core/files/watcher/file_watcher.py rename to src/HABApp/core/files/watcher/file_watcher.py index 6c475f71..0d83feba 100644 --- a/HABApp/core/files/watcher/file_watcher.py +++ b/src/HABApp/core/files/watcher/file_watcher.py @@ -1,20 +1,20 @@ -import asyncio +from asyncio import run_coroutine_threadsafe, sleep from pathlib import Path from time import time -from typing import Any, Callable, List, Set +from typing import Any, List, Set, Awaitable, Callable import HABApp from HABApp.core.wrapper import ignore_exception -from .base_watcher import BaseWatcher as __BaseWatcher - +from .base_watcher import EventFilterBase +from .base_watcher import FileSystemEventHandler DEBOUNCE_TIME: float = 0.6 -class AggregatingAsyncEventHandler(__BaseWatcher): - def __init__(self, folder: Path, func: Callable[[List[Path]], Any], file_ending: str, +class AggregatingAsyncEventHandler(FileSystemEventHandler): + def __init__(self, folder: Path, func: Callable[[List[Path]], Awaitable[Any]], filter: EventFilterBase, watch_subfolders: bool = False): - super().__init__(folder, file_ending, watch_subfolders=watch_subfolders) + super().__init__(folder, filter, watch_subfolders=watch_subfolders) self.func = func @@ -24,7 +24,7 @@ def __init__(self, folder: Path, func: Callable[[List[Path]], Any], file_ending: @ignore_exception def file_changed(self, dst: str): # Map from thread to async - asyncio.run_coroutine_threadsafe(self._event_waiter(Path(dst)), loop=HABApp.core.const.loop) + run_coroutine_threadsafe(self._event_waiter(Path(dst)), loop=HABApp.core.const.loop) @ignore_exception async def _event_waiter(self, dst: Path): @@ -32,7 +32,7 @@ async def _event_waiter(self, dst: Path): self._files.add(dst) # debounce time - await asyncio.sleep(DEBOUNCE_TIME) + await sleep(DEBOUNCE_TIME) # check if a new event came if self.last_event > ts: @@ -41,8 +41,10 @@ async def _event_waiter(self, dst: Path): # Copy Path so we're done here files = list(self._files) self._files.clear() - self.func(files) - def trigger_all(self): - files = HABApp.core.lib.list_files(self.folder, self.file_ending, self.watch_subfolders) - self.func(files) + # process + await self.func(HABApp.core.lib.sort_files(files)) + + async def trigger_all(self): + files = HABApp.core.lib.list_files(self.folder, self.filter, self.watch_subfolders) + await self.func(files) diff --git a/HABApp/core/files/watcher/folder_watcher.py b/src/HABApp/core/files/watcher/folder_watcher.py similarity index 75% rename from HABApp/core/files/watcher/folder_watcher.py rename to src/HABApp/core/files/watcher/folder_watcher.py index 5efb2467..053ecaf7 100644 --- a/HABApp/core/files/watcher/folder_watcher.py +++ b/src/HABApp/core/files/watcher/folder_watcher.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from threading import Lock from typing import Optional, Dict @@ -5,7 +6,9 @@ from watchdog.observers import Observer from watchdog.observers.api import ObservedWatch -from .base_watcher import BaseWatcher +from .base_watcher import FileSystemEventHandler + +log = logging.getLogger('HABApp.files.watcher') LOCK = Lock() @@ -29,11 +32,15 @@ def start(): return None -def add_folder_watch(handler: BaseWatcher): +def add_folder_watch(handler: FileSystemEventHandler): assert OBSERVER is not None - assert isinstance(handler, BaseWatcher), type(handler) + assert isinstance(handler, FileSystemEventHandler), type(handler) assert isinstance(handler.folder, Path) and handler.folder.is_dir() + log.debug( + f'Adding {"recursive " if handler.watch_subfolders else ""}watcher for {handler.folder} with {handler.filter}' + ) + with LOCK: _folder = str(handler.folder) assert _folder not in WATCHES diff --git a/HABApp/core/items/__init__.py b/src/HABApp/core/items/__init__.py similarity index 100% rename from HABApp/core/items/__init__.py rename to src/HABApp/core/items/__init__.py diff --git a/HABApp/core/items/base_item.py b/src/HABApp/core/items/base_item.py similarity index 92% rename from HABApp/core/items/base_item.py rename to src/HABApp/core/items/base_item.py index 565ce56b..fe57ca7b 100644 --- a/HABApp/core/items/base_item.py +++ b/src/HABApp/core/items/base_item.py @@ -1,8 +1,9 @@ import datetime from typing import Any, Callable, Union -import tzlocal -from pytz import utc +from eascheduler.const import local_tz +from pendulum import UTC, DateTime +from pendulum import now as pd_now import HABApp from .base_item_times import ChangedTime, ItemNoChangeWatch, ItemNoUpdateWatch, UpdatedTime @@ -31,7 +32,7 @@ def __init__(self, name: str): self._name: str = name - _now = datetime.datetime.now(tz=utc) + _now = pd_now(UTC) self._last_change: ChangedTime = ChangedTime(self._name, _now) self._last_update: UpdatedTime = UpdatedTime(self._name, _now) @@ -43,18 +44,18 @@ def name(self) -> str: return self._name @property - def last_change(self) -> datetime.datetime: + def last_change(self) -> DateTime: """ :return: Timestamp of the last time when the item has been changed (read only) """ - return self._last_change.dt.astimezone(tzlocal.get_localzone()).replace(tzinfo=None) + return self._last_change.dt.in_timezone(local_tz).naive() @property - def last_update(self) -> datetime.datetime: + def last_update(self) -> DateTime: """ :return: Timestamp of the last time when the item has been updated (read only) """ - return self._last_update.dt.astimezone(tzlocal.get_localzone()).replace(tzinfo=None) + return self._last_update.dt.in_timezone(local_tz).naive() def __repr__(self): ret = '' diff --git a/HABApp/core/items/base_item_times.py b/src/HABApp/core/items/base_item_times.py similarity index 91% rename from HABApp/core/items/base_item_times.py rename to src/HABApp/core/items/base_item_times.py index 95279093..97f28177 100644 --- a/HABApp/core/items/base_item_times.py +++ b/src/HABApp/core/items/base_item_times.py @@ -1,8 +1,8 @@ import asyncio -import datetime import logging import typing from datetime import timedelta +from pendulum import DateTime from HABApp.core.wrapper import log_exception from .base_item_watch import BaseWatch, ItemNoChangeWatch, ItemNoUpdateWatch @@ -14,12 +14,12 @@ class ItemTimes: WATCH: typing.Union[typing.Type[ItemNoUpdateWatch], typing.Type[ItemNoChangeWatch]] - def __init__(self, name: str, dt: datetime.datetime): + def __init__(self, name: str, dt: DateTime): self.name: str = name - self.dt: datetime.datetime = dt + self.dt: DateTime = dt self.tasks: typing.List[BaseWatch] = [] - def set(self, dt: datetime.datetime, events=True): + def set(self, dt: DateTime, events=True): self.dt = dt if not self.tasks: return diff --git a/HABApp/core/items/base_item_watch.py b/src/HABApp/core/items/base_item_watch.py similarity index 100% rename from HABApp/core/items/base_item_watch.py rename to src/HABApp/core/items/base_item_watch.py diff --git a/HABApp/core/items/base_valueitem.py b/src/HABApp/core/items/base_valueitem.py similarity index 97% rename from HABApp/core/items/base_valueitem.py rename to src/HABApp/core/items/base_valueitem.py index 0ee21ea7..5394c249 100644 --- a/HABApp/core/items/base_valueitem.py +++ b/src/HABApp/core/items/base_valueitem.py @@ -1,9 +1,9 @@ -import datetime import logging import typing from math import ceil, floor -from pytz import utc +from pendulum import UTC +from pendulum import now as pd_now import HABApp from .base_item import BaseItem @@ -16,8 +16,8 @@ class BaseValueItem(BaseItem): :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 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): @@ -33,7 +33,7 @@ def set_value(self, new_value) -> bool: """ state_changed = self.value != new_value - _now = datetime.datetime.now(tz=utc) + _now = pd_now(UTC) if state_changed: self._last_change.set(_now) self._last_update.set(_now) diff --git a/HABApp/core/items/item.py b/src/HABApp/core/items/item.py similarity index 100% rename from HABApp/core/items/item.py rename to src/HABApp/core/items/item.py diff --git a/HABApp/core/items/item_aggregation.py b/src/HABApp/core/items/item_aggregation.py similarity index 97% rename from HABApp/core/items/item_aggregation.py rename to src/HABApp/core/items/item_aggregation.py index a5d1eae6..0e8669ed 100644 --- a/HABApp/core/items/item_aggregation.py +++ b/src/HABApp/core/items/item_aggregation.py @@ -135,8 +135,7 @@ async def _add_value(self, event: 'HABApp.core.events.ValueChangeEvent'): self._vals.append(event.value) if self.__task is None: - # todo: rename to asyncio.create_task once we go py3.7 only - self.__task = asyncio.ensure_future(self.__update_task()) + self.__task = asyncio.create_task(self.__update_task()) try: val = self.__aggregation_func(self._vals) diff --git a/HABApp/core/items/item_color.py b/src/HABApp/core/items/item_color.py similarity index 84% rename from HABApp/core/items/item_color.py rename to src/HABApp/core/items/item_color.py index 368b7954..e268e5ac 100644 --- a/HABApp/core/items/item_color.py +++ b/src/HABApp/core/items/item_color.py @@ -1,6 +1,7 @@ -import colorsys import typing +from typing import Optional +from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb from .base_valueitem import BaseValueItem HUE_FACTOR = 360 @@ -57,27 +58,20 @@ def get_rgb(self, max_rgb_value=255) -> typing.Tuple[int, int, int]: :param max_rgb_value: the max value for rgb, typically 255 (default) or 65.536 :return: rgb tuple """ - r, g, b = colorsys.hsv_to_rgb( - self.hue / HUE_FACTOR, - self.saturation / PERCENT_FACTOR, - self.brightness / PERCENT_FACTOR - ) - return int(r * max_rgb_value), int(g * max_rgb_value), int(b * max_rgb_value) + return hsb_to_rgb(self.hue, self.saturation, self.brightness, max_rgb_value=max_rgb_value) - def set_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': + def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'ColorItem': """Set a rgb value :param r: red value :param g: green value :param b: blue value :param max_rgb_value: the max value for rgb, typically 255 (default) or 65.536 + :param ndigits: Round the hsb values to the specified digits, None to disable rounding :return: self """ - h, s, v = colorsys.rgb_to_hsv(r / max_rgb_value, g / max_rgb_value, b / max_rgb_value) - self.hue = h * HUE_FACTOR - self.saturation = s * PERCENT_FACTOR - self.brightness = v * PERCENT_FACTOR - self.set_value(self.hue, self.saturation, self.brightness) + h, s, b = rgb_to_hsb(r, g, b, max_rgb_value=max_rgb_value, ndigits=ndigits) + self.set_value(h, s, b) return self def post_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': diff --git a/HABApp/core/items/tmp_data.py b/src/HABApp/core/items/tmp_data.py similarity index 100% rename from HABApp/core/items/tmp_data.py rename to src/HABApp/core/items/tmp_data.py diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py new file mode 100644 index 00000000..18ada577 --- /dev/null +++ b/src/HABApp/core/lib/__init__.py @@ -0,0 +1,3 @@ +from .funcs import list_files, sort_files +from .pending_future import PendingFuture +from .rgb_hsv import hsb_to_rgb, rgb_to_hsb diff --git a/src/HABApp/core/lib/funcs.py b/src/HABApp/core/lib/funcs.py new file mode 100644 index 00000000..82be5368 --- /dev/null +++ b/src/HABApp/core/lib/funcs.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import List, Iterable, TYPE_CHECKING + +if TYPE_CHECKING: + import HABApp + + +def list_files(folder: Path, file_filter: 'HABApp.core.files.watcher.file_watcher.EventFilterBase', + recursive: bool = False) -> List[Path]: + # glob is much quicker than iter_dir() + files = folder.glob('**/*' if recursive else '*') + return sorted(filter(lambda x: file_filter.notify(str(x)), files), key=lambda x: x.relative_to(folder)) + + +def sort_files(files: Iterable[Path]) -> List[Path]: + return sorted(files) diff --git a/HABApp/core/lib/handler.py b/src/HABApp/core/lib/handler.py similarity index 100% rename from HABApp/core/lib/handler.py rename to src/HABApp/core/lib/handler.py diff --git a/HABApp/core/lib/pending_future.py b/src/HABApp/core/lib/pending_future.py similarity index 87% rename from HABApp/core/lib/pending_future.py rename to src/HABApp/core/lib/pending_future.py index 088377e3..59c881c8 100644 --- a/HABApp/core/lib/pending_future.py +++ b/src/HABApp/core/lib/pending_future.py @@ -1,6 +1,6 @@ import asyncio import typing -from asyncio import Task, ensure_future, sleep, run_coroutine_threadsafe +from asyncio import Task, sleep, run_coroutine_threadsafe, create_task from typing import Any, Awaitable, Callable, Optional from HABApp.core.const import loop @@ -38,8 +38,7 @@ def reset(self, thread_safe=False): if thread_safe: self.task = run_coroutine_threadsafe(self.__countdown(), loop) else: - # todo: rename to asyncio.create_task once we go py3.7 only - self.task = ensure_future(self.__countdown()) + self.task = create_task(self.__countdown()) async def __countdown(self): try: diff --git a/src/HABApp/core/lib/rgb_hsv.py b/src/HABApp/core/lib/rgb_hsv.py new file mode 100644 index 00000000..188883f2 --- /dev/null +++ b/src/HABApp/core/lib/rgb_hsv.py @@ -0,0 +1,40 @@ +from colorsys import hsv_to_rgb as _hsv_to_rgb +from colorsys import rgb_to_hsv as _rgb_to_hsv +from typing import Optional, Tuple, Union + + +def rgb_to_hsb(r: Union[int, float], g: Union[int, float], b: Union[int, float], + max_rgb_value: int = 255, ndigits: Optional[int] = 2) -> Tuple[float, float, float]: + """Convert from rgb to hsb/hsv + + :param r: red value + :param g: green value + :param b: blue value + :param max_rgb_value: maximal possible rgb value (e.g. 255 for 8 bit or 65.535 for 16bit values) + :param ndigits: Round the hsb values to the specified digits, None to disable rounding + :return: Values for hue, saturation and brightness / value + """ + h, s, v = _rgb_to_hsv(r / max_rgb_value, g / max_rgb_value, b / max_rgb_value) + h *= 360 + s *= 100 + v *= 100 + + if ndigits is not None: + h = round(h, ndigits) + s = round(s, ndigits) + v = round(v, ndigits) + + return h, s, v + + +def hsb_to_rgb(h, s, b, max_rgb_value=255) -> Tuple[int, int, int]: + """Convert from rgb to hsv/hsb + + :param h: hue + :param s: saturation + :param b: brightness / value + :param max_rgb_value: maximal value for the returned rgb values (e.g. 255 for 8 bit or 65.535 16bit values) + :return: Values for red, green and blue + """ + r, g, b = _hsv_to_rgb(h / 360, s / 100, b / 100) + return round(r * max_rgb_value), round(g * max_rgb_value), round(b * max_rgb_value) diff --git a/HABApp/core/logger.py b/src/HABApp/core/logger.py similarity index 100% rename from HABApp/core/logger.py rename to src/HABApp/core/logger.py diff --git a/HABApp/core/wrappedfunction.py b/src/HABApp/core/wrappedfunction.py similarity index 81% rename from HABApp/core/wrappedfunction.py rename to src/HABApp/core/wrappedfunction.py index 1158514d..da7ffa9d 100644 --- a/HABApp/core/wrappedfunction.py +++ b/src/HABApp/core/wrappedfunction.py @@ -1,24 +1,20 @@ -import asyncio -import concurrent.futures 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 -try: - from pstats import SortKey # type: ignore[attr-defined] - STAT_SORT_KEY = SortKey.CUMULATIVE -except ImportError: - STAT_SORT_KEY = 'cumulative', 'cumtime' +from threading import _MainThread, current_thread import HABApp - default_logger = logging.getLogger('HABApp.Worker') class WrappedFunction: - _WORKERS = concurrent.futures.ThreadPoolExecutor(10, 'HabApp_') + _WORKERS = ThreadPoolExecutor(10, 'HabApp_') _EVENT_LOOP = None def __init__(self, func, logger=None, warn_too_long=True, name=None): @@ -28,7 +24,7 @@ def __init__(self, func, logger=None, warn_too_long=True, name=None): # name of the function self.name = self._func.__name__ if not name else name - self.is_async = asyncio.iscoroutinefunction(self._func) + self.is_async = iscoroutinefunction(self._func) self.__time_submitted = 0.0 @@ -44,7 +40,11 @@ def run(self, *args, **kwargs): if self.is_async: # schedule run async, we need to pass the event loop because we can create an async WrappedFunction # from a worker thread (if we have a mixture between async and non-async)! - asyncio.run_coroutine_threadsafe(self.async_run(*args, **kwargs), loop=WrappedFunction._EVENT_LOOP) + if isinstance(current_thread(), _MainThread): + create_task(self.async_run(*args, **kwargs)) + else: + run_coroutine_threadsafe(self.async_run(*args, **kwargs), loop=WrappedFunction._EVENT_LOOP) + else: self.__time_submitted = time.time() WrappedFunction._WORKERS.submit(self.__run, *args, **kwargs) @@ -59,9 +59,9 @@ def __format_traceback(self, e: Exception, *args, **kwargs): 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.HABAppError): + 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.HABAppError( + HABApp.core.const.topics.ERRORS, HABApp.core.events.habapp_events.HABAppException( func_name=self.name, exception=e, traceback='\n'.join(lines) ) ) @@ -101,7 +101,7 @@ def __run(self, *args, **kwargs): self.log.warning(f'Execution of {self.name} took too long: {__dur:.2f}s') s = io.StringIO() - ps = Stats(pr, stream=s).sort_stats(STAT_SORT_KEY) + 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:" diff --git a/HABApp/core/wrapper.py b/src/HABApp/core/wrapper.py similarity index 98% rename from HABApp/core/wrapper.py rename to src/HABApp/core/wrapper.py index 4c4287ee..8b816a16 100644 --- a/HABApp/core/wrapper.py +++ b/src/HABApp/core/wrapper.py @@ -75,7 +75,7 @@ def process_exception(func: typing.Union[typing.Callable, str], e: Exception, # 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.HABAppError( + HABApp.core.const.topics.ERRORS, HABApp.core.events.habapp_events.HABAppException( func_name=func_name, exception=e, traceback='\n'.join(lines) ) ) @@ -181,7 +181,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): # 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.HABAppError( + HABApp.core.events.habapp_events.HABAppException( func_name=f_name, exception=exc_val, traceback='\n'.join(tb) ) ) diff --git a/HABApp/mqtt/__init__.py b/src/HABApp/mqtt/__init__.py similarity index 100% rename from HABApp/mqtt/__init__.py rename to src/HABApp/mqtt/__init__.py diff --git a/HABApp/mqtt/events/__init__.py b/src/HABApp/mqtt/events/__init__.py similarity index 100% rename from HABApp/mqtt/events/__init__.py rename to src/HABApp/mqtt/events/__init__.py diff --git a/HABApp/mqtt/events/mqtt_events.py b/src/HABApp/mqtt/events/mqtt_events.py similarity index 100% rename from HABApp/mqtt/events/mqtt_events.py rename to src/HABApp/mqtt/events/mqtt_events.py diff --git a/HABApp/mqtt/events/mqtt_filters.py b/src/HABApp/mqtt/events/mqtt_filters.py similarity index 100% rename from HABApp/mqtt/events/mqtt_filters.py rename to src/HABApp/mqtt/events/mqtt_filters.py diff --git a/HABApp/mqtt/interface.py b/src/HABApp/mqtt/interface.py similarity index 100% rename from HABApp/mqtt/interface.py rename to src/HABApp/mqtt/interface.py diff --git a/HABApp/mqtt/items/__init__.py b/src/HABApp/mqtt/items/__init__.py similarity index 100% rename from HABApp/mqtt/items/__init__.py rename to src/HABApp/mqtt/items/__init__.py diff --git a/HABApp/mqtt/items/mqtt_item.py b/src/HABApp/mqtt/items/mqtt_item.py similarity index 100% rename from HABApp/mqtt/items/mqtt_item.py rename to src/HABApp/mqtt/items/mqtt_item.py diff --git a/HABApp/mqtt/items/mqtt_pair_item.py b/src/HABApp/mqtt/items/mqtt_pair_item.py similarity index 100% rename from HABApp/mqtt/items/mqtt_pair_item.py rename to src/HABApp/mqtt/items/mqtt_pair_item.py diff --git a/HABApp/mqtt/mqtt_connection.py b/src/HABApp/mqtt/mqtt_connection.py similarity index 100% rename from HABApp/mqtt/mqtt_connection.py rename to src/HABApp/mqtt/mqtt_connection.py diff --git a/HABApp/mqtt/mqtt_interface.py b/src/HABApp/mqtt/mqtt_interface.py similarity index 100% rename from HABApp/mqtt/mqtt_interface.py rename to src/HABApp/mqtt/mqtt_interface.py diff --git a/HABApp/openhab/__init__.py b/src/HABApp/openhab/__init__.py similarity index 96% rename from HABApp/openhab/__init__.py rename to src/HABApp/openhab/__init__.py index 1f2488ea..74274f43 100644 --- a/HABApp/openhab/__init__.py +++ b/src/HABApp/openhab/__init__.py @@ -1,9 +1,9 @@ -# no external dependencies -import HABApp.openhab.exceptions -import HABApp.openhab.events - -import HABApp.openhab.interface_async -import HABApp.openhab.interface - -# items use the interface for the convenience functions -import HABApp.openhab.items +# no external dependencies +import HABApp.openhab.exceptions +import HABApp.openhab.events + +import HABApp.openhab.interface_async +import HABApp.openhab.interface + +# items use the interface for the convenience functions +import HABApp.openhab.items diff --git a/HABApp/openhab/connection_handler/__init__.py b/src/HABApp/openhab/connection_handler/__init__.py similarity index 100% rename from HABApp/openhab/connection_handler/__init__.py rename to src/HABApp/openhab/connection_handler/__init__.py diff --git a/HABApp/openhab/connection_handler/func_async.py b/src/HABApp/openhab/connection_handler/func_async.py similarity index 100% rename from HABApp/openhab/connection_handler/func_async.py rename to src/HABApp/openhab/connection_handler/func_async.py diff --git a/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py similarity index 100% rename from HABApp/openhab/connection_handler/func_sync.py rename to src/HABApp/openhab/connection_handler/func_sync.py diff --git a/HABApp/openhab/connection_handler/http_connection.py b/src/HABApp/openhab/connection_handler/http_connection.py similarity index 98% rename from HABApp/openhab/connection_handler/http_connection.py rename to src/HABApp/openhab/connection_handler/http_connection.py index 10b822a2..43214e2a 100644 --- a/HABApp/openhab/connection_handler/http_connection.py +++ b/src/HABApp/openhab/connection_handler/http_connection.py @@ -247,7 +247,7 @@ async def start_connection(): read_bufsize=2**19 # 512k buffer ) - FUT_UUID = asyncio.ensure_future(try_uuid()) + FUT_UUID = asyncio.create_task(try_uuid()) async def start_sse_event_listener(): @@ -340,7 +340,7 @@ async def try_uuid(): log.error(line) # Keep trying to connect - FUT_UUID = asyncio.ensure_future(try_uuid()) + FUT_UUID = asyncio.create_task(try_uuid()) return None if IS_READ_ONLY: @@ -359,7 +359,7 @@ async def try_uuid(): # start sse processing if FUT_SSE is not None: FUT_SSE.cancel() - FUT_SSE = asyncio.ensure_future(start_sse_event_listener()) + FUT_SSE = asyncio.create_task(start_sse_event_listener()) ON_CONNECTED() return None diff --git a/HABApp/openhab/connection_handler/http_connection_waiter.py b/src/HABApp/openhab/connection_handler/http_connection_waiter.py similarity index 100% rename from HABApp/openhab/connection_handler/http_connection_waiter.py rename to src/HABApp/openhab/connection_handler/http_connection_waiter.py diff --git a/HABApp/openhab/connection_logic/__init__.py b/src/HABApp/openhab/connection_logic/__init__.py similarity index 100% rename from HABApp/openhab/connection_logic/__init__.py rename to src/HABApp/openhab/connection_logic/__init__.py diff --git a/HABApp/openhab/connection_logic/_plugin.py b/src/HABApp/openhab/connection_logic/_plugin.py similarity index 93% rename from HABApp/openhab/connection_logic/_plugin.py rename to src/HABApp/openhab/connection_logic/_plugin.py index 3940e455..7bc80434 100644 --- a/HABApp/openhab/connection_logic/_plugin.py +++ b/src/HABApp/openhab/connection_logic/_plugin.py @@ -2,7 +2,6 @@ import logging from typing import List, Optional -from HABApp.core.const import loop from HABApp.core.wrapper import ExceptionToHABApp log = logging.getLogger('HABApp.openhab.plugin') @@ -38,7 +37,7 @@ def setup(self): pass def on_connect(self): - self.fut = asyncio.ensure_future(self.on_connect_function(), loop=loop) + self.fut = asyncio.create_task(self.on_connect_function()) def on_disconnect(self): if self.fut is not None: @@ -67,6 +66,7 @@ def on_disconnect(): def setup_plugins(): + log.debug('Starting setup') for p in PLUGINS: with ExceptionToHABApp(log, ignore_exception=True): p.setup() diff --git a/HABApp/openhab/connection_logic/connection.py b/src/HABApp/openhab/connection_logic/connection.py similarity index 87% rename from HABApp/openhab/connection_logic/connection.py rename to src/HABApp/openhab/connection_logic/connection.py index 9b9069d3..bd35adf1 100644 --- a/HABApp/openhab/connection_logic/connection.py +++ b/src/HABApp/openhab/connection_logic/connection.py @@ -48,11 +48,19 @@ def on_sse_event(event_dict: dict): __item.set_value(event.value) HABApp.core.EventBus.post_event(event.name, event) return None - elif isinstance(event, HABApp.openhab.events.ThingStatusInfoEvent): + + if isinstance(event, HABApp.openhab.events.ThingStatusInfoEvent): __thing = Items.get_item(event.name) # type: HABApp.openhab.items.Thing __thing.process_event(event) HABApp.core.EventBus.post_event(event.name, event) return None + + # Workaround because there is no GroupItemStateEvent + if isinstance(event, HABApp.openhab.events.GroupItemStateChangedEvent): + __item = Items.get_item(event.name) # type: HABApp.openhab.items.GroupItem + __item.set_value(event.value) + HABApp.core.EventBus.post_event(event.name, event) + return None except HABApp.core.Items.ItemNotFoundException: pass diff --git a/HABApp/openhab/connection_logic/plugin_load_items.py b/src/HABApp/openhab/connection_logic/plugin_load_items.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_load_items.py rename to src/HABApp/openhab/connection_logic/plugin_load_items.py diff --git a/HABApp/openhab/connection_logic/plugin_ping.py b/src/HABApp/openhab/connection_logic/plugin_ping.py similarity index 96% rename from HABApp/openhab/connection_logic/plugin_ping.py rename to src/HABApp/openhab/connection_logic/plugin_ping.py index cdf8445a..c004bdf8 100644 --- a/HABApp/openhab/connection_logic/plugin_ping.py +++ b/src/HABApp/openhab/connection_logic/plugin_ping.py @@ -37,7 +37,7 @@ def on_connect(self): self.ping_sent = None self.ping_new = None - self.fut_ping = asyncio.ensure_future(self.async_ping(), loop=HABApp.core.const.loop) + self.fut_ping = asyncio.create_task(self.async_ping()) def on_disconnect(self): if self.fut_ping is not None: diff --git a/HABApp/openhab/connection_logic/plugin_thing_overview.py b/src/HABApp/openhab/connection_logic/plugin_thing_overview.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_thing_overview.py rename to src/HABApp/openhab/connection_logic/plugin_thing_overview.py diff --git a/HABApp/openhab/connection_logic/plugin_things/__init__.py b/src/HABApp/openhab/connection_logic/plugin_things/__init__.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/__init__.py rename to src/HABApp/openhab/connection_logic/plugin_things/__init__.py diff --git a/HABApp/openhab/connection_logic/plugin_things/_log.py b/src/HABApp/openhab/connection_logic/plugin_things/_log.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/_log.py rename to src/HABApp/openhab/connection_logic/plugin_things/_log.py diff --git a/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py b/src/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/cfg_validator.py rename to src/HABApp/openhab/connection_logic/plugin_things/cfg_validator.py diff --git a/HABApp/openhab/connection_logic/plugin_things/filters.py b/src/HABApp/openhab/connection_logic/plugin_things/filters.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/filters.py rename to src/HABApp/openhab/connection_logic/plugin_things/filters.py diff --git a/HABApp/openhab/connection_logic/plugin_things/item_worker.py b/src/HABApp/openhab/connection_logic/plugin_things/item_worker.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/item_worker.py rename to src/HABApp/openhab/connection_logic/plugin_things/item_worker.py diff --git a/HABApp/openhab/connection_logic/plugin_things/items_file.py b/src/HABApp/openhab/connection_logic/plugin_things/items_file.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/items_file.py rename to src/HABApp/openhab/connection_logic/plugin_things/items_file.py diff --git a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py similarity index 78% rename from HABApp/openhab/connection_logic/plugin_things/plugin_things.py rename to src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index 7f4eb1d3..c546dd87 100644 --- a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -1,8 +1,12 @@ import asyncio +import time from pathlib import Path -from typing import Dict, Set, List +from typing import Dict, Set, Optional import HABApp +from HABApp.core.files.file import HABAppFile +from HABApp.core.files.folders import add_folder as add_habapp_folder +from HABApp.core.files.watcher import AggregatingAsyncEventHandler from HABApp.core.lib import PendingFuture from HABApp.core.logger import log_warning, HABAppError from HABApp.openhab.connection_handler.func_async import async_get_things @@ -27,43 +31,40 @@ def __init__(self): self.created_items: Dict[str, Set[str]] = {} self.do_cleanup = PendingFuture(self.clean_items, 120) + self.watcher: Optional[AggregatingAsyncEventHandler] = None + + self.cache_ts: float = 0.0 + self.cache_cfg: dict = {} + def setup(self): - if not HABApp.CONFIG.directories.config.is_dir(): + path = HABApp.CONFIG.directories.config + if not path.is_dir(): log.info('Config folder does not exist - textual thing config disabled!') return None - # Add event bus listener - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - HABApp.core.const.topics.FILES, - HABApp.core.WrappedFunction(self.file_load_event), - HABApp.core.events.habapp_events.RequestFileLoadEvent - ) - ) + class HABAppThingConfigFile(HABAppFile): + LOGGER = log + LOAD_FUNC = self.file_load + UNLOAD_FUNC = self.file_unload - # watch folder - HABApp.core.files.watch_folder(HABApp.CONFIG.directories.config, '.yml', True) + folder = add_habapp_folder('config/', path, 50) + folder.add_file_type(HABAppThingConfigFile) + self.watcher = folder.add_watch('.yml') - async def file_load_event(self, event: HABApp.core.events.habapp_events.RequestFileLoadEvent): - if HABApp.core.files.file_name.is_config(event.name): - await self.update_thing_config(event.get_path()) + async def file_unload(self, prefix: str, path: Path): + return None async def on_connect_function(self): + if self.watcher is None: + return None + try: await asyncio.sleep(0.3) - files = list(HABApp.core.lib.list_files(HABApp.CONFIG.directories.config, '.yml')) - if not files: - log.debug(f'No manual configuration files found in {HABApp.CONFIG.directories.config}') - return None - - # if oh is not ready we will get None, but we will trigger again on reconnect - data = await async_get_things() - if data is None: - return None + self.cache_cfg = await async_get_things() + self.cache_ts = time.time() - for f in files: - await self.update_thing_config(f, data) + await self.watcher.trigger_all() except asyncio.CancelledError: pass @@ -74,24 +75,19 @@ async def clean_items(self): items.update(s) await cleanup_items(items) - async def update_thing_configs(self, files: List[Path]): - data = await async_get_things() - if data is None: - return None - - for file in files: - await self.update_thing_config(file, data) - - @HABApp.core.wrapper.ignore_exception - async def update_thing_config(self, path: Path, data=None): + 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() if not _name.startswith('thing_') or not _name.endswith('.yml'): + log.warning(f'Name for "{name}" does not start with "thing_" -> skip!') return None # only load if we don't supply the data - if data is None: - data = await async_get_things() + 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 # remove created items self.created_items.pop(path.name, None) @@ -109,7 +105,7 @@ async def update_thing_config(self, path: Path, data=None): if not path.is_file(): log.debug(f'File {path} does not exist -> skipping Thing configuration!') return None - log.debug(f'Loading {path}!') + log.debug(f'Loading {name}!') # load the config file with path.open(mode='r', encoding='utf-8') as file: @@ -199,5 +195,7 @@ async def update_thing_config(self, path: Path, data=None): create_items_file(output_file, create_items) + self.cache_cfg = {} + PLUGIN_MANUAL_THING_CFG = ManualThingConfig.create_plugin() diff --git a/HABApp/openhab/connection_logic/plugin_things/str_builder.py b/src/HABApp/openhab/connection_logic/plugin_things/str_builder.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/str_builder.py rename to src/HABApp/openhab/connection_logic/plugin_things/str_builder.py diff --git a/HABApp/openhab/connection_logic/plugin_things/thing_config.py b/src/HABApp/openhab/connection_logic/plugin_things/thing_config.py similarity index 95% rename from HABApp/openhab/connection_logic/plugin_things/thing_config.py rename to src/HABApp/openhab/connection_logic/plugin_things/thing_config.py index a6fc87d1..8fffb780 100644 --- a/HABApp/openhab/connection_logic/plugin_things/thing_config.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/thing_config.py @@ -8,18 +8,18 @@ from ._log import log_cfg as log -def ensure_same_types(a, b, key: str): - t_a = type(a) - t_b = type(b) - if t_a is t_b: +def ensure_same_types(key: str, org, val): + t_org = type(org) + t_val = type(val) + if t_org is t_val: return None - _a = str(t_a) + _a = str(t_org) _a = _a[7:-1] if _a.startswith('' else _a - _b = str(t_b) + _b = str(t_val) _b = _b[7:-1] if _b.startswith('' else _b - raise ValueError(f"Datatype of parameter '{key}' must be {_a} but is {_b}!") + raise ValueError(f"Datatype of parameter '{key}' must be {_a} but is {_b}: '{val}'") re_ref = re.compile(r'\$(\w+)') @@ -94,7 +94,7 @@ def __setitem__(self, o_key, value): log.debug(f' -> "{value}"') org = self.org[key] - ensure_same_types(value, org, o_key) + ensure_same_types(o_key, org, value) if value == org: return None diff --git a/HABApp/openhab/connection_logic/plugin_things/thing_worker.py b/src/HABApp/openhab/connection_logic/plugin_things/thing_worker.py similarity index 100% rename from HABApp/openhab/connection_logic/plugin_things/thing_worker.py rename to src/HABApp/openhab/connection_logic/plugin_things/thing_worker.py diff --git a/HABApp/openhab/definitions/__init__.py b/src/HABApp/openhab/definitions/__init__.py similarity index 100% rename from HABApp/openhab/definitions/__init__.py rename to src/HABApp/openhab/definitions/__init__.py diff --git a/HABApp/openhab/definitions/definitions.py b/src/HABApp/openhab/definitions/definitions.py similarity index 100% rename from HABApp/openhab/definitions/definitions.py rename to src/HABApp/openhab/definitions/definitions.py diff --git a/HABApp/openhab/definitions/helpers/__init__.py b/src/HABApp/openhab/definitions/helpers/__init__.py similarity index 100% rename from HABApp/openhab/definitions/helpers/__init__.py rename to src/HABApp/openhab/definitions/helpers/__init__.py diff --git a/HABApp/openhab/definitions/helpers/log_table.py b/src/HABApp/openhab/definitions/helpers/log_table.py similarity index 100% rename from HABApp/openhab/definitions/helpers/log_table.py rename to src/HABApp/openhab/definitions/helpers/log_table.py diff --git a/HABApp/openhab/definitions/helpers/persistence_data.py b/src/HABApp/openhab/definitions/helpers/persistence_data.py similarity index 100% rename from HABApp/openhab/definitions/helpers/persistence_data.py rename to src/HABApp/openhab/definitions/helpers/persistence_data.py diff --git a/HABApp/openhab/definitions/rest/__init__.py b/src/HABApp/openhab/definitions/rest/__init__.py similarity index 100% rename from HABApp/openhab/definitions/rest/__init__.py rename to src/HABApp/openhab/definitions/rest/__init__.py diff --git a/HABApp/openhab/definitions/rest/base.py b/src/HABApp/openhab/definitions/rest/base.py similarity index 100% rename from HABApp/openhab/definitions/rest/base.py rename to src/HABApp/openhab/definitions/rest/base.py diff --git a/HABApp/openhab/definitions/rest/habapp_data.py b/src/HABApp/openhab/definitions/rest/habapp_data.py similarity index 100% rename from HABApp/openhab/definitions/rest/habapp_data.py rename to src/HABApp/openhab/definitions/rest/habapp_data.py diff --git a/HABApp/openhab/definitions/rest/items.py b/src/HABApp/openhab/definitions/rest/items.py similarity index 100% rename from HABApp/openhab/definitions/rest/items.py rename to src/HABApp/openhab/definitions/rest/items.py diff --git a/HABApp/openhab/definitions/rest/links.py b/src/HABApp/openhab/definitions/rest/links.py similarity index 100% rename from HABApp/openhab/definitions/rest/links.py rename to src/HABApp/openhab/definitions/rest/links.py diff --git a/HABApp/openhab/definitions/rest/things.py b/src/HABApp/openhab/definitions/rest/things.py similarity index 100% rename from HABApp/openhab/definitions/rest/things.py rename to src/HABApp/openhab/definitions/rest/things.py diff --git a/HABApp/openhab/definitions/values.py b/src/HABApp/openhab/definitions/values.py similarity index 100% rename from HABApp/openhab/definitions/values.py rename to src/HABApp/openhab/definitions/values.py diff --git a/HABApp/openhab/events/__init__.py b/src/HABApp/openhab/events/__init__.py similarity index 98% rename from HABApp/openhab/events/__init__.py rename to src/HABApp/openhab/events/__init__.py index 5d1d8836..d3275ba9 100644 --- a/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 .thing_events import ThingStatusInfoChangedEvent, ThingStatusInfoEvent, \ - ThingConfigStatusInfoEvent, ThingFirmwareStatusInfoEvent -from .event_filters import ItemStateEventFilter, ItemStateChangedEventFilter +from .base_event import OpenhabEvent +from .item_events import ItemStateEvent, ItemStateChangedEvent, ItemCommandEvent, ItemAddedEvent,\ + ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent +from .channel_events import ChannelTriggeredEvent +from .thing_events import ThingStatusInfoChangedEvent, ThingStatusInfoEvent, \ + ThingConfigStatusInfoEvent, ThingFirmwareStatusInfoEvent +from .event_filters import ItemStateEventFilter, ItemStateChangedEventFilter diff --git a/HABApp/openhab/events/base_event.py b/src/HABApp/openhab/events/base_event.py similarity index 95% rename from HABApp/openhab/events/base_event.py rename to src/HABApp/openhab/events/base_event.py index 2ec87332..d40a5e74 100644 --- a/HABApp/openhab/events/base_event.py +++ b/src/HABApp/openhab/events/base_event.py @@ -1,6 +1,6 @@ - -class OpenhabEvent: - - @classmethod - def from_dict(cls, topic: str, payload: dict): - raise NotImplementedError() + +class OpenhabEvent: + + @classmethod + def from_dict(cls, topic: str, payload: dict): + raise NotImplementedError() diff --git a/HABApp/openhab/events/channel_events.py b/src/HABApp/openhab/events/channel_events.py similarity index 100% rename from HABApp/openhab/events/channel_events.py rename to src/HABApp/openhab/events/channel_events.py diff --git a/HABApp/openhab/events/event_filters.py b/src/HABApp/openhab/events/event_filters.py similarity index 100% rename from HABApp/openhab/events/event_filters.py rename to src/HABApp/openhab/events/event_filters.py diff --git a/HABApp/openhab/events/item_events.py b/src/HABApp/openhab/events/item_events.py similarity index 96% rename from HABApp/openhab/events/item_events.py rename to src/HABApp/openhab/events/item_events.py index c2f7ade7..6af017d9 100644 --- a/HABApp/openhab/events/item_events.py +++ b/src/HABApp/openhab/events/item_events.py @@ -1,218 +1,218 @@ -import typing -import HABApp.core - -from ..map_values import map_openhab_values -from .base_event import OpenhabEvent - -# 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: - """ - name: str - value: typing.Any - - def __init__(self, name: str = '', value: typing.Any = None): - super().__init__() - - # smarthome/items/NAME/state - self.name: str = name - self.value: typing.Any = value - - @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'])) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' - - -class ItemStateChangedEvent(OpenhabEvent, HABApp.core.events.ValueChangeEvent): - """ - :ivar str ~.name: - :ivar ~.value: - :ivar ~.old_value: - """ - name: str - value: typing.Any - old_value: typing.Any - - def __init__(self, name: str = '', value: typing.Any = None, old_value: typing.Any = None): - super().__init__() - - self.name: str = name - self.value: typing.Any = value - self.old_value: typing.Any = old_value - - @classmethod - def from_dict(cls, topic: str, payload: dict): - # smarthome/items/Ping/statechanged - return cls( - topic[NAME_START:-13], - map_openhab_values(payload['type'], payload['value']), - map_openhab_values(payload['oldType'], payload['oldValue']) - ) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' - - -class ItemCommandEvent(OpenhabEvent): - """ - :ivar str ~.name: - :ivar ~.value: - """ - name: str - value: typing.Any - - def __init__(self, name: str = '', value: typing.Any = None): - super().__init__() - - self.name: str = name - self.value: typing.Any = value - - @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'])) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' - - -class ItemAddedEvent(OpenhabEvent): - """ - :ivar str ~.name: - :ivar str ~.type: - """ - name: str - type: str - - def __init__(self, name: str = '', type: str = ''): - super().__init__() - - self.name: str = name - self.type: str = type - - @classmethod - 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']) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' - - -class ItemUpdatedEvent(OpenhabEvent): - """ - :ivar str ~.name: - :ivar str ~.type: - """ - name: str - type: str - - def __init__(self, name: str = '', type: str = ''): - super().__init__() - - self.name: str = name - self.type: str = type - - @classmethod - def from_dict(cls, topic: str, payload: dict): - # smarthome/items/NAME/updated - # 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]}, - # {"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', - # 'type': 'ItemUpdatedEvent' - return cls(topic[NAME_START:-8], payload[0]['type']) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' - - -class ItemRemovedEvent(OpenhabEvent): - """ - :ivar str ~.name: - """ - name: str - - def __init__(self, name: str = ''): - super().__init__() - - self.name = name - - @classmethod - def from_dict(cls, topic: str, payload: dict): - # smarthome/items/Test/removed - return cls(topic[NAME_START:-8]) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}>' - - -class ItemStatePredictedEvent(OpenhabEvent): - """ - :ivar str ~.name: - :ivar ~.value: - """ - name: str - value: typing.Any - - def __init__(self, name: str = '', value: typing.Any = None): - super().__init__() - - # smarthome/items/NAME/state - self.name: str = name - self.value: typing.Any = value - - @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'])) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' - - -class GroupItemStateChangedEvent(OpenhabEvent): - """ - :ivar str ~.name: - :ivar str ~.item: - :ivar ~.value: - :ivar ~.old_value: - """ - name: str - item: str - value: typing.Any - old_value: typing.Any - - def __init__(self, name: str = '', item: str = '', value: typing.Any = None, old_value: typing.Any = None): - super().__init__() - - self.name: str = name - self.item: str = item - - self.value: typing.Any = value - self.old_value: typing.Any = old_value - - @classmethod - def from_dict(cls, topic: str, payload: dict): - # 'smarthome/items/TestGroupAVG/TestNumber1/statechanged' - parts = topic.split('/') - - return cls( - parts[2], parts[3], - map_openhab_values(payload['type'], payload['value']), - map_openhab_values(payload['oldType'], payload['oldValue']) - ) - - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' +import typing +import HABApp.core + +from ..map_values import map_openhab_values +from .base_event import OpenhabEvent + +# 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: + """ + name: str + value: typing.Any + + def __init__(self, name: str = '', value: typing.Any = None): + super().__init__() + + # smarthome/items/NAME/state + self.name: str = name + self.value: typing.Any = value + + @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'])) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' + + +class ItemStateChangedEvent(OpenhabEvent, HABApp.core.events.ValueChangeEvent): + """ + :ivar str ~.name: + :ivar ~.value: + :ivar ~.old_value: + """ + name: str + value: typing.Any + old_value: typing.Any + + def __init__(self, name: str = '', value: typing.Any = None, old_value: typing.Any = None): + super().__init__() + + self.name: str = name + self.value: typing.Any = value + self.old_value: typing.Any = old_value + + @classmethod + def from_dict(cls, topic: str, payload: dict): + # smarthome/items/Ping/statechanged + return cls( + topic[NAME_START:-13], + map_openhab_values(payload['type'], payload['value']), + map_openhab_values(payload['oldType'], payload['oldValue']) + ) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' + + +class ItemCommandEvent(OpenhabEvent): + """ + :ivar str ~.name: + :ivar ~.value: + """ + name: str + value: typing.Any + + def __init__(self, name: str = '', value: typing.Any = None): + super().__init__() + + self.name: str = name + self.value: typing.Any = value + + @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'])) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' + + +class ItemAddedEvent(OpenhabEvent): + """ + :ivar str ~.name: + :ivar str ~.type: + """ + name: str + type: str + + def __init__(self, name: str = '', type: str = ''): + super().__init__() + + self.name: str = name + self.type: str = type + + @classmethod + 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']) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' + + +class ItemUpdatedEvent(OpenhabEvent): + """ + :ivar str ~.name: + :ivar str ~.type: + """ + name: str + type: str + + def __init__(self, name: str = '', type: str = ''): + super().__init__() + + self.name: str = name + self.type: str = type + + @classmethod + def from_dict(cls, topic: str, payload: dict): + # smarthome/items/NAME/updated + # 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]}, + # {"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', + # 'type': 'ItemUpdatedEvent' + return cls(topic[NAME_START:-8], payload[0]['type']) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' + + +class ItemRemovedEvent(OpenhabEvent): + """ + :ivar str ~.name: + """ + name: str + + def __init__(self, name: str = ''): + super().__init__() + + self.name = name + + @classmethod + def from_dict(cls, topic: str, payload: dict): + # smarthome/items/Test/removed + return cls(topic[NAME_START:-8]) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}>' + + +class ItemStatePredictedEvent(OpenhabEvent): + """ + :ivar str ~.name: + :ivar ~.value: + """ + name: str + value: typing.Any + + def __init__(self, name: str = '', value: typing.Any = None): + super().__init__() + + # smarthome/items/NAME/state + self.name: str = name + self.value: typing.Any = value + + @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'])) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' + + +class GroupItemStateChangedEvent(OpenhabEvent): + """ + :ivar str ~.name: + :ivar str ~.item: + :ivar ~.value: + :ivar ~.old_value: + """ + name: str + item: str + value: typing.Any + old_value: typing.Any + + def __init__(self, name: str = '', item: str = '', value: typing.Any = None, old_value: typing.Any = None): + super().__init__() + + self.name: str = name + self.item: str = item + + self.value: typing.Any = value + self.old_value: typing.Any = old_value + + @classmethod + def from_dict(cls, topic: str, payload: dict): + # 'smarthome/items/TestGroupAVG/TestNumber1/statechanged' + parts = topic.split('/') + + return cls( + parts[2], parts[3], + map_openhab_values(payload['type'], payload['value']), + map_openhab_values(payload['oldType'], payload['oldValue']) + ) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' diff --git a/HABApp/openhab/events/thing_events.py b/src/HABApp/openhab/events/thing_events.py similarity index 100% rename from HABApp/openhab/events/thing_events.py rename to src/HABApp/openhab/events/thing_events.py diff --git a/HABApp/openhab/exceptions.py b/src/HABApp/openhab/exceptions.py similarity index 100% rename from HABApp/openhab/exceptions.py rename to src/HABApp/openhab/exceptions.py diff --git a/HABApp/openhab/interface.py b/src/HABApp/openhab/interface.py similarity index 100% rename from HABApp/openhab/interface.py rename to src/HABApp/openhab/interface.py diff --git a/HABApp/openhab/interface_async.py b/src/HABApp/openhab/interface_async.py similarity index 100% rename from HABApp/openhab/interface_async.py rename to src/HABApp/openhab/interface_async.py diff --git a/HABApp/openhab/items/__init__.py b/src/HABApp/openhab/items/__init__.py similarity index 97% rename from HABApp/openhab/items/__init__.py rename to src/HABApp/openhab/items/__init__.py index f8a8ceb8..3fe20927 100644 --- a/HABApp/openhab/items/__init__.py +++ b/src/HABApp/openhab/items/__init__.py @@ -1,12 +1,12 @@ -from .base_item import OpenhabItem -from .contact_item import ContactItem -from .dimmer_item import DimmerItem -from .rollershutter_item import RollershutterItem -from .switch_item import SwitchItem -from .color_item import ColorItem -from .number_item import NumberItem -from .datetime_item import DatetimeItem -from .string_item import StringItem, LocationItem, PlayerItem -from .image_item import ImageItem -from .group_item import GroupItem -from .thing_item import Thing +from .base_item import OpenhabItem +from .contact_item import ContactItem +from .dimmer_item import DimmerItem +from .rollershutter_item import RollershutterItem +from .switch_item import SwitchItem +from .color_item import ColorItem +from .number_item import NumberItem +from .datetime_item import DatetimeItem +from .string_item import StringItem, LocationItem, PlayerItem +from .image_item import ImageItem +from .group_item import GroupItem +from .thing_item import Thing diff --git a/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py similarity index 100% rename from HABApp/openhab/items/base_item.py rename to src/HABApp/openhab/items/base_item.py diff --git a/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py similarity index 84% rename from HABApp/openhab/items/color_item.py rename to src/HABApp/openhab/items/color_item.py index 29bf10c7..5bf5935a 100644 --- a/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -1,8 +1,8 @@ -import colorsys -import typing +from typing import Optional, Tuple -from HABApp.openhab.items.commands import OnOffCommand, PercentCommand +from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb from HABApp.openhab.items.base_item import OpenhabItem +from HABApp.openhab.items.commands import OnOffCommand, PercentCommand from ..definitions import HSBValue, OnOffValue, PercentValue HUE_FACTOR = 360 @@ -62,33 +62,26 @@ def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): brightness if brightness is not None else self.brightness) ) - def get_rgb(self, max_rgb_value=255) -> typing.Tuple[int, int, int]: + def get_rgb(self, max_rgb_value=255) -> Tuple[int, int, int]: """Return a rgb equivalent of the color :param max_rgb_value: the max value for rgb, typically 255 (default) or 65.536 :return: rgb tuple """ - r, g, b = colorsys.hsv_to_rgb( - self.hue / HUE_FACTOR, - self.saturation / PERCENT_FACTOR, - self.brightness / PERCENT_FACTOR - ) - return int(r * max_rgb_value), int(g * max_rgb_value), int(b * max_rgb_value) + return hsb_to_rgb(self.hue, self.saturation, self.brightness, max_rgb_value=max_rgb_value) - def set_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': + def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'ColorItem': """Set a rgb value :param r: red value :param g: green value :param b: blue value :param max_rgb_value: the max value for rgb, typically 255 (default) or 65.536 + :param ndigits: Round the hsb values to the specified digits, None to disable rounding :return: self """ - h, s, v = colorsys.rgb_to_hsv(r / max_rgb_value, g / max_rgb_value, b / max_rgb_value) - self.hue = h * HUE_FACTOR - self.saturation = s * PERCENT_FACTOR - self.brightness = v * PERCENT_FACTOR - self.set_value(self.hue, self.saturation, self.brightness) + h, s, b = rgb_to_hsb(r, g, b, max_rgb_value=max_rgb_value, ndigits=ndigits) + self.set_value(h, s, b) return self def post_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': diff --git a/HABApp/openhab/items/commands.py b/src/HABApp/openhab/items/commands.py similarity index 100% rename from HABApp/openhab/items/commands.py rename to src/HABApp/openhab/items/commands.py diff --git a/HABApp/openhab/items/contact_item.py b/src/HABApp/openhab/items/contact_item.py similarity index 96% rename from HABApp/openhab/items/contact_item.py rename to src/HABApp/openhab/items/contact_item.py index 0b2e1b20..cb7bf712 100644 --- a/HABApp/openhab/items/contact_item.py +++ b/src/HABApp/openhab/items/contact_item.py @@ -1,39 +1,39 @@ -from HABApp.openhab.items.base_item import OpenhabItem -from ..definitions import OpenClosedValue - - -class ContactItem(OpenhabItem): - - def set_value(self, new_value) -> bool: - - if isinstance(new_value, OpenClosedValue): - new_value = new_value.value - - if new_value is not None and new_value != OpenClosedValue.OPEN and new_value != OpenClosedValue.CLOSED: - raise ValueError(f'Invalid value for ContactItem: {new_value}') - return super().set_value(new_value) - - def is_open(self) -> bool: - """Test value against open-value""" - return self.value == OpenClosedValue.OPEN - - def is_closed(self) -> bool: - """Test value against closed-value""" - return self.value == OpenClosedValue.CLOSED - - def __str__(self): - return self.value - - def __eq__(self, other): - if isinstance(other, ContactItem): - return self.value == other.value - elif isinstance(other, str): - return self.value == other - elif isinstance(other, int): - if other and self.is_open(): - return True - if not other and self.is_closed(): - return True - return False - - return NotImplemented +from HABApp.openhab.items.base_item import OpenhabItem +from ..definitions import OpenClosedValue + + +class ContactItem(OpenhabItem): + + def set_value(self, new_value) -> bool: + + if isinstance(new_value, OpenClosedValue): + new_value = new_value.value + + if new_value is not None and new_value != OpenClosedValue.OPEN and new_value != OpenClosedValue.CLOSED: + raise ValueError(f'Invalid value for ContactItem: {new_value}') + return super().set_value(new_value) + + def is_open(self) -> bool: + """Test value against open-value""" + return self.value == OpenClosedValue.OPEN + + def is_closed(self) -> bool: + """Test value against closed-value""" + return self.value == OpenClosedValue.CLOSED + + def __str__(self): + return self.value + + def __eq__(self, other): + if isinstance(other, ContactItem): + return self.value == other.value + elif isinstance(other, str): + return self.value == other + elif isinstance(other, int): + if other and self.is_open(): + return True + if not other and self.is_closed(): + return True + return False + + return NotImplemented diff --git a/HABApp/openhab/items/datetime_item.py b/src/HABApp/openhab/items/datetime_item.py similarity index 100% rename from HABApp/openhab/items/datetime_item.py rename to src/HABApp/openhab/items/datetime_item.py diff --git a/HABApp/openhab/items/dimmer_item.py b/src/HABApp/openhab/items/dimmer_item.py similarity index 100% rename from HABApp/openhab/items/dimmer_item.py rename to src/HABApp/openhab/items/dimmer_item.py diff --git a/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py similarity index 100% rename from HABApp/openhab/items/group_item.py rename to src/HABApp/openhab/items/group_item.py diff --git a/HABApp/openhab/items/image_item.py b/src/HABApp/openhab/items/image_item.py similarity index 100% rename from HABApp/openhab/items/image_item.py rename to src/HABApp/openhab/items/image_item.py diff --git a/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py similarity index 100% rename from HABApp/openhab/items/number_item.py rename to src/HABApp/openhab/items/number_item.py diff --git a/HABApp/openhab/items/rollershutter_item.py b/src/HABApp/openhab/items/rollershutter_item.py similarity index 100% rename from HABApp/openhab/items/rollershutter_item.py rename to src/HABApp/openhab/items/rollershutter_item.py diff --git a/HABApp/openhab/items/string_item.py b/src/HABApp/openhab/items/string_item.py similarity index 100% rename from HABApp/openhab/items/string_item.py rename to src/HABApp/openhab/items/string_item.py diff --git a/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py similarity index 96% rename from HABApp/openhab/items/switch_item.py rename to src/HABApp/openhab/items/switch_item.py index f5222a4f..3642b0b1 100644 --- a/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -1,43 +1,43 @@ -from HABApp.openhab.items.base_item import OpenhabItem -from HABApp.openhab.items.commands import OnOffCommand -from ..definitions import OnOffValue - - -class SwitchItem(OpenhabItem, OnOffCommand): - - def set_value(self, new_value) -> bool: - - if isinstance(new_value, OnOffValue): - new_value = new_value.value - - if new_value is not None and new_value != OnOffValue.ON and new_value != OnOffValue.OFF: - raise ValueError(f'Invalid value for SwitchItem {self.name}: {new_value}') - return super().set_value(new_value) - - def is_on(self) -> bool: - """Test value against on-value""" - return True if self.value == OnOffValue.ON else False - - def is_off(self) -> bool: - """Test value against off-value""" - return True if self.value == OnOffValue.OFF else False - - def __str__(self): - return self.value - - def __eq__(self, other): - if isinstance(other, SwitchItem): - return self.value == other.value - elif isinstance(other, str): - return self.value == other - elif isinstance(other, int): - if other and self.is_on(): - return True - if not other and self.is_off(): - return True - return False - - return NotImplemented - - def __bool__(self): - return self.is_on() +from HABApp.openhab.items.base_item import OpenhabItem +from HABApp.openhab.items.commands import OnOffCommand +from ..definitions import OnOffValue + + +class SwitchItem(OpenhabItem, OnOffCommand): + + def set_value(self, new_value) -> bool: + + if isinstance(new_value, OnOffValue): + new_value = new_value.value + + if new_value is not None and new_value != OnOffValue.ON and new_value != OnOffValue.OFF: + raise ValueError(f'Invalid value for SwitchItem {self.name}: {new_value}') + return super().set_value(new_value) + + def is_on(self) -> bool: + """Test value against on-value""" + return True if self.value == OnOffValue.ON else False + + def is_off(self) -> bool: + """Test value against off-value""" + return True if self.value == OnOffValue.OFF else False + + def __str__(self): + return self.value + + def __eq__(self, other): + if isinstance(other, SwitchItem): + return self.value == other.value + elif isinstance(other, str): + return self.value == other + elif isinstance(other, int): + if other and self.is_on(): + return True + if not other and self.is_off(): + return True + return False + + return NotImplemented + + def __bool__(self): + return self.is_on() diff --git a/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py similarity index 89% rename from HABApp/openhab/items/thing_item.py rename to src/HABApp/openhab/items/thing_item.py index fe797e2a..9aff08d4 100644 --- a/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -1,6 +1,5 @@ -from datetime import datetime - -from pytz import utc +from pendulum import UTC +from pendulum import now as pd_now from HABApp.core.items.base_item import BaseItem from ..events import ThingStatusInfoEvent @@ -17,7 +16,7 @@ def __init__(self, name: str): self.status: str = '' def __update_timestamps(self, changed: bool): - _now = datetime.now(tz=utc) + _now = pd_now(UTC) self._last_update.set(_now) if changed: self._last_change.set(_now) diff --git a/HABApp/openhab/map_events.py b/src/HABApp/openhab/map_events.py similarity index 100% rename from HABApp/openhab/map_events.py rename to src/HABApp/openhab/map_events.py diff --git a/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py similarity index 89% rename from HABApp/openhab/map_items.py rename to src/HABApp/openhab/map_items.py index 3d61ce81..a54c56a7 100644 --- a/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -61,7 +61,14 @@ def map_item(name, openhab_type: str, openhab_value: str) -> typing.Optional[Bas if openhab_type == "DateTime": if value is None: return DatetimeItem(name, value) - dt = datetime.datetime.strptime(value.replace('+', '000+'), '%Y-%m-%dT%H:%M:%S.%f%z') + # 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 diff --git a/HABApp/openhab/map_values.py b/src/HABApp/openhab/map_values.py similarity index 81% rename from HABApp/openhab/map_values.py rename to src/HABApp/openhab/map_values.py index 4cb1dde8..79eec9bb 100644 --- a/HABApp/openhab/map_values.py +++ b/src/HABApp/openhab/map_values.py @@ -21,8 +21,14 @@ 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 - dt = datetime.datetime.strptime(openhab_value.replace('+', '000+'), '%Y-%m-%dT%H:%M:%S.%f%z') + # 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 # --> TypeError: can't compare offset-naive and offset-aware datetimes dt = dt.astimezone(tz=None) # Changes datetime object so it uses system timezone diff --git a/HABApp/parameters/__init__.py b/src/HABApp/parameters/__init__.py similarity index 100% rename from HABApp/parameters/__init__.py rename to src/HABApp/parameters/__init__.py diff --git a/HABApp/parameters/parameter.py b/src/HABApp/parameters/parameter.py similarity index 100% rename from HABApp/parameters/parameter.py rename to src/HABApp/parameters/parameter.py diff --git a/src/HABApp/parameters/parameter_files.py b/src/HABApp/parameters/parameter_files.py new file mode 100644 index 00000000..c6cfa02a --- /dev/null +++ b/src/HABApp/parameters/parameter_files.py @@ -0,0 +1,66 @@ +import logging +import threading +import asyncio +from pathlib import Path + +import HABApp +from HABApp.core.files.file import HABAppFile +from HABApp.core.files.folders import add_folder as add_habapp_folder +from .parameters import get_parameter_file, remove_parameter_file, set_parameter_file + +log = logging.getLogger('HABApp.RuleParameters') + +LOCK = threading.Lock() +PARAM_PREFIX = 'params/' + + +async def load_file(name: str, path: Path): + with LOCK: # serialize to get proper error messages + with path.open(mode='r', encoding='utf-8') as file: + data = HABApp.core.const.yml.load(file) + if data is None: + data = {} + set_parameter_file(path.stem, data) + + log.debug(f'Loaded params from {name}!') + + +async def unload_file(name: str, path: Path): + with LOCK: # serialize to get proper error messages + remove_parameter_file(path.stem) + + log.debug(f'Removed params from {path.name}!') + + +def save_file(file: str): + assert isinstance(file, str), type(file) + filename = HABApp.CONFIG.directories.param / (file + '.yml') + + with LOCK: # serialize to get proper error messages + log.info(f'Updated {filename}') + with filename.open('w', encoding='utf-8') as outfile: + HABApp.core.const.yml.dump(get_parameter_file(file), outfile) + + +class HABAppParameterFile(HABAppFile): + LOGGER = log + LOAD_FUNC = load_file + UNLOAD_FUNC = unload_file + + +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!') + return False + + folder = add_habapp_folder(PARAM_PREFIX, path, 100) + folder.add_file_type(HABAppParameterFile) + watcher = folder.add_watch('.yml') + await watcher.trigger_all() + + +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) diff --git a/HABApp/parameters/parameters.py b/src/HABApp/parameters/parameters.py similarity index 90% rename from HABApp/parameters/parameters.py rename to src/HABApp/parameters/parameters.py index 3d535dab..f2ccbcd6 100644 --- a/HABApp/parameters/parameters.py +++ b/src/HABApp/parameters/parameters.py @@ -1,9 +1,7 @@ -import HABApp import typing import voluptuous - _PARAMETERS: typing.Dict[str, dict] = {} _VALIDATORS: typing.Dict[str, voluptuous.Schema] = {} @@ -48,9 +46,7 @@ def set_file_validator(filename: str, validator: typing.Any, allow_extra_keys=Tr # todo: move this to file handling so we get the extension if old_validator != new_validator: - name = HABApp.core.files.file_name.PREFIX_PARAMS + '/' + filename + '.yml' - path = HABApp.core.files.file_name.path_from_name(name) - HABApp.core.files.all.process([path]) + reload_param_file(filename) def add_parameter(file: str, *keys, default_value): @@ -94,4 +90,4 @@ def get_value(file: str, *keys) -> typing.Any: # Import here to prevent cyclic imports -from .parameter_files import save_file # noqa: E402 +from .parameter_files import save_file, reload_param_file # noqa: E402 diff --git a/src/HABApp/rule/__init__.py b/src/HABApp/rule/__init__.py new file mode 100644 index 00000000..3dccd554 --- /dev/null +++ b/src/HABApp/rule/__init__.py @@ -0,0 +1,3 @@ +from .rule import Rule, get_parent_rule + +from HABApp.rule.interfaces import FinishedProcessInfo diff --git a/src/HABApp/rule/habappscheduler.py b/src/HABApp/rule/habappscheduler.py new file mode 100644 index 00000000..03d86efd --- /dev/null +++ b/src/HABApp/rule/habappscheduler.py @@ -0,0 +1,105 @@ +import random +from datetime import datetime as dt_datetime, time as dt_time, timedelta as dt_timedelta +from typing import Callable +from typing import Iterable, Union + +from eascheduler import SchedulerView +from eascheduler.executors import ExecutorBase +from eascheduler.jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ + SunsetJob +from eascheduler.schedulers import ThreadSafeAsyncScheduler + +import HABApp +from HABApp.core import WrappedFunction + + +class WrappedFunctionExecutor(ExecutorBase): + def __init__(self, func: Callable, *args, **kwargs): + assert isinstance(func, WrappedFunction), type(func) + super().__init__(func, *args, **kwargs) + + def execute(self): + self._func.run(*self._args, **self._kwargs) + + +class HABAppScheduler(SchedulerView): + def __init__(self, rule: 'HABApp.rule.Rule'): + super().__init__(ThreadSafeAsyncScheduler(), WrappedFunctionExecutor) + self._rule: 'HABApp.rule.Rule' = rule + + 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)) + 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)) + 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)) + 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)) + 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: + """Create a job that will run at a certain time of day + + :param time: Time when the job will run + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param kwargs: |param_scheduled_cb_kwargs| + """ + callback = WrappedFunction(callback, name=self._rule._get_cb_name(callback)) + 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)) + 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)) + 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)) + 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)) + return super().on_sun_dusk(callback, *args, **kwargs) + + def soon(self, callback, *args, **kwargs) -> OneTimeJob: + """ + Run the callback as soon as possible. + + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param kwargs: |param_scheduled_cb_kwargs| + """ + return self.at(None, callback, *args, **kwargs) + + def every_minute(self, callback, *args, **kwargs) -> ReoccurringJob: + """Picks a random second and runs the callback every minute + + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param kwargs: |param_scheduled_cb_kwargs| + """ + start = dt_timedelta(seconds=random.randint(0, 60 - 1)) + interval = dt_timedelta(seconds=60) + return self.every(start, interval, callback, *args, **kwargs) + + def every_hour(self, callback, *args, **kwargs) -> ReoccurringJob: + """Picks a random minute and second and run the callback every hour + + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param kwargs: |param_scheduled_cb_kwargs| + """ + start = dt_timedelta(seconds=random.randint(0, 3600 - 1)) + interval = dt_timedelta(hours=1) + return self.every(start, interval, callback, *args, **kwargs) diff --git a/HABApp/rule/interfaces/__init__.py b/src/HABApp/rule/interfaces/__init__.py similarity index 100% rename from HABApp/rule/interfaces/__init__.py rename to src/HABApp/rule/interfaces/__init__.py diff --git a/HABApp/rule/interfaces/http.py b/src/HABApp/rule/interfaces/http.py similarity index 100% rename from HABApp/rule/interfaces/http.py rename to src/HABApp/rule/interfaces/http.py diff --git a/HABApp/rule/interfaces/rule_subprocess.py b/src/HABApp/rule/interfaces/rule_subprocess.py similarity index 100% rename from HABApp/rule/interfaces/rule_subprocess.py rename to src/HABApp/rule/interfaces/rule_subprocess.py diff --git a/HABApp/rule/rule.py b/src/HABApp/rule/rule.py similarity index 51% rename from HABApp/rule/rule.py rename to src/HABApp/rule/rule.py index dcd6c89f..0107ab43 100644 --- a/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -1,479 +1,315 @@ -import asyncio -import datetime -import logging -import random -import sys -import traceback -import typing -import warnings -import weakref - -import HABApp -import HABApp.core -import HABApp.openhab -import HABApp.rule_manager -import HABApp.util -from HABApp.core.events import AllEvents -from .interfaces import async_subprocess_exec -from .scheduler import ReoccurringScheduledCallback, OneTimeCallback, DayOfWeekScheduledCallback, \ - TYPING_DATE_TIME, SunScheduledCallback -from .scheduler.base import ScheduledCallbackBase as _ScheduledCallbackBase - - -log = logging.getLogger('HABApp.Rule') - - -# Func to log deprecation warnings -def send_warnings_to_log(message, category, filename, lineno, file=None, line=None): - log.warning('%s:%s: %s:%s' % (filename, lineno, category.__name__, message)) - return - - -# Setup deprecation warnings -warnings.simplefilter('default') -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 - - # 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__ - - self.__event_listener: typing.List[HABApp.core.EventBusListener] = [] - self.__future_events: typing.List[_ScheduledCallbackBase] = [] - self.__unload_functions: typing.List[typing.Callable[[], None]] = [] - self.__cancel_objs: weakref.WeakSet = weakref.WeakSet() - - # schedule cleanup of this rule - self.register_on_unload(self.__cleanup_rule) - self.register_on_unload(self.__cleanup_objs) - - # suggest a rule name if it is not - self.rule_name: str = self.__rule_file.suggest_rule_name(self) - - # interfaces - self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = self.__runtime.async_http if not test else None - self.mqtt: HABApp.mqtt.interface = HABApp.mqtt.interface - 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 - future_events = self.__future_events - - self.__event_listener = None - self.__future_events = None - - # Actually remove the listeners/events - for listener in event_listeners: - HABApp.core.EventBus.remove_listener(listener) - - for event in future_events: - event.cancel() - return None - - def post_event(self, name, event): - """ - Post an event to the event bus - - :param name: name or item to post event to - :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, - event - ) - - def listen_event(self, name: typing.Union[HABApp.core.items.BaseValueItem, str], - callback: typing.Callable[[typing.Any], typing.Any], - event_type: typing.Union[typing.Type['HABApp.core.events.AllEvents'], - 'HABApp.core.events.EventFilter', typing.Any] = AllEvents - ) -> HABApp.core.EventBusListener: - """ - Register an event listener - - :param name: item or name to listen to. Use None to listen to all events - :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 - 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` - """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - name = name.name if isinstance(name, HABApp.core.items.BaseValueItem) 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) - - self.__event_listener.append(listener) - HABApp.core.EventBus.add_listener(listener) - return listener - - def execute_subprocess(self, callback, program, *args, capture_output=True): - """Run another program - - :param callback: |param_scheduled_cb| after process has finished. First parameter will - be an instance of :class:`~HABApp.rule.FinishedProcessInfo` - :param program: program or path to program to run - :param args: |param_scheduled_cb_args| - :param capture_output: Capture program output, set to `False` to only capture return code - :return: - """ - - assert isinstance(program, str), type(program) - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - - asyncio.run_coroutine_threadsafe( - async_subprocess_exec(cb.run, program, *args, capture_output=capture_output), - HABApp.core.const.loop - ) - - def run_every(self, - time: TYPING_DATE_TIME, interval: typing.Union[int, datetime.timedelta], - callback, *args, **kwargs) -> ReoccurringScheduledCallback: - """ - Run a function periodically - - :param time: |param_scheduled_time| - :param interval: - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = ReoccurringScheduledCallback(cb, *args, **kwargs) - future_event.interval(interval) - self.__future_events.append(future_event) - return future_event - - def run_on_sun(self, sun_event: str, callback, *args, run_if_missed=False, **kwargs) -> SunScheduledCallback: - """Run a function on sunrise/sunset etc - - :param sun_event: 'sunrise', 'sunset', 'dusk', 'dawn' - :param run_if_missed: run the event if we missed it for today - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = SunScheduledCallback(cb, *args, **kwargs) - future_event.sun_trigger(sun_event) - future_event._calculate_next_call() - self.__future_events.append(future_event) - return future_event - - def run_on_day_of_week(self, - time: datetime.time, weekdays, callback, *args, **kwargs) -> DayOfWeekScheduledCallback: - """ - - :param time: datetime.time - :param weekdays: - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - assert isinstance(time, datetime.time), type(time) - - # names of weekdays in local language - lookup = {datetime.date(2001, 1, i).strftime('%A'): i for i in range(1, 8)} - lookup.update({datetime.date(2001, 1, i).strftime('%A')[:3]: i for i in range(1, 8)}) - - # abbreviations in German and English - lookup.update({"Mo": 1, "Di": 2, "Mi": 3, "Do": 4, "Fr": 5, "Sa": 6, "So": 7}) - lookup.update({"Mon": 1, "Tue": 2, "Wed": 3, "Thu": 4, "Fri": 5, "Sat": 6, "Sun": 7}) - lookup = {k.lower(): v for k, v in lookup.items()} - - if isinstance(weekdays, int) or isinstance(weekdays, str): - weekdays = [weekdays] - for i, val in enumerate(weekdays): - if not isinstance(val, str): - continue - weekdays[i] = lookup[val.lower()] - - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) - future_event.weekdays(weekdays) - future_event.time(time) - self.__future_events.append(future_event) - return future_event - - def run_on_every_day(self, time: datetime.time, callback, *args, **kwargs) -> DayOfWeekScheduledCallback: - """ - - :param time: datetime.time - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) - future_event.weekdays('all') - future_event.time(time) - self.__future_events.append(future_event) - return future_event - - def run_on_workdays(self, time: datetime.time, callback, *args, **kwargs) -> DayOfWeekScheduledCallback: - """ - - :param time: datetime.time - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) - future_event.weekdays('workday') - future_event.time(time) - self.__future_events.append(future_event) - return future_event - - def run_on_weekends(self, time: datetime.time, callback, *args, **kwargs) -> DayOfWeekScheduledCallback: - """ - - :param time: datetime.time - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) - future_event.weekdays('weekend') - future_event.time(time) - self.__future_events.append(future_event) - return future_event - - def run_daily(self, callback, *args, **kwargs) -> ReoccurringScheduledCallback: - """ - Picks a random hour, minute and second and runs the callback every day - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - start = datetime.timedelta(seconds=random.randint(0, 24 * 3600 - 1)) - interval = datetime.timedelta(days=1) - return self.run_every(start, interval, callback, *args, **kwargs) - - def run_hourly(self, callback, *args, **kwargs) -> ReoccurringScheduledCallback: - """ - Picks a random minute and second and run the callback every hour - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - start = datetime.timedelta(seconds=random.randint(0, 3600 - 1)) - interval = datetime.timedelta(seconds=3600) - return self.run_every(start, interval, callback, *args, **kwargs) - - def run_minutely(self, callback, *args, **kwargs) -> ReoccurringScheduledCallback: - """ - Picks a random second and runs the callback every minute - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - start = datetime.timedelta(seconds=random.randint(0, 60 - 1)) - interval = datetime.timedelta(seconds=60) - return self.run_every(start, interval, callback, *args, **kwargs) - - def run_at(self, date_time: TYPING_DATE_TIME, callback, *args, **kwargs) -> OneTimeCallback: - """ - Run a function at a specified date_time - - :param date_time: - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = OneTimeCallback(cb, *args, **kwargs) - future_event.set_run_time(date_time) - self.__future_events.append(future_event) - return future_event - - def run_in(self, seconds: typing.Union[int, datetime.timedelta], callback, *args, **kwargs) -> OneTimeCallback: - """ - Run the callback in x seconds - - :param int seconds: Wait time in seconds or a timedelta obj before calling the function - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - assert isinstance(seconds, (int, datetime.timedelta)), f'{seconds} ({type(seconds)})' - fut = datetime.timedelta(seconds=seconds) if not isinstance(seconds, datetime.timedelta) else seconds - - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = OneTimeCallback(cb, *args, **kwargs) - future_event.set_run_time(fut) - self.__future_events.append(future_event) - return future_event - - def run_soon(self, callback, *args, **kwargs) -> OneTimeCallback: - """ - Run the callback as soon as possible (typically in the next second). - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) - future_event = OneTimeCallback(cb, *args, **kwargs) - future_event.set_run_time(None) - self.__future_events.append(future_event) - return future_event - - def get_rule(self, rule_name: str) -> 'typing.Union[Rule, typing.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) - - # ----------------------------------------------------------------------------------------------------------------- - # 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): - - # Check if items do exists - if not HABApp.core.Items.get_all_items(): - return None - - 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.') - - - @HABApp.core.wrapper.log_exception - def _process_events(self, now): - - # sheduled events - clean_events = False - for future_event in self.__future_events: # type: OneTimeCallback - future_event.check_due(now) - future_event.execute() - if future_event.is_finished: - clean_events = True - - # remove finished events - if clean_events: - self.__future_events = [k for k in self.__future_events if not k.is_finished] - return None - - @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 +import asyncio +import datetime +import logging +import random +import sys +import traceback +import typing +import warnings +import weakref + +import HABApp +import HABApp.core +import HABApp.openhab +import HABApp.rule_manager +import HABApp.util +from HABApp.core.events import AllEvents +from .interfaces import async_subprocess_exec +from HABApp.rule.habappscheduler import HABAppScheduler + + +log = logging.getLogger('HABApp.Rule') + + +# Func to log deprecation warnings +def send_warnings_to_log(message, category, filename, lineno, file=None, line=None): + log.warning('%s:%s: %s:%s' % (filename, lineno, category.__name__, message)) + return + + +# Setup deprecation warnings +warnings.simplefilter('default') +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 + + # 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__ + + self.__event_listener: typing.List[HABApp.core.EventBusListener] = [] + self.__unload_functions: typing.List[typing.Callable[[], None]] = [] + self.__cancel_objs: weakref.WeakSet = weakref.WeakSet() + + # schedule cleanup of this rule + self.register_on_unload(self.__cleanup_rule) + self.register_on_unload(self.__cleanup_objs) + + # scheduler + self.run: HABAppScheduler = HABAppScheduler(self) + + # suggest a rule name if it is not + self.rule_name: str = self.__rule_file.suggest_rule_name(self) + + # interfaces + self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = self.__runtime.async_http if not test else None + self.mqtt: HABApp.mqtt.interface = HABApp.mqtt.interface + 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): + """ + Post an event to the event bus + + :param name: name or item to post event to + :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, + event + ) + + def listen_event(self, name: typing.Union[HABApp.core.items.BaseValueItem, str], + callback: typing.Callable[[typing.Any], typing.Any], + event_type: typing.Union[typing.Type['HABApp.core.events.AllEvents'], + 'HABApp.core.events.EventFilter', typing.Any] = AllEvents + ) -> HABApp.core.EventBusListener: + """ + Register an event listener + + :param name: item or name to listen to. Use None to listen to all events + :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 + 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` + """ + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) + name = name.name if isinstance(name, HABApp.core.items.BaseValueItem) 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) + + self.__event_listener.append(listener) + HABApp.core.EventBus.add_listener(listener) + return listener + + def execute_subprocess(self, callback, program, *args, capture_output=True): + """Run another program + + :param callback: |param_scheduled_cb| after process has finished. First parameter will + be an instance of :class:`~HABApp.rule.FinishedProcessInfo` + :param program: program or path to program to run + :param args: |param_scheduled_cb_args| + :param capture_output: Capture program output, set to `False` to only capture return code + :return: + """ + + assert isinstance(program, str), type(program) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) + + asyncio.run_coroutine_threadsafe( + async_subprocess_exec(cb.run, program, *args, capture_output=capture_output), + HABApp.core.const.loop + ) + + def get_rule(self, rule_name: str) -> 'typing.Union[Rule, typing.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) + + # ----------------------------------------------------------------------------------------------------------------- + # deprecated functions + # ----------------------------------------------------------------------------------------------------------------- + def run_every(self, time, interval: typing.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: typing.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_in is deprecated. Please use self.run.at', 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): + + # Check if items do exists + if not HABApp.core.Items.get_all_items(): + return None + + 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.') + + @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/HABApp/rule_manager/__init__.py b/src/HABApp/rule_manager/__init__.py similarity index 97% rename from HABApp/rule_manager/__init__.py rename to src/HABApp/rule_manager/__init__.py index d1f548b9..a7403acd 100644 --- a/HABApp/rule_manager/__init__.py +++ b/src/HABApp/rule_manager/__init__.py @@ -1,2 +1,2 @@ -from .rule_manager import RuleManager -from .rule_file import RuleFile +from .rule_manager import RuleManager +from .rule_file import RuleFile diff --git a/HABApp/rule_manager/benchmark/__init__.py b/src/HABApp/rule_manager/benchmark/__init__.py similarity index 100% rename from HABApp/rule_manager/benchmark/__init__.py rename to src/HABApp/rule_manager/benchmark/__init__.py diff --git a/HABApp/rule_manager/benchmark/bench_base.py b/src/HABApp/rule_manager/benchmark/bench_base.py similarity index 88% rename from HABApp/rule_manager/benchmark/bench_base.py rename to src/HABApp/rule_manager/benchmark/bench_base.py index 08501999..76a1089e 100644 --- a/HABApp/rule_manager/benchmark/bench_base.py +++ b/src/HABApp/rule_manager/benchmark/bench_base.py @@ -31,7 +31,7 @@ def do_bench_start(self): self.errors.clear() self.err_watcher = self.listen_event(ERRORS, self._err_event) - self.run_in(1, self.do_bench_run) + self.run.at(1, self.do_bench_run) def do_bench_run(self): try: @@ -42,11 +42,11 @@ def do_bench_run(self): print('') self.set_up() - self.run() + self.run_bench() finally: self.tear_down() finally: - self.run_in(1, self.do_bench_finished) + self.run.at(1, self.do_bench_finished) def set_up(self): pass @@ -54,7 +54,7 @@ def set_up(self): def tear_down(self): pass - def run(self): + def run_bench(self): raise NotImplementedError() def do_bench_finished(self): @@ -69,4 +69,4 @@ def do_bench_finished(self): if self.next_rule is None: HABApp.runtime.shutdown.request_shutdown() else: - self.run_soon(self.next_rule.do_bench_start) + self.run.soon(self.next_rule.do_bench_start) diff --git a/HABApp/rule_manager/benchmark/bench_file.py b/src/HABApp/rule_manager/benchmark/bench_file.py similarity index 86% rename from HABApp/rule_manager/benchmark/bench_file.py rename to src/HABApp/rule_manager/benchmark/bench_file.py index 10fd1797..07f5b09f 100644 --- a/HABApp/rule_manager/benchmark/bench_file.py +++ b/src/HABApp/rule_manager/benchmark/bench_file.py @@ -9,7 +9,7 @@ class BenchFile(RuleFile): def __init__(self, rule_manager): - super().__init__(rule_manager, path=Path('BenchmarkFile')) + super().__init__(rule_manager, 'BenchmarkFile', path=Path('BenchmarkFile')) def create_rules(self, created_rules: list): glob = globals() @@ -23,7 +23,7 @@ def create_rules(self, created_rules: list): if HABApp.CONFIG.openhab.connection.host: rule = rule.link_rule(OpenhabBenchRule()) - rule_ha.run_in(5, rule_ha.do_bench_start) + rule_ha.run.at(5, rule_ha.do_bench_start) glob.pop('__HABAPP__RUNTIME__') glob.pop('__HABAPP__RULE_FILE__') diff --git a/HABApp/rule_manager/benchmark/bench_habapp.py b/src/HABApp/rule_manager/benchmark/bench_habapp.py similarity index 99% rename from HABApp/rule_manager/benchmark/bench_habapp.py rename to src/HABApp/rule_manager/benchmark/bench_habapp.py index fc6d433e..209c8b73 100644 --- a/HABApp/rule_manager/benchmark/bench_habapp.py +++ b/src/HABApp/rule_manager/benchmark/bench_habapp.py @@ -39,7 +39,7 @@ def tear_down(self): pass self.cleanup() - def run(self): + def run_bench(self): # These are the benchmarks self.bench_rtt_time() diff --git a/HABApp/rule_manager/benchmark/bench_mqtt.py b/src/HABApp/rule_manager/benchmark/bench_mqtt.py similarity index 99% rename from HABApp/rule_manager/benchmark/bench_mqtt.py rename to src/HABApp/rule_manager/benchmark/bench_mqtt.py index f0aa73d3..4640b8cc 100644 --- a/HABApp/rule_manager/benchmark/bench_mqtt.py +++ b/src/HABApp/rule_manager/benchmark/bench_mqtt.py @@ -40,7 +40,7 @@ def tear_down(self): pass self.cleanup() - def run(self): + def run_bench(self): # These are the benchmarks self.bench_rtt_time() diff --git a/HABApp/rule_manager/benchmark/bench_oh.py b/src/HABApp/rule_manager/benchmark/bench_oh.py similarity index 98% rename from HABApp/rule_manager/benchmark/bench_oh.py rename to src/HABApp/rule_manager/benchmark/bench_oh.py index f7b6a77b..1fef0cca 100644 --- a/HABApp/rule_manager/benchmark/bench_oh.py +++ b/src/HABApp/rule_manager/benchmark/bench_oh.py @@ -51,7 +51,7 @@ def set_up(self): def tear_down(self): self.cleanup() - def run(self): + def run_bench(self): # These are the benchmarks self.bench_item_create() self.bench_rtt_time() @@ -59,7 +59,7 @@ def run(self): def bench_item_create(self): print('Bench item operations ', end='') - max_duration = 30 # how long should each bench take + max_duration = 10 # how long should each bench take times = BenchContainer() diff --git a/HABApp/rule_manager/benchmark/bench_times.py b/src/HABApp/rule_manager/benchmark/bench_times.py similarity index 100% rename from HABApp/rule_manager/benchmark/bench_times.py rename to src/HABApp/rule_manager/benchmark/bench_times.py diff --git a/HABApp/rule_manager/rule_file.py b/src/HABApp/rule_manager/rule_file.py similarity index 93% rename from HABApp/rule_manager/rule_file.py rename to src/HABApp/rule_manager/rule_file.py index a91f68ae..3f114e28 100644 --- a/HABApp/rule_manager/rule_file.py +++ b/src/HABApp/rule_manager/rule_file.py @@ -1,113 +1,114 @@ -import collections -import logging -import runpy -import typing -from pathlib import Path - -import HABApp - -log = logging.getLogger('HABApp.Rules') - - - -class RuleFile: - def __init__(self, rule_manager, path: Path): - from .rule_manager import RuleManager - - assert isinstance(rule_manager, RuleManager) - self.rule_manager = rule_manager - - self.path: Path = path - - self.rules = {} # type: typing.Dict[str, HABApp.Rule] - - self.class_ctr: typing.Dict[str, int] = collections.defaultdict(lambda: 1) - - def suggest_rule_name(self, obj) -> 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] - found = self.class_ctr[name] - self.class_ctr[name] += 1 - - return f'{name:s}.{found:d}' if found > 1 else f'{name:s}' - - def check_all_rules(self): - for rule in self.rules.values(): # type: HABApp.Rule - rule._check_rule() - - def unload(self): - - # If we don't have any rules we can not unload - if not self.rules: - return None - - # unload all registered callbacks - for rule in self.rules.values(): # type: HABApp.Rule - rule._unload() - - log.debug(f'File {self.path} successfully unloaded!') - return None - - def __process_tc(self, tb: list): - tb.insert(0, f"Could not load {self.path}!") - return [line.replace('', self.path.name) for line in tb] - - def create_rules(self, created_rules: list): - # 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, - }) - - def load(self) -> bool: - - created_rules: typing.List[HABApp.rule.Rule] = [] - - ign = HABApp.core.wrapper.ExceptionToHABApp(logger=log) - ign.proc_tb = self.__process_tc - - with ign: - self.create_rules(created_rules) - - if ign.raised_exception: - # unload all rule instances which might have already been created otherwise they might - # still listen to events and do stuff - for rule in created_rules: - with ign: - rule._unload() - return False - - if not created_rules: - log.warning(f'Found no instances of HABApp.Rule in {str(self.path)}') - return True - - with ign: - for rule in created_rules: - # ensure that we have a rule name - rule.rule_name = self.suggest_rule_name(rule) - - # rule name must be unique for every file - if rule.rule_name in self.rules: - raise ValueError(f'Rule name must be unique!\n"{rule.rule_name}" is already used!') - - self.rules[rule.rule_name] = rule - log.info(f'Added rule "{rule.rule_name}" from {self.path.name}') - - if ign.raised_exception: - # unload all rule instances which might have already been created otherwise they might - # still listen to events and do stuff - for rule in created_rules: - with ign: - rule._unload() - return False - - return True +import collections +import logging +import runpy +import typing +from pathlib import Path + +import HABApp + +log = logging.getLogger('HABApp.Rules') + + + +class RuleFile: + def __init__(self, rule_manager, name: str, path: Path): + from .rule_manager import RuleManager + + assert isinstance(rule_manager, RuleManager) + self.rule_manager = rule_manager + + self.name: str = name + self.path: Path = path + + self.rules = {} # type: typing.Dict[str, HABApp.Rule] + + self.class_ctr: typing.Dict[str, int] = collections.defaultdict(lambda: 1) + + def suggest_rule_name(self, obj) -> 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] + found = self.class_ctr[name] + self.class_ctr[name] += 1 + + return f'{name:s}.{found:d}' if found > 1 else f'{name:s}' + + def check_all_rules(self): + for rule in self.rules.values(): # type: HABApp.Rule + rule._check_rule() + + def unload(self): + + # If we don't have any rules we can not unload + if not self.rules: + return None + + # unload all registered callbacks + for rule in self.rules.values(): # type: HABApp.Rule + rule._unload() + + log.debug(f'File {self.name} successfully unloaded!') + return None + + def __process_tc(self, tb: list): + tb.insert(0, f"Could not load {self.path}!") + return [line.replace('', self.path.name) for line in tb] + + def create_rules(self, created_rules: list): + # 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, + }) + + def load(self) -> bool: + + created_rules: typing.List[HABApp.rule.Rule] = [] + + ign = HABApp.core.wrapper.ExceptionToHABApp(logger=log) + ign.proc_tb = self.__process_tc + + with ign: + self.create_rules(created_rules) + + if ign.raised_exception: + # unload all rule instances which might have already been created otherwise they might + # still listen to events and do stuff + for rule in created_rules: + with ign: + rule._unload() + return False + + if not created_rules: + log.warning(f'Found no instances of HABApp.Rule in {str(self.path)}') + return True + + with ign: + for rule in created_rules: + # ensure that we have a rule name + rule.rule_name = self.suggest_rule_name(rule) + + # rule name must be unique for every file + if rule.rule_name in self.rules: + raise ValueError(f'Rule name must be unique!\n"{rule.rule_name}" is already used!') + + self.rules[rule.rule_name] = rule + log.info(f'Added rule "{rule.rule_name}" from {self.name}') + + if ign.raised_exception: + # unload all rule instances which might have already been created otherwise they might + # still listen to events and do stuff + for rule in created_rules: + with ign: + rule._unload() + return False + + return True diff --git a/HABApp/rule_manager/rule_manager.py b/src/HABApp/rule_manager/rule_manager.py similarity index 50% rename from HABApp/rule_manager/rule_manager.py rename to src/HABApp/rule_manager/rule_manager.py index fff77a5b..ba7af956 100644 --- a/HABApp/rule_manager/rule_manager.py +++ b/src/HABApp/rule_manager/rule_manager.py @@ -1,215 +1,165 @@ -import asyncio -import datetime -import logging -import math -import threading -import time -import typing - -from pytz import utc - -import HABApp -from pathlib import Path -from HABApp.core.files import file_load_failed, file_load_ok -from HABApp.core.files.watcher import AggregatingAsyncEventHandler -from HABApp.core.logger import log_warning -from HABApp.core.wrapper import log_exception -from .rule_file import RuleFile -import HABApp.__cmd_args__ as cmd_args - -log = logging.getLogger('HABApp.Rules') - - -LOAD_DELAY = 1 - - -async def set_load_ok(name: str): - await asyncio.sleep(LOAD_DELAY) - file_load_ok(name) - - -async def set_load_failed(name: str): - await asyncio.sleep(LOAD_DELAY) - file_load_failed(name) - - -class RuleManager: - - def __init__(self, parent): - assert isinstance(parent, HABApp.runtime.Runtime) - self.runtime = parent - - self.files: typing.Dict[str, RuleFile] = {} - - # serialize loading - self.__load_lock = threading.Lock() - self.__files_lock = threading.Lock() - - # Processing - self.__process_last_sec = 60 - - self.watcher: typing.Optional[AggregatingAsyncEventHandler] = None - - def setup(self): - - if cmd_args.DO_BENCH: - from HABApp.rule_manager.benchmark import BenchFile - self.files['bench'] = file = BenchFile(self) - if not file.load(): - log.error('Failed to load Benchmark!') - HABApp.runtime.shutdown.request_shutdown() - return None - file.check_all_rules() - return - - # Add event bus listener - HABApp.core.files.add_event_bus_listener('rule', self.request_file_load, self.request_file_unload, log) - - # Folder watcher - self.watcher = HABApp.core.files.watch_folder(HABApp.CONFIG.directories.rules, '.py', True) - - # Initial loading of rules - HABApp.core.WrappedFunction(self.load_rules_on_startup, logger=log, warn_too_long=False).run() - - def load_rules_on_startup(self): - - if HABApp.CONFIG.openhab.connection.host and HABApp.CONFIG.openhab.general.wait_for_openhab: - items_found = False - while not items_found: - time.sleep(3) - for item in HABApp.core.Items.get_all_items(): - if isinstance(item, HABApp.openhab.items.OpenhabItem): - items_found = True - break - - # stop waiting if we want to shut down - if HABApp.runtime.shutdown.requested: - return None - time.sleep(2.2) - else: - time.sleep(5.2) - - # trigger event for every file - self.watcher.trigger_all() - return None - - @log_exception - async def process_scheduled_events(self): - - while not HABApp.runtime.shutdown.requested: - - now = datetime.datetime.now(tz=utc) - - # process only once per second - if now.second == self.__process_last_sec: - await asyncio.sleep(0.1) - continue - - # remember sec - self.__process_last_sec = now.second - - with self.__files_lock: - for file in self.files.values(): - assert isinstance(file, RuleFile), type(file) - for rule in file.rules.values(): - rule._process_events(now) - - # sleep longer, try to sleep until the next full second - end = datetime.datetime.now(tz=utc) - if end.second == self.__process_last_sec: - frac, whole = math.modf(time.time()) - sleep_time = 1 - frac + 0.005 # prevent rounding error and add a little bit of security - await asyncio.sleep(sleep_time) - - - @log_exception - def get_async(self): - return asyncio.gather(self.process_scheduled_events()) - - @log_exception - def get_rule(self, rule_name): - found = [] - for file in self.files.values(): - if rule_name is None: - for rule in file.rules.values(): - found.append(rule) - else: - if rule_name in file.rules: - found.append(file.rules[rule_name]) - - # if we want all return them - if rule_name is None: - return found - - # if we want a special one throw error - if not found: - raise KeyError(f'No Rule with name "{rule_name}" found!') - return found if len(found) > 1 else found[0] - - - @log_exception - def request_file_unload(self, name: str, path: Path, request_lock=True): - path_str = str(path) - - try: - if request_lock: - self.__load_lock.acquire() - - # Only unload already loaded files - with self.__files_lock: - already_loaded = path_str in self.files - if not already_loaded: - log_warning(log, f'Rule file {path} is not yet loaded and therefore can not be unloaded') - return None - - log.debug(f'Removing file: {path}') - with self.__files_lock: - rule = self.files.pop(path_str) - rule.unload() - except Exception as e: - err = HABApp.core.logger.HABAppError(log) - err.add(f"Could not remove {path}!") - err.add_exception(e, True) - err.dump() - return None - finally: - if request_lock: - self.__load_lock.release() - - @log_exception - def request_file_load(self, name: str, path: Path): - path_str = str(path) - - # Only load existing files - if not path.is_file(): - log_warning(log, f'Rule file {path} does not exist and can not be loaded!') - return None - - with self.__load_lock: - # Unload if we have already loaded - with self.__files_lock: - already_loaded = path_str in self.files - if already_loaded: - self.request_file_unload(name, path, request_lock=False) - - log.debug(f'Loading file: {path}') - with self.__files_lock: - self.files[path_str] = file = RuleFile(self, path) - - if not file.load(): - # If the load has failed we remove it again. - # Unloading is handled directly in the load function - self.files.pop(path_str) - log.warning(f'Failed to load {path_str}!') - - # signal that we have loaded the file but with a small delay - asyncio.run_coroutine_threadsafe(set_load_failed(name), HABApp.core.const.loop) - return None - - log.debug(f'File {path_str} successfully loaded!') - - # signal that we have loaded the file but with a small delay - asyncio.run_coroutine_threadsafe(set_load_ok(name), HABApp.core.const.loop) - - # Do simple checks which prevent errors - file.check_all_rules() +import logging +import threading +import typing +from asyncio import sleep +from pathlib import Path + +import HABApp +import HABApp.__cmd_args__ as cmd_args +from HABApp.core.files.errors import AlreadyHandledFileError +from HABApp.core.files.file import HABAppFile +from HABApp.core.files.folders import add_folder as add_habapp_folder +from HABApp.core.files.watcher import AggregatingAsyncEventHandler +from HABApp.core.logger import log_warning +from HABApp.core.wrapper import log_exception +from HABApp.runtime import shutdown +from .rule_file import RuleFile + +log = logging.getLogger('HABApp.Rules') + + +class RuleManager: + + def __init__(self, parent): + assert isinstance(parent, HABApp.runtime.Runtime) + self.runtime = parent + + self.files: typing.Dict[str, RuleFile] = {} + + # serialize loading + self.__load_lock = threading.Lock() + self.__files_lock = threading.Lock() + + # Processing + self.__process_last_sec = 60 + + self.watcher: typing.Optional[AggregatingAsyncEventHandler] = None + + async def setup(self): + + # shutdown + shutdown.register_func(self.shutdown, msg='Cancel rule schedulers') + + 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) + if not ok: + log.error('Failed to load Benchmark!') + HABApp.runtime.shutdown.request_shutdown() + return None + file.check_all_rules() + return + + class HABAppRuleFile(HABAppFile): + LOGGER = log + LOAD_FUNC = self.request_file_load + UNLOAD_FUNC = self.request_file_unload + + path = HABApp.CONFIG.directories.rules + folder = add_habapp_folder('rules/', path, 0) + folder.add_file_type(HABAppRuleFile) + self.watcher = folder.add_watch('.py', True) + + # Initial loading of rules + HABApp.core.WrappedFunction(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: + items_found = False + while not items_found: + await sleep(3) + for item in HABApp.core.Items.get_all_items(): + if isinstance(item, HABApp.openhab.items.OpenhabItem): + items_found = True + break + + # stop waiting if we want to shut down + if HABApp.runtime.shutdown.requested: + return None + await sleep(2.2) + else: + await sleep(5.2) + + # trigger event for every file + await self.watcher.trigger_all() + return None + + @log_exception + def get_rule(self, rule_name): + found = [] + for file in self.files.values(): + if rule_name is None: + for rule in file.rules.values(): + found.append(rule) + else: + if rule_name in file.rules: + found.append(file.rules[rule_name]) + + # if we want all return them + if rule_name is None: + return found + + # if we want a special one throw error + if not found: + raise KeyError(f'No Rule with name "{rule_name}" found!') + return found if len(found) > 1 else found[0] + + async def request_file_unload(self, name: str, path: Path, request_lock=True): + path_str = str(path) + + try: + if request_lock: + self.__load_lock.acquire() + + # Only unload already loaded files + with self.__files_lock: + already_loaded = path_str in self.files + if not already_loaded: + log_warning(log, f'Rule file {path} is not yet loaded and therefore can not be unloaded') + return None + + log.debug(f'Removing file: {name}') + with self.__files_lock: + rule = self.files.pop(path_str) + + await HABApp.core.const.loop.run_in_executor(HABApp.core.WrappedFunction._WORKERS, rule.unload) + finally: + if request_lock: + self.__load_lock.release() + + async def request_file_load(self, name: str, path: Path): + path_str = str(path) + + # Only load existing files + if not path.is_file(): + log_warning(log, f'Rule file {name} ({path}) does not exist and can not be loaded!') + return None + + with self.__load_lock: + # Unload if we have already loaded + with self.__files_lock: + already_loaded = path_str in self.files + if already_loaded: + await self.request_file_unload(name, path, request_lock=False) + + log.debug(f'Loading file: {name}') + 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) + if not ok: + self.files.pop(path_str) + log.warning(f'Failed to load {path_str}!') + raise AlreadyHandledFileError() + + log.debug(f'File {name} successfully loaded!') + + # Do simple checks which prevent errors + file.check_all_rules() + + def shutdown(self): + for f in self.files.values(): + for rule in f.rules.values(): + rule._unload() diff --git a/HABApp/runtime/__init__.py b/src/HABApp/runtime/__init__.py similarity index 98% rename from HABApp/runtime/__init__.py rename to src/HABApp/runtime/__init__.py index f55b1c69..ac8b7bca 100644 --- a/HABApp/runtime/__init__.py +++ b/src/HABApp/runtime/__init__.py @@ -1,2 +1,2 @@ -from . import shutdown +from . import shutdown from .runtime import Runtime \ No newline at end of file diff --git a/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py similarity index 64% rename from HABApp/runtime/runtime.py rename to src/HABApp/runtime/runtime.py index 0ea9ce39..2c3632fb 100644 --- a/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -1,59 +1,60 @@ -import asyncio -from pathlib import Path - -import HABApp.config -import HABApp.core -import HABApp.mqtt.mqtt_connection -import HABApp.parameters.parameter_files -import HABApp.rule_manager -import HABApp.util -from HABApp.openhab import connection_logic as openhab_connection -from HABApp.runtime import shutdown - - -class Runtime: - - def __init__(self): - self.config: HABApp.config.Config = None - - self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = HABApp.rule.interfaces.AsyncHttpConnection() - - # OpenHAB - self.openhab_connection: HABApp.openhab.OpenhabConnection = None - - # Rule engine - self.rule_manager: HABApp.rule_manager.RuleManager = None - - # Async Workers & shutdown callback - HABApp.core.WrappedFunction._EVENT_LOOP = HABApp.core.const.loop - shutdown.register_func(HABApp.core.WrappedFunction._WORKERS.shutdown, msg='Stopping workers') - - def startup(self, config_folder: Path): - - # Start Folder watcher! - HABApp.core.files.watcher.start() - - self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) - - # MQTT - HABApp.mqtt.mqtt_connection.setup() - HABApp.mqtt.mqtt_connection.connect() - - # openhab - openhab_connection.setup() - - # Parameter Files - HABApp.parameters.parameter_files.setup_param_files() - - # Rule engine - self.rule_manager = HABApp.rule_manager.RuleManager(self) - self.rule_manager.setup() - - - @HABApp.core.wrapper.log_exception - def get_async(self): - return asyncio.gather( - self.async_http.create_client(), - openhab_connection.start(), - self.rule_manager.get_async(), - ) +from pathlib import Path + +import HABApp.config +import HABApp.core +import HABApp.mqtt.mqtt_connection +import HABApp.parameters.parameter_files +import HABApp.rule_manager +import HABApp.util +import eascheduler +from HABApp.core.wrapper import process_exception +from HABApp.openhab import connection_logic as openhab_connection +from HABApp.runtime import shutdown + + +class Runtime: + + def __init__(self): + self.config: HABApp.config.Config = None + + self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = HABApp.rule.interfaces.AsyncHttpConnection() + + # Rule engine + self.rule_manager: HABApp.rule_manager.RuleManager = None + + # Async Workers & shutdown callback + # Setup scheduler + eascheduler.schedulers.ThreadSafeAsyncScheduler.LOOP = HABApp.core.const.loop + HABApp.core.WrappedFunction._EVENT_LOOP = HABApp.core.const.loop + shutdown.register_func(HABApp.core.WrappedFunction._WORKERS.shutdown, msg='Stopping workers') + + @HABApp.core.wrapper.log_exception + async def start(self, config_folder: Path): + # setup exception handler for the scheduler + eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) + + # Start Folder watcher! + HABApp.core.files.watcher.start() + + self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) + + await HABApp.core.files.setup() + + # MQTT + HABApp.mqtt.mqtt_connection.setup() + HABApp.mqtt.mqtt_connection.connect() + + # openhab + openhab_connection.setup() + + # Parameter Files + await HABApp.parameters.parameter_files.setup_param_files() + + # Rule engine + self.rule_manager = HABApp.rule_manager.RuleManager(self) + await self.rule_manager.setup() + + await self.async_http.create_client() + await openhab_connection.start() + + shutdown.register_func(HABApp.core.const.loop.stop, msg='Stopping asyncio loop') diff --git a/HABApp/runtime/shutdown.py b/src/HABApp/runtime/shutdown.py similarity index 100% rename from HABApp/runtime/shutdown.py rename to src/HABApp/runtime/shutdown.py diff --git a/HABApp/util/__init__.py b/src/HABApp/util/__init__.py similarity index 97% rename from HABApp/util/__init__.py rename to src/HABApp/util/__init__.py index 6feab77d..44d7c959 100644 --- a/HABApp/util/__init__.py +++ b/src/HABApp/util/__init__.py @@ -1,9 +1,9 @@ -from . import functions -from .counter_item import CounterItem -from .period_counter import PeriodCounter -from .threshold import Threshold -from .statistics import Statistics -from . import multimode - -# 27.04.2020 - this can be removed in some time +from . import functions +from .counter_item import CounterItem +from .period_counter import PeriodCounter +from .threshold import Threshold +from .statistics import Statistics +from . import multimode + +# 27.04.2020 - this can be removed in some time from .multimode import MultiModeItem \ No newline at end of file diff --git a/HABApp/util/counter_item.py b/src/HABApp/util/counter_item.py similarity index 100% rename from HABApp/util/counter_item.py rename to src/HABApp/util/counter_item.py diff --git a/src/HABApp/util/functions/__init__.py b/src/HABApp/util/functions/__init__.py new file mode 100644 index 00000000..4fba70f2 --- /dev/null +++ b/src/HABApp/util/functions/__init__.py @@ -0,0 +1,2 @@ +from .min_max import min, max +from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb diff --git a/HABApp/util/functions/min_max.py b/src/HABApp/util/functions/min_max.py similarity index 100% rename from HABApp/util/functions/min_max.py rename to src/HABApp/util/functions/min_max.py diff --git a/HABApp/util/multimode/__init__.py b/src/HABApp/util/multimode/__init__.py similarity index 100% rename from HABApp/util/multimode/__init__.py rename to src/HABApp/util/multimode/__init__.py diff --git a/HABApp/util/multimode/item.py b/src/HABApp/util/multimode/item.py similarity index 100% rename from HABApp/util/multimode/item.py rename to src/HABApp/util/multimode/item.py diff --git a/HABApp/util/multimode/mode_base.py b/src/HABApp/util/multimode/mode_base.py similarity index 100% rename from HABApp/util/multimode/mode_base.py rename to src/HABApp/util/multimode/mode_base.py diff --git a/HABApp/util/multimode/mode_switch.py b/src/HABApp/util/multimode/mode_switch.py similarity index 100% rename from HABApp/util/multimode/mode_switch.py rename to src/HABApp/util/multimode/mode_switch.py diff --git a/HABApp/util/multimode/mode_value.py b/src/HABApp/util/multimode/mode_value.py similarity index 100% rename from HABApp/util/multimode/mode_value.py rename to src/HABApp/util/multimode/mode_value.py diff --git a/HABApp/util/period_counter.py b/src/HABApp/util/period_counter.py similarity index 96% rename from HABApp/util/period_counter.py rename to src/HABApp/util/period_counter.py index 66fc7aa5..401a706d 100644 --- a/HABApp/util/period_counter.py +++ b/src/HABApp/util/period_counter.py @@ -1,63 +1,63 @@ -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 +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/HABApp/util/statistics.py b/src/HABApp/util/statistics.py similarity index 100% rename from HABApp/util/statistics.py rename to src/HABApp/util/statistics.py diff --git a/HABApp/util/threshold.py b/src/HABApp/util/threshold.py similarity index 96% rename from HABApp/util/threshold.py rename to src/HABApp/util/threshold.py index 4be9dd9d..f4b49dc0 100644 --- a/HABApp/util/threshold.py +++ b/src/HABApp/util/threshold.py @@ -1,60 +1,60 @@ - -class Threshold: - def __init__(self, threshold1, threshold2): - """This is a simple Schmitt Trigger implementation. - If the value is > upper_threshold is_higher will return true. - The return value will stay true until the value goes below the lower threshold. - - :param threshold1: - :param threshold2: - """ - vals = sorted([threshold1, threshold2]) - self.lower_threshold = vals[0] - self.upper_threshold = vals[1] - assert self.lower_threshold < self.upper_threshold - - self.__threshold = self.upper_threshold - - def is_on(self, value): - self.check_value(value) - return self.__threshold == self.lower_threshold - - def is_off(self, value): - return not self.is_on(value) - - @property - def current_threshold(self): - return self.__threshold - - def check_value(self, value): - if value > self.upper_threshold: - self.__threshold = self.lower_threshold - - if value < self.lower_threshold: - self.__threshold = self.upper_threshold - - return self.__threshold - - def __lt__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.check_value(other) < other - - def __le__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.check_value(other) <= other - - def __ge__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.check_value(other) >= other - - def __gt__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.check_value(other) > other + +class Threshold: + def __init__(self, threshold1, threshold2): + """This is a simple Schmitt Trigger implementation. + If the value is > upper_threshold is_higher will return true. + The return value will stay true until the value goes below the lower threshold. + + :param threshold1: + :param threshold2: + """ + vals = sorted([threshold1, threshold2]) + self.lower_threshold = vals[0] + self.upper_threshold = vals[1] + assert self.lower_threshold < self.upper_threshold + + self.__threshold = self.upper_threshold + + def is_on(self, value): + self.check_value(value) + return self.__threshold == self.lower_threshold + + def is_off(self, value): + return not self.is_on(value) + + @property + def current_threshold(self): + return self.__threshold + + def check_value(self, value): + if value > self.upper_threshold: + self.__threshold = self.lower_threshold + + if value < self.lower_threshold: + self.__threshold = self.upper_threshold + + return self.__threshold + + def __lt__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.check_value(other) < other + + def __le__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.check_value(other) <= other + + def __ge__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.check_value(other) >= other + + def __gt__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.check_value(other) > other diff --git a/tests/conftest.py b/tests/conftest.py index c18c7d54..4eb195c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,9 +32,15 @@ def f(*args, **kwargs): @pytest.fixture(autouse=True, scope='function') def show_errors(monkeypatch): + # Patch the wrapper so that we always raise the exception monkeypatch.setattr(HABApp.core.wrapper, 'ignore_exception', raise_err) monkeypatch.setattr(HABApp.core.wrapper, 'log_exception', raise_err) + # 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) + @pytest.yield_fixture(autouse=True, scope='function') def event_loop(): diff --git a/tests/rule_runner/rule_runner.py b/tests/rule_runner/rule_runner.py index 1601d752..53812194 100644 --- a/tests/rule_runner/rule_runner.py +++ b/tests/rule_runner/rule_runner.py @@ -1,8 +1,6 @@ -import datetime import sys -import pytz - +import HABApp.rule.habappscheduler as ha_sched from HABApp.core import WrappedFunction from HABApp.runtime import Runtime @@ -24,10 +22,28 @@ def suggest_rule_name(self, obj): return parts[-1][:-2] +class SyncScheduler: + ALL = [] + + def __init__(self): + SyncScheduler.ALL.append(self) + self.jobs = [] + + def add_job(self, job): + self.jobs.append(job) + + def remove_job(self, job): + self.jobs.remove(job) + + def cancel_all(self): + self.jobs.clear() + + class SimpleRuleRunner: def __init__(self): self.vars: dict = _get_topmost_globals() self.loaded_rules = [] + self.original_scheduler = None def submit(self, callback, *args, **kwargs): # submit never raises and exception, so we don't do it here, too @@ -45,6 +61,10 @@ def set_up(self): self.worker = WrappedFunction._WORKERS WrappedFunction._WORKERS = self + # patch scheduler + self.original_scheduler = ha_sched.ThreadSafeAsyncScheduler + ha_sched.ThreadSafeAsyncScheduler = SyncScheduler + def tear_down(self): self.vars.pop('__UNITTEST__') self.vars.pop('__HABAPP__RUNTIME__') @@ -55,12 +75,12 @@ def tear_down(self): loaded_rules.clear() WrappedFunction._WORKERS = self.worker - + ha_sched.ThreadSafeAsyncScheduler = self.original_scheduler def process_events(self): - now = datetime.datetime.now(tz=pytz.utc) - for rule in self.loaded_rules: - rule._process_events(now) + for s in SyncScheduler.ALL: + for job in s.jobs: + job._func.execute() def __enter__(self): self.set_up() diff --git a/tests/test_core/test_files/test_file_dependencies.py b/tests/test_core/test_files/test_file_dependencies.py index 9bd0e1bb..f9e5b3d3 100644 --- a/tests/test_core/test_files/test_file_dependencies.py +++ b/tests/test_core/test_files/test_file_dependencies.py @@ -1,26 +1,20 @@ import logging +from asyncio import sleep from pathlib import Path +from typing import List, Tuple import pytest import HABApp -from HABApp.core.files.all import file_load_ok, process -from HABApp.core.files.file import FileProperties, HABAppFile -from ...helpers import TmpEventBus - -FILE_PROPS = {} - - -@classmethod -def from_path(cls, name: str, path) -> HABAppFile: - return cls(name, MockFile(name), FILE_PROPS[name]) +from HABApp.core.files.file.file import FileProperties, HABAppFile +from HABApp.core.files.folders import add_folder +from HABApp.core.files.folders.folders import FOLDERS +from HABApp.core.files.manager import process_file class MockFile: def __init__(self, name: str): - if name.startswith('params/'): - name = name[7:] - self.name = name + self.name = name.split('/')[1] def as_posix(self): return f'/my_param/{self.name}' @@ -32,104 +26,126 @@ def __repr__(self): return f'' -@pytest.fixture -def cfg(monkeypatch): - monkeypatch.setattr(HABAppFile, 'from_path', from_path) - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('/my_rules/')) - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('/my_config/')) - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('/my_param/')) - - yield - - -def test_reload_on(cfg, sync_worker, event_bus: TmpEventBus): - order = [] - - def process_event(event): - order.append(event.name) - file_load_ok(event.name) - - FILE_PROPS.clear() - 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) - - process([MockFile('param2'), MockFile('param1')]) - - assert order == ['params/param1', 'params/param2', 'params/param1'] - order.clear() - - process([]) - assert order == [] - - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() - - process([MockFile('param1')]) - assert order == ['params/param1'] - order.clear() - - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() - - -def test_reload_dep(cfg, sync_worker, event_bus: TmpEventBus): - order = [] - - def process_event(event): - order.append(event.name) - file_load_ok(event.name) - - FILE_PROPS.clear() - FILE_PROPS['params/param1'] = FileProperties(depends_on=['params/param2'], reloads_on=['params/param2']) - FILE_PROPS['params/param2'] = FileProperties() - - event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - - process([MockFile('param2'), MockFile('param1')]) - - assert order == ['params/param2', 'params/param1'] - order.clear() - - process([]) - assert order == [] - - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() +class CfgObj: + def __init__(self): + self.properties = {} + self.operation: List[Tuple[str, str]] = [] - process([MockFile('param1')]) - assert order == ['params/param1'] - order.clear() + class TestFile(HABAppFile): + LOGGER = logging.getLogger('test') + LOAD_FUNC = self.load_file + UNLOAD_FUNC = self.unload_file + self.cls = TestFile - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() + async def load_file(self, name: str, path: Path): + self.operation.append(('load', name)) + async def unload_file(self, name: str, path: Path): + self.operation.append(('unload', name)) -def test_missing_dependencies(cfg, sync_worker, event_bus: TmpEventBus, caplog): - order = [] + async def wait_complete(self): + while HABApp.core.files.manager.worker.TASK is not None: + await sleep(0.05) - def process_event(event): - order.append(event.name) - file_load_ok(event.name) + async def process_file(self, name: str): + await process_file(name, MockFile(name)) - FILE_PROPS['params/param1'] = FileProperties(depends_on=['params/param4', 'params/param5']) - FILE_PROPS['params/param2'] = FileProperties(depends_on=['params/param4']) - FILE_PROPS['params/param3'] = FileProperties() + def create_file(self, name, path) -> HABAppFile: + return self.cls(name, MockFile(name), self.properties[name]) - event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - process([MockFile('param1'), MockFile('param2'), MockFile('param3')]) - - assert order == ['params/param3'] - order.clear() - - process([]) - assert order == [] +@pytest.fixture +def cfg(monkeypatch): + obj = CfgObj() + + monkeypatch.setattr(HABApp.core.files.manager.worker, 'TASK_SLEEP', 0.001) + monkeypatch.setattr(HABApp.core.files.manager.worker, 'TASK_DURATION', 0.001) + monkeypatch.setattr(HABApp.core.files.file, 'create_file', obj.create_file) + + FOLDERS.clear() + add_folder('rules/', Path('c:/HABApp/my_rules/'), 0) + add_folder('configs/', Path('c:/HABApp/my_config/'), 10) + add_folder('params/', Path('c:/HABApp/my_param/'), 20) + + yield obj + + FOLDERS.clear() + + +# def test_reload_on(cfg, sync_worker, event_bus: TmpEventBus): +# order = [] +# +# def process_event(event): +# order.append(event.name) +# file_load_ok(event.name) +# +# FILE_PROPS.clear() +# 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) +# +# process([MockFile('param2'), MockFile('param1')]) +# +# assert order == ['params/param1', 'params/param2', 'params/param1'] +# order.clear() +# +# process([]) +# assert order == [] +# +# process([MockFile('param2')]) +# assert order == ['params/param2', 'params/param1'] +# order.clear() +# +# process([MockFile('param1')]) +# assert order == ['params/param1'] +# order.clear() +# +# process([MockFile('param2')]) +# assert order == ['params/param2', 'params/param1'] +# 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() + + await cfg.process_file('params/param1') + await cfg.process_file('params/param2') + await cfg.wait_complete() + + assert cfg.operation == [('load', 'params/param2'), ('load', 'params/param1')] + cfg.operation.clear() + + await cfg.process_file('params/param2') + await cfg.wait_complete() + assert cfg.operation == [('load', 'params/param2'), ('load', 'params/param1')] + cfg.operation.clear() + + await cfg.process_file('params/param1') + await cfg.wait_complete() + assert cfg.operation == [('load', 'params/param1')] + cfg.operation.clear() + + await cfg.process_file('params/param2') + await cfg.wait_complete() + assert cfg.operation == [('load', 'params/param2'), ('load', 'params/param1')] + 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']) + cfg.properties['params/param3'] = FileProperties() + + await cfg.process_file('params/param1') + await cfg.process_file('params/param2') + await cfg.process_file('params/param3') + await cfg.wait_complete() + + assert cfg.operation == [('load', 'params/param3')] msg1 = ( 'HABApp.files', logging.ERROR, "File depends on file that doesn't exist: params/param4" @@ -143,56 +159,56 @@ def process_event(event): assert msg2 in caplog.record_tuples -def test_missing_loads(cfg, sync_worker, event_bus: TmpEventBus, caplog): - order = [] - - def process_event(event): - order.append(event.name) - file_load_ok(event.name) - - 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) - - process([MockFile('param1'), MockFile('param2')]) - - assert order == ['params/param1', 'params/param2'] - order.clear() - - process([]) - assert order == [] - - msg1 = ( - 'HABApp.files', logging.WARNING, "File reloads on file that doesn't exist: params/param4" - ) - msg2 = ('HABApp.files', logging.WARNING, - "File reloads on files that don't exist: params/param4, params/param5") - - assert msg1 in caplog.record_tuples - assert msg2 in caplog.record_tuples - - -def test_load_continue_after_missing(cfg, sync_worker, event_bus: TmpEventBus, caplog): - order = [] - - def process_event(event): - order.append(event.name) - file_load_ok(event.name) - - FILE_PROPS.clear() - 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) - - process([MockFile('p1')]) - - # File can not be loaded - assert order == [] - - # Add missing file - process([MockFile('p2')]) - - # Both files get loaded - assert order == ['params/p2', 'params/p1'] +# def test_missing_loads(cfg, sync_worker, event_bus: TmpEventBus, caplog): +# order = [] +# +# def process_event(event): +# order.append(event.name) +# file_load_ok(event.name) +# +# 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) +# +# process([MockFile('param1'), MockFile('param2')]) +# +# assert order == ['params/param1', 'params/param2'] +# order.clear() +# +# process([]) +# assert order == [] +# +# msg1 = ( +# 'HABApp.files', logging.WARNING, "File reloads on file that doesn't exist: params/param4" +# ) +# msg2 = ('HABApp.files', logging.WARNING, +# "File reloads on files that don't exist: params/param4, params/param5") +# +# assert msg1 in caplog.record_tuples +# assert msg2 in caplog.record_tuples +# +# +# def test_load_continue_after_missing(cfg, sync_worker, event_bus: TmpEventBus, caplog): +# order = [] +# +# def process_event(event): +# order.append(event.name) +# file_load_ok(event.name) +# +# FILE_PROPS.clear() +# 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) +# +# process([MockFile('p1')]) +# +# # File can not be loaded +# assert order == [] +# +# # Add missing file +# process([MockFile('p2')]) +# +# # Both files get loaded +# assert order == ['params/p2', 'params/p1'] diff --git a/tests/test_core/test_files/test_file_properties.py b/tests/test_core/test_files/test_file_properties.py index 6902cceb..9663e057 100644 --- a/tests/test_core/test_files/test_file_properties.py +++ b/tests/test_core/test_files/test_file_properties.py @@ -1,5 +1,5 @@ -from HABApp.core.files.file_props import get_props -from HABApp.core.files.file import HABAppFile, CircularReferenceError, FileProperties, ALL +from HABApp.core.files.file.properties import get_properties as get_props +from HABApp.core.files.file.file import HABAppFile, CircularReferenceError, FileProperties, FILES, FileState import pytest @@ -86,24 +86,25 @@ def test_prop_missing(): def test_deps(): - ALL.clear() - ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) - ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) + FILES.clear() + FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) + FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) f1.check_properties() f2.check_properties() - assert f2.can_be_loaded() - assert not f1.can_be_loaded() + assert f2.state is FileState.DEPENDENCIES_OK + assert f1.state is FileState.DEPENDENCIES_MISSING - f2.is_loaded = True - assert f1.can_be_loaded() + f2.state = FileState.LOADED + f1.check_dependencies() + assert f1.state is FileState.DEPENDENCIES_OK def test_reloads(): - ALL.clear() - ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(reloads_on=['name2', 'asdf'])) - ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) + FILES.clear() + FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(reloads_on=['name2', 'asdf'])) + FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) f1.check_properties() assert f1.properties.reloads_on == ['name2', 'asdf'] @@ -111,19 +112,19 @@ def test_reloads(): def test_circ(): - ALL.clear() - ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) - ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties(depends_on=['name3'])) - ALL['name3'] = f3 = HABAppFile('name3', 'path3', FileProperties(depends_on=['name1'])) + FILES.clear() + FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) + FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties(depends_on=['name3'])) + FILES['name3'] = f3 = HABAppFile('name3', 'path3', FileProperties(depends_on=['name1'])) with pytest.raises(CircularReferenceError) as e: - f1.check_properties() - assert str(e.value) == "name1 -> name2 -> name3 -> name1" + f1._check_circ_refs((f1.name,), 'depends_on') + assert e.value.stack == ('name1', 'name2', 'name3', 'name1') with pytest.raises(CircularReferenceError) as e: - f2.check_properties() - assert str(e.value) == "name2 -> name3 -> name1 -> name2" + f2._check_circ_refs((f2.name,), 'depends_on') + assert e.value.stack == ('name2', 'name3', 'name1', 'name2',) with pytest.raises(CircularReferenceError) as e: - f3.check_properties() - assert str(e.value) == "name3 -> name1 -> name2 -> name3" + f3._check_circ_refs((f3.name,), 'depends_on') + assert e.value.stack == ('name3', 'name1', 'name2', 'name3', ) diff --git a/tests/test_core/test_files/test_rel_name.py b/tests/test_core/test_files/test_rel_name.py index e8e280a5..7ea70e35 100644 --- a/tests/test_core/test_files/test_rel_name.py +++ b/tests/test_core/test_files/test_rel_name.py @@ -2,22 +2,31 @@ import pytest -import HABApp -from HABApp.core.files import name_from_path, path_from_name +from HABApp.core.files.folders import get_path, get_name, add_folder, get_prefixes +from HABApp.core.files.folders.folders import FOLDERS @pytest.fixture -def cfg(monkeypatch): - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('c:/HABApp/my_rules/')) - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('c:/HABApp/my_config/')) - monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('c:/HABApp/my_param/')) +def cfg(): + FOLDERS.clear() + add_folder('rules/', Path('c:/HABApp/my_rules/'), 0) + add_folder('configs/', Path('c:/HABApp/my_config/'), 10) + add_folder('params/', Path('c:/HABApp/my_param/'), 20) yield None + FOLDERS.clear() + def cmp(path: Path, name: str): - assert name_from_path(path) == name - assert path_from_name(name) == path + assert get_name(path) == name + assert get_path(name) == path + + +def test_prefix_sort(cfg): + assert get_prefixes() == ['params/', 'configs/', 'rules/'] + add_folder('params1/', Path('c:/HABApp/my_para1m/'), 50) + assert get_prefixes() == ['params1/', 'params/', 'configs/', 'rules/'] def test_from_path(cfg): @@ -32,4 +41,24 @@ def test_from_path(cfg): def test_err(cfg): with pytest.raises(ValueError): - name_from_path(Path('c:/HABApp/rules/rule.py')) + get_name(Path('c:/HABApp/rules/rule.py')) + + +def test_mixed(): + FOLDERS.clear() + add_folder('rules/', Path('c:/HABApp/rules'), 1) + add_folder('configs/', Path('c:/HABApp/rules/my_config'), 2) + add_folder('params/', Path('c:/HABApp/rules/my_param'), 3) + + cmp(Path('c:/HABApp/rules/rule.py'), 'rules/rule.py') + cmp(Path('c:/HABApp/rules/my_config/params.yml'), 'configs/params.yml') + cmp(Path('c:/HABApp/rules/my_param/cfg.yml'), 'params/cfg.yml') + + FOLDERS.clear() + add_folder('rules/', Path('c:/HABApp/rules'), 1) + add_folder('configs/', Path('c:/HABApp/rules/my_cfg'), 2) + add_folder('params/', Path('c:/HABApp/rules/my_param'), 3) + + cmp(Path('c:/HABApp/rules/rule.py'), 'rules/rule.py') + cmp(Path('c:/HABApp/rules/my_cfg/params.yml'), 'configs/params.yml') + cmp(Path('c:/HABApp/rules/my_param/cfg.yml'), 'params/cfg.yml') diff --git a/tests/test_core/test_files/test_watcher.py b/tests/test_core/test_files/test_watcher.py index ecb3fd6a..b43e14bb 100644 --- a/tests/test_core/test_files/test_watcher.py +++ b/tests/test_core/test_files/test_watcher.py @@ -9,6 +9,7 @@ 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 @@ -19,7 +20,7 @@ async def test_file_events(monkeypatch, event_bus: TmpEventBus, sync_worker): monkeypatch.setattr(HABApp.core.files.watcher.file_watcher, 'DEBOUNCE_TIME', wait_time) m = Mock() - handler = AggregatingAsyncEventHandler(Path('folder'), m, '.tmp', False) + handler = AggregatingAsyncEventHandler(Path('folder'), m, FileEndingFilter('.tmp'), False) loop = asyncio.get_event_loop() diff --git a/tests/test_core/test_items/test_item.py b/tests/test_core/test_items/test_item.py index 085ddb15..71fa15df 100644 --- a/tests/test_core/test_items/test_item.py +++ b/tests/test_core/test_items/test_item.py @@ -1,7 +1,8 @@ import unittest -from datetime import datetime, timedelta +from datetime import timedelta -import pytz +from pendulum import UTC +from pendulum import now as pd_now from HABApp.core.items import Item from . import ItemTests @@ -22,22 +23,22 @@ def test_repr(self): def test_time_update(self): i = Item('test') i.set_value('test') - i._last_change.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) - i._last_update.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) + i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) + i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) i.set_value('test') - self.assertGreater(i._last_update.dt, datetime.now(tz=pytz.utc) - timedelta(milliseconds=100)) - self.assertLess(i._last_change.dt, datetime.now(tz=pytz.utc) - timedelta(milliseconds=100)) + self.assertGreater(i._last_update.dt, pd_now(UTC) - timedelta(milliseconds=100)) + self.assertLess(i._last_change.dt, pd_now(UTC) - timedelta(milliseconds=100)) def test_time_change(self): i = Item('test') i.set_value('test') - i._last_change.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5)) - i._last_update.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5)) + i._last_change.set(pd_now(UTC) - timedelta(seconds=5)) + i._last_update.set(pd_now(UTC) - timedelta(seconds=5)) i.set_value('test1') - self.assertGreater(i._last_update.dt, datetime.now(tz=pytz.utc) - timedelta(milliseconds=100)) - self.assertGreater(i._last_change.dt, datetime.now(tz=pytz.utc) - timedelta(milliseconds=100)) + self.assertGreater(i._last_update.dt, pd_now(UTC) - timedelta(milliseconds=100)) + self.assertGreater(i._last_change.dt, pd_now(UTC) - timedelta(milliseconds=100)) if __name__ == '__main__': diff --git a/tests/test_core/test_items/test_item_aggregation.py b/tests/test_core/test_items/test_item_aggregation.py index 2fb2e6d6..ec939a78 100644 --- a/tests/test_core/test_items/test_item_aggregation.py +++ b/tests/test_core/test_items/test_item_aggregation.py @@ -20,11 +20,11 @@ async def post_val(t, v): await asyncio.sleep(t) src.post_value(v) - asyncio.ensure_future(post_val(1 * INTERVAL, 1)) - asyncio.ensure_future(post_val(2 * INTERVAL, 3)) - asyncio.ensure_future(post_val(3 * INTERVAL, 5)) - asyncio.ensure_future(post_val(4 * INTERVAL, 4)) - asyncio.ensure_future(post_val(5 * INTERVAL, 2)) + asyncio.create_task(post_val(1 * INTERVAL, 1)) + asyncio.create_task(post_val(2 * INTERVAL, 3)) + asyncio.create_task(post_val(3 * INTERVAL, 5)) + asyncio.create_task(post_val(4 * INTERVAL, 4)) + asyncio.create_task(post_val(5 * INTERVAL, 2)) await asyncio.sleep(INTERVAL + INTERVAL / 2) assert agg.value == (1, [1]) @@ -66,11 +66,11 @@ async def post_val(t, v): await asyncio.sleep(t) src.post_value(v) - asyncio.ensure_future(post_val(1 * INTERVAL, 1)) - asyncio.ensure_future(post_val(2 * INTERVAL, 3)) - asyncio.ensure_future(post_val(3 * INTERVAL, 5)) - asyncio.ensure_future(post_val(4 * INTERVAL, 7)) - asyncio.ensure_future(post_val(5 * INTERVAL, 9)) + asyncio.create_task(post_val(1 * INTERVAL, 1)) + asyncio.create_task(post_val(2 * INTERVAL, 3)) + asyncio.create_task(post_val(3 * INTERVAL, 5)) + asyncio.create_task(post_val(4 * INTERVAL, 7)) + asyncio.create_task(post_val(5 * INTERVAL, 9)) await asyncio.sleep(INTERVAL / 2) await asyncio.sleep(5 * INTERVAL) diff --git a/tests/test_core/test_items/test_item_color.py b/tests/test_core/test_items/test_item_color.py index 965bea2d..a1539f52 100644 --- a/tests/test_core/test_items/test_item_color.py +++ b/tests/test_core/test_items/test_item_color.py @@ -76,7 +76,7 @@ def test_rgb_to_hsv(): def test_hsv_to_rgb(): i = ColorItem('test', 23, 44, 66) - assert i.get_rgb() == (168, 122, 94) + assert i.get_rgb() == (168, 123, 94) def test_post_update(sync_worker, event_bus: TmpEventBus): diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index ef89531d..c16c37ab 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -1,9 +1,10 @@ import asyncio -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import MagicMock import pytest -import pytz +from pendulum import UTC +from pendulum import now as pd_now import HABApp import HABApp.core.items.tmp_data @@ -13,7 +14,7 @@ @pytest.fixture(scope="function") def u(): - a = UpdatedTime('test', datetime.now(tz=pytz.utc)) + a = UpdatedTime('test', pd_now(UTC)) w1 = a.add_watch(1) w2 = a.add_watch(3) @@ -26,7 +27,7 @@ def u(): @pytest.fixture(scope="function") def c(): - a = ChangedTime('test', datetime.now(tz=pytz.utc)) + a = ChangedTime('test', pd_now(UTC)) w1 = a.add_watch(1) w2 = a.add_watch(3) @@ -38,7 +39,7 @@ def c(): def test_sec_timedelta(): - a = UpdatedTime('test', datetime.now(tz=pytz.utc)) + a = UpdatedTime('test', pd_now(UTC)) w1 = a.add_watch(1) # We return the same object because it is the same time @@ -63,7 +64,7 @@ async def test_rem(u: UpdatedTime): @pytest.mark.asyncio async def test_cancel_running(u: UpdatedTime): - u.set(datetime.now(tz=pytz.utc)) + u.set(pd_now(UTC)) w1 = u.tasks[0] w2 = u.tasks[1] @@ -75,7 +76,7 @@ async def test_cancel_running(u: UpdatedTime): assert w2 in u.tasks w2.cancel() await asyncio.sleep(0.05) - u.set(datetime.now(tz=pytz.utc)) + u.set(pd_now(UTC)) await asyncio.sleep(0.05) assert w2 not in u.tasks @@ -83,11 +84,11 @@ async def test_cancel_running(u: UpdatedTime): @pytest.mark.asyncio async def test_event_update(u: UpdatedTime): m = MagicMock() - u.set(datetime.now(tz=pytz.utc)) + u.set(pd_now(UTC)) list = HABApp.core.EventBusListener('test', HABApp.core.WrappedFunction(m, name='MockFunc')) HABApp.core.EventBus.add_listener(list) - u.set(datetime.now(tz=pytz.utc)) + u.set(pd_now(UTC)) await asyncio.sleep(1) m.assert_not_called() @@ -113,11 +114,11 @@ async def test_event_update(u: UpdatedTime): @pytest.mark.asyncio async def test_event_change(c: ChangedTime): m = MagicMock() - c.set(datetime.now(tz=pytz.utc)) + c.set(pd_now(UTC)) list = HABApp.core.EventBusListener('test', HABApp.core.WrappedFunction(m, name='MockFunc')) HABApp.core.EventBus.add_listener(list) - c.set(datetime.now(tz=pytz.utc)) + c.set(pd_now(UTC)) await asyncio.sleep(1) m.assert_not_called() diff --git a/tests/test_core/test_items/tests_all_items.py b/tests/test_core/test_items/tests_all_items.py index 0c980f5a..af320b5b 100644 --- a/tests/test_core/test_items/tests_all_items.py +++ b/tests/test_core/test_items/tests_all_items.py @@ -1,7 +1,8 @@ import typing -from datetime import datetime, timedelta +from datetime import timedelta -import pytz +from pendulum import UTC +from pendulum import now as pd_now from HABApp.core import Items from HABApp.core.items import Item @@ -43,19 +44,19 @@ def test_time_value_update(self): for value in self.TEST_VALUES: i = self.CLS('test') i.set_value(value) - i._last_change.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) - i._last_update.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) + i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) + i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) i.set_value(value) - assert i._last_update.dt > datetime.now(tz=pytz.utc) - timedelta(milliseconds=100) - assert i._last_change.dt < datetime.now(tz=pytz.utc) - timedelta(milliseconds=100) + assert i._last_update.dt > pd_now(UTC) - timedelta(milliseconds=100) + assert i._last_change.dt < pd_now(UTC) - timedelta(milliseconds=100) def test_time_value_change(self): i = self.CLS('test') for value in self.TEST_VALUES: - i._last_change.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) - i._last_update.set(datetime.now(tz=pytz.utc) - timedelta(seconds=5), events=False) + i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) + i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) i.set_value(value) - assert i._last_update.dt > datetime.now(tz=pytz.utc) - timedelta(milliseconds=100) - assert i._last_change.dt > datetime.now(tz=pytz.utc) - timedelta(milliseconds=100) + assert i._last_update.dt > pd_now(UTC) - timedelta(milliseconds=100) + assert i._last_change.dt > pd_now(UTC) - timedelta(milliseconds=100) diff --git a/tests/test_core/test_wrapped_func.py b/tests/test_core/test_wrapped_func.py index 8d2c5bd0..f3e14026 100644 --- a/tests/test_core/test_wrapped_func.py +++ b/tests/test_core/test_wrapped_func.py @@ -83,7 +83,7 @@ def tmp(): self.assertTrue(self.err_func.called) err = self.err_func.call_args[0][0] - assert isinstance(err, HABApp.core.events.habapp_events.HABAppError) + assert isinstance(err, HABApp.core.events.habapp_events.HABAppException) assert err.func_name == 'tmp' assert isinstance(err.exception, ZeroDivisionError) assert err.traceback.startswith('File ') @@ -126,7 +126,7 @@ async def tmp(): assert err_func.called err = err_func.call_args[0][0] - assert isinstance(err, HABApp.core.events.habapp_events.HABAppError) + assert isinstance(err, HABApp.core.events.habapp_events.HABAppException) assert err.func_name == 'tmp' assert isinstance(err.exception, ZeroDivisionError) assert err.traceback.startswith('File ') diff --git a/tests/test_mqtt/test_retain.py b/tests/test_mqtt/test_retain.py index 73ea75df..85fcccdb 100644 --- a/tests/test_mqtt/test_retain.py +++ b/tests/test_mqtt/test_retain.py @@ -7,6 +7,7 @@ def __init__(self, topic='', payload='', retain=False): self.topic = topic self.payload = payload.encode('utf-8') self.retain = retain + self.qos = 0 def test_retain_create(): diff --git a/tests/test_openhab/test_items/test_items.py b/tests/test_openhab/test_items/test_items.py index 820205e4..b7338ad0 100644 --- a/tests/test_openhab/test_items/test_items.py +++ b/tests/test_openhab/test_items/test_items.py @@ -7,7 +7,7 @@ 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 alle openhab items inherit from OpenhabItem + # 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: diff --git a/tests/test_openhab/test_items/test_mapping.py b/tests/test_openhab/test_items/test_mapping.py index 6adb2ebd..87fe7f9c 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -1,5 +1,6 @@ from HABApp.openhab.map_items import map_item -from HABApp.openhab.items import NumberItem +from HABApp.openhab.items import NumberItem, DatetimeItem +from datetime import datetime def test_exception(): @@ -14,3 +15,16 @@ def test_number_unit_of_measurement(): assert map_item('test5', 'Number:Intensity', '5.0 W/m2') == NumberItem('test', 5) assert map_item('test6', 'Number:Dimensionless', '6.0') == NumberItem('test', 6) assert map_item('test7', 'Number:Angle', '7.0 °') == NumberItem('test', 7) + + +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') == \ + 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') == \ + 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_plugins/test_thing/test_errors.py b/tests/test_openhab/test_plugins/test_thing/test_errors.py index cdea0b99..00ef3511 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_errors.py +++ b/tests/test_openhab/test_plugins/test_thing/test_errors.py @@ -1,3 +1,5 @@ +import time + import pytest from HABApp.openhab.connection_logic.plugin_things.plugin_things import ManualThingConfig @@ -36,9 +38,13 @@ async def test_errors(caplog): file = MockFile('/thing_test.yml', data=text) file.warn_on_delete = False - await cfg.update_thing_config(file, data) + cfg.cache_cfg = data + cfg.cache_ts = time.time() + await cfg.file_load('/thing_test.yml', file) - assert caplog.records[0].message == 'Duplicate item: Name1' + errors = [rec.message for rec in caplog.records if rec.levelno >= 30] + assert errors == ['Duplicate item: Name1'] + caplog.clear() text = """ test: False @@ -56,6 +62,9 @@ async def test_errors(caplog): file = MockFile('/thing_test.yml', data=text) file.warn_on_delete = False - await cfg.update_thing_config(file, data) + cfg.cache_cfg = data + cfg.cache_ts = time.time() + await cfg.file_load('/thing_test.yml', file) - assert caplog.records[1].message == '"â_ß_{_)" is not a valid name for an item!' + errors = [rec.message for rec in caplog.records if rec.levelno >= 30] + assert errors[0] == '"â_ß_{_)" is not a valid name for an item!' diff --git a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py index 85d9fe87..8db5b5df 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py +++ b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py @@ -21,6 +21,7 @@ def cfg(): 'group_1': ['controller'], 'binding_cmdrepollperiod': 2600, + "wakeup_interval": 3600, }) @@ -51,16 +52,19 @@ def test_param_split(cfg: ThingConfigChanger): def test_set_keys(cfg: ThingConfigChanger): cfg[1] = 5 + cfg['wakeup_interval'] = 7200 with raises(KeyError): cfg[3] = 7 + with raises(KeyError): cfg['1'] = 7 def test_set_wrong_type(cfg: ThingConfigChanger): - with raises(ValueError): + with raises(ValueError) as e: cfg[1] = "asdf" + assert str(e.value) == "Datatype of parameter '1' must be 'int' but is 'str': 'asdf'" with raises(ValueError): cfg['Group1'] = 'asdf' diff --git a/tests/test_rule/test_rule_funcs.py b/tests/test_rule/test_rule_funcs.py index ab9be796..c7c28ee3 100644 --- a/tests/test_rule/test_rule_funcs.py +++ b/tests/test_rule/test_rule_funcs.py @@ -1,64 +1,10 @@ import unittest -from datetime import datetime, timedelta, time from unittest.mock import MagicMock from HABApp import Rule - from ..rule_runner import SimpleRuleRunner -class TestCases(unittest.TestCase): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.runner = SimpleRuleRunner() - self.rule: Rule = None - - def setUp(self): - - self.runner.set_up() - self.rule = Rule() - - def tearDown(self): - self.runner.tear_down() - self.rule = None - - def test_run_reoccurring(self): - r = self.rule - test = [time(11, 30, 0), timedelta(seconds=30), None, datetime.now() + timedelta(seconds=5)] - for t in test: - if isinstance(test, time): - r.run_on_weekends(t, lambda x: x) - r.run_on_workdays(t, lambda x: x) - r.run_on_every_day(t, lambda x: x) - - r.run_every(t, 60, lambda x : x) - r.run_every(t, timedelta(seconds=30), lambda x : x) - - def test_run_convenience_funcs(self): - r = self.rule - - def cb(): - pass - - r.run_daily(cb) - r.run_hourly(cb) - r.run_minutely(cb) - r.run_soon(cb) - - def test_run_scheduler(self): - r = self.rule - - def cb(): - pass - - r.run_in(5, cb) - - for t in [time(11, 30, 0), timedelta(seconds=30), None, datetime.now() + timedelta(seconds=1)]: - r.run_at(t, cb) - - def test_unload_function(): with SimpleRuleRunner(): diff --git a/tests/test_rule/test_scheduler.py b/tests/test_rule/test_scheduler.py deleted file mode 100644 index 169e4fd3..00000000 --- a/tests/test_rule/test_scheduler.py +++ /dev/null @@ -1,152 +0,0 @@ -import unittest.mock -from datetime import datetime, time, timedelta - -from pytz import utc - -import HABApp -from HABApp.rule import scheduler - - -class FuncWrapper: - mock = unittest.mock.MagicMock() - - def run(self, *args, **kwargs): - self.mock(*args, **kwargs) - - -func = FuncWrapper() - - -def test_one_time(): - func.mock.reset_mock() - s = scheduler.OneTimeCallback(func) - - s.set_run_time(None) - s.check_due(datetime.now(tz=utc)) - func.mock.assert_not_called() - s.execute() - func.mock.assert_called() - - -def test_workday(): - func.mock.reset_mock() - s = scheduler.DayOfWeekScheduledCallback(func) - - s.weekdays('workday') - s.time(datetime(year=2000, month=12, day=30, hour=12)) - - assert s.get_next_call() == datetime(2001, 1, 1, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 2, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 3, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 4, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 5, 12) - - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 8, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 9, 12) - - -def test_weekend(): - func.mock.reset_mock() - s = scheduler.DayOfWeekScheduledCallback(func) - - s.weekdays('weekend') - s.time(datetime(year=2001, month=1, day=1, hour=12)) - - assert s.get_next_call() == datetime(2001, 1, 6, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 7, 12) - - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 13, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 14, 12) - - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 20, 12) - s._calculate_next_call() - assert s.get_next_call() == datetime(2001, 1, 21, 12) - - -def test_every_day(): - func.mock.reset_mock() - s = scheduler.DayOfWeekScheduledCallback(func) - - s.weekdays('all') - s.time(time(hour=0, minute=0)) - - now = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - - assert s.get_next_call() == now + timedelta(days=1) - s._calculate_next_call() - assert s.get_next_call() == now + timedelta(days=2) - s._calculate_next_call() - assert s.get_next_call() == now + timedelta(days=3) - - -def test_sun(): - HABApp.CONFIG.location.latitude = 52.52437 - HABApp.CONFIG.location.longitude = 13.41053 - HABApp.CONFIG.location.elevation = 43 - HABApp.CONFIG.location.on_all_values_set() - - func.mock.reset_mock() - s = scheduler.SunScheduledCallback(func) - - s.sun_trigger('sunrise') - s._calculate_next_call() - assert s._next_base.tzinfo is not None - - s.earliest(time(hour=12)) - s._update_run_time() - assert s._next_call.astimezone(scheduler.base.local_tz).time() == time(12) - - s.earliest(None) - s.latest(time(hour=2)) - s._update_run_time() - assert s._next_call.astimezone(scheduler.base.local_tz).time() == time(2) - - -def cmp_ts(a: datetime, b: datetime): - diff = abs(a.timestamp() - b.timestamp()) - assert diff < 0.0001, f'Diff: {diff}\n{a}\n{b}' - - -def test_boundary(): - func.mock.reset_mock() - s = scheduler.reoccurring_cb.ReoccurringScheduledCallback(func) - - now = datetime.now() - s.interval(timedelta(seconds=15)) - cmp_ts(s.get_next_call(), now + timedelta(seconds=15)) - - def b_func(d: datetime): - return d + timedelta(seconds=15) - - s.boundary_func(b_func) - cmp_ts(s.get_next_call(), now + timedelta(seconds=30)) - - # offset etc comes after the custom function - s.offset(timedelta(seconds=-10)) - cmp_ts(s.get_next_call(), now + timedelta(seconds=20)) - - -def test_boundary_func(): - func.mock.reset_mock() - s = scheduler.reoccurring_cb.ReoccurringScheduledCallback(func) - - now = datetime.now() - s.interval(timedelta(seconds=15)) - cmp_ts(s.get_next_call(), now + timedelta(seconds=15)) - - def b_func(d: datetime): - s.offset(timedelta(seconds=15)) - return d - - s.boundary_func(b_func) - cmp_ts(s.get_next_call(), now + timedelta(seconds=30)) diff --git a/tests/test_utils/test_functions.py b/tests/test_utils/test_functions.py index 39fee845..07def503 100644 --- a/tests/test_utils/test_functions.py +++ b/tests/test_utils/test_functions.py @@ -1,4 +1,6 @@ -from HABApp.util.functions import max, min +import pytest + +from HABApp.util.functions import hsb_to_rgb, max, min, rgb_to_hsb def test_none_remove(): @@ -22,3 +24,24 @@ def test_max(): assert max([1, None]) == 1 assert max([2, 3, None]) == 3 + + +@pytest.mark.parametrize("rgb,hsv", [ + ((224, 201, 219), (313.04, 10.27, 87.84)), + (( 0, 201, 219), (184.93, 100.00, 85.88)), + ((128, 138, 33), ( 65.71, 76.09, 54.12)), + (( 0, 0, 0), ( 0, 0, 0)), +]) +def test_rgb_to_hsv(rgb, hsv): + assert hsv == rgb_to_hsb(*rgb) + + +@pytest.mark.parametrize("hsv,rgb", [ + (( 75, 75, 75), (155, 191, 48)), + ((150, 40, 100), (153, 255, 204)), + ((234, 46, 72), ( 99, 108, 184)), + (( 0, 100, 100), (255, 0, 0)), + (( 0, 0, 0), ( 0, 0, 0)), +]) +def test_hsv_to_rgb(hsv, rgb): + assert rgb == hsb_to_rgb(*hsv) diff --git a/tox.ini b/tox.ini index 575e5e42..6a928c93 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ # content of: tox.ini , put in same dir as setup.py [tox] envlist = - py36 py37 py38 py39 @@ -10,16 +9,12 @@ envlist = [gh-actions] python = - 3.6: py36 3.7: py37 3.8: py38, flake, docs 3.9: py39 [testenv] deps = - pytest - pytest-asyncio - mock;python_version<"3.8" -r{toxinidir}/requirements.txt commands = @@ -28,7 +23,6 @@ commands = [testenv:flake] deps = {[testenv]deps} - flake8 # pydocstyle commands = flake8 -v @@ -39,7 +33,6 @@ description = invoke sphinx-build to build the HTML docs deps = {[testenv]deps} - sphinx -r{toxinidir}/_doc/requirements.txt commands =