From 61cfb58d0d5626cd2711239412e33336eccf0836 Mon Sep 17 00:00:00 2001 From: Eelco Date: Tue, 11 Nov 2025 16:10:43 +0100 Subject: [PATCH 01/24] feat: add Homey integration documentation and code formatting Add comprehensive Homey Web API integration documentation outlining architecture, implementation phases, and entity support (lights, switches, buttons, scenes). Document includes setup requirements, configuration details, and real-time WebSocket updates following existing Home Assistant and OpenHAB integration patterns. Additionally, standardize code formatting across Python files by converting single quotes to double quotes for consistency and improving string formatting. Changes: - Add HOMEY_INTEGRATION.md with complete integration specifications - Standardize quote style from single to double quotes - Improve code formatting consistency across web interface files --- HOMEY_INTEGRATION.md | 573 +++++++ docker/MQTTManager/CMakeLists.txt | 37 +- .../include/button/homey_button.cpp | 104 ++ .../include/button/homey_button.hpp | 19 + .../include/entity_manager/entity_manager.cpp | 716 ++++++--- .../include/homey_manager/homey_manager.cpp | 224 +++ .../include/homey_manager/homey_manager.hpp | 64 + .../MQTTManager/include/light/homey_light.cpp | 335 ++++ .../MQTTManager/include/light/homey_light.hpp | 25 + .../include/scenes/homey_scene.cpp | 95 ++ .../include/scenes/homey_scene.hpp | 25 + .../include/switch/homey_switch.cpp | 139 ++ .../include/switch/homey_switch.hpp | 19 + .../include/thermostat/homey_thermostat.cpp | 129 ++ .../include/thermostat/homey_thermostat.hpp | 20 + .../nginx/sites-enabled/nspanelmanager.conf | 18 +- docker/web/nspanelmanager/web/api.py | 349 +++-- docker/web/nspanelmanager/web/homey_api.py | 334 ++++ docker/web/nspanelmanager/web/htmx.py | 1344 ++++++++++++----- .../templates/modals/initial_setup/homey.html | 144 ++ ...to_entities_page_select_entity_source.html | 128 +- .../entity_add_or_edit_button_to_room.html | 68 +- .../entity_add_or_edit_light_to_room.html | 142 +- .../entity_add_or_edit_scene.html | 86 +- .../entity_add_or_edit_switch_to_room.html | 212 ++- ...entity_add_or_edit_thermostat_to_room.html | 50 +- .../web/templates/settings.html | 158 +- docker/web/nspanelmanager/web/urls.py | 469 ++++-- docker/web/nspanelmanager/web/views.py | 828 ++++++---- 29 files changed, 5493 insertions(+), 1361 deletions(-) create mode 100644 HOMEY_INTEGRATION.md create mode 100644 docker/MQTTManager/include/button/homey_button.cpp create mode 100644 docker/MQTTManager/include/button/homey_button.hpp create mode 100644 docker/MQTTManager/include/homey_manager/homey_manager.cpp create mode 100644 docker/MQTTManager/include/homey_manager/homey_manager.hpp create mode 100644 docker/MQTTManager/include/light/homey_light.cpp create mode 100644 docker/MQTTManager/include/light/homey_light.hpp create mode 100644 docker/MQTTManager/include/scenes/homey_scene.cpp create mode 100644 docker/MQTTManager/include/scenes/homey_scene.hpp create mode 100644 docker/MQTTManager/include/switch/homey_switch.cpp create mode 100644 docker/MQTTManager/include/switch/homey_switch.hpp create mode 100644 docker/MQTTManager/include/thermostat/homey_thermostat.cpp create mode 100644 docker/MQTTManager/include/thermostat/homey_thermostat.hpp create mode 100644 docker/web/nspanelmanager/web/homey_api.py create mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/homey.html diff --git a/HOMEY_INTEGRATION.md b/HOMEY_INTEGRATION.md new file mode 100644 index 00000000..a7e08724 --- /dev/null +++ b/HOMEY_INTEGRATION.md @@ -0,0 +1,573 @@ +# Homey Integration for NSPanelManager + +## Overview +This document outlines the comprehensive integration of Homey Web API into NSPanelManager to enable control of Homey devices (lights, switches, buttons) and scenes (Flows and Moods). This integration follows the same architecture pattern as existing Home Assistant and OpenHAB integrations. + +--- + +## 1. Requirements + +### 1.1 Supported Entities +- **Lights**: Devices with capabilities `onoff`, `dim`, `light_hue`, `light_saturation`, `light_temperature`, `light_mode` +- **Switches**: Devices with capability `onoff` +- **Buttons**: Devices with capability `button` +- **Scenes**: Homey Flows (prefixed with `[F]`) and Moods (prefixed with `[M]`) + +### 1.2 Configuration +- Homey IP address or mDNS name (e.g., `homey.local`) +- Homey API key (generated in Homey app) +- Settings stored in NSPanelManager database + +### 1.3 Real-time Updates +- WebSocket connection for real-time device state synchronization +- Event-driven architecture similar to Home Assistant integration + +### 1.4 UI/UX Requirements +- Homey as third integration option alongside HA and OpenHAB +- Settings configuration page for Homey credentials +- Entity selection and filtering by capability +- Scene naming with [F] and [M] prefixes + +--- + +## 2. Architecture Overview + +``` +┌─────────────────────────────────┐ +│ Web Interface (Python) │ +├─────────────────────────────────┤ +│ • homey_api.py (HTTP requests) │ +│ • api.py (extend entity fetching)│ +│ • htmx.py (UI integration) │ +└──────────────┬──────────────────┘ + │ + ┌──────┴──────────┐ + │ │ + ┌────▼─────┐ ┌──────▼──────────┐ + │ SQLite │ │ Homey API │ + │ Database │ │ (HTTP/REST) │ + └──────────┘ └──────┬──────────┘ + │ +┌─────────────────────────┴───────────────────────┐ +│ MQTTManager (C++) │ +├─────────────────────────────────────────────────┤ +│ • HomeyManager (WebSocket) │ +│ • HomeyLight / HomeySwitch / HomeyThermostat │ +│ • HomeyButton / HomeyScene │ +│ • EntityManager (instantiation logic) │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 3. Implementation Phases + +### Phase 1: Foundation & Settings +**Status**: 🟨 In Progress (Python API client completed) + +#### 3.1.1 Settings Configuration +- [ ] Add `homey_address` setting to database +- [ ] Add `homey_token` setting to database +- [ ] Create settings UI in web interface +- [ ] Add Homey section to initial setup wizard +- [ ] Implement settings persistence + +#### 3.1.2 Documentation +- [ ] Create API key generation guide +- [ ] Document Homey IP address configuration +- [ ] Document settings validation + +**Deliverables**: +- ✅ Python homey_api.py module created with device/flow/mood discovery +- Settings stored in web_settings table +- UI form for Homey configuration +- Initial setup step for Homey + +--- + +### Phase 2: Python Web Interface - Device Discovery +**Status**: 🟨 In Progress (API client and core integration done) + +#### 3.2.1 Homey API Client +- [x] Create `docker/web/nspanelmanager/web/homey_api.py` +- [x] Implement `get_all_homey_devices()` +- [x] Implement capability filtering logic +- [x] Implement `get_all_homey_flows()` +- [x] Implement `get_all_homey_moods()` +- [x] Implement error handling and logging + +#### 3.2.2 API Integration +- [x] Add Homey to `get_all_available_entities()` in `api.py` +- [x] Filter devices by supported capabilities +- [x] Add [F] prefix to Flow names +- [x] Add [M] prefix to Mood names +- [x] Return standardized entity format + +#### 3.2.3 Entity Addition UI +- [ ] Extend entity source selection in `htmx.py` +- [ ] Add Homey option to `partial_add_entity_to_entities_page_select_entity_source()` +- [ ] Add Homey to supported entity types lists +- [ ] Implement entity-specific configuration screens + +**Deliverables**: +- ✅ `homey_api.py` module with device discovery +- ✅ Integration with `get_all_available_entities()` in api.py +- ⏳ Ability to fetch and display Homey devices/scenes (pending htmx.py UI) + +--- + +### Phase 3: Entity Creation & Storage +**Status**: ⬜ Not Started + +#### 3.3.1 Light Entity Creation +- [ ] Extend `partial_entity_add_light_entity()` for Homey +- [ ] Extend `create_or_update_light_entity()` for Homey +- [ ] Store Homey device ID in entity_data JSON +- [ ] Store capability list in entity_data JSON +- [ ] Map light properties (can_dim, can_color_temperature, can_rgb) + +#### 3.3.2 Switch Entity Creation +- [ ] Extend `partial_entity_add_switch_entity()` for Homey +- [ ] Extend `create_or_update_switch_entity()` for Homey +- [ ] Store Homey device ID in entity_data JSON + +#### 3.3.3 Button Entity Creation +- [ ] Extend `partial_entity_add_button_entity()` for Homey +- [ ] Extend `create_or_update_button_entity()` for Homey +- [ ] Store Homey device ID in entity_data JSON + +#### 3.3.4 Scene Entity Creation +- [ ] Extend `partial_entity_add_scene_entity()` for Homey +- [ ] Extend `create_or_update_scene_entity()` for Homey +- [ ] Store Homey flow/mood ID in backend_name +- [ ] Store type indicator (flow/mood) for display + +**Entity Data JSON Structures**: + +Light: +```json +{ + "controller": "homey", + "homey_device_id": "device-uuid", + "can_dim": true, + "can_color_temperature": true, + "can_rgb": true, + "capabilities": ["onoff", "dim", "light_temperature", "light_hue", "light_saturation"] +} +``` + +Switch: +```json +{ + "controller": "homey", + "homey_device_id": "device-uuid", + "capabilities": ["onoff"] +} +``` + +Button: +```json +{ + "controller": "homey", + "homey_device_id": "device-uuid", + "capabilities": ["button"] +} +``` + +Scene: +```json +{ + "controller": "homey", + "homey_id": "flow-or-mood-uuid", + "homey_type": "flow" +} +``` + +**Deliverables**: +- Entity creation UI for Homey entities +- Proper storage of Homey-specific data +- Database records with correct controller type + +--- + +### Phase 4: C++ Backend - Homey Manager +**Status**: ⬜ Not Started + +#### 3.4.1 HomeyManager Header +- [ ] Create `docker/MQTTManager/include/homey_manager/homey_manager.hpp` +- [ ] Define class structure (static methods, WebSocket, events) +- [ ] Define configuration struct +- [ ] Define observer/signal pattern + +#### 3.4.2 HomeyManager Implementation +- [ ] Implement `init()` - Start thread and WebSocket +- [ ] Implement `connect()` - Connect to Homey WebSocket +- [ ] Implement `reload_config()` - Reload from database +- [ ] Implement authentication with API key +- [ ] Implement WebSocket message handling +- [ ] Implement device event processing +- [ ] Implement observer pattern for entity updates +- [ ] Implement disconnect/reconnect logic +- [ ] Add comprehensive logging + +#### 3.4.3 Configuration Management +- [ ] Load homey_address from settings +- [ ] Load homey_token from settings +- [ ] Handle configuration changes +- [ ] Validate credentials + +**Deliverables**: +- Functional HomeyManager with WebSocket connection +- Real-time device event handling +- Observer pattern for entity state changes + +--- + +### Phase 5: C++ Backend - Entity Types +**Status**: ⬜ Not Started + +#### 3.5.1 Homey Light +- [ ] Create `docker/MQTTManager/include/light/homey_light.hpp` +- [ ] Create `docker/MQTTManager/include/light/homey_light.cpp` +- [ ] Extend Light base class +- [ ] Implement state synchronization +- [ ] Implement brightness control +- [ ] Implement color temperature control +- [ ] Implement RGB color control +- [ ] Implement hue/saturation control +- [ ] Handle device events from HomeyManager +- [ ] Implement `send_state_update_to_controller()` + +#### 3.5.2 Homey Switch +- [ ] Create `docker/MQTTManager/include/switch/homey_switch.hpp` +- [ ] Create `docker/MQTTManager/include/switch/homey_switch.cpp` +- [ ] Extend SwitchEntity base class +- [ ] Implement on/off control +- [ ] Handle device events +- [ ] Implement `send_state_update_to_controller()` + +#### 3.5.3 Homey Button +- [ ] Create `docker/MQTTManager/include/button/homey_button.hpp` +- [ ] Create `docker/MQTTManager/include/button/homey_button.cpp` +- [ ] Extend ButtonEntity base class +- [ ] Implement button press triggering +- [ ] Handle device events +- [ ] Implement `send_state_update_to_controller()` + +#### 3.5.4 Homey Scene +- [ ] Create `docker/MQTTManager/include/scenes/homey_scene.hpp` +- [ ] Create `docker/MQTTManager/include/scenes/homey_scene.cpp` +- [ ] Extend Scene base class +- [ ] Support both Flows and Moods +- [ ] Implement scene activation +- [ ] Store and detect scene type (flow/mood) + +**Deliverables**: +- All four entity types implemented +- Full control capabilities +- Proper state synchronization + +--- + +### Phase 6: Integration with EntityManager +**Status**: ⬜ Not Started + +#### 3.6.1 EntityManager Modifications +- [ ] Modify `load_lights()` - Add Homey light instantiation +- [ ] Modify `load_switches()` - Add Homey switch instantiation +- [ ] Modify `load_buttons()` - Add Homey button instantiation +- [ ] Modify `load_scenes()` - Add Homey scene instantiation +- [ ] Add includes for new Homey entity headers + +#### 3.6.2 CMake Updates +- [ ] Add Homey source files to CMakeLists.txt +- [ ] Ensure compilation of new modules +- [ ] Verify dependency linking + +#### 3.6.3 Initialization +- [ ] Initialize HomeyManager in application startup +- [ ] Ensure proper thread management +- [ ] Handle shutdown gracefully + +**Deliverables**: +- EntityManager properly instantiates Homey entities +- Project compiles with all new files +- HomeyManager starts on application launch + +--- + +### Phase 7: Testing & Debugging +**Status**: ⬜ Not Started + +#### 3.7.1 Unit Testing +- [ ] Test Homey API connectivity +- [ ] Test capability filtering +- [ ] Test entity data storage/retrieval +- [ ] Test WebSocket connection handling +- [ ] Test device state synchronization + +#### 3.7.2 Integration Testing +- [ ] Test light control (on/off, brightness, color) +- [ ] Test switch control +- [ ] Test button triggering +- [ ] Test scene activation +- [ ] Test real-time updates +- [ ] Test reconnection logic +- [ ] Test configuration changes + +#### 3.7.3 Debugging +- [ ] Verify database records +- [ ] Check WebSocket logs +- [ ] Monitor entity state changes +- [ ] Test error scenarios +- [ ] Validate API compatibility + +**Deliverables**: +- Comprehensive test coverage +- Bug fixes and refinements +- Performance validation + +--- + +### Phase 8: Documentation & UX Polish +**Status**: ⬜ Not Started + +#### 3.8.1 User Documentation +- [ ] Create Homey setup guide +- [ ] Document API key generation +- [ ] Document supported device types +- [ ] Create troubleshooting guide + +#### 3.8.2 Code Documentation +- [ ] Document HomeyManager API +- [ ] Document entity classes +- [ ] Add code comments for complex logic +- [ ] Create architecture diagrams + +#### 3.8.3 UX Improvements +- [ ] Improve error messages +- [ ] Add connection status indicators +- [ ] Enhance entity filtering UI +- [ ] Add validation feedback + +**Deliverables**: +- Complete user documentation +- Code documentation +- Polished UI/UX + +--- + +## 4. File Structure Changes + +### New Files +``` +docker/web/nspanelmanager/web/ +├── homey_api.py (NEW) + +docker/MQTTManager/include/ +├── homey_manager/ +│ ├── homey_manager.hpp (NEW) +│ └── homey_manager.cpp (NEW) +├── light/ +│ ├── homey_light.hpp (NEW) +│ └── homey_light.cpp (NEW) +├── switch/ +│ ├── homey_switch.hpp (NEW) +│ └── homey_switch.cpp (NEW) +├── button/ +│ ├── homey_button.hpp (NEW) +│ └── homey_button.cpp (NEW) +└── scenes/ + ├── homey_scene.hpp (NEW) + └── homey_scene.cpp (NEW) +``` + +### Modified Files +``` +docker/web/nspanelmanager/web/ +├── api.py (MODIFIED) +├── htmx.py (MODIFIED) + +docker/MQTTManager/ +├── CMakeLists.txt (MODIFIED) +├── include/entity_manager/ +│ ├── entity_manager.hpp (MODIFIED) +│ └── entity_manager.cpp (MODIFIED) +``` + +--- + +## 5. Homey API Integration Details + +### 5.1 REST API Endpoints (Python) + +#### Get All Devices +``` +GET http://{homey_address}/api/manager/devices/device +Headers: Authorization: Bearer {api_key} +``` + +Response includes device data with capabilities. + +#### Get All Flows +``` +GET http://{homey_address}/api/manager/flow/flow +Headers: Authorization: Bearer {api_key} +``` + +#### Get All Moods +Access via devices or dedicated mood endpoints. + +#### Trigger Device Capability (for control) +``` +PUT http://{homey_address}/api/manager/devices/device/{device_id}/capability/{capability_id} +Headers: Authorization: Bearer {api_key} +Body: { "value": } +``` + +#### Trigger Flow +``` +POST http://{homey_address}/api/manager/flow/flow/{flow_id}/trigger +Headers: Authorization: Bearer {api_key} +``` + +### 5.2 WebSocket API (C++) + +#### Connection +``` +ws://{homey_address}/api/manager/devices/device?token={api_key} +``` + +#### Message Format +```json +{ + "type": "device.update", + "device": { + "id": "device-uuid", + "capabilities": { + "onoff": { "value": true }, + "dim": { "value": 0.85 }, + "light_temperature": { "value": 4000 } + } + } +} +``` + +--- + +## 6. Capability Mapping + +| Homey Capability | NSPanel Feature | Data Type | Range | +| ------------------- | --------------- | --------------- | ------------------- | +| `onoff` | On/Off toggle | Boolean | true/false | +| `dim` | Brightness | Float (decimal) | 0.0 - 1.0 → 0-100% | +| `light_hue` | Hue | Float | 0.0 - 1.0 → 0-360° | +| `light_saturation` | Saturation | Float | 0.0 - 1.0 → 0-100% | +| `light_temperature` | Color Temp | Integer | Kelvin (1000-10000) | +| `light_mode` | Light Mode | String | (mode-specific) | +| `button` | Button Press | (trigger) | (event-based) | + +--- + +## 7. Entity Data Schema + +### Database Schema (No changes required) +Uses existing Entity and Scene tables with controller field in entity_data JSON. + +### entity_data JSON Schema + +**Light Entity**: +```json +{ + "controller": "homey", + "homey_device_id": "", + "homey_device_name": "", + "can_dim": true, + "can_color_temperature": true, + "can_rgb": true, + "capabilities": ["onoff", "dim", "light_temperature", "light_hue", "light_saturation"], + "controlled_by_nspanel_main_page": true, + "is_ceiling_light": false +} +``` + +**Switch Entity**: +```json +{ + "controller": "homey", + "homey_device_id": "", + "homey_device_name": "", + "capabilities": ["onoff"] +} +``` + +**Button Entity**: +```json +{ + "controller": "homey", + "homey_device_id": "", + "homey_device_name": "", + "capabilities": ["button"] +} +``` + +**Scene (Backend Name)**: +``` +homey_flow_ (for Flows) +homey_mood_ (for Moods) +``` + +--- + +## 8. Progress Checklist + +### Summary Status: 🟨 In Progress (2/8 phases started, 10/32 tasks completed) + +- 🟨 **Phase 1**: Foundation & Settings (1/2 sections, API client done) +- 🟨 **Phase 2**: Python Web Interface (2/3 sections, API + core integration done) +- [ ] **Phase 3**: Entity Creation & Storage (0/4 sections) +- [ ] **Phase 4**: C++ Backend - Homey Manager (0/3 sections) +- [ ] **Phase 5**: C++ Backend - Entity Types (0/4 sections) +- [ ] **Phase 6**: Integration with EntityManager (0/3 sections) +- [ ] **Phase 7**: Testing & Debugging (0/3 sections) +- [ ] **Phase 8**: Documentation & UX Polish (0/3 sections) + +### Recently Completed +- ✅ Created comprehensive HOMEY_INTEGRATION.md documentation +- ✅ Created homey_api.py with device/flow/mood discovery +- ✅ Extended api.py to integrate Homey entity fetching + +--- + +## 9. Additional Notes + +### Future Enhancements +- Abstract interface for easier integration of new controllers +- Homey device discovery via mDNS +- API rate limiting and caching +- Homey webhook support for even faster updates +- Multi-Homey support + +### Known Limitations +- Initially single Homey instance (can be extended later) +- Button entity only supports basic press (not long press/double press initially) +- Moods treated as scenes (same behavior as Flows) + +### Dependencies +- None additional (all libraries already in project) + - Python: requests (existing) + - C++: ixwebsocket (existing), nlohmann/json (existing) + +--- + +## 10. References + +### Homey API Documentation +- [Homey API V3 Local](https://athombv.github.io/node-homey-api/HomeyAPIV3Local.html) +- [Getting Started with API Keys](https://support.homey.app/hc/en-us/articles/8178797067292-Getting-started-with-API-Keys) +- [ManagerDevices.Device](https://athombv.github.io/node-homey-api/HomeyAPIV3Local.ManagerDevices.Device.html) +- [ManagerDevices.Capability](https://athombv.github.io/node-homey-api/HomeyAPIV3Local.ManagerDevices.Capability.html) + +### Project References +- Home Assistant integration: `docker/MQTTManager/include/home_assistant_manager/` +- OpenHAB integration: `docker/MQTTManager/include/openhab_manager/` diff --git a/docker/MQTTManager/CMakeLists.txt b/docker/MQTTManager/CMakeLists.txt index dc570ae4..fdc1a522 100644 --- a/docker/MQTTManager/CMakeLists.txt +++ b/docker/MQTTManager/CMakeLists.txt @@ -82,40 +82,45 @@ set_target_properties(MQTTManager_OpenhabManager PROPERTIES PUBLIC_HEADER ${CMAK target_include_directories(MQTTManager_OpenhabManager PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) target_link_libraries(MQTTManager_OpenhabManager Boost::stacktrace_backtrace spdlog::spdlog nlohmann_json::nlohmann_json ixwebsocket::ixwebsocket Boost::boost MQTTManager_Config MQTTManager_WebsocketServer gtest::gtest) +add_library(MQTTManager_HomeyManager STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/homey_manager/homey_manager.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/homey_manager/homey_manager.hpp) +set_target_properties(MQTTManager_HomeyManager PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/homey_manager/homey_manager.hpp) +target_include_directories(MQTTManager_HomeyManager PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) +target_link_libraries(MQTTManager_HomeyManager Boost::stacktrace_backtrace spdlog::spdlog nlohmann_json::nlohmann_json ixwebsocket::ixwebsocket Boost::boost MQTTManager_Config MQTTManager_WebsocketServer gtest::gtest) + add_library(MQTTManager_Weather STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/weather/weather.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/weather/weather.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/weather/weather_icons.hpp) set_target_properties(MQTTManager_Weather PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/weather/weather.hpp) target_include_directories(MQTTManager_Weather PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) target_link_libraries(MQTTManager_Weather MQTTManager_WebHelper spdlog::spdlog nlohmann_json::nlohmann_json MQTTManager_Config MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTT_Manager gtest::gtest) -add_library(MQTTManager_Light STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.cpp) -set_target_properties(MQTTManager_Light PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.hpp) +add_library(MQTTManager_Light STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/homey_light.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/homey_light.hpp) +set_target_properties(MQTTManager_Light PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/home_assistant_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/openhab_light.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/light/homey_light.hpp) target_include_directories(MQTTManager_Light PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_Light MQTTManager_HomeAssistantManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) +target_link_libraries(MQTTManager_Light MQTTManager_HomeAssistantManager MQTTManager_HomeyManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) -add_library(MQTTManager_Button STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/home_assistant_button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/home_assistant_button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/nspm_button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/nspm_button.hpp) -set_target_properties(MQTTManager_Button PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.hpp) +add_library(MQTTManager_Button STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/home_assistant_button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/home_assistant_button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/nspm_button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/nspm_button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/homey_button.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/homey_button.hpp) +set_target_properties(MQTTManager_Button PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/button.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/button/homey_button.hpp) target_include_directories(MQTTManager_Button PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_Button MQTTManager_HomeAssistantManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) +target_link_libraries(MQTTManager_Button MQTTManager_HomeAssistantManager MQTTManager_HomeyManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) -add_library(MQTTManager_Thermostat STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/thermostat.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/thermostat.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/home_assistant_thermostat.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/home_assistant_thermostat.cpp) +add_library(MQTTManager_Thermostat STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/thermostat.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/thermostat.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/home_assistant_thermostat.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/home_assistant_thermostat.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/homey_thermostat.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/homey_thermostat.cpp) set_target_properties(MQTTManager_Thermostat PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/thermostat/thermostat.hpp) target_include_directories(MQTTManager_Thermostat PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_Thermostat MQTTManager_HomeAssistantManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) +target_link_libraries(MQTTManager_Thermostat MQTTManager_HomeAssistantManager MQTTManager_HomeyManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager MQTTManager_WebHelper gtest::gtest) -add_library(MQTTManager_Switch STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp) -set_target_properties(MQTTManager_Switch PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp) +add_library(MQTTManager_Switch STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/homey_switch.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/homey_switch.hpp) +set_target_properties(MQTTManager_Switch PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/home_assistant_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/openhab_switch.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/switch/homey_switch.hpp) target_include_directories(MQTTManager_Switch PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_Switch MQTTManager_HomeAssistantManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) +target_link_libraries(MQTTManager_Switch MQTTManager_HomeAssistantManager MQTTManager_HomeyManager spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager Boost::boost MQTTManager_Entity Protobuf_MQTTManager MQTTManager_CommandManager gtest::gtest) add_library(MQTTManager_NSPanel STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/nspanel/nspanel.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/nspanel/nspanel.hpp) set_target_properties(MQTTManager_NSPanel PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/nspanel/nspanel.hpp) target_include_directories(MQTTManager_NSPanel PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) target_link_libraries(MQTTManager_NSPanel spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager MQTTManager_Room MQTTManager_WebsocketServer tz::tz Boost::boost MQTTManager_Config Protobuf_MQTTManager MQTTManager_DatabaseManager gtest::gtest) -add_library(MQTTManager_Scene STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/nspm_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/nspm_scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/home_assistant_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/home_assistant_scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/openhab_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/openhab_scene.cpp) -# set_target_properties(MQTTManager_Scene PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/scene/scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scene/nspm_scene.hpp) +add_library(MQTTManager_Scene STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/nspm_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/nspm_scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/home_assistant_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/home_assistant_scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/openhab_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/openhab_scene.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/homey_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scenes/homey_scene.cpp) +# set_target_properties(MQTTManager_Scene PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/scene/scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scene/nspm_scene.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/scene/homey_scene.hpp) target_include_directories(MQTTManager_Scene PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_Scene spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager MQTTManager_Room MQTTManager_Entity MQTTManager_Light MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager gtest::gtest) +target_link_libraries(MQTTManager_Scene spdlog::spdlog nlohmann_json::nlohmann_json MQTT_Manager MQTTManager_Room MQTTManager_Entity MQTTManager_Light MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTTManager_HomeyManager gtest::gtest) add_library(MQTTManager_RoomEntitiesPage STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/room/room_entities_page.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/room/room_entities_page.hpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/room/room_entities_page.cpp) target_include_directories(MQTTManager_RoomEntitiesPage PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) @@ -128,7 +133,7 @@ target_link_libraries(MQTTManager_Room MQTTManager_RoomEntitiesPage spdlog::spdl add_library(MQTTManager_EntityManager STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/entity_manager/entity_manager.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/entity_manager/entity_manager.hpp) set_target_properties(MQTTManager_EntityManager PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/entity_manager/entity_manager.hpp) target_include_directories(MQTTManager_EntityManager PUBLIC MQTTManager_WebHelper ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(MQTTManager_EntityManager MQTTManager_WebHelper spdlog::spdlog MQTT_Manager MQTTManager_NSPanel MQTTManager_Light MQTTManager_Button MQTTManager_Thermostat MQTTManager_Switch MQTTManager_WebsocketServer Boost::boost Boost::stacktrace_backtrace dl MQTTManager_Weather gtest::gtest) +target_link_libraries(MQTTManager_EntityManager MQTTManager_WebHelper spdlog::spdlog MQTT_Manager MQTTManager_NSPanel MQTTManager_Light MQTTManager_Button MQTTManager_Thermostat MQTTManager_Switch MQTTManager_WebsocketServer Boost::boost Boost::stacktrace_backtrace dl MQTTManager_Weather MQTTManager_HomeyManager gtest::gtest) add_library(MQTTManager_Config STATIC ${CMAKE_INCLUDE_SRC_DIRECTORY}/mqtt_manager_config/mqtt_manager_config.cpp ${CMAKE_INCLUDE_SRC_DIRECTORY}/mqtt_manager_config/mqtt_manager_config.hpp) # set_target_properties(MQTTManager_Config PROPERTIES PUBLIC_HEADER ${CMAKE_INCLUDE_SRC_DIRECTORY}/mqtt_manager_config/mqtt_manager_config.hpp) @@ -138,7 +143,7 @@ target_link_libraries(MQTTManager_Config spdlog::spdlog MQTTManager_WebHelper nl add_executable(${PROJECT_NAME} src/main.cpp) target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_INCLUDE_SRC_DIRECTORY}) -target_link_libraries(${PROJECT_NAME} MQTT_Manager MQTTManager_Config MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTTManager_EntityManager MQTTManager_Scene MQTTManager_Room MQTTManager_WebsocketServer MQTTManager_CommandManager gtest::gtest) +target_link_libraries(${PROJECT_NAME} MQTT_Manager MQTTManager_Config MQTTManager_HomeAssistantManager MQTTManager_OpenhabManager MQTTManager_HomeyManager MQTTManager_EntityManager MQTTManager_Scene MQTTManager_Room MQTTManager_WebsocketServer MQTTManager_CommandManager gtest::gtest) if (TEST_MODE==1) enable_testing() diff --git a/docker/MQTTManager/include/button/homey_button.cpp b/docker/MQTTManager/include/button/homey_button.cpp new file mode 100644 index 00000000..4e2e6c6f --- /dev/null +++ b/docker/MQTTManager/include/button/homey_button.cpp @@ -0,0 +1,104 @@ +#include "homey_button.hpp" +#include "database_manager/database_manager.hpp" +#include "mqtt_manager_config/mqtt_manager_config.hpp" +#include +#include +#include +#include +#include + +HomeyButton::HomeyButton(uint32_t button_id) : ButtonEntity(button_id) +{ + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOMEY) + { + SPDLOG_ERROR("HomeyButton has not been recognized as controlled by HOMEY. Will stop processing button."); + return; + } + + nlohmann::json entity_data; + try + { + auto button_entity = database_manager::database.get(this->_id); + entity_data = button_entity.get_entity_data_json(); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to load button {}: {}", this->_id, e.what()); + return; + } + + if (entity_data.contains("homey_device_id")) + { + this->_homey_device_id = entity_data["homey_device_id"]; + } + else + { + SPDLOG_ERROR("No homey_device_id defined for Button {}::{}", this->_id, this->_friendly_name); + return; + } + + SPDLOG_DEBUG("Loaded Homey button {}::{}, device ID: {}", this->_id, this->_friendly_name, this->_homey_device_id); + HomeyManager::attach_event_observer(this->_homey_device_id, boost::bind(&HomeyButton::homey_event_callback, this, _1)); +} + +HomeyButton::~HomeyButton() +{ + HomeyManager::detach_event_observer(this->_homey_device_id, boost::bind(&HomeyButton::homey_event_callback, this, _1)); +} + +void HomeyButton::send_state_update_to_controller() +{ + SPDLOG_DEBUG("Homey button {}::{} send_state_update_to_controller (trigger button press)", this->_id, this->_friendly_name); + + // Get Homey connection settings + auto homey_address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS, ""); + auto homey_token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN, ""); + + if (homey_address.empty() || homey_token.empty()) + { + SPDLOG_ERROR("Homey address or token not configured for button {}::{}", this->_id, this->_friendly_name); + return; + } + + // Construct URL: http://{homey_address}/api/device/{device_id}/capability/button + std::string url = fmt::format("http://{}/api/device/{}/capability/button", homey_address, this->_homey_device_id); + + // Create request body - button trigger uses null value + nlohmann::json request_body; + request_body["value"] = nullptr; + + // Send HTTP PUT request with bearer token authentication + try + { + std::vector headers = { + fmt::format("Authorization: Bearer {}", homey_token), + "Content-Type: application/json"}; + + std::string response = WebHelper::send_authorized_request(url, request_body.dump(), headers, WebHelper::HTTP_METHOD::PUT); + SPDLOG_DEBUG("Homey button {}::{} trigger response: {}", this->_id, this->_friendly_name, response); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to trigger Homey button {}::{}: {}", this->_id, this->_friendly_name, e.what()); + } + + // Buttons don't have persistent state, so no optimistic mode handling needed +} + +void HomeyButton::homey_event_callback(nlohmann::json data) +{ + try + { + // Homey WebSocket sends: {"id": "device-uuid", "capabilitiesObj": {...}} + // Buttons typically don't send state updates, but we'll handle them if they do + SPDLOG_DEBUG("Got event update for Homey button {}::{}.", this->_id, this->_friendly_name); + + // Buttons are typically one-way (trigger only), so no state to process + // But we keep this callback for potential future use + } + catch (std::exception &e) + { + SPDLOG_ERROR("Caught exception when processing Homey event for button {}::{}: {}", + this->_id, this->_friendly_name, boost::diagnostic_information(e, true)); + } +} diff --git a/docker/MQTTManager/include/button/homey_button.hpp b/docker/MQTTManager/include/button/homey_button.hpp new file mode 100644 index 00000000..b329ab02 --- /dev/null +++ b/docker/MQTTManager/include/button/homey_button.hpp @@ -0,0 +1,19 @@ +#ifndef MQTT_MANAGER_HOMEY_BUTTON +#define MQTT_MANAGER_HOMEY_BUTTON + +#include "button_entity.hpp" +#include + +class HomeyButton : public ButtonEntity +{ +public: + HomeyButton(uint32_t button_id); + ~HomeyButton(); + void send_state_update_to_controller(); + void homey_event_callback(nlohmann::json event_data); + +private: + std::string _homey_device_id; +}; + +#endif // !MQTT_MANAGER_HOMEY_BUTTON diff --git a/docker/MQTTManager/include/entity_manager/entity_manager.cpp b/docker/MQTTManager/include/entity_manager/entity_manager.cpp index 3a03a21a..cd81033b 100644 --- a/docker/MQTTManager/include/entity_manager/entity_manager.cpp +++ b/docker/MQTTManager/include/entity_manager/entity_manager.cpp @@ -1,9 +1,11 @@ #include "button/button.hpp" #include "button/home_assistant_button.hpp" +#include "button/homey_button.hpp" #include "button/nspm_button.hpp" #include "command_manager/command_manager.hpp" #include "entity/entity.hpp" #include "light/home_assistant_light.hpp" +#include "light/homey_light.hpp" #include "light/light.hpp" #include "light/openhab_light.hpp" #include "mqtt_manager/mqtt_manager.hpp" @@ -12,11 +14,14 @@ #include "room/room.hpp" #include "room/room_entities_page.hpp" #include "scenes/home_assistant_scene.hpp" +#include "scenes/homey_scene.hpp" #include "scenes/nspm_scene.hpp" #include "scenes/openhab_scene.hpp" #include "scenes/scene.hpp" +#include "switch/homey_switch.hpp" #include "switch/switch.hpp" #include "thermostat/home_assistant_thermostat.hpp" +#include "thermostat/homey_thermostat.hpp" #include "web_helper/WebHelper.hpp" #include "websocket_server/websocket_server.hpp" #include @@ -55,13 +60,16 @@ #define ITEM_IN_LIST(list, item) (std::find(list.cbegin(), list.cend(), item) != list.end()); -static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) { +static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) +{ ((std::string *)userp)->append((char *)contents, size * nmemb); return size * nmemb; } -void EntityManager::init() { - if (!EntityManager::_update_all_rooms_status_thread.joinable()) { +void EntityManager::init() +{ + if (!EntityManager::_update_all_rooms_status_thread.joinable()) + { SPDLOG_INFO("No thread to handle 'All rooms' status exists, starting..."); EntityManager::_last_room_update_time = std::chrono::system_clock::now(); EntityManager::_update_all_rooms_status_thread = std::thread(EntityManager::update_all_rooms_status); @@ -80,7 +88,8 @@ void EntityManager::init() { EntityManager::load_entities(); } -void EntityManager::load_entities() { +void EntityManager::load_entities() +{ SPDLOG_INFO("Loading config..."); EntityManager::_weather_manager.reload_config(); @@ -98,31 +107,38 @@ void EntityManager::load_entities() { SPDLOG_INFO("Total loaded Entities: {}", EntityManager::_entities.size()); } -void EntityManager::attach_entity_added_listener(void (*listener)(std::shared_ptr)) { +void EntityManager::attach_entity_added_listener(void (*listener)(std::shared_ptr)) +{ EntityManager::_entity_added_signal.connect(listener); } -void EntityManager::detach_entity_added_listener(void (*listener)(std::shared_ptr)) { +void EntityManager::detach_entity_added_listener(void (*listener)(std::shared_ptr)) +{ EntityManager::_entity_added_signal.disconnect(listener); } -void EntityManager::load_rooms() { +void EntityManager::load_rooms() +{ auto room_ids = database_manager::database.select(&database_manager::Room::id, sqlite_orm::from()); SPDLOG_INFO("Loading {} rooms.", room_ids.size()); // Remove room if it does no longer exist - EntityManager::_rooms.erase(std::remove_if(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [&room_ids](auto room) { - return std::find_if(room_ids.begin(), room_ids.end(), [&room](auto id) { return id == room->get_id(); }) == room_ids.end(); - }), + EntityManager::_rooms.erase(std::remove_if(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [&room_ids](auto room) + { return std::find_if(room_ids.begin(), room_ids.end(), [&room](auto id) + { return id == room->get_id(); }) == room_ids.end(); }), EntityManager::_rooms.end()); // Cause existing room to reload config or add a new room if it does not exist. - for (auto &room_id : room_ids) { + for (auto &room_id : room_ids) + { auto existing_room = EntityManager::get_room(room_id); - if (existing_room) [[likely]] { + if (existing_room) [[likely]] + { (*existing_room)->reload_config(); (*existing_room)->post_init(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_rooms_mutex); auto room = std::shared_ptr(new Room(room_id)); SPDLOG_INFO("Room {}::{} was found in database but not in config. Creating room.", room->get_id(), room->get_name()); @@ -132,28 +148,32 @@ void EntityManager::load_rooms() { } } - std::sort(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [](const std::shared_ptr &a, const std::shared_ptr &b) { - return a->get_display_order() < b->get_display_order(); - }); + std::sort(EntityManager::_rooms.begin(), EntityManager::_rooms.end(), [](const std::shared_ptr &a, const std::shared_ptr &b) + { return a->get_display_order() < b->get_display_order(); }); } -void EntityManager::load_nspanels() { +void EntityManager::load_nspanels() +{ auto nspanel_ids = database_manager::database.select(&database_manager::NSPanel::id, sqlite_orm::from()); SPDLOG_INFO("Loading {} NSPanels.", nspanel_ids.size()); // Check if any existing NSPanel has been removed. - EntityManager::_nspanels.erase(std::remove_if(EntityManager::_nspanels.begin(), EntityManager::_nspanels.end(), [&nspanel_ids](auto nspanel) { - return std::find_if(nspanel_ids.begin(), nspanel_ids.end(), [&nspanel](auto id) { return id == nspanel->get_id(); }) == nspanel_ids.end(); - }), + EntityManager::_nspanels.erase(std::remove_if(EntityManager::_nspanels.begin(), EntityManager::_nspanels.end(), [&nspanel_ids](auto nspanel) + { return std::find_if(nspanel_ids.begin(), nspanel_ids.end(), [&nspanel](auto id) + { return id == nspanel->get_id(); }) == nspanel_ids.end(); }), EntityManager::_nspanels.end()); // Cause existing NSPanel to reload config or add a new NSPanel if it does not exist. - for (auto &nspanel_id : nspanel_ids) { + for (auto &nspanel_id : nspanel_ids) + { auto existing_nspanel = EntityManager::get_nspanel_by_id(nspanel_id); - if (existing_nspanel) [[likely]] { + if (existing_nspanel) [[likely]] + { (*existing_nspanel)->reload_config(); (*existing_nspanel)->send_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_nspanels_mutex); auto panel = std::shared_ptr(new NSPanel(nspanel_id)); SPDLOG_INFO("NSPanel {}::{} was found in database but not in config. Creating panel.", panel->get_id(), panel->get_name()); @@ -162,45 +182,67 @@ void EntityManager::load_nspanels() { } } -void EntityManager::load_lights() { +void EntityManager::load_lights() +{ auto light_ids = database_manager::database.select(&database_manager::Entity::id, sqlite_orm::from(), sqlite_orm::where(sqlite_orm::glob(&database_manager::Entity::entity_type, "light"))); SPDLOG_INFO("Loading {} lights.", light_ids.size()); // Check if any existing light has been removed. - EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&light_ids](auto entity) { - return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::LIGHT && std::find_if(light_ids.begin(), light_ids.end(), [&entity](auto id) { return id == entity->get_id(); }) == light_ids.end(); - }), + EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&light_ids](auto entity) + { return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::LIGHT && std::find_if(light_ids.begin(), light_ids.end(), [&entity](auto id) + { return id == entity->get_id(); }) == light_ids.end(); }), EntityManager::_entities.end()); // Cause existing lights to reload config or add a new light if it does not exist. - for (auto &light_id : light_ids) { + for (auto &light_id : light_ids) + { auto existing_light = EntityManager::get_entity_by_id(MQTT_MANAGER_ENTITY_TYPE::LIGHT, light_id); - if (existing_light) [[likely]] { + if (existing_light) [[likely]] + { (*existing_light)->reload_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_entities_mutex); - try { + try + { auto light_settings = database_manager::database.get(light_id); nlohmann::json entity_data = light_settings.get_entity_data_json(); - if (entity_data.contains("controller")) { + if (entity_data.contains("controller")) + { std::string controller = entity_data["controller"]; - if (controller.compare("home_assistant") == 0) { + if (controller.compare("home_assistant") == 0) + { std::shared_ptr light = std::shared_ptr(new HomeAssistantLight(light_settings.id)); SPDLOG_INFO("Light {}::{} was found in database but not in config. Creating light.", light->get_id(), light->get_name()); EntityManager::_entities.push_back(light); - } else if (controller.compare("openhab") == 0) { + } + else if (controller.compare("openhab") == 0) + { std::shared_ptr light = std::shared_ptr(new OpenhabLight(light_settings.id)); SPDLOG_INFO("Light {}::{} was found in database but not in config. Creating light.", light->get_id(), light->get_name()); EntityManager::_entities.push_back(light); - } else { + } + else if (controller.compare("homey") == 0) + { + std::shared_ptr light = std::shared_ptr(new HomeyLight(light_settings.id)); + SPDLOG_INFO("Light {}::{} was found in database but not in config. Creating light.", light->get_id(), light->get_name()); + EntityManager::_entities.push_back(light); + } + else + { SPDLOG_ERROR("Unknown light controller '{}'. Will ignore entity.", controller); } - } else { + } + else + { SPDLOG_ERROR("Light {}::{} does not define a controller!", light_settings.id, light_settings.friendly_name); } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } @@ -209,45 +251,67 @@ void EntityManager::load_lights() { SPDLOG_DEBUG("Loaded {} lights", light_ids.size()); } -void EntityManager::load_buttons() { +void EntityManager::load_buttons() +{ auto button_ids = database_manager::database.select(&database_manager::Entity::id, sqlite_orm::from(), sqlite_orm::where(sqlite_orm::glob(&database_manager::Entity::entity_type, "button"))); SPDLOG_INFO("Loading {} buttons.", button_ids.size()); // Check if any existing button has been removed. - EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&button_ids](auto entity) { - return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::BUTTON && std::find_if(button_ids.begin(), button_ids.end(), [&entity](auto id) { return id == entity->get_id(); }) == button_ids.end(); - }), + EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&button_ids](auto entity) + { return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::BUTTON && std::find_if(button_ids.begin(), button_ids.end(), [&entity](auto id) + { return id == entity->get_id(); }) == button_ids.end(); }), EntityManager::_entities.end()); // Cause existing buttons to reload config or add a new button if it does not exist. - for (auto &button_id : button_ids) { + for (auto &button_id : button_ids) + { auto existing_button = EntityManager::get_entity_by_id(MQTT_MANAGER_ENTITY_TYPE::BUTTON, button_id); - if (existing_button) [[likely]] { + if (existing_button) [[likely]] + { (*existing_button)->reload_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_entities_mutex); - try { + try + { auto button_settings = database_manager::database.get(button_id); nlohmann::json entity_data = button_settings.get_entity_data_json(); - if (entity_data.contains("controller")) { + if (entity_data.contains("controller")) + { std::string controller = entity_data["controller"]; - if (controller.compare("home_assistant") == 0) { + if (controller.compare("home_assistant") == 0) + { std::shared_ptr button_entity = std::shared_ptr(new HomeAssistantButton(button_settings.id)); SPDLOG_INFO("Button {}::{} was found in database but not in config. Creating button.", button_entity->get_id(), button_entity->get_name()); EntityManager::_entities.push_back(button_entity); - } else if (controller.compare("nspm") == 0) { + } + else if (controller.compare("nspm") == 0) + { std::shared_ptr button_entity = std::shared_ptr(new NSPMButton(button_settings.id)); SPDLOG_INFO("Button {}::{} was found in database but not in config. Creating button.", button_entity->get_id(), button_entity->get_name()); EntityManager::_entities.push_back(button_entity); - } else { + } + else if (controller.compare("homey") == 0) + { + std::shared_ptr button_entity = std::shared_ptr(new HomeyButton(button_settings.id)); + SPDLOG_INFO("Button {}::{} was found in database but not in config. Creating button.", button_entity->get_id(), button_entity->get_name()); + EntityManager::_entities.push_back(button_entity); + } + else + { SPDLOG_ERROR("Unknown button type '{}'. Will ignore entity.", controller); } - } else { + } + else + { SPDLOG_ERROR("Button {}::{} does not define a controller!", button_settings.id, button_settings.friendly_name); } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } @@ -256,46 +320,68 @@ void EntityManager::load_buttons() { SPDLOG_DEBUG("Loaded {} lights", button_ids.size()); } -void EntityManager::load_thermostats() { +void EntityManager::load_thermostats() +{ auto thermostat_ids = database_manager::database.select(&database_manager::Entity::id, sqlite_orm::from(), sqlite_orm::where(sqlite_orm::glob(&database_manager::Entity::entity_type, "thermostat"))); SPDLOG_INFO("Loading {} thermostats.", thermostat_ids.size()); // Check if any existing thermostat has been removed. - EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&thermostat_ids](auto entity) { - return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::THERMOSTAT && std::find_if(thermostat_ids.begin(), thermostat_ids.end(), [&entity](auto id) { return id == entity->get_id(); }) == thermostat_ids.end(); - }), + EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&thermostat_ids](auto entity) + { return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::THERMOSTAT && std::find_if(thermostat_ids.begin(), thermostat_ids.end(), [&entity](auto id) + { return id == entity->get_id(); }) == thermostat_ids.end(); }), EntityManager::_entities.end()); // Cause existing thermostats to reload config or add a new thermostat if it does not exist. - for (auto &thermostat_id : thermostat_ids) { + for (auto &thermostat_id : thermostat_ids) + { auto existing_thermostat = EntityManager::get_entity_by_id(MQTT_MANAGER_ENTITY_TYPE::THERMOSTAT, thermostat_id); - if (existing_thermostat) [[likely]] { + if (existing_thermostat) [[likely]] + { (*existing_thermostat)->reload_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_entities_mutex); - try { + try + { auto thermostat_settings = database_manager::database.get(thermostat_id); nlohmann::json entity_data = thermostat_settings.get_entity_data_json(); - if (entity_data.contains("controller")) { + if (entity_data.contains("controller")) + { std::string controller = entity_data["controller"]; - if (controller.compare("home_assistant") == 0) { + if (controller.compare("home_assistant") == 0) + { std::shared_ptr thermostat_entity = std::shared_ptr(new HomeAssistantThermostat(thermostat_settings.id)); SPDLOG_INFO("Thermostat {}::{} was found in database but not in config. Creating thermostat.", thermostat_entity->get_id(), thermostat_entity->get_name()); EntityManager::_entities.push_back(thermostat_entity); - } else if (controller.compare("openhab") == 0) { + } + else if (controller.compare("openhab") == 0) + { // std::shared_ptr button_entity = std::shared_ptr(new NSPMButton(thermostat_settings.id)); // SPDLOG_INFO("Button {}::{} was found in database but not in config. Creating button.", button_entity->get_id(), button_entity->get_name()); // EntityManager::_entities.push_back(button_entity); SPDLOG_ERROR("TODO: Implement OpenHAB thermostat."); - } else { + } + else if (controller.compare("homey") == 0) + { + std::shared_ptr thermostat_entity = std::shared_ptr(new HomeyThermostat(thermostat_settings.id)); + SPDLOG_INFO("Thermostat {}::{} was found in database but not in config. Creating thermostat.", thermostat_entity->get_id(), thermostat_entity->get_name()); + EntityManager::_entities.push_back(thermostat_entity); + } + else + { SPDLOG_ERROR("Unknown thermostat type '{}'. Will ignore entity.", controller); } - } else { + } + else + { SPDLOG_ERROR("Thermostat {}::{} does not define a controller!", thermostat_settings.id, thermostat_settings.friendly_name); } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } @@ -304,45 +390,67 @@ void EntityManager::load_thermostats() { SPDLOG_DEBUG("Loaded {} thermostats", thermostat_ids.size()); } -void EntityManager::load_switches() { +void EntityManager::load_switches() +{ auto switch_ids = database_manager::database.select(&database_manager::Entity::id, sqlite_orm::from(), sqlite_orm::where(sqlite_orm::glob(&database_manager::Entity::entity_type, "switch"))); SPDLOG_INFO("Loading {} switches.", switch_ids.size()); // Check if any existing switch has been removed. - EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&switch_ids](auto entity) { - return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::SWITCH_ENTITY && std::find_if(switch_ids.begin(), switch_ids.end(), [&entity](auto id) { return id == entity->get_id(); }) == switch_ids.end(); - }), + EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&switch_ids](auto entity) + { return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::SWITCH_ENTITY && std::find_if(switch_ids.begin(), switch_ids.end(), [&entity](auto id) + { return id == entity->get_id(); }) == switch_ids.end(); }), EntityManager::_entities.end()); // Cause existing switches to reload config or add a new switch if it does not exist. - for (auto &switch_id : switch_ids) { + for (auto &switch_id : switch_ids) + { auto existing_switch = EntityManager::get_entity_by_id(MQTT_MANAGER_ENTITY_TYPE::SWITCH_ENTITY, switch_id); - if (existing_switch) [[likely]] { + if (existing_switch) [[likely]] + { (*existing_switch)->reload_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_entities_mutex); - try { + try + { auto switch_settings = database_manager::database.get(switch_id); nlohmann::json entity_data = switch_settings.get_entity_data_json(); - if (entity_data.contains("controller")) { + if (entity_data.contains("controller")) + { std::string controller = entity_data["controller"]; - if (controller.compare("home_assistant") == 0) { + if (controller.compare("home_assistant") == 0) + { std::shared_ptr switch_entity = std::shared_ptr(new HomeAssistantSwitch(switch_settings.id)); SPDLOG_INFO("Switch {}::{} was found in database but not in config. Creating switch.", switch_entity->get_id(), switch_entity->get_name()); EntityManager::_entities.push_back(switch_entity); - } else if (controller.compare("openhab") == 0) { + } + else if (controller.compare("openhab") == 0) + { std::shared_ptr switch_entity = std::shared_ptr(new OpenhabSwitch(switch_settings.id)); SPDLOG_INFO("Switch {}::{} was found in database but not in config. Creating switch.", switch_entity->get_id(), switch_entity->get_name()); EntityManager::_entities.push_back(switch_entity); - } else { + } + else if (controller.compare("homey") == 0) + { + std::shared_ptr switch_entity = std::shared_ptr(new HomeySwitch(switch_settings.id)); + SPDLOG_INFO("Switch {}::{} was found in database but not in config. Creating switch.", switch_entity->get_id(), switch_entity->get_name()); + EntityManager::_entities.push_back(switch_entity); + } + else + { SPDLOG_ERROR("Unknown switch type '{}'. Will ignore entity.", controller); } - } else { + } + else + { SPDLOG_ERROR("Switch {}::{} does not define a controller!", switch_settings.id, switch_settings.friendly_name); } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } @@ -351,42 +459,63 @@ void EntityManager::load_switches() { SPDLOG_DEBUG("Loaded {} lights", switch_ids.size()); } -void EntityManager::load_scenes() { +void EntityManager::load_scenes() +{ auto scene_ids = database_manager::database.select(&database_manager::Scene::id, sqlite_orm::from()); SPDLOG_INFO("Loading {} scenes.", scene_ids.size()); // Check if any existing scene has been removed. - EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&scene_ids](auto entity) { - return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::SCENE && std::find_if(scene_ids.begin(), scene_ids.end(), [&entity](auto id) { return entity->get_id() == id; }) == scene_ids.end(); - }), + EntityManager::_entities.erase(std::remove_if(EntityManager::_entities.begin(), EntityManager::_entities.end(), [&scene_ids](auto entity) + { return entity->get_type() == MQTT_MANAGER_ENTITY_TYPE::SCENE && std::find_if(scene_ids.begin(), scene_ids.end(), [&entity](auto id) + { return entity->get_id() == id; }) == scene_ids.end(); }), EntityManager::_entities.end()); // Cause existing NSPanel to reload config or add a new NSPanel if it does not exist. - for (auto &scene_id : scene_ids) { + for (auto &scene_id : scene_ids) + { auto existing_scene = EntityManager::get_entity_by_id(MQTT_MANAGER_ENTITY_TYPE::SCENE, scene_id); - if (existing_scene) [[likely]] { + if (existing_scene) [[likely]] + { (*existing_scene)->reload_config(); - } else { + } + else + { std::lock_guard mutex_guard(EntityManager::_entities_mutex); - try { + try + { auto scene_settings = database_manager::database.get(scene_id); - if (scene_settings.scene_type.compare("home_assistant") == 0) { + if (scene_settings.scene_type.compare("home_assistant") == 0) + { std::shared_ptr scene = std::shared_ptr(new HomeAssistantScene(scene_settings.id)); SPDLOG_INFO("Scene {}::{} was found in database but not in config. Creating scene.", scene->get_id(), scene->get_name()); EntityManager::_entities.push_back(scene); - } else if (scene_settings.scene_type.compare("openhab") == 0) { + } + else if (scene_settings.scene_type.compare("openhab") == 0) + { std::shared_ptr scene = std::shared_ptr(new OpenhabScene(scene_settings.id)); SPDLOG_INFO("Scene {}::{} was found in database but not in config. Creating scene.", scene->get_id(), scene->get_name()); EntityManager::_entities.push_back(scene); - } else if (scene_settings.scene_type.compare("nspm_scene") == 0) { + } + else if (scene_settings.scene_type.compare("nspm_scene") == 0) + { std::shared_ptr scene = std::shared_ptr(new NSPMScene(scene_settings.id)); SPDLOG_INFO("Scene {}::{} was found in database but not in config. Creating scene.", scene->get_id(), scene->get_name()); EntityManager::_entities.push_back(scene); - } else { + } + else if (scene_settings.scene_type.compare("homey") == 0) + { + std::shared_ptr scene = std::shared_ptr(new HomeyScene(scene_settings.id)); + SPDLOG_INFO("Scene {}::{} was found in database but not in config. Creating scene.", scene->get_id(), scene->get_name()); + EntityManager::_entities.push_back(scene); + } + else + { SPDLOG_ERROR("Unknown scene type '{}'. Will ignore entity.", scene_settings.scene_type); } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } @@ -395,39 +524,49 @@ void EntityManager::load_scenes() { SPDLOG_DEBUG("Loaded {} scenes", scene_ids.size()); } -void EntityManager::load_global_room_entities_pages() { +void EntityManager::load_global_room_entities_pages() +{ { EntityManager::_global_room_entities_pages_mutex.lock(); auto global_page_ids = database_manager::database.select(&database_manager::RoomEntitiesPage::id, sqlite_orm::from(), sqlite_orm::where(sqlite_orm::is_null(&database_manager::RoomEntitiesPage::room_id))); SPDLOG_INFO("Loading {} global pages.", global_page_ids.size()); // Check if any existing room entity page has been removed. - EntityManager::_global_room_entities_pages.erase(std::remove_if(EntityManager::_global_room_entities_pages.begin(), EntityManager::_global_room_entities_pages.end(), [&global_page_ids](auto page) { - return std::find_if(global_page_ids.begin(), global_page_ids.end(), [&page](auto id) { return page->get_id() == id; }) == global_page_ids.end(); - }), + EntityManager::_global_room_entities_pages.erase(std::remove_if(EntityManager::_global_room_entities_pages.begin(), EntityManager::_global_room_entities_pages.end(), [&global_page_ids](auto page) + { return std::find_if(global_page_ids.begin(), global_page_ids.end(), [&page](auto id) + { return page->get_id() == id; }) == global_page_ids.end(); }), EntityManager::_global_room_entities_pages.end()); // Cause existing NSPanel to reload config or add a new NSPanel if it does not exist. - for (auto &page_id : global_page_ids) { - auto existing_page = std::find_if(EntityManager::_global_room_entities_pages.begin(), EntityManager::_global_room_entities_pages.end(), [&page_id](auto page) { return page->get_id() == page_id; }); - if (existing_page != EntityManager::_global_room_entities_pages.end()) [[likely]] { + for (auto &page_id : global_page_ids) + { + auto existing_page = std::find_if(EntityManager::_global_room_entities_pages.begin(), EntityManager::_global_room_entities_pages.end(), [&page_id](auto page) + { return page->get_id() == page_id; }); + if (existing_page != EntityManager::_global_room_entities_pages.end()) [[likely]] + { // Unlock mutex to allow entities page to gather information then lock again to continue loop. EntityManager::_global_room_entities_pages_mutex.unlock(); (*existing_page)->reload_config(false); EntityManager::_global_room_entities_pages_mutex.lock(); - } else { + } + else + { std::shared_ptr new_page = nullptr; - try { + try + { // Unlock mutex to allow entities page to gather information then lock again to continue loop. EntityManager::_global_room_entities_pages_mutex.unlock(); auto page_settings = database_manager::database.get(page_id); new_page = std::shared_ptr(new RoomEntitiesPage(page_settings.id, nullptr)); - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } EntityManager::_global_room_entities_pages_mutex.lock(); - if (new_page != nullptr) { + if (new_page != nullptr) + { EntityManager::_global_room_entities_pages.push_back(new_page); } } @@ -436,63 +575,78 @@ void EntityManager::load_global_room_entities_pages() { } // Now that all pages has loaded, send state updates to panels: - for (auto &page : EntityManager::_global_room_entities_pages) { + for (auto &page : EntityManager::_global_room_entities_pages) + { page->post_init(true); } SPDLOG_DEBUG("Loaded {} pages", EntityManager::_global_room_entities_pages.size()); } -std::expected, EntityManager::EntityError> EntityManager::get_room(uint32_t room_id) { - try { +std::expected, EntityManager::EntityError> EntityManager::get_room(uint32_t room_id) +{ + try + { std::lock_guard mutex_guard(EntityManager::_rooms_mutex); - for (auto room = EntityManager::_rooms.begin(); room != EntityManager::_rooms.end(); room++) { - if ((*room)->get_id() == room_id) { + for (auto room = EntityManager::_rooms.begin(); room != EntityManager::_rooms.end(); room++) + { + if ((*room)->get_id() == room_id) + { return std::shared_ptr((*room)); } } - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); } return std::unexpected(EntityManager::EntityError::NOT_FOUND); } -std::expected>, EntityManager::EntityError> EntityManager::get_all_rooms() { +std::expected>, EntityManager::EntityError> EntityManager::get_all_rooms() +{ std::lock_guard mutex_guard(EntityManager::_rooms_mutex); return EntityManager::_rooms; } -std::expected>, EntityManager::EntityError> EntityManager::get_all_global_room_entities_pages() { +std::expected>, EntityManager::EntityError> EntityManager::get_all_global_room_entities_pages() +{ std::lock_guard mutex_guard(EntityManager::_global_room_entities_pages_mutex); - if (EntityManager::_global_room_entities_pages.empty()) { + if (EntityManager::_global_room_entities_pages.empty()) + { return std::unexpected(EntityManager::EntityError::NONE_LOADED); } return EntityManager::_global_room_entities_pages; } -std::expected EntityManager::get_number_of_global_room_entities_pages() { +std::expected EntityManager::get_number_of_global_room_entities_pages() +{ std::lock_guard mutex_guard(EntityManager::_global_room_entities_pages_mutex); - if (EntityManager::_global_room_entities_pages.empty()) { + if (EntityManager::_global_room_entities_pages.empty()) + { return std::unexpected(EntityManager::EntityError::NONE_LOADED); } return EntityManager::_global_room_entities_pages.size(); } -void EntityManager::update_all_rooms_status() { +void EntityManager::update_all_rooms_status() +{ SPDLOG_INFO("Started thread to handle 'All rooms' status updates."); bool has_performed_initial_update = false; - for (;;) { + for (;;) + { // Wait for notification that a room has been updated - if (has_performed_initial_update) { + if (has_performed_initial_update) + { std::unique_lock mutex_guard(EntityManager::_rooms_mutex); - EntityManager::_room_update_condition_variable.wait(mutex_guard, [&]() { - return !EntityManager::_all_rooms_status_updated; - }); + EntityManager::_room_update_condition_variable.wait(mutex_guard, [&]() + { return !EntityManager::_all_rooms_status_updated; }); } // Wait until changes has settled as when a user changes light states in "All rooms" mode a burst of changes will occur from all rooms. uint32_t backoff_time = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::ALL_ROOMS_STATUS_BACKOFF_TIME); - while (EntityManager::_last_room_update_time.load() + std::chrono::milliseconds(backoff_time) > std::chrono::system_clock::now()) { + while (EntityManager::_last_room_update_time.load() + std::chrono::milliseconds(backoff_time) > std::chrono::system_clock::now()) + { std::this_thread::sleep_for(EntityManager::_last_room_update_time.load() + std::chrono::milliseconds(backoff_time) - std::chrono::system_clock::now()); } @@ -519,48 +673,63 @@ void EntityManager::update_all_rooms_status() { // Determine if any light is on in any of the rooms bool any_light_on = false; - for (auto room : EntityManager::_rooms) { + for (auto room : EntityManager::_rooms) + { std::vector> entities = room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT); - for (auto light : entities) { - if (light->get_state() && light->get_controlled_from_main_page()) { + for (auto light : entities) + { + if (light->get_state() && light->get_controlled_from_main_page()) + { any_light_on = true; break; } } } - for (auto room : EntityManager::_rooms) { - for (auto &light : room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT)) { + for (auto room : EntityManager::_rooms) + { + for (auto &light : room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT)) + { // Light is not controlled from main page, exclude it from calculations. - if (!light->get_controlled_from_main_page()) { + if (!light->get_controlled_from_main_page()) + { continue; } - if ((any_light_on && light->get_state()) || !any_light_on) { + if ((any_light_on && light->get_state()) || !any_light_on) + { total_light_level_all += light->get_brightness(); - if (light->can_color_temperature()) { + if (light->can_color_temperature()) + { total_kelvin_level_all += light->get_color_temperature(); num_kelvin_lights_total++; } num_lights_total++; } - if (light->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::TABLE) { + if (light->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::TABLE) + { // SPDLOG_TRACE("Room {}::{} found table light {}::{}, state: {}", this->_id, this->_name, light->get_id(), light->get_name(), light->get_state() ? "ON" : "OFF"); num_lights_table++; - if (light->get_state()) { + if (light->get_state()) + { total_light_level_table += light->get_brightness(); - if (light->can_color_temperature()) { + if (light->can_color_temperature()) + { total_kelvin_table += light->get_color_temperature(); num_kelvin_lights_table++; } num_lights_table_on++; } - } else if (light->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::CEILING) { + } + else if (light->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::CEILING) + { // SPDLOG_TRACE("Room {}::{} found ceiling light {}::{}, state: {}", this->_id, this->_name, light->get_id(), light->get_name(), light->get_state() ? "ON" : "OFF"); num_lights_ceiling++; - if (light->get_state()) { + if (light->get_state()) + { total_light_level_ceiling += light->get_brightness(); - if (light->can_color_temperature()) { + if (light->can_color_temperature()) + { total_kelvin_ceiling += light->get_color_temperature(); num_kelvin_lights_ceiling++; } @@ -575,79 +744,104 @@ void EntityManager::update_all_rooms_status() { all_rooms_status.set_num_table_lights_on(num_lights_table_on); all_rooms_status.set_num_ceiling_lights_on(num_lights_ceiling_on); - if (num_lights_total > 0) { + if (num_lights_total > 0) + { all_rooms_status.set_average_dim_level(total_light_level_all / num_lights_total); - if (num_kelvin_lights_total > 0) { + if (num_kelvin_lights_total > 0) + { float average_kelvin = (float)total_kelvin_level_all / num_kelvin_lights_total; average_kelvin -= MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); uint8_t kelvin_pct = (average_kelvin / (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX) - MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN))) * 100; - if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) { + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) + { kelvin_pct = 100 - kelvin_pct; } all_rooms_status.set_average_color_temperature(kelvin_pct); - } else { + } + else + { all_rooms_status.set_average_color_temperature(0); } - } else { + } + else + { all_rooms_status.set_average_dim_level(0); all_rooms_status.set_average_color_temperature(0); } - if (num_lights_table_on > 0) { + if (num_lights_table_on > 0) + { all_rooms_status.set_table_lights_dim_level(total_light_level_table / num_lights_table_on); - if (num_kelvin_lights_table > 0) { + if (num_kelvin_lights_table > 0) + { float average_kelvin = (float)total_kelvin_table / num_kelvin_lights_table; average_kelvin -= MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); uint8_t kelvin_pct = (average_kelvin / (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX) - MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN))) * 100; - if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) { + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) + { kelvin_pct = 100 - kelvin_pct; } all_rooms_status.set_table_lights_color_temperature_value(kelvin_pct); - } else { + } + else + { all_rooms_status.set_table_lights_color_temperature_value(0); } - } else { + } + else + { // SPDLOG_TRACE("No table lights found, setting value to 0."); all_rooms_status.set_table_lights_dim_level(0); all_rooms_status.set_table_lights_color_temperature_value(0); } - if (num_lights_ceiling_on > 0) { + if (num_lights_ceiling_on > 0) + { all_rooms_status.set_ceiling_lights_dim_level(total_light_level_ceiling / num_lights_ceiling_on); - if (num_kelvin_lights_ceiling > 0) { + if (num_kelvin_lights_ceiling > 0) + { float average_kelvin = (float)total_kelvin_table / num_kelvin_lights_table; average_kelvin -= MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN); uint8_t kelvin_pct = (average_kelvin / (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MAX) - MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::COLOR_TEMP_MIN))) * 100; - if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) { + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::REVERSE_COLOR_TEMP)) + { kelvin_pct = 100 - kelvin_pct; } all_rooms_status.set_ceiling_lights_color_temperature_value(kelvin_pct); - } else { + } + else + { all_rooms_status.set_ceiling_lights_color_temperature_value(0); } - } else { + } + else + { // SPDLOG_TRACE("No ceiling lights found, setting value to 0."); all_rooms_status.set_ceiling_lights_dim_level(0); all_rooms_status.set_ceiling_lights_color_temperature_value(0); } std::string all_rooms_status_string; - if (all_rooms_status.SerializeToString(&all_rooms_status_string)) { + if (all_rooms_status.SerializeToString(&all_rooms_status_string)) + { SPDLOG_DEBUG("All rooms status updated. Waiting for next notify."); MQTT_Manager::publish(fmt::format("nspanel/mqttmanager_{}/all_rooms_status", MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::MANAGER_ADDRESS)), all_rooms_status_string, true); has_performed_initial_update = true; - } else { + } + else + { SPDLOG_ERROR("Failed to serialize 'All rooms' status. Will try again next time there is a room status change."); } } } -void EntityManager::_room_updated_callback(Room *room) { +void EntityManager::_room_updated_callback(Room *room) +{ { std::unique_lock mutex_guard(EntityManager::_rooms_mutex); EntityManager::_last_room_update_time = std::chrono::system_clock::now(); @@ -656,24 +850,33 @@ void EntityManager::_room_updated_callback(Room *room) { EntityManager::_room_update_condition_variable.notify_all(); } -void EntityManager::_command_callback(NSPanelMQTTManagerCommand &command) { - if (command.has_first_page_turn_on() && command.first_page_turn_on().global()) { +void EntityManager::_command_callback(NSPanelMQTTManagerCommand &command) +{ + if (command.has_first_page_turn_on() && command.first_page_turn_on().global()) + { auto nspanel = EntityManager::get_nspanel_by_id(command.nspanel_id()); - if (!nspanel) { + if (!nspanel) + { SPDLOG_ERROR("NSPanel with ID {} not found while processing command. Will cancel processing.", command.nspanel_id()); return; } std::vector> rooms; - if ((*nspanel)->is_locked_to_default_room()) { + if ((*nspanel)->is_locked_to_default_room()) + { SPDLOG_DEBUG("NSPanel {}::{} is locked to it's default room.", (*nspanel)->get_id(), (*nspanel)->get_name()); auto room = EntityManager::get_room((*nspanel)->get_default_room_id()); - if (room) { + if (room) + { rooms.push_back(*room); - } else { + } + else + { SPDLOG_ERROR("Default room for NSPanel {}::{} not found.", (*nspanel)->get_id(), (*nspanel)->get_name()); } - } else { + } + else + { std::lock_guard lock_guard(EntityManager::_rooms_mutex); rooms = EntityManager::_rooms; } @@ -684,39 +887,54 @@ void EntityManager::_command_callback(NSPanelMQTTManagerCommand &command) { // Check if ANY table or ceiling light is turned on. bool any_ceiling_light_on = false; bool any_table_light_on = false; - for (auto &room : rooms) { - for (auto &entity : room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT)) { - if (entity->get_controlled_from_main_page() && entity->get_state()) { - if (entity->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::CEILING) { + for (auto &room : rooms) + { + for (auto &entity : room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT)) + { + if (entity->get_controlled_from_main_page() && entity->get_state()) + { + if (entity->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::CEILING) + { any_ceiling_light_on = true; - } else if (entity->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::TABLE) { + } + else if (entity->get_light_type() == MQTT_MANAGER_LIGHT_TYPE::TABLE) + { any_table_light_on = true; } } - if (any_ceiling_light_on && any_table_light_on) { + if (any_ceiling_light_on && any_table_light_on) + { break; } } - if (any_ceiling_light_on && any_table_light_on) { + if (any_ceiling_light_on && any_table_light_on) + { break; } } - if (!any_ceiling_light_on && !any_table_light_on) { + if (!any_ceiling_light_on && !any_table_light_on) + { // Turn on all lights in all the room SPDLOG_DEBUG("No lights are turned on, will send command to ALL rooms while processing 'All rooms' command"); - for (auto &room : rooms) { + for (auto &room : rooms) + { turn_on_command->set_global(false); turn_on_command->set_selected_room(room->get_id()); room->command_callback(base_command); } - } else { + } + else + { // Lights are turned on in any/some rooms, send command to rooms where lights are turned on to change brightness of those lights SPDLOG_DEBUG("Lights are turned on, will send command to all rooms with lights on while processing 'All rooms' command. Ceiling lights on: {}, table lights on: {}", any_ceiling_light_on ? "Yes" : "No", any_table_light_on ? "Yes" : "No"); - for (auto &room : rooms) { + for (auto &room : rooms) + { std::vector> lights = room->get_all_entities_by_type(MQTT_MANAGER_ENTITY_TYPE::LIGHT); - for (auto &light : lights) { - if (light->get_state() && light->get_controlled_from_main_page()) { + for (auto &light : lights) + { + if (light->get_state() && light->get_controlled_from_main_page()) + { turn_on_command->set_global(false); turn_on_command->set_selected_room(room->get_id()); room->command_callback(base_command); @@ -725,71 +943,90 @@ void EntityManager::_command_callback(NSPanelMQTTManagerCommand &command) { } } } - } else if (command.has_toggle_entity_from_entities_page()) { + } + else if (command.has_toggle_entity_from_entities_page()) + { auto entity = EntityManager::get_entity_by_page_id_and_slot(command.toggle_entity_from_entities_page().entity_page_id(), command.toggle_entity_from_entities_page().entity_slot()); - if (entity && (*entity)->can_toggle()) { + if (entity && (*entity)->can_toggle()) + { SPDLOG_DEBUG("Will toggle entity in slot {} in page with ID {}.", command.toggle_entity_from_entities_page().entity_slot(), command.toggle_entity_from_entities_page().entity_page_id()); // Handle light separately as they require some special handling in regards to what brightness to turn on to. - if ((*entity)->get_type() == MQTT_MANAGER_ENTITY_TYPE::LIGHT) { + if ((*entity)->get_type() == MQTT_MANAGER_ENTITY_TYPE::LIGHT) + { auto light_entity = std::dynamic_pointer_cast(*entity); - if (light_entity) { - if (light_entity->get_state()) { + if (light_entity) + { + if (light_entity->get_state()) + { light_entity->turn_off(true); - } else { + } + else + { // Calculate average brightness of all lights in room and turn on light to that level, if lights are off then turn on to default brightness uint16_t room_id = light_entity->get_room_id(); auto room = EntityManager::get_room(room_id); - if (room) { + if (room) + { auto room_entities = (*room)->get_all_entities(); // Remove any entities that are not lights - room_entities.erase(std::remove_if(room_entities.begin(), room_entities.end(), [](auto entity) { - return entity->get_type() != MQTT_MANAGER_ENTITY_TYPE::LIGHT; - }), + room_entities.erase(std::remove_if(room_entities.begin(), room_entities.end(), [](auto entity) + { return entity->get_type() != MQTT_MANAGER_ENTITY_TYPE::LIGHT; }), room_entities.end()); - bool any_light_entity_on = std::find_if(room_entities.begin(), room_entities.end(), [](auto &entity) { + bool any_light_entity_on = std::find_if(room_entities.begin(), room_entities.end(), [](auto &entity) + { auto light = std::dynamic_pointer_cast(entity); - return light && light->get_state(); - }) != room_entities.end(); + return light && light->get_state(); }) != room_entities.end(); // Remove any lights that are not on if a light is on - if (any_light_entity_on) { - room_entities.erase(std::remove_if(room_entities.begin(), room_entities.end(), [](auto entity) { + if (any_light_entity_on) + { + room_entities.erase(std::remove_if(room_entities.begin(), room_entities.end(), [](auto entity) + { auto light = std::dynamic_pointer_cast(entity); - return light && !light->get_state(); - }), + return light && !light->get_state(); }), room_entities.end()); } - uint64_t total_light_brightness = std::accumulate(room_entities.begin(), room_entities.end(), 0, [](uint64_t sum, auto &entity) { + uint64_t total_light_brightness = std::accumulate(room_entities.begin(), room_entities.end(), 0, [](uint64_t sum, auto &entity) + { auto light = std::dynamic_pointer_cast(entity); - return sum + (light ? light->get_brightness() : 0); - }); + return sum + (light ? light->get_brightness() : 0); }); uint16_t average_light_brightness = total_light_brightness / room_entities.size(); - if (average_light_brightness == 0) { + if (average_light_brightness == 0) + { average_light_brightness = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::LIGHT_TURN_ON_BRIGHTNESS); } light_entity->set_brightness(average_light_brightness, false); light_entity->turn_on(true); - } else { + } + else + { SPDLOG_ERROR("Failed to get room that light resides in. Will not be able to turn on light from toggle command."); } } - } else { + } + else + { SPDLOG_ERROR("Received command to toggle light entity in slot {} in page with ID {} but entity could not be cast to a light.", command.toggle_entity_from_entities_page().entity_slot(), command.toggle_entity_from_entities_page().entity_page_id()); } - } else { + } + else + { (*entity)->toggle(); } - } else { + } + else + { SPDLOG_DEBUG("Received command to toggle entity in slot {} in page with ID {} bot did not find such an entity.", command.toggle_entity_from_entities_page().entity_slot(), command.toggle_entity_from_entities_page().entity_page_id()); } } } -void EntityManager::remove_entity(std::shared_ptr entity) { +void EntityManager::remove_entity(std::shared_ptr entity) +{ SPDLOG_DEBUG("Removing entity with ID {}.", entity->get_id()); { std::lock_guard mutex_guard(EntityManager::_entities_mutex); @@ -798,53 +1035,72 @@ void EntityManager::remove_entity(std::shared_ptr entity) { EntityManager::_entity_removed_signal(std::static_pointer_cast(entity)); } -void EntityManager::mqtt_topic_callback(const std::string &topic, const std::string &payload) { +void EntityManager::mqtt_topic_callback(const std::string &topic, const std::string &payload) +{ EntityManager::_process_message(topic, payload); } -void EntityManager::websocket_callback(StompFrame frame) { +void EntityManager::websocket_callback(StompFrame frame) +{ } -bool EntityManager::mqtt_callback(const std::string &topic, const std::string &payload) { +bool EntityManager::mqtt_callback(const std::string &topic, const std::string &payload) +{ SPDLOG_TRACE("Processing message on topic: {}, payload: {}", topic, payload); - try { + try + { return EntityManager::_process_message(topic, payload); - } catch (const std::exception ex) { + } + catch (const std::exception ex) + { SPDLOG_ERROR("Caught std::exception while processing message. Exception: ", ex.what()); - } catch (...) { + } + catch (...) + { SPDLOG_ERROR("Caught unknown exception while processing message."); } return false; } -bool EntityManager::_process_message(const std::string &topic, const std::string &payload) { - try { +bool EntityManager::_process_message(const std::string &topic, const std::string &payload) +{ + try + { SPDLOG_DEBUG("Got MQTT message on topic {}", topic); - if (topic.compare("nspanel/mqttmanager/command") == 0) { + if (topic.compare("nspanel/mqttmanager/command") == 0) + { SPDLOG_TRACE("Received command payload: {}", payload); nlohmann::json data = nlohmann::json::parse(payload); - if (!data.contains("mac_origin")) { + if (!data.contains("mac_origin")) + { SPDLOG_ERROR("Command payload did not contain a 'mac_origin' attribute. Will cancel processing."); return true; } std::string mac_origin = data["mac_origin"]; - if (data.contains("command")) { + if (data.contains("command")) + { std::string command = data["command"]; - if (command.compare("register_request") == 0) { + if (command.compare("register_request") == 0) + { EntityManager::_handle_register_request(data); - } else { + } + else + { SPDLOG_ERROR("Got command but no handler for it exists. Command: {}", command); } - } else { + } + else + { SPDLOG_ERROR("Received unknown message on command topic. Message: {}", payload); } return true; } - - } catch (std::exception &e) { + } + catch (std::exception &e) + { SPDLOG_ERROR("Caught exception: {}", e.what()); SPDLOG_ERROR("Stacktrace: {}", boost::diagnostic_information(e, true)); } @@ -852,22 +1108,26 @@ bool EntityManager::_process_message(const std::string &topic, const std::string return false; // Message was not processed by us, keep looking. } -void EntityManager::_handle_register_request(const nlohmann::json &data) { +void EntityManager::_handle_register_request(const nlohmann::json &data) +{ std::string mac_address = data["mac_origin"]; std::string name = data["friendly_name"]; SPDLOG_INFO("Got register request from NSPanel with name {} and MAC: {}", name, mac_address); auto panel = EntityManager::get_nspanel_by_mac(mac_address); - if (panel && (*panel)->get_state() != MQTT_MANAGER_NSPANEL_STATE::AWAITING_ACCEPT && (*panel)->get_state() != MQTT_MANAGER_NSPANEL_STATE::DENIED) { + if (panel && (*panel)->get_state() != MQTT_MANAGER_NSPANEL_STATE::AWAITING_ACCEPT && (*panel)->get_state() != MQTT_MANAGER_NSPANEL_STATE::DENIED) + { SPDLOG_TRACE("Has registered to manager? {}", (*panel)->has_registered_to_manager() ? "TRUE" : "FALSE"); (*panel)->register_to_manager(data); } - if (!panel) { + if (!panel) + { nlohmann::json init_data = data; SPDLOG_INFO("Panel is not registered to manager, adding panel but as 'pending accept' status."); std::shared_ptr new_panel = NSPanel::create_from_discovery_request(init_data); - if (new_panel != nullptr) { + if (new_panel != nullptr) + { std::lock_guard lock_guard(EntityManager::_nspanels_mutex); EntityManager::_nspanels.push_back(new_panel); nlohmann::json data = { @@ -878,11 +1138,14 @@ void EntityManager::_handle_register_request(const nlohmann::json &data) { } } -std::expected, EntityManager::EntityError> EntityManager::get_nspanel_by_id(uint id) { +std::expected, EntityManager::EntityError> EntityManager::get_nspanel_by_id(uint id) +{ SPDLOG_TRACE("Trying to find NSPanel by ID {}", id); std::lock_guard mutex_guard(EntityManager::_nspanels_mutex); - for (auto nspanel : EntityManager::_nspanels) { - if (nspanel->get_id() == id) { + for (auto nspanel : EntityManager::_nspanels) + { + if (nspanel->get_id() == id) + { SPDLOG_TRACE("Found NSPanel by ID {}", id); return nspanel; } @@ -891,12 +1154,15 @@ std::expected, EntityManager::EntityError> EntityManage return std::unexpected(EntityManager::EntityError::NOT_FOUND); } -std::expected, EntityManager::EntityError> EntityManager::get_nspanel_by_mac(std::string mac) { +std::expected, EntityManager::EntityError> EntityManager::get_nspanel_by_mac(std::string mac) +{ SPDLOG_TRACE("Trying to find NSPanel by MAC {}", mac); std::lock_guard mutex_guard(EntityManager::_nspanels_mutex); - for (auto nspanel : EntityManager::_nspanels) { + for (auto nspanel : EntityManager::_nspanels) + { SPDLOG_TRACE("Found NSPanel with mac '{}'. Searching for mac '{}'.", nspanel->get_mac(), mac); - if (nspanel->get_mac().compare(mac) == 0) { + if (nspanel->get_mac().compare(mac) == 0) + { SPDLOG_TRACE("Found NSPanel by MAC {}", mac); return nspanel; } diff --git a/docker/MQTTManager/include/homey_manager/homey_manager.cpp b/docker/MQTTManager/include/homey_manager/homey_manager.cpp new file mode 100644 index 00000000..4c463605 --- /dev/null +++ b/docker/MQTTManager/include/homey_manager/homey_manager.cpp @@ -0,0 +1,224 @@ +#include "homey_manager.hpp" +#include "mqtt_manager_config/mqtt_manager_config.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void HomeyManager::connect() +{ + SPDLOG_DEBUG("Initializing Homey Manager component."); + ix::initNetSystem(); + + HomeyManager::reload_config(); + + bool init_success = false; + while (!init_success) + { + try + { + std::lock_guard lock_guard(HomeyManager::_settings_mutex); + if (HomeyManager::_websocket == nullptr) + { + HomeyManager::_websocket = new ix::WebSocket(); + HomeyManager::_websocket->setPingInterval(30); + } + + std::string homey_websocket_url = HomeyManager::_homey_address; + if (homey_websocket_url.empty()) + { + SPDLOG_ERROR("No Homey address configured. Will not continue to load Homey component."); + return; + } + + WebsocketServer::register_warning(WebsocketServer::ActiveWarningLevel::ERROR, "Homey not connected."); + + // Construct WebSocket URL: ws://homey.local/api/manager/devices/device?token=TOKEN + boost::algorithm::replace_first(homey_websocket_url, "https://", "ws://"); + boost::algorithm::replace_first(homey_websocket_url, "http://", "ws://"); + + // Remove trailing slash if present + if (boost::algorithm::ends_with(homey_websocket_url, "/")) + { + homey_websocket_url = homey_websocket_url.substr(0, homey_websocket_url.length() - 1); + } + + homey_websocket_url.append("/api/manager/devices/device?token="); + homey_websocket_url.append(HomeyManager::_homey_token); + + if (boost::algorithm::starts_with(homey_websocket_url, "wss://")) + { + SPDLOG_DEBUG("Setting TLS options for Homey WebSocket"); + ix::SocketTLSOptions tls_options; + tls_options.tls = true; + tls_options.caFile = "NONE"; + HomeyManager::_websocket->setTLSOptions(tls_options); + } + + SPDLOG_INFO("Will connect to Homey websocket at {}", homey_websocket_url); + HomeyManager::_websocket->setUrl(homey_websocket_url); + HomeyManager::_websocket->setOnMessageCallback(&HomeyManager::_websocket_message_callback); + HomeyManager::_websocket->start(); + + init_success = true; // Successfully reached end of section without exception + } + catch (std::exception &e) + { + SPDLOG_ERROR("Caught exception: {}", e.what()); + SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); + } + } +} + +void HomeyManager::reload_config() +{ + bool reconnect = false; + { + std::lock_guard lock_guard(HomeyManager::_settings_mutex); + std::string address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS); + std::string token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN); + + if (HomeyManager::_homey_address.compare(address) != 0 || HomeyManager::_homey_token.compare(token) != 0) + { + HomeyManager::_homey_address = address; + HomeyManager::_homey_token = token; + reconnect = true; + } + } + + if (reconnect) + { + SPDLOG_INFO("Will connect to Homey with new settings. Server address: {}", HomeyManager::_homey_address); + if (HomeyManager::_websocket != nullptr) + { + HomeyManager::_websocket->close(); + HomeyManager::connect(); + } + } +} + +void HomeyManager::_websocket_message_callback(const ix::WebSocketMessagePtr &msg) +{ + if (msg->type == ix::WebSocketMessageType::Message) + { + HomeyManager::_process_websocket_message(msg->str); + } + else if (msg->type == ix::WebSocketMessageType::Open) + { + SPDLOG_INFO("Connected to Homey websocket."); + HomeyManager::_connected = true; + WebsocketServer::remove_warning("Homey not connected."); + } + else if (msg->type == ix::WebSocketMessageType::Close) + { + WebsocketServer::register_warning(WebsocketServer::ActiveWarningLevel::ERROR, "Homey not connected."); + SPDLOG_WARN("Disconnected from Homey websocket."); + HomeyManager::_connected = false; + } + else if (msg->type == ix::WebSocketMessageType::Error) + { + WebsocketServer::register_warning(WebsocketServer::ActiveWarningLevel::ERROR, "Homey not connected."); + SPDLOG_ERROR("Failed to connect to Homey websocket. Reason: {}", msg->errorInfo.reason); + HomeyManager::_connected = false; + } +} + +void HomeyManager::_process_websocket_message(const std::string &message) +{ + try + { + nlohmann::json data = nlohmann::json::parse(message); + + // Homey WebSocket sends device update events in this format: + // {"event": "device.update", "args": [{"id": "device-uuid", "capabilitiesObj": {...}}]} + if (data.contains("event") && data.contains("args")) + { + std::string event_type = data["event"]; + + if (event_type == "device.update" && data["args"].is_array() && !data["args"].empty()) + { + HomeyManager::_process_homey_event(data); + } + else + { + SPDLOG_DEBUG("Received Homey event: {}", event_type); + } + } + else + { + SPDLOG_DEBUG("Got unhandled Homey message: {}", message); + } + } + catch (std::exception &ex) + { + SPDLOG_ERROR("Exception processing Homey WebSocket message: {}", boost::diagnostic_information(ex, true)); + } + catch (...) + { + SPDLOG_ERROR("Caught unknown exception while processing Homey WebSocket message: {}", message); + } +} + +void HomeyManager::send_json(nlohmann::json &data) +{ + std::string buffer = data.dump(); + HomeyManager::_send_string(buffer); +} + +void HomeyManager::_send_string(std::string &data) +{ + if (HomeyManager::_websocket != nullptr && HomeyManager::_connected) + { + std::lock_guard mtex_lock(HomeyManager::_mutex_websocket_write_access); + SPDLOG_TRACE("[Homey WS] Sending data: {}", data); + HomeyManager::_websocket->send(data); + } +} + +void HomeyManager::_process_homey_event(nlohmann::json &event_data) +{ + try + { + // Extract device info from the event + // Format: {"event": "device.update", "args": [{"id": "device-uuid", "capabilitiesObj": {...}}]} + if (event_data.contains("args") && event_data["args"].is_array() && !event_data["args"].empty()) + { + nlohmann::json device_data = event_data["args"][0]; + + if (device_data.contains("id")) + { + std::string device_id = device_data["id"]; + + if (HomeyManager::_homey_observers.find(device_id) != HomeyManager::_homey_observers.end()) + { + try + { + SPDLOG_TRACE("Homey Event for device {}: {}", device_id, device_data.dump()); + HomeyManager::_homey_observers.at(device_id)(device_data); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Caught exception during processing of Homey event. Diagnostic information: {}", boost::diagnostic_information(e, true)); + SPDLOG_ERROR("Stacktrace: {}", boost::stacktrace::to_string(boost::stacktrace::stacktrace())); + } + } + } + } + } + catch (std::exception &ex) + { + SPDLOG_ERROR("Exception in _process_homey_event: {}", boost::diagnostic_information(ex, true)); + } +} diff --git a/docker/MQTTManager/include/homey_manager/homey_manager.hpp b/docker/MQTTManager/include/homey_manager/homey_manager.hpp new file mode 100644 index 00000000..937de3d3 --- /dev/null +++ b/docker/MQTTManager/include/homey_manager/homey_manager.hpp @@ -0,0 +1,64 @@ +#ifndef MQTT_MANAGER_HOMEY_MANAGER_HPP +#define MQTT_MANAGER_HOMEY_MANAGER_HPP + +#include +#include +#include +#include +#include +#include +#include +#include + +class HomeyManager +{ +public: + static void init(); // Start a new thread and connect to Homey. + + static void connect(); + + /* + * Reload config from DB and if needed, reconnect. + */ + static void reload_config(); + + static void send_json(nlohmann::json &data); // Helper to convert from JSON to std::string and then send + + /** + * Attach an event listener to handle Homey device events. + */ + template + static void attach_event_observer(std::string device_id, CALLBACK_BIND callback) + { + HomeyManager::_homey_observers[device_id].disconnect(callback); // Disconnect first in case it was already connected, otherwise multiple signals will be sent. + HomeyManager::_homey_observers[device_id].connect(callback); + } + + /** + * Detach an event listener for Homey device events. + */ + template + static void detach_event_observer(std::string device_id, CALLBACK_BIND callback) + { + HomeyManager::_homey_observers[device_id].disconnect(callback); + } + +private: + static inline boost::ptr_map> _homey_observers; + static void _process_homey_event(nlohmann::json &event_data); + + static inline std::mutex _mutex_websocket_write_access; // Mutex to prevent simultaneous write access to WebSocket + static void _send_string(std::string &data); // Lowest level send function. Will handle mutex. + + static inline ix::WebSocket *_websocket = nullptr; + static void _websocket_message_callback(const ix::WebSocketMessagePtr &msg); + static void _process_websocket_message(const std::string &data); + + static inline std::atomic _connected = false; + + static inline std::mutex _settings_mutex; + static inline std::string _homey_address; + static inline std::string _homey_token; +}; + +#endif // !MQTT_MANAGER_HOMEY_MANAGER_HPP diff --git a/docker/MQTTManager/include/light/homey_light.cpp b/docker/MQTTManager/include/light/homey_light.cpp new file mode 100644 index 00000000..ca446d42 --- /dev/null +++ b/docker/MQTTManager/include/light/homey_light.cpp @@ -0,0 +1,335 @@ +#include "homey_light.hpp" +#include "database_manager/database_manager.hpp" +#include "entity/entity.hpp" +#include "light/light.hpp" +#include "mqtt_manager_config/mqtt_manager_config.hpp" +#include +#include +#include +#include +#include +#include +#include + +HomeyLight::HomeyLight(uint32_t light_id) : Light(light_id) +{ + // Process Homey specific details. General light data is loaded in the "Light" constructor. + + this->_current_brightness = 0; + this->_current_color_temperature = 0; + this->_current_hue = 0; + this->_current_saturation = 0; + this->_current_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; + this->_current_state = false; + this->_requested_brightness = 0; + this->_requested_color_temperature = 0; + this->_requested_hue = 0; + this->_requested_saturation = 0; + this->_requested_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; + this->_requested_state = false; + + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOMEY) + { + SPDLOG_ERROR("HomeyLight has not been recognized as controlled by HOMEY. Will stop processing light."); + return; + } + + nlohmann::json entity_data; + try + { + auto light = database_manager::database.get(this->_id); + entity_data = light.get_entity_data_json(); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to load light {}: {}", this->_id, e.what()); + return; + } + + if (entity_data.contains("homey_device_id")) + { + this->_homey_device_id = entity_data["homey_device_id"]; + } + else + { + SPDLOG_ERROR("No homey_device_id defined for Light {}::{}", this->_id, this->_name); + return; + } + + if (entity_data.contains("capabilities") && entity_data["capabilities"].is_array()) + { + for (const auto &cap : entity_data["capabilities"]) + { + this->_capabilities.push_back(cap); + } + } + + SPDLOG_DEBUG("Loaded Homey light {}::{}, device ID: {}", this->_id, this->_name, this->_homey_device_id); + HomeyManager::attach_event_observer(this->_homey_device_id, boost::bind(&HomeyLight::homey_event_callback, this, _1)); + + this->send_state_update_to_nspanel(); // Send initial state to NSPanel +} + +HomeyLight::~HomeyLight() +{ + HomeyManager::detach_event_observer(this->_homey_device_id, boost::bind(&HomeyLight::homey_event_callback, this, _1)); +} + +bool HomeyLight::_has_capability(const std::string &capability) +{ + return std::find(this->_capabilities.begin(), this->_capabilities.end(), capability) != this->_capabilities.end(); +} + +void HomeyLight::_send_capability_update(const std::string &capability, nlohmann::json value) +{ + SPDLOG_DEBUG("Homey light {}::{} sending capability {} = {}", this->_id, this->_name, capability, value.dump()); + + // Get Homey connection settings + auto homey_address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS, ""); + auto homey_token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN, ""); + + if (homey_address.empty() || homey_token.empty()) + { + SPDLOG_ERROR("Homey address or token not configured for light {}::{}", this->_id, this->_name); + return; + } + + // Construct URL: http://{homey_address}/api/device/{device_id}/capability/{capability} + std::string url = fmt::format("http://{}/api/device/{}/capability/{}", homey_address, this->_homey_device_id, capability); + + // Create request body with capability value + nlohmann::json request_body; + request_body["value"] = value; + + // Send HTTP PUT request with bearer token authentication + try + { + std::vector headers = { + fmt::format("Authorization: Bearer {}", homey_token), + "Content-Type: application/json"}; + + std::string response = WebHelper::send_authorized_request(url, request_body.dump(), headers, WebHelper::HTTP_METHOD::PUT); + SPDLOG_DEBUG("Homey light {}::{} capability {} update response: {}", this->_id, this->_name, capability, response); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to send capability update to Homey for light {}::{}: {}", this->_id, this->_name, e.what()); + } +} + +void HomeyLight::send_state_update_to_controller() +{ + SPDLOG_DEBUG("Homey light {}::{} send_state_update_to_controller", this->_id, this->_name); + + // Turn on/off + if (this->_has_capability("onoff")) + { + this->_send_capability_update("onoff", this->_requested_state); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_state = this->_requested_state; + } + } + + // Only send other values if turning on + if (this->_requested_state) + { + // Brightness + if (this->_has_capability("dim") && this->_requested_brightness != this->_current_brightness) + { + // Homey expects dim as 0.0 to 1.0 + float dim_value = (float)this->_requested_brightness / 100.0; + this->_send_capability_update("dim", dim_value); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_brightness = this->_requested_brightness; + } + } + + // Color temperature mode + if (this->_requested_mode == MQTT_MANAGER_LIGHT_MODE::DEFAULT) + { + if (this->_has_capability("light_temperature") && this->_requested_color_temperature != this->_current_color_temperature) + { + this->_send_capability_update("light_temperature", this->_requested_color_temperature); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_color_temperature = this->_requested_color_temperature; + this->_current_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; + } + } + } + // RGB mode + else if (this->_requested_mode == MQTT_MANAGER_LIGHT_MODE::RGB) + { + if (this->_has_capability("light_hue") && this->_requested_hue != this->_current_hue) + { + // Homey expects hue as 0.0 to 1.0 (representing 0-360 degrees) + float hue_value = (float)this->_requested_hue / 360.0; + this->_send_capability_update("light_hue", hue_value); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_hue = this->_requested_hue; + } + } + + if (this->_has_capability("light_saturation") && this->_requested_saturation != this->_current_saturation) + { + // Homey expects saturation as 0.0 to 1.0 + float saturation_value = (float)this->_requested_saturation / 100.0; + this->_send_capability_update("light_saturation", saturation_value); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_saturation = this->_requested_saturation; + this->_current_mode = MQTT_MANAGER_LIGHT_MODE::RGB; + } + } + } + } + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->send_state_update_to_nspanel(); + this->_entity_changed_callbacks(this); + } +} + +void HomeyLight::homey_event_callback(nlohmann::json data) +{ + try + { + // Homey WebSocket sends: {"id": "device-uuid", "capabilitiesObj": {...}} + if (!data.contains("capabilitiesObj") || data["capabilitiesObj"].is_null()) + { + SPDLOG_DEBUG("Homey event for light {}::{} has no capabilitiesObj", this->_id, this->_name); + return; + } + + SPDLOG_DEBUG("Got event update for Homey light {}::{}.", this->_id, this->_name); + nlohmann::json capabilities = data["capabilitiesObj"]; + bool changed_attribute = false; + + // Handle onoff capability + if (capabilities.contains("onoff") && !capabilities["onoff"].is_null()) + { + if (capabilities["onoff"].contains("value") && !capabilities["onoff"]["value"].is_null()) + { + bool new_state = capabilities["onoff"]["value"]; + + if (new_state != this->_current_state) + { + changed_attribute = true; + } + + this->_current_state = new_state; + this->_requested_state = new_state; + + if (!new_state) + { + this->_requested_brightness = 0; + } + } + } + + // Handle dim capability (brightness) + if (capabilities.contains("dim") && !capabilities["dim"].is_null()) + { + if (capabilities["dim"].contains("value") && !capabilities["dim"]["value"].is_null()) + { + float dim_value = capabilities["dim"]["value"]; + uint8_t new_brightness = (uint8_t)(dim_value * 100.0); // Convert 0.0-1.0 to 0-100 + + if (new_brightness != this->_current_brightness) + { + changed_attribute = true; + } + + this->_current_brightness = new_brightness; + this->_requested_brightness = new_brightness; + } + } + else if (this->_current_state && this->_can_dim) + { + // If light is on but no brightness given, assume 100% + if (this->_current_brightness != 100) + { + changed_attribute = true; + } + this->_current_brightness = 100; + this->_requested_brightness = 100; + } + + // Handle color temperature + if (capabilities.contains("light_temperature") && !capabilities["light_temperature"].is_null()) + { + if (capabilities["light_temperature"].contains("value") && !capabilities["light_temperature"]["value"].is_null()) + { + uint32_t new_temp = capabilities["light_temperature"]["value"]; + + if (new_temp != this->_current_color_temperature) + { + changed_attribute = true; + } + + this->_current_color_temperature = new_temp; + this->_requested_color_temperature = new_temp; + this->_current_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; + this->_requested_mode = MQTT_MANAGER_LIGHT_MODE::DEFAULT; + } + } + + // Handle hue + if (capabilities.contains("light_hue") && !capabilities["light_hue"].is_null()) + { + if (capabilities["light_hue"].contains("value") && !capabilities["light_hue"]["value"].is_null()) + { + float hue_value = capabilities["light_hue"]["value"]; + uint16_t new_hue = (uint16_t)(hue_value * 360.0); // Convert 0.0-1.0 to 0-360 + + if (new_hue != this->_current_hue) + { + changed_attribute = true; + } + + this->_current_hue = new_hue; + this->_requested_hue = new_hue; + this->_current_mode = MQTT_MANAGER_LIGHT_MODE::RGB; + this->_requested_mode = MQTT_MANAGER_LIGHT_MODE::RGB; + } + } + + // Handle saturation + if (capabilities.contains("light_saturation") && !capabilities["light_saturation"].is_null()) + { + if (capabilities["light_saturation"].contains("value") && !capabilities["light_saturation"]["value"].is_null()) + { + float saturation_value = capabilities["light_saturation"]["value"]; + uint8_t new_saturation = (uint8_t)(saturation_value * 100.0); // Convert 0.0-1.0 to 0-100 + + if (new_saturation != this->_current_saturation) + { + changed_attribute = true; + } + + this->_current_saturation = new_saturation; + this->_requested_saturation = new_saturation; + } + } + + if (changed_attribute) + { + this->send_state_update_to_nspanel(); + this->_signal_entity_changed(); + } + } + catch (std::exception &e) + { + SPDLOG_ERROR("Caught exception when processing Homey event for light {}::{}: {}", + this->_id, this->_name, boost::diagnostic_information(e, true)); + } +} diff --git a/docker/MQTTManager/include/light/homey_light.hpp b/docker/MQTTManager/include/light/homey_light.hpp new file mode 100644 index 00000000..9e823027 --- /dev/null +++ b/docker/MQTTManager/include/light/homey_light.hpp @@ -0,0 +1,25 @@ +#ifndef MQTT_MANAGER_HOMEY_LIGHT +#define MQTT_MANAGER_HOMEY_LIGHT + +#include "light.hpp" +#include +#include + +class HomeyLight : public Light +{ +public: + HomeyLight(uint32_t light_id); + ~HomeyLight(); + void send_state_update_to_controller(); + void homey_event_callback(nlohmann::json event_data); + +private: + std::string _homey_device_id; + std::vector _capabilities; + + // Helper methods for capability control + void _send_capability_update(const std::string &capability, nlohmann::json value); + bool _has_capability(const std::string &capability); +}; + +#endif // !MQTT_MANAGER_HOMEY_LIGHT diff --git a/docker/MQTTManager/include/scenes/homey_scene.cpp b/docker/MQTTManager/include/scenes/homey_scene.cpp new file mode 100644 index 00000000..bb4952cd --- /dev/null +++ b/docker/MQTTManager/include/scenes/homey_scene.cpp @@ -0,0 +1,95 @@ +#include "homey_scene.hpp" +#include "database_manager/database_manager.hpp" +#include +#include +#include +#include + +HomeyScene::HomeyScene(uint32_t scene_id) : Scene(scene_id) +{ + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOMEY) + { + SPDLOG_ERROR("HomeyScene has not been recognized as controlled by HOMEY. Will stop processing scene."); + return; + } + + // Parse backend_name to get Homey ID and type + // Format: "homey_flow_" or "homey_mood_" + if (boost::starts_with(this->_backend_name, "homey_flow_")) + { + this->_homey_scene_type = HOMEY_SCENE_TYPE::HOMEY_FLOW; + this->_homey_id = this->_backend_name.substr(11); // Remove "homey_flow_" prefix + } + else if (boost::starts_with(this->_backend_name, "homey_mood_")) + { + this->_homey_scene_type = HOMEY_SCENE_TYPE::HOMEY_MOOD; + this->_homey_id = this->_backend_name.substr(11); // Remove "homey_mood_" prefix + } + else + { + SPDLOG_ERROR("Invalid Homey scene backend_name format: {}. Expected 'homey_flow_' or 'homey_mood_'", this->_backend_name); + return; + } + + SPDLOG_DEBUG("Loaded Homey scene {}::{}, type: {}, ID: {}", + this->_id, + this->_scene_name, + this->_homey_scene_type == HOMEY_SCENE_TYPE::HOMEY_FLOW ? "Flow" : "Mood", + this->_homey_id); +} + +HomeyScene::~HomeyScene() +{ + // No cleanup needed for scenes +} + +void HomeyScene::activate() +{ + SPDLOG_DEBUG("Activating Homey scene {}::{}, type: {}, ID: {}", + this->_id, + this->_scene_name, + this->_homey_scene_type == HOMEY_SCENE_TYPE::HOMEY_FLOW ? "Flow" : "Mood", + this->_homey_id); + + // Get Homey connection settings + auto homey_address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS, ""); + auto homey_token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN, ""); + + if (homey_address.empty() || homey_token.empty()) + { + SPDLOG_ERROR("Homey address or token not configured for scene {}::{}", this->_id, this->_scene_name); + return; + } + + std::string url; + nlohmann::json request_body; + + if (this->_homey_scene_type == HOMEY_SCENE_TYPE::HOMEY_FLOW) + { + // For Flows: POST /api/manager/flow/flow/{flow_id}/trigger + url = fmt::format("http://{}/api/manager/flow/flow/{}/trigger", homey_address, this->_homey_id); + // Flow trigger doesn't require a body, but we'll send empty JSON + request_body = nlohmann::json::object(); + } + else + { + // For Moods: Similar trigger endpoint + url = fmt::format("http://{}/api/mood/{}/trigger", homey_address, this->_homey_id); + request_body = nlohmann::json::object(); + } + + // Send HTTP POST request with bearer token authentication + try + { + std::vector headers = { + fmt::format("Authorization: Bearer {}", homey_token), + "Content-Type: application/json"}; + + std::string response = WebHelper::send_authorized_request(url, request_body.dump(), headers, WebHelper::HTTP_METHOD::POST); + SPDLOG_DEBUG("Homey scene {}::{} activation response: {}", this->_id, this->_scene_name, response); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to activate Homey scene {}::{}: {}", this->_id, this->_scene_name, e.what()); + } +} diff --git a/docker/MQTTManager/include/scenes/homey_scene.hpp b/docker/MQTTManager/include/scenes/homey_scene.hpp new file mode 100644 index 00000000..797e7dce --- /dev/null +++ b/docker/MQTTManager/include/scenes/homey_scene.hpp @@ -0,0 +1,25 @@ +#ifndef MQTT_MANAGER_HOMEY_SCENE +#define MQTT_MANAGER_HOMEY_SCENE + +#include "scene.hpp" +#include + +enum HOMEY_SCENE_TYPE +{ + HOMEY_FLOW, + HOMEY_MOOD +}; + +class HomeyScene : public Scene +{ +public: + HomeyScene(uint32_t scene_id); + ~HomeyScene(); + void activate(); + +private: + std::string _homey_id; + HOMEY_SCENE_TYPE _homey_scene_type; +}; + +#endif // !MQTT_MANAGER_HOMEY_SCENE diff --git a/docker/MQTTManager/include/switch/homey_switch.cpp b/docker/MQTTManager/include/switch/homey_switch.cpp new file mode 100644 index 00000000..01787561 --- /dev/null +++ b/docker/MQTTManager/include/switch/homey_switch.cpp @@ -0,0 +1,139 @@ +#include "homey_switch.hpp" +#include "database_manager/database_manager.hpp" +#include "mqtt_manager_config/mqtt_manager_config.hpp" +#include +#include +#include +#include +#include + +HomeySwitch::HomeySwitch(uint32_t switch_id) : SwitchEntity(switch_id) +{ + this->_state = false; + + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOMEY) + { + SPDLOG_ERROR("HomeySwitch has not been recognized as controlled by HOMEY. Will stop processing switch."); + return; + } + + nlohmann::json entity_data; + try + { + auto switch_entity = database_manager::database.get(this->_id); + entity_data = switch_entity.get_entity_data_json(); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to load switch {}: {}", this->_id, e.what()); + return; + } + + if (entity_data.contains("homey_device_id")) + { + this->_homey_device_id = entity_data["homey_device_id"]; + } + else + { + SPDLOG_ERROR("No homey_device_id defined for Switch {}::{}", this->_id, this->_friendly_name); + return; + } + + SPDLOG_DEBUG("Loaded Homey switch {}::{}, device ID: {}", this->_id, this->_friendly_name, this->_homey_device_id); + HomeyManager::attach_event_observer(this->_homey_device_id, boost::bind(&HomeySwitch::homey_event_callback, this, _1)); + + this->send_state_update_to_nspanel(); // Send initial state to NSPanel +} + +HomeySwitch::~HomeySwitch() +{ + HomeyManager::detach_event_observer(this->_homey_device_id, boost::bind(&HomeySwitch::homey_event_callback, this, _1)); +} + +void HomeySwitch::send_state_update_to_controller() +{ + SPDLOG_DEBUG("Homey switch {}::{} send_state_update_to_controller. State: {}", this->_id, this->_name, this->_requested_state ? "ON" : "OFF"); + + // Get Homey connection settings + auto homey_address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS, ""); + auto homey_token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN, ""); + + if (homey_address.empty() || homey_token.empty()) + { + SPDLOG_ERROR("Homey address or token not configured for switch {}::{}", this->_id, this->_name); + return; + } + + // Construct URL: http://{homey_address}/api/device/{device_id}/capability/onoff + std::string url = fmt::format("http://{}/api/device/{}/capability/onoff", homey_address, this->_homey_device_id); + + // Create request body with state value + nlohmann::json request_body; + request_body["value"] = this->_requested_state; + + // Send HTTP PUT request with bearer token authentication + try + { + std::vector headers = { + fmt::format("Authorization: Bearer {}", homey_token), + "Content-Type: application/json"}; + + std::string response = WebHelper::send_authorized_request(url, request_body.dump(), headers, WebHelper::HTTP_METHOD::PUT); + SPDLOG_DEBUG("Homey switch {}::{} state update response: {}", this->_id, this->_name, response); + + if (MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::OPTIMISTIC_MODE)) + { + this->_current_state = this->_requested_state; + this->send_state_update_to_nspanel(); + this->_entity_changed_callbacks(this); + } + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to send state update to Homey for switch {}::{}: {}", this->_id, this->_name, e.what()); + } +} + +void HomeySwitch::homey_event_callback(nlohmann::json data) +{ + try + { + // Homey WebSocket sends: {"id": "device-uuid", "capabilitiesObj": {...}} + if (!data.contains("capabilitiesObj") || data["capabilitiesObj"].is_null()) + { + SPDLOG_DEBUG("Homey event for switch {}::{} has no capabilitiesObj", this->_id, this->_friendly_name); + return; + } + + SPDLOG_DEBUG("Got event update for Homey switch {}::{}.", this->_id, this->_friendly_name); + nlohmann::json capabilities = data["capabilitiesObj"]; + bool changed_attribute = false; + + // Handle onoff capability + if (capabilities.contains("onoff") && !capabilities["onoff"].is_null()) + { + if (capabilities["onoff"].contains("value") && !capabilities["onoff"]["value"].is_null()) + { + bool new_state = capabilities["onoff"]["value"]; + + if (new_state != this->_state) + { + changed_attribute = true; + } + + this->_state = new_state; + } + } + + if (changed_attribute) + { + this->send_state_update_to_nspanel(); + this->_signal_entity_changed(); + } + } + catch (std::exception &e) + { + SPDLOG_ERROR("Caught exception when processing Homey event for switch {}::{}: {}", + this->_id, this->_friendly_name, boost::diagnostic_information(e, true)); + } +} diff --git a/docker/MQTTManager/include/switch/homey_switch.hpp b/docker/MQTTManager/include/switch/homey_switch.hpp new file mode 100644 index 00000000..95e1d87b --- /dev/null +++ b/docker/MQTTManager/include/switch/homey_switch.hpp @@ -0,0 +1,19 @@ +#ifndef MQTT_MANAGER_HOMEY_SWITCH +#define MQTT_MANAGER_HOMEY_SWITCH + +#include "switch_entity.hpp" +#include + +class HomeySwitch : public SwitchEntity +{ +public: + HomeySwitch(uint32_t switch_id); + ~HomeySwitch(); + void send_state_update_to_controller(); + void homey_event_callback(nlohmann::json event_data); + +private: + std::string _homey_device_id; +}; + +#endif // !MQTT_MANAGER_HOMEY_SWITCH diff --git a/docker/MQTTManager/include/thermostat/homey_thermostat.cpp b/docker/MQTTManager/include/thermostat/homey_thermostat.cpp new file mode 100644 index 00000000..7422d5b9 --- /dev/null +++ b/docker/MQTTManager/include/thermostat/homey_thermostat.cpp @@ -0,0 +1,129 @@ +#include "homey_thermostat.hpp" +#include "database_manager/database_manager.hpp" +#include "entity/entity.hpp" +#include "mqtt_manager_config/mqtt_manager_config.hpp" +#include "protobuf_nspanel.pb.h" +#include "thermostat/thermostat.hpp" +#include "web_helper/web_helper.hpp" +#include +#include +#include +#include +#include + +HomeyThermostat::HomeyThermostat(uint32_t thermostat_id) : ThermostatEntity(thermostat_id) +{ + // Process Homey specific details. General thermostat data is loaded in the "ThermostatEntity" constructor. + if (this->_controller != MQTT_MANAGER_ENTITY_CONTROLLER::HOMEY) + { + SPDLOG_ERROR("HomeyThermostat has not been recognized as controlled by HOMEY. Will stop processing thermostat."); + return; + } + + nlohmann::json entity_data; + try + { + auto thermostat = database_manager::database.get(this->_id); + entity_data = thermostat.get_entity_data_json(); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to load thermostat {}: {}", this->_id, e.what()); + return; + } + + if (entity_data.contains("homey_device_id")) + { + this->_homey_device_id = entity_data["homey_device_id"]; + } + else + { + SPDLOG_ERROR("No Homey device ID defined for Thermostat {}::{}", this->_id, this->_name); + return; + } + + SPDLOG_DEBUG("Loaded thermostat {}::{}, Homey device ID: {}", this->_id, this->_name, this->_homey_device_id); + + this->send_state_update_to_nspanel(); // Send initial state to NSPanel +} + +HomeyThermostat::~HomeyThermostat() +{ + // Cleanup if needed +} + +void HomeyThermostat::_send_capability_update(const std::string &capability, const nlohmann::json &value) +{ + std::string homey_address = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_ADDRESS); + std::string homey_token = MqttManagerConfig::get_setting_with_default(MQTT_MANAGER_SETTING::HOMEY_TOKEN); + + if (homey_address.empty() || homey_token.empty()) + { + SPDLOG_ERROR("Homey address or token not configured for thermostat {}::{}", this->_id, this->_name); + return; + } + + nlohmann::json request_body; + request_body["value"] = value; + + std::string url = fmt::format("http://{}/api/device/{}/capability/{}", homey_address, this->_homey_device_id, capability); + std::vector headers = { + fmt::format("Authorization: Bearer {}", homey_token), + "Content-Type: application/json"}; + + try + { + std::string response = WebHelper::send_authorized_request(url, request_body.dump(), headers, WebHelper::HTTP_METHOD::PUT); + SPDLOG_DEBUG("Thermostat {}::{} sent {} update to Homey: {}", this->_id, this->_name, capability, value.dump()); + } + catch (const std::exception &e) + { + SPDLOG_ERROR("Failed to send {} update to Homey for thermostat {}::{}: {}", capability, this->_id, this->_name, e.what()); + } +} + +void HomeyThermostat::send_state_update_to_controller() +{ + // Send target temperature if it has changed + if (this->_requested_temperature != this->_current_temperature) + { + this->_send_capability_update("target_temperature", this->_requested_temperature); + } + + // Note: Homey thermostats typically use target_temperature as the main control + // Mode, fan mode, and other advanced features may not be supported by all Homey devices + // For now, we focus on the core temperature control functionality +} + +void HomeyThermostat::command_callback(NSPanelMQTTManagerCommand &command) +{ + // Handle commands from NSPanel via MQTT + if (!command.has_thermostat_command()) + { + SPDLOG_WARN("Thermostat {}::{} received command without thermostat_command", this->_id, this->_name); + return; + } + + const NSPanelMQTTManagerCommand_ThermostatCommand &thermostat_cmd = command.thermostat_command(); + + // Handle temperature change + if (thermostat_cmd.has_temperature()) + { + float new_temperature = thermostat_cmd.temperature(); + SPDLOG_DEBUG("Thermostat {}::{} received temperature command: {}", this->_id, this->_name, new_temperature); + this->set_temperature(new_temperature); + this->send_state_update_to_controller(); + } + + // Handle mode changes if provided + if (thermostat_cmd.has_mode()) + { + std::string new_mode = thermostat_cmd.mode(); + SPDLOG_DEBUG("Thermostat {}::{} received mode command: {}", this->_id, this->_name, new_mode); + this->set_mode(new_mode); + this->send_state_update_to_controller(); + } + + // Send updated state to NSPanel + this->send_state_update_to_nspanel(); +} diff --git a/docker/MQTTManager/include/thermostat/homey_thermostat.hpp b/docker/MQTTManager/include/thermostat/homey_thermostat.hpp new file mode 100644 index 00000000..5409ab17 --- /dev/null +++ b/docker/MQTTManager/include/thermostat/homey_thermostat.hpp @@ -0,0 +1,20 @@ +#ifndef MQTT_MANAGER_HOMEY_THERMOSTAT_HPP +#define MQTT_MANAGER_HOMEY_THERMOSTAT_HPP + +#include "thermostat.hpp" +#include + +class HomeyThermostat : public ThermostatEntity +{ +public: + HomeyThermostat(uint32_t thermostat_id); + ~HomeyThermostat(); + void send_state_update_to_controller(); + void command_callback(NSPanelMQTTManagerCommand &command); + +private: + std::string _homey_device_id; + void _send_capability_update(const std::string &capability, const nlohmann::json &value); +}; + +#endif // MQTT_MANAGER_HOMEY_THERMOSTAT_HPP diff --git a/docker/nginx/sites-enabled/nspanelmanager.conf b/docker/nginx/sites-enabled/nspanelmanager.conf index 8f185535..c8e7be91 100644 --- a/docker/nginx/sites-enabled/nspanelmanager.conf +++ b/docker/nginx/sites-enabled/nspanelmanager.conf @@ -25,10 +25,6 @@ server { # max upload size client_max_body_size 75M; # adjust to taste - location /static { - alias /usr/src/app/nspanelmanager/web/static; - } - location /websocket { proxy_pass http://mqttmanager; proxy_http_version 1.1; @@ -36,9 +32,13 @@ server { proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host;} + + location /{ + proxy_pass http://django; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host;} + # Finally, send all non-media requests to the Django server. - location / { - uwsgi_pass django; - include /etc/nginx/sites-enabled/uwsgi_params; - } -} \ No newline at end of file +} diff --git a/docker/web/nspanelmanager/web/api.py b/docker/web/nspanelmanager/web/api.py index 1d5e0b88..80f234cd 100644 --- a/docker/web/nspanelmanager/web/api.py +++ b/docker/web/nspanelmanager/web/api.py @@ -18,7 +18,13 @@ from .apps import restart_mqtt_manager_process from .models import NSPanel, Room, LightState, Scene, RelayGroup -from web.settings_helper import get_setting_with_default, get_nspanel_setting_with_default, set_setting_value +from web.settings_helper import ( + get_setting_with_default, + get_nspanel_setting_with_default, + set_setting_value, +) +import web.homey_api + def get_nspanel_json_representation(panel): panel_config = { @@ -26,14 +32,24 @@ def get_nspanel_json_representation(panel): "id": panel.id, "mac": panel.mac_address, "name": panel.friendly_name, - "is_us_panel": get_nspanel_setting_with_default(panel.id, "is_us_panel", "False") == "True", + "is_us_panel": get_nspanel_setting_with_default( + panel.id, "is_us_panel", "False" + ) + == "True", "address": panel.ip_address, - "relay1_is_light": get_nspanel_setting_with_default(panel.id, "relay1_is_light", "False") == "True", - "relay2_is_light": get_nspanel_setting_with_default(panel.id, "relay2_is_light", "False") == "True", - "denied": "True" if panel.denied else "False" + "relay1_is_light": get_nspanel_setting_with_default( + panel.id, "relay1_is_light", "False" + ) + == "True", + "relay2_is_light": get_nspanel_setting_with_default( + panel.id, "relay2_is_light", "False" + ) + == "True", + "denied": "True" if panel.denied else "False", } return panel_config + # TODO: Rework how available entities are gathered def get_all_available_entities(request): # TODO: Implement manually entered entities @@ -48,29 +64,49 @@ def get_all_available_entities(request): openhab_type_filter = filter_data["openhab_type_filter"] # Get Home Assistant lights - return_json = { - "entities": [], - "errors": [] - } + return_json = {"entities": [], "errors": []} # Home Assistant - if get_setting_with_default("home_assistant_token") != "" and get_setting_with_default("home_assistant_address") != "": + if ( + get_setting_with_default("home_assistant_token") != "" + and get_setting_with_default("home_assistant_address") != "" + ): home_assistant_request_headers = { - "Authorization": "Bearer " + get_setting_with_default("home_assistant_token"), + "Authorization": "Bearer " + + get_setting_with_default("home_assistant_token"), "content-type": "application/json", } try: environment = environ.Env() - if "IS_HOME_ASSISTANT_ADDON" in environment and environment("IS_HOME_ASSISTANT_ADDON") == "true": - home_assistant_api_address = get_setting_with_default("home_assistant_address") + "/core/api/states" + if ( + "IS_HOME_ASSISTANT_ADDON" in environment + and environment("IS_HOME_ASSISTANT_ADDON") == "true" + ): + home_assistant_api_address = ( + get_setting_with_default("home_assistant_address") + + "/core/api/states" + ) else: - home_assistant_api_address = get_setting_with_default("home_assistant_address") + "/api/states" - logging.debug("Trying to get Home Assistant entities via api address: " + home_assistant_api_address) - home_assistant_response = requests.get(home_assistant_api_address, headers=home_assistant_request_headers, timeout=5, verify=False) + home_assistant_api_address = ( + get_setting_with_default("home_assistant_address") + "/api/states" + ) + logging.debug( + "Trying to get Home Assistant entities via api address: " + + home_assistant_api_address + ) + home_assistant_response = requests.get( + home_assistant_api_address, + headers=home_assistant_request_headers, + timeout=5, + verify=False, + ) if home_assistant_response.status_code == 200: for entity in home_assistant_response.json(): entity_type = entity["entity_id"].split(".")[0] - if (len(home_assistant_type_filter) > 0 and entity_type in home_assistant_type_filter) or len(home_assistant_type_filter) == 0: + if ( + len(home_assistant_type_filter) > 0 + and entity_type in home_assistant_type_filter + ) or len(home_assistant_type_filter) == 0: data = { "type": "home_assistant", "label": entity["entity_id"], @@ -85,23 +121,35 @@ def get_all_available_entities(request): elif data["entity_id"].startswith("switch."): data["entity_type"] = "switch" else: - logging.warn("Unknown entity type for entity: " + data["entity_id"]) + logging.warn( + "Unknown entity type for entity: " + data["entity_id"] + ) return_json["entities"].append(data) else: return_json["errors"].append( - "Failed to get Home Assistant lights, got return code: " + str(home_assistant_response.status_code)) - print("ERROR! Got status code other than 200. Got code: " + - str(home_assistant_response.status_code)) + "Failed to get Home Assistant lights, got return code: " + + str(home_assistant_response.status_code) + ) + print( + "ERROR! Got status code other than 200. Got code: " + + str(home_assistant_response.status_code) + ) except Exception as e: return_json["errors"].append( - "Failed to get Home Assistant lights: " + str(traceback.format_exc())) + "Failed to get Home Assistant lights: " + str(traceback.format_exc()) + ) logging.exception("Failed to get Home Assistant lights!") else: - print("No home assistant configuration values. Will not gather Home Assistant entities.") + print( + "No home assistant configuration values. Will not gather Home Assistant entities." + ) # OpenHAB - if get_setting_with_default("openhab_token") != "" and get_setting_with_default("openhab_address") != "": + if ( + get_setting_with_default("openhab_token") != "" + and get_setting_with_default("openhab_address") != "" + ): # TODO: Sort out how to map channels from items to the correct POST request when MQTT is received openhab_request_headers = { "Authorization": "Bearer " + get_setting_with_default("openhab_token"), @@ -109,8 +157,11 @@ def get_all_available_entities(request): } try: if "things" in openhab_type_filter: - openhab_response = requests.get(get_setting_with_default( - "openhab_address") + "/rest/things", headers=openhab_request_headers, verify=False) + openhab_response = requests.get( + get_setting_with_default("openhab_address") + "/rest/things", + headers=openhab_request_headers, + verify=False, + ) if openhab_response.status_code == 200: for entity in openhab_response.json(): @@ -120,8 +171,16 @@ def get_all_available_entities(request): for channel in entity["channels"]: # Check if this thing has a channel that indicates that it might be a light add_items_with_channels_of_type = [ - "Dimmer", "Number", "Color", "Switch", "String"] - if "itemType" in channel and (channel["itemType"] in add_items_with_channels_of_type): + "Dimmer", + "Number", + "Color", + "Switch", + "String", + ] + if "itemType" in channel and ( + channel["itemType"] + in add_items_with_channels_of_type + ): add_entity = True if "linkedItems" in channel: # Add all available items to the list of items for this thing @@ -130,109 +189,197 @@ def get_all_available_entities(request): items.append(linkedItem) if add_entity: # return_json["openhab_lights"].append(entity["label"]) - return_json["entities"].append({ - "type": "openhab", - "openhab_type": "thing", - "label": entity["label"], - "entity_id": entity["label"], - "items": items, - "raw_data": entity, - }) + return_json["entities"].append( + { + "type": "openhab", + "openhab_type": "thing", + "label": entity["label"], + "entity_id": entity["label"], + "items": items, + "raw_data": entity, + } + ) else: return_json["errors"].append( - "Failed to get OpenHAB lights, got return code: " + str(openhab_response.status_code)) - print("ERROR! Got status code other than 200. Got code: " + - str(openhab_response.status_code)) + "Failed to get OpenHAB lights, got return code: " + + str(openhab_response.status_code) + ) + print( + "ERROR! Got status code other than 200. Got code: " + + str(openhab_response.status_code) + ) elif "rules" in openhab_type_filter: - openhab_response = requests.get(get_setting_with_default( - "openhab_address") + "/rest/rules", headers=openhab_request_headers, verify=False) + openhab_response = requests.get( + get_setting_with_default("openhab_address") + "/rest/rules", + headers=openhab_request_headers, + verify=False, + ) if openhab_response.status_code == 200: for entity in openhab_response.json(): if "name" in entity: - return_json["entities"].append({ - "type": "openhab", - "openhab_type": "rule", - "label": entity["name"], - "entity_id": entity["uid"], - "raw_data": entity, - "items": [] - }) + return_json["entities"].append( + { + "type": "openhab", + "openhab_type": "rule", + "label": entity["name"], + "entity_id": entity["uid"], + "raw_data": entity, + "items": [], + } + ) else: return_json["errors"].append( - "Failed to get OpenHAB lights, got return code: " + str(openhab_response.status_code)) - print("ERROR! Got status code other than 200. Got code: " + - str(openhab_response.status_code)) + "Failed to get OpenHAB lights, got return code: " + + str(openhab_response.status_code) + ) + print( + "ERROR! Got status code other than 200. Got code: " + + str(openhab_response.status_code) + ) except Exception as e: return_json["errors"].append( - "Failed to get OpenHAB lights: " + str(traceback.format_exc())) + "Failed to get OpenHAB lights: " + str(traceback.format_exc()) + ) logging.exception("Failed to get OpenHAB lights!") else: print("No OpenHAB configuration values. Will not gather OpenHAB entities.") - return return_json + # Homey + if ( + get_setting_with_default("homey_token") != "" + and get_setting_with_default("homey_address") != "" + ): + try: + # Get Homey devices + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + for device in homey_devices["items"]: + data = { + "type": "homey", + "label": device["label"], + "entity_id": device["item_id"], + "entity_type": device["device_type"], + "raw_data": device, + } + return_json["entities"].append(data) + else: + for error in homey_devices["errors"]: + return_json["errors"].append("Homey: " + error) + + # Get Homey flows + homey_flows = web.homey_api.get_all_homey_flows() + if len(homey_flows["errors"]) == 0: + for flow in homey_flows["items"]: + data = { + "type": "homey", + "label": flow["label"], + "entity_id": flow["item_id"], + "entity_type": "scene", + "raw_data": flow, + } + return_json["entities"].append(data) + + # Get Homey moods + homey_moods = web.homey_api.get_all_homey_moods() + if len(homey_moods["errors"]) == 0: + for mood in homey_moods["items"]: + data = { + "type": "homey", + "label": mood["label"], + "entity_id": mood["item_id"], + "entity_type": "scene", + "raw_data": mood, + } + return_json["entities"].append(data) + except Exception as e: + return_json["errors"].append( + "Failed to get Homey entities: " + str(traceback.format_exc()) + ) + logging.exception("Failed to get Homey entities!") + else: + print("No Homey configuration values. Will not gather Homey entities.") + + return return_json def get_nspanel_config(request): try: - logging.info("Trying to load config for NSPanel with MAC " + request.GET['mac']) + logging.info("Trying to load config for NSPanel with MAC " + request.GET["mac"]) nspanel = NSPanel.objects.get(mac_address=request.GET["mac"]) base = {} base["name"] = nspanel.friendly_name base["home"] = nspanel.room.id base["default_page"] = get_nspanel_setting_with_default( - nspanel.id, "default_page", "0") + nspanel.id, "default_page", "0" + ) base["raise_to_100_light_level"] = get_setting_with_default( - "raise_to_100_light_level") - base["color_temp_min"] = get_setting_with_default( - "color_temp_min") - base["color_temp_max"] = get_setting_with_default( - "color_temp_max") - base["reverse_color_temp"] = get_setting_with_default( - "reverse_color_temp") - base["min_button_push_time"] = get_setting_with_default( - "min_button_push_time") + "raise_to_100_light_level" + ) + base["color_temp_min"] = get_setting_with_default("color_temp_min") + base["color_temp_max"] = get_setting_with_default("color_temp_max") + base["reverse_color_temp"] = get_setting_with_default("reverse_color_temp") + base["min_button_push_time"] = get_setting_with_default("min_button_push_time") base["button_long_press_time"] = get_setting_with_default( - "button_long_press_time") + "button_long_press_time" + ) base["special_mode_trigger_time"] = get_setting_with_default( - "special_mode_trigger_time") + "special_mode_trigger_time" + ) base["special_mode_release_time"] = get_setting_with_default( - "special_mode_release_time") + "special_mode_release_time" + ) base["screen_dim_level"] = get_nspanel_setting_with_default( - nspanel.id, "screen_dim_level", get_setting_with_default("screen_dim_level")) + nspanel.id, "screen_dim_level", get_setting_with_default("screen_dim_level") + ) base["screensaver_dim_level"] = get_nspanel_setting_with_default( - nspanel.id, "screensaver_dim_level", get_setting_with_default("screensaver_dim_level")) + nspanel.id, + "screensaver_dim_level", + get_setting_with_default("screensaver_dim_level"), + ) base["screensaver_activation_timeout"] = get_nspanel_setting_with_default( - nspanel.id, "screensaver_activation_timeout", get_setting_with_default("screensaver_activation_timeout")) + nspanel.id, + "screensaver_activation_timeout", + get_setting_with_default("screensaver_activation_timeout"), + ) base["screensaver_mode"] = get_nspanel_setting_with_default( - nspanel.id, "screensaver_mode", get_setting_with_default("screensaver_mode")) - base["clock_us_style"] = get_setting_with_default( - "clock_us_style") - base["use_fahrenheit"] = get_setting_with_default( - "use_fahrenheit") + nspanel.id, "screensaver_mode", get_setting_with_default("screensaver_mode") + ) + base["clock_us_style"] = get_setting_with_default("clock_us_style") + base["use_fahrenheit"] = get_setting_with_default("use_fahrenheit") base["is_us_panel"] = get_nspanel_setting_with_default( - nspanel.id, "is_us_panel", "False") + nspanel.id, "is_us_panel", "False" + ) base["lock_to_default_room"] = get_nspanel_setting_with_default( - nspanel.id, "lock_to_default_room", "False") + nspanel.id, "lock_to_default_room", "False" + ) base["reverse_relays"] = get_nspanel_setting_with_default( - nspanel.id, "reverse_relays", False) + nspanel.id, "reverse_relays", False + ) base["relay1_default_mode"] = get_nspanel_setting_with_default( - nspanel.id, "relay1_default_mode", "False") + nspanel.id, "relay1_default_mode", "False" + ) base["relay2_default_mode"] = get_nspanel_setting_with_default( - nspanel.id, "relay2_default_mode", "False") + nspanel.id, "relay2_default_mode", "False" + ) base["temperature_calibration"] = float( - get_nspanel_setting_with_default(nspanel.id, "temperature_calibration", 0)) + get_nspanel_setting_with_default(nspanel.id, "temperature_calibration", 0) + ) base["button1_mode"] = nspanel.button1_mode base["button1_mqtt_topic"] = get_nspanel_setting_with_default( - nspanel.id, "button1_mqtt_topic", "") + nspanel.id, "button1_mqtt_topic", "" + ) base["button1_mqtt_payload"] = get_nspanel_setting_with_default( - nspanel.id, "button1_mqtt_payload", "") + nspanel.id, "button1_mqtt_payload", "" + ) base["button2_mode"] = nspanel.button2_mode base["button2_mqtt_topic"] = get_nspanel_setting_with_default( - nspanel.id, "button2_mqtt_topic", "") + nspanel.id, "button2_mqtt_topic", "" + ) base["button2_mqtt_payload"] = get_nspanel_setting_with_default( - nspanel.id, "button2_mqtt_payload", "") + nspanel.id, "button2_mqtt_payload", "" + ) if nspanel.button1_detached_mode_light: base["button1_detached_light"] = nspanel.button1_detached_mode_light.id @@ -244,7 +391,7 @@ def get_nspanel_config(request): else: base["button2_detached_light"] = -1 base["rooms"] = [] - for room in Room.objects.all().order_by('displayOrder'): + for room in Room.objects.all().order_by("displayOrder"): base["rooms"].append(room.id) base["scenes"] = {} for scene in Scene.objects.filter(room__isnull=True): @@ -276,7 +423,7 @@ def set_panel_status(request, panel_mac: str): if nspanels.exists(): nspanel = nspanels.first() # We got a match - json_payload = json.loads(request.body.decode('utf-8')) + json_payload = json.loads(request.body.decode("utf-8")) nspanel.wifi_rssi = int(json_payload["rssi"]) nspanel.heap_used_pct = int(json_payload["heap_used_pct"]) nspanel.temperature = round(json_payload["temperature"], 2) @@ -293,8 +440,8 @@ def set_panel_online_status(request, panel_mac: str): if nspanels.exists(): nspanel = nspanels.first() # We got a match - payload = json.loads(request.body.decode('utf-8')) - nspanel.online_state = (payload["state"] == "online") + payload = json.loads(request.body.decode("utf-8")) + nspanel.online_state = payload["state"] == "online" nspanel.save() return HttpResponse("", status=200) @@ -310,18 +457,20 @@ def get_scenes(request): "scene_name": scene.friendly_name, "room_name": scene.room.friendly_name if scene.room != None else None, "room_id": scene.room.id if scene.room != None else None, - "light_states": [] + "light_states": [], } for state in scene.lightstate_set.all(): - scene_info["light_states"].append({ - "light_id": state.light.id, - "light_type": state.light.type, - "color_mode": state.color_mode, - "light_level": state.light_level, - "color_temp": state.color_temperature, - "hue": state.hue, - "saturation": state.saturation - }) + scene_info["light_states"].append( + { + "light_id": state.light.id, + "light_type": state.light.type, + "color_mode": state.color_mode, + "light_level": state.light_level, + "color_temp": state.color_temperature, + "hue": state.hue, + "saturation": state.saturation, + } + ) return_json["scenes"].append(scene_info) return JsonResponse(return_json) diff --git a/docker/web/nspanelmanager/web/homey_api.py b/docker/web/nspanelmanager/web/homey_api.py new file mode 100644 index 00000000..f987af6c --- /dev/null +++ b/docker/web/nspanelmanager/web/homey_api.py @@ -0,0 +1,334 @@ +import json +import traceback +import logging +import requests +from web.settings_helper import get_setting_with_default + + +def get_all_homey_devices(filter={}): + """ + Fetch all devices from Homey that match supported capabilities. + Filters for lights (onoff, dim, light_hue, light_saturation, light_temperature), + switches (onoff), and buttons (button). + """ + return_json = {"items": [], "errors": []} + + if ( + get_setting_with_default("homey_address") == "" + or get_setting_with_default("homey_token") == "" + ): + return_json["errors"].append("Homey address and/or API token not configured") + return return_json + + homey_request_headers = { + "Authorization": "Bearer " + get_setting_with_default("homey_token"), + "content-type": "application/json", + } + + try: + homey_api_address = ( + "http://" + + get_setting_with_default("homey_address") + + "/api/manager/devices/device" + ) + logging.debug( + "Trying to get Homey devices via api address: " + homey_api_address + ) + + homey_response = requests.get( + homey_api_address, headers=homey_request_headers, timeout=5, verify=False + ) + + if homey_response.status_code == 200: + devices = homey_response.json() + + # Handle both dict and list response formats + if isinstance(devices, dict): + devices = devices.values() + + for device in devices: + if not isinstance(device, dict): + continue + + device_id = device.get("id", "") + device_name = device.get("name", "Unknown Device") + capabilities = device.get("capabilities", []) + + # Check if device has any supported capabilities + supported_capabilities = [] + device_type = None + + # Handle both list and dict formats + if isinstance(capabilities, list): + cap_list = capabilities + elif isinstance(capabilities, dict): + cap_list = list(capabilities.keys()) + else: + cap_list = [] + + for cap_name in cap_list: + if cap_name in [ + "onoff", + "dim", + "light_hue", + "light_saturation", + "light_temperature", + "light_mode", + "button", + ]: + supported_capabilities.append(cap_name) + + logging.debug( + f"Device {device_name}: capabilities={cap_list}, supported={supported_capabilities}" + ) + + # Determine device type based on capabilities + if ( + "button" in supported_capabilities + and len(supported_capabilities) == 1 + ): + device_type = "button" + elif "onoff" in supported_capabilities and not any( + light_cap in supported_capabilities + for light_cap in [ + "dim", + "light_hue", + "light_saturation", + "light_temperature", + ] + ): + device_type = "switch" + elif "onoff" in supported_capabilities or any( + light_cap in supported_capabilities + for light_cap in [ + "dim", + "light_hue", + "light_saturation", + "light_temperature", + ] + ): + device_type = "light" + + if supported_capabilities and device_type: + data = { + "type": "homey", + "label": device_name, + "item_id": device_id, + "device_type": device_type, + "capabilities": supported_capabilities, + "item": device, + } + return_json["items"].append(data) + + else: + return_json["errors"].append( + "Failed to get Homey devices, got return code: " + + str(homey_response.status_code) + ) + logging.error( + "ERROR! Got status code other than 200. Got code: " + + str(homey_response.status_code) + ) + + except Exception as e: + return_json["errors"].append( + "Failed to get Homey devices: " + str(traceback.format_exc()) + ) + logging.exception("Failed to get Homey devices!") + + return return_json + + +def get_all_homey_flows(): + """ + Fetch all Flows from Homey. + """ + return_json = {"items": [], "errors": []} + + if ( + get_setting_with_default("homey_address") == "" + or get_setting_with_default("homey_token") == "" + ): + return_json["errors"].append("Homey address and/or API token not configured") + return return_json + + homey_request_headers = { + "Authorization": "Bearer " + get_setting_with_default("homey_token"), + "content-type": "application/json", + } + + try: + homey_api_address = ( + "http://" + + get_setting_with_default("homey_address") + + "/api/manager/flow/flow" + ) + logging.debug("Trying to get Homey flows via api address: " + homey_api_address) + + homey_response = requests.get( + homey_api_address, headers=homey_request_headers, timeout=5, verify=False + ) + + if homey_response.status_code == 200: + flows = homey_response.json() + + # Handle both dict and list response formats + if isinstance(flows, dict): + flows = flows.values() + + for flow in flows: + if not isinstance(flow, dict): + continue + + flow_id = flow.get("id", "") + flow_name = flow.get("name", "Unknown Flow") + + data = { + "type": "homey", + "label": "[F] " + flow_name, # Prefix with [F] for Flow + "item_id": flow_id, + "scene_type": "homey_flow", + "item": flow, + } + return_json["items"].append(data) + + else: + return_json["errors"].append( + "Failed to get Homey flows, got return code: " + + str(homey_response.status_code) + ) + logging.error( + "ERROR! Got status code other than 200. Got code: " + + str(homey_response.status_code) + ) + + except Exception as e: + return_json["errors"].append( + "Failed to get Homey flows: " + str(traceback.format_exc()) + ) + logging.exception("Failed to get Homey flows!") + + return return_json + + +def get_all_homey_moods(): + """ + Fetch all Moods from Homey. + Moods in Homey are accessed via the insights or a specific moods endpoint. + """ + return_json = {"items": [], "errors": []} + + if ( + get_setting_with_default("homey_address") == "" + or get_setting_with_default("homey_token") == "" + ): + return_json["errors"].append("Homey address and/or API token not configured") + return return_json + + homey_request_headers = { + "Authorization": "Bearer " + get_setting_with_default("homey_token"), + "content-type": "application/json", + } + + try: + # Try to get moods via the manager endpoint + homey_api_address = ( + "http://" + get_setting_with_default("homey_address") + "/api/manager/mood" + ) + logging.debug("Trying to get Homey moods via api address: " + homey_api_address) + + homey_response = requests.get( + homey_api_address, headers=homey_request_headers, timeout=5, verify=False + ) + + if homey_response.status_code == 200: + moods = homey_response.json() + + # Handle both dict and list response formats + if isinstance(moods, dict): + moods = moods.values() + + for mood in moods: + if not isinstance(mood, dict): + continue + + mood_id = mood.get("id", "") + mood_name = mood.get("name", "Unknown Mood") + + data = { + "type": "homey", + "label": "[M] " + mood_name, # Prefix with [M] for Mood + "item_id": mood_id, + "scene_type": "homey_mood", + "item": mood, + } + return_json["items"].append(data) + + elif homey_response.status_code == 404: + # Moods endpoint might not exist, log as debug + logging.debug( + "Homey moods endpoint not found (404). Moods support may not be available." + ) + else: + logging.warning( + "Got status code " + + str(homey_response.status_code) + + " when fetching moods" + ) + + except Exception as e: + # Moods fetching failure is non-fatal, just log it + logging.debug("Could not fetch Homey moods: " + str(traceback.format_exc())) + + return return_json + + +def test_homey_connection(): + """ + Test if the Homey API connection is working. + Returns (success: bool, message: str) + """ + if ( + get_setting_with_default("homey_address") == "" + or get_setting_with_default("homey_token") == "" + ): + return False, "Homey address and/or API token not configured" + + homey_request_headers = { + "Authorization": "Bearer " + get_setting_with_default("homey_token"), + "content-type": "application/json", + } + + try: + homey_api_address = ( + "http://" + + get_setting_with_default("homey_address") + + "/api/manager/devices/device" + ) + logging.debug("Testing Homey connection to: " + homey_api_address) + + homey_response = requests.get( + homey_api_address, headers=homey_request_headers, timeout=5, verify=False + ) + + if homey_response.status_code == 200: + return True, "Successfully connected to Homey" + elif homey_response.status_code == 401: + return False, "Invalid API token. Please check your Homey API key" + elif homey_response.status_code == 403: + return False, "API token does not have required permissions" + else: + return ( + False, + f"Connection failed with status code {homey_response.status_code}", + ) + + except requests.exceptions.Timeout: + return False, "Connection to Homey timed out. Check the address and network" + except requests.exceptions.ConnectionError: + return ( + False, + "Could not connect to Homey. Check the address and ensure Homey is online", + ) + except Exception as e: + return False, f"Connection test failed: {str(e)}" diff --git a/docker/web/nspanelmanager/web/htmx.py b/docker/web/nspanelmanager/web/htmx.py index 69e7061d..8d7086d0 100644 --- a/docker/web/nspanelmanager/web/htmx.py +++ b/docker/web/nspanelmanager/web/htmx.py @@ -22,14 +22,33 @@ from time import sleep from web.views import get_base_data -from web.components.nspanel_room_entities_pages.nspanel_room_entities_pages import NSPanelRoomEntitiesPages +from web.components.nspanel_room_entities_pages.nspanel_room_entities_pages import ( + NSPanelRoomEntitiesPages, +) from web.components.rooms_list.rooms_list import RoomsList -from .models import NSPanel, Room, RoomEntitiesPage, Settings, Scene, RelayGroup, RelayGroupBinding, Entity, Message +from .models import ( + NSPanel, + Room, + RoomEntitiesPage, + Settings, + Scene, + RelayGroup, + RelayGroupBinding, + Entity, + Message, +) from .apps import start_mqtt_manager, send_mqttmanager_reload_command -from web.settings_helper import delete_nspanel_setting, get_setting_with_default, set_setting_value, get_nspanel_setting_with_default, set_nspanel_setting_value +from web.settings_helper import ( + delete_nspanel_setting, + get_setting_with_default, + set_setting_value, + get_nspanel_setting_with_default, + set_nspanel_setting_value, +) from web.views import get_file_md5sum, relay_groups + def partial_index_nspanels_section(request): if get_setting_with_default("use_fahrenheit") == "True": temperature_unit = "°F" @@ -43,21 +62,20 @@ def partial_index_nspanels_section(request): nspanels.append(panel_info) data = { - 'nspanels': nspanels, - 'temperature_unit': temperature_unit, + "nspanels": nspanels, + "temperature_unit": temperature_unit, } - return render(request, 'index_htmx_nspanels_section.html', data) + return render(request, "index_htmx_nspanels_section.html", data) + def partial_nspanel_index_view(request, nspanel_id): try: - if request.method == 'GET': + if request.method == "GET": data = { - "nspanel": { - "data": NSPanel.objects.get(id=nspanel_id) - }, + "nspanel": {"data": NSPanel.objects.get(id=nspanel_id)}, } - return render(request, 'partial/nspanel_index_view_htmx.html', data) + return render(request, "partial/nspanel_index_view_htmx.html", data) else: return JsonResponse({"status": "error"}, status=405) except Exception as ex: @@ -81,7 +99,7 @@ def unblock_nspanel(request, nspanel_id): @csrf_exempt def nspanel_accept_register_request(request, nspanel_id): try: - if request.method == 'POST': + if request.method == "POST": nspanel = NSPanel.objects.get(id=nspanel_id) nspanel.denied = False nspanel.accepted = True @@ -101,7 +119,7 @@ def nspanel_accept_register_request(request, nspanel_id): @csrf_exempt def nspanel_deny_register_request(request, nspanel_id): try: - if request.method == 'POST': + if request.method == "POST": nspanel = NSPanel.objects.get(id=nspanel_id) nspanel.denied = True nspanel.save() @@ -121,7 +139,7 @@ def nspanel_deny_register_request(request, nspanel_id): @csrf_exempt def nspanel_delete(request, nspanel_id): try: - if request.method == 'DELETE': + if request.method == "DELETE": nspanel = NSPanel.objects.get(id=nspanel_id) nspanel.delete() response = HttpResponse("", status=200) @@ -135,19 +153,54 @@ def nspanel_delete(request, nspanel_id): def select_room_temperature_sensor_provider(request, room_id): - if (get_setting_with_default("home_assistant_address") == "" or get_setting_with_default("home_assistant_token") == "") and get_setting_with_default("openhab_address") != "" and get_setting_with_default("openhab_token") != "": + if ( + ( + get_setting_with_default("home_assistant_address") == "" + or get_setting_with_default("home_assistant_token") == "" + ) + and get_setting_with_default("openhab_address") != "" + and get_setting_with_default("openhab_token") != "" + ): # OpenHAB connection configured but not Home Assistant. Skip selecting source: - return redirect('htmx_partial_select_room_temperature_sensor_from_list', entity_source="openhab", room_id=room_id) - elif get_setting_with_default("home_assistant_address") != "" and get_setting_with_default("home_assistant_token") != "" and (get_setting_with_default("openhab_address") == "" or get_setting_with_default("openhab_token") == ""): + return redirect( + "htmx_partial_select_room_temperature_sensor_from_list", + entity_source="openhab", + room_id=room_id, + ) + elif ( + get_setting_with_default("home_assistant_address") != "" + and get_setting_with_default("home_assistant_token") != "" + and ( + get_setting_with_default("openhab_address") == "" + or get_setting_with_default("openhab_token") == "" + ) + ): # OpenHAB connection not configured but Home Assistant is. Skip selecting source: - return redirect('htmx_partial_select_room_temperature_sensor_from_list', entity_source="home_assistant", room_id=room_id) - elif get_setting_with_default("home_assistant_address") != "" and get_setting_with_default("home_assistant_token") != "" and get_setting_with_default("openhab_address") != "" and get_setting_with_default("openhab_token") != "": - return render(request, 'partial/select_room_temperature_sensor_provider.html', {'room_id': room_id}) + return redirect( + "htmx_partial_select_room_temperature_sensor_from_list", + entity_source="home_assistant", + room_id=room_id, + ) + elif ( + get_setting_with_default("home_assistant_address") != "" + and get_setting_with_default("home_assistant_token") != "" + and get_setting_with_default("openhab_address") != "" + and get_setting_with_default("openhab_token") != "" + ): + return render( + request, + "partial/select_room_temperature_sensor_provider.html", + {"room_id": room_id}, + ) else: - return JsonResponse({ - "status": "error", - "text": "Unknown sources configured. Check configuration for Home Assistant and/or OpenHAB in settings." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "Unknown sources configured. Check configuration for Home Assistant and/or OpenHAB in settings.", + }, + status=500, + ) + def select_room_temperature_sensor_from_list(request, entity_source, room_id): data = { @@ -156,54 +209,88 @@ def select_room_temperature_sensor_from_list(request, entity_source, room_id): } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["sensor"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["sensor"]} + ) if len(ha_items["errors"]) == 0: data["entities"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["entities"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) return render(request, "partial/select_room_temperature_sensor.html", data) - def select_room_temperature_sensor(request): - return render(request, 'partial/select_room_temperature_sensor.html') + return render(request, "partial/select_room_temperature_sensor.html") def select_weather_location(request): - return render(request, 'partial/select_weather_location.html') + return render(request, "partial/select_weather_location.html") + def select_weather_outside_temperature_sensor(request): - return render(request, 'partial/select_weather_outside_temperature_sensor.html') + return render(request, "partial/select_weather_outside_temperature_sensor.html") + def select_weather_outside_temperature_sensor_provider(request): - if (get_setting_with_default("home_assistant_address") == "" or get_setting_with_default("home_assistant_token") == "") and get_setting_with_default("openhab_address") != "" and get_setting_with_default("openhab_token") != "": + if ( + ( + get_setting_with_default("home_assistant_address") == "" + or get_setting_with_default("home_assistant_token") == "" + ) + and get_setting_with_default("openhab_address") != "" + and get_setting_with_default("openhab_token") != "" + ): # OpenHAB connection configured but not Home Assistant. Skip selecting source: - return redirect('htmx_partial_select_weather_outside_temperature_sensor_from_list', entity_source="openhab") - elif get_setting_with_default("home_assistant_address") != "" and get_setting_with_default("home_assistant_token") != "" and (get_setting_with_default("openhab_address") == "" or get_setting_with_default("openhab_token") == ""): + return redirect( + "htmx_partial_select_weather_outside_temperature_sensor_from_list", + entity_source="openhab", + ) + elif ( + get_setting_with_default("home_assistant_address") != "" + and get_setting_with_default("home_assistant_token") != "" + and ( + get_setting_with_default("openhab_address") == "" + or get_setting_with_default("openhab_token") == "" + ) + ): # OpenHAB connection not configured but Home Assistant is. Skip selecting source: - return redirect('htmx_partial_select_weather_outside_temperature_sensor_from_list', entity_source="home_assistant") - elif get_setting_with_default("home_assistant_address") != "" and get_setting_with_default("home_assistant_token") != "" and get_setting_with_default("openhab_address") != "" and get_setting_with_default("openhab_token") != "": - return render(request, 'partial/select_weather_outside_temperature_sensor_provider.html') + return redirect( + "htmx_partial_select_weather_outside_temperature_sensor_from_list", + entity_source="home_assistant", + ) + elif ( + get_setting_with_default("home_assistant_address") != "" + and get_setting_with_default("home_assistant_token") != "" + and get_setting_with_default("openhab_address") != "" + and get_setting_with_default("openhab_token") != "" + ): + return render( + request, "partial/select_weather_outside_temperature_sensor_provider.html" + ) else: - return JsonResponse({ - "status": "error", - "text": "Unknown sources configured. Check configuration for Home Assistant and/or OpenHAB in settings." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "Unknown sources configured. Check configuration for Home Assistant and/or OpenHAB in settings.", + }, + status=500, + ) + def select_weather_outside_temperature_sensor_from_list(request, entity_source): data = { @@ -212,49 +299,55 @@ def select_weather_outside_temperature_sensor_from_list(request, entity_source): } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["sensor"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["sensor"]} + ) if len(ha_items["errors"]) == 0: data["entities"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["entities"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, "partial/select_weather_outside_temperature_sensor.html", data) + return render( + request, "partial/select_weather_outside_temperature_sensor.html", data + ) + @csrf_exempt def interface_theme(request): - new_theme = request.POST.get('theme-dropdown') + new_theme = request.POST.get("theme-dropdown") set_setting_value("theme", new_theme) return JsonResponse({"status": "OK"}, status=200) def relay_group_create_new_modal(request): - return render(request, 'modals/relay_groups/create_or_edit_relay_group_modal.html') + return render(request, "modals/relay_groups/create_or_edit_relay_group_modal.html") def relay_group_edit_modal(request, relay_group_id): - data = { - 'relay_group': RelayGroup.objects.get(id=relay_group_id) - } - return render(request, 'modals/relay_groups/create_or_edit_relay_group_modal.html', data) + data = {"relay_group": RelayGroup.objects.get(id=relay_group_id)} + return render( + request, "modals/relay_groups/create_or_edit_relay_group_modal.html", data + ) + def relay_group_save(request): if request.method == "POST": if "relay_group_id" in request.POST: - rg = RelayGroup.objects.get(id=request.POST['relay_group_id']) + rg = RelayGroup.objects.get(id=request.POST["relay_group_id"]) else: rg = RelayGroup() rg.friendly_name = request.POST["name"] @@ -282,7 +375,7 @@ def relay_group_add_relay_modal(request, relay_group_id): "nspanels": NSPanel.objects.filter(accepted=True, denied=False), "relay_group_id": relay_group_id, } - return render(request, 'modals/relay_groups/add_relay_modal.html', data) + return render(request, "modals/relay_groups/add_relay_modal.html", data) def relay_group_add_relay(request, relay_group_id): @@ -291,7 +384,12 @@ def relay_group_add_relay(request, relay_group_id): nspanel = NSPanel.objects.get(id=request.POST["nspanel_id"]) relay_num = request.POST["relay_selection"] - exists = RelayGroupBinding.objects.filter(nspanel=nspanel, relay_num=relay_num, relay_group=rg).count() > 0 + exists = ( + RelayGroupBinding.objects.filter( + nspanel=nspanel, relay_num=relay_num, relay_group=rg + ).count() + > 0 + ) if not exists: binding = RelayGroupBinding() binding.relay_group = rg @@ -330,21 +428,31 @@ def handle_entity_modal_result(request): elif request.session["action"] == "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE": return create_or_update_scene_entity(request) else: - return JsonResponse({ - "status": "error", - "text": "Unknown action! Action: " + request.session["action"] - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "Unknown action! Action: " + request.session["action"], + }, + status=500, + ) def handle_entity_modal_entity_selected(request, entity): - entity_data = json.loads(base64.b64decode(entity).decode('utf-8')) + entity_data = json.loads(base64.b64decode(entity).decode("utf-8")) if request.session["action"] == "ADD_LIGHT_TO_ROOM": return partial_entity_add_light_entity(request, json.dumps(entity_data)) elif request.session["action"] == "ADD_SWITCH_TO_ROOM": return partial_entity_add_switch_entity(request, json.dumps(entity_data)) else: - return JsonResponse({"status": "error", "text": "Unknown entity type! Type: " + entity_data["entity_type"]}, status=500) + return JsonResponse( + { + "status": "error", + "text": "Unknown entity type! Type: " + entity_data["entity_type"], + }, + status=500, + ) + def partial_select_new_entity_item_list(request, action, action_args): # This is used in the last step of adding an entity to call the correct @@ -352,13 +460,14 @@ def partial_select_new_entity_item_list(request, action, action_args): request.session["action"] = action request.session["action_args"] = action_args # TODO: Move "get_all_available_entities" from api.py to seperate files - data = { - "entities": get_all_available_entities(request) - } - return render(request, 'partial/select_entity/entity_list.html', data) + data = {"entities": get_all_available_entities(request)} + return render(request, "partial/select_entity/entity_list.html", data) + def partial_entity_add_light_entity(request): # TODO: Move "get_all_available_entities" from api.py to seperate files + import web.homey_api + data = { "entity_source": request.session["entity_source"], "control_mode": "", @@ -368,60 +477,85 @@ def partial_entity_add_light_entity(request): "openhab_item_color_temperature": "", "openhab_item_color": "", "home_assistant_item": "", - "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. + "homey_item": "", + "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["light", "switch"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["light", "switch"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["openhab_items"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, 'partial/select_entity/entity_add_or_edit_light_to_room.html', data) + return render( + request, "partial/select_entity/entity_add_or_edit_light_to_room.html", data + ) def partial_entity_edit_light_entity(request, light_id): + import web.homey_api + light = Entity.objects.get(id=light_id) request.session["action"] = "ADD_LIGHT_TO_ROOM" - request.session["action_args"] = json.dumps({ - "entity_id": light_id, - "room_id": light.room.id, - "page_id": light.entities_page.id, - "page_slot": light.room_view_position, - }) + request.session["action_args"] = json.dumps( + { + "entity_id": light_id, + "room_id": light.room.id, + "page_id": light.entities_page.id, + "page_slot": light.room_view_position, + } + ) entity_data = light.entity_data data = { "light": light, "entity_name": light.friendly_name, "entity_source": entity_data["controller"], - "controlled_by_nspanel_main_page": entity_data.get("controlled_by_nspanel_main_page", True), + "controlled_by_nspanel_main_page": entity_data.get( + "controlled_by_nspanel_main_page", True + ), "can_color_temperature": entity_data.get("can_color_temperature", False), "can_rgb": entity_data.get("can_rgb", False), "home_assistant_item": entity_data.get("home_assistant_name", ""), - "openhab_brightness_item": "", # Set below - "openhab_color_temperature_item": entity_data.get("openhab_item_color_temp", ""), + "homey_item": entity_data.get("homey_device_id", ""), + "openhab_brightness_item": "", # Set below + "openhab_color_temperature_item": entity_data.get( + "openhab_item_color_temp", "" + ), "openhab_rgb_item": entity_data.get("openhab_item_rgb", ""), "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if entity_data.get("can_dim", False): @@ -432,183 +566,338 @@ def partial_entity_edit_light_entity(request, light_id): data["openhab_brightness_item"] = entity_data.get("openhab_item_switch", "") if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["light", "switch"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["light", "switch"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) > 0: - return JsonResponse({ - "status": "error", - "text": "Failed to fetch OpenHAB items. Check logs for more information." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "Failed to fetch OpenHAB items. Check logs for more information.", + }, + status=500, + ) else: data["openhab_items"] = openhab_items["items"] + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + # Create homey_device_names mapping for JavaScript + homey_device_names = {} + for device in homey_devices["items"]: + homey_device_names[device["item_id"]] = device.get("label", "") + data["homey_device_names"] = json.dumps(homey_device_names) + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) - return render(request, 'partial/select_entity/entity_add_or_edit_light_to_room.html', data) + return render( + request, "partial/select_entity/entity_add_or_edit_light_to_room.html", data + ) def partial_entity_add_switch_entity(request): + import web.homey_api + data = { "entity_source": request.session["entity_source"], "openhab_item": "", "home_assistant_item": "", - "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. + "homey_item": "", + "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["switch", "input_boolean"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["switch", "input_boolean"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["openhab_items"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, 'partial/select_entity/entity_add_or_edit_switch_to_room.html', data) + return render( + request, "partial/select_entity/entity_add_or_edit_switch_to_room.html", data + ) def partial_entity_add_button_entity(request): + import web.homey_api + data = { "entity_source": request.session["entity_source"], "openhab_item": "", "home_assistant_item": "", - "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. + "homey_item": "", + "controlled_by_nspanel_main_page": True, # By default when adding a light. Make it controlled by the NSPanel main page. "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["button", "input_button"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["button", "input_button"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, 'partial/select_entity/entity_add_or_edit_button_to_room.html', data) + return render( + request, "partial/select_entity/entity_add_or_edit_button_to_room.html", data + ) def partial_entity_add_thermostat_entity(request): + import web.homey_api + data = get_base_data(request) data |= { "entity_source": request.session["entity_source"], "openhab_item": "", "home_assistant_item": "", + "homey_item": "", "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["climate"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["climate"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["openhab_items"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + # Filter for devices with thermostat capabilities + thermostat_devices = [] + for device in homey_devices["items"]: + capabilities = device.get("capabilities", []) + if ( + "target_temperature" in capabilities + or "measure_temperature" in capabilities + ): + thermostat_devices.append(device) + data["homey_items"] = thermostat_devices + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, 'partial/select_entity/entity_add_or_edit_thermostat_to_room.html', data) + return render( + request, + "partial/select_entity/entity_add_or_edit_thermostat_to_room.html", + data, + ) def partial_entity_edit_switch_entity(request, switch_id): + import web.homey_api + switch = Entity.objects.get(id=switch_id) request.session["action"] = "ADD_SWITCH_TO_ROOM" - request.session["action_args"] = json.dumps({ - "entity_id": switch_id, - "room_id": switch.room.id, - "page_id": switch.entities_page.id, - "page_slot": switch.room_view_position, - }) + request.session["action_args"] = json.dumps( + { + "entity_id": switch_id, + "room_id": switch.room.id, + "page_id": switch.entities_page.id, + "page_slot": switch.room_view_position, + } + ) + entity_data = switch.entity_data data = { "light": switch, "edit_light_id": switch_id, + "entity_source": entity_data["controller"], "entity": { "name": switch.friendly_name, }, + "backend_name": entity_data.get("home_assistant_name", "") + or entity_data.get("openhab_item_switch", ""), + "homey_item": entity_data.get("homey_device_id", ""), + "home_assistant_items": [], + "openhab_items": [], + "homey_items": [], } - return render(request, "partial/select_entity/entity_add_or_edit_switch_to_room.html", data) + + if data["entity_source"] == "home_assistant": + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["switch", "input_boolean"]} + ) + if len(ha_items["errors"]) == 0: + data["home_assistant_items"] = ha_items["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) + elif data["entity_source"] == "openhab": + openhab_items = web.openhab_api.get_all_openhab_items() + if len(openhab_items["errors"]) == 0: + data["openhab_items"] = openhab_items["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) + + return render( + request, "partial/select_entity/entity_add_or_edit_switch_to_room.html", data + ) def partial_entity_edit_button_entity(request, button_id): + import web.homey_api + button = Entity.objects.get(id=button_id) request.session["action"] = "ADD_BUTTON_TO_ROOM" - request.session["action_args"] = json.dumps({ - "entity_id": button_id, - "room_id": button.room.id, - "page_id": button.entities_page.id, - "page_slot": button.room_view_position, - }) + request.session["action_args"] = json.dumps( + { + "entity_id": button_id, + "room_id": button.room.id, + "page_id": button.entities_page.id, + "page_slot": button.room_view_position, + } + ) + entity_data = button.entity_data data = { "button": button, "edit_button_id": button_id, - "entity_source": button.entity_data["controller"], + "entity_source": entity_data["controller"], "entity": { "name": button.friendly_name, }, + "backend_name": entity_data.get("home_assistant_name", ""), + "homey_item": entity_data.get("homey_device_id", ""), + "home_assistant_items": [], + "homey_items": [], } + if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["button", "input_button"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["button", "input_button"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + data["homey_items"] = homey_devices["items"] + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) - return render(request, "partial/select_entity/entity_add_or_edit_button_to_room.html", data) + return render( + request, "partial/select_entity/entity_add_or_edit_button_to_room.html", data + ) def partial_entity_edit_thermostat_entity(request, thermostat_id): + import web.homey_api + thermostat = Entity.objects.get(id=thermostat_id) request.session["action"] = "ADD_THERMOSTAT_TO_ROOM" - request.session["action_args"] = json.dumps({ - "entity_id": thermostat_id, - "room_id": thermostat.room.id, - "page_id": thermostat.entities_page.id, - "page_slot": thermostat.room_view_position, - }) + request.session["action_args"] = json.dumps( + { + "entity_id": thermostat_id, + "room_id": thermostat.room.id, + "page_id": thermostat.entities_page.id, + "page_slot": thermostat.room_view_position, + } + ) data = get_base_data(request) data |= { @@ -620,25 +909,49 @@ def partial_entity_edit_thermostat_entity(request, thermostat_id): }, } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["climate"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["climate"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_items() if len(openhab_items["errors"]) == 0: data["openhab_items"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_devices = web.homey_api.get_all_homey_devices() + if len(homey_devices["errors"]) == 0: + # Filter for devices with thermostat capabilities + thermostat_devices = [] + for device in homey_devices["items"]: + capabilities = device.get("capabilities", []) + if ( + "target_temperature" in capabilities + or "measure_temperature" in capabilities + ): + thermostat_devices.append(device) + data["homey_items"] = thermostat_devices + else: + return JsonResponse( + {"status": "error", "text": "Failed to get items from Homey!"}, + status=500, + ) - return render(request, "partial/select_entity/entity_add_or_edit_thermostat_to_room.html", data) + return render( + request, + "partial/select_entity/entity_add_or_edit_thermostat_to_room.html", + data, + ) def partial_entity_edit_scene_entity(request, scene_id): @@ -668,36 +981,53 @@ def partial_entity_edit_scene_entity(request, scene_id): def partial_entity_add_scene_entity(request): # TODO: Move "get_all_available_entities" from api.py to seperate files + import web.homey_api + data = { "entity_source": request.session["entity_source"], "openhab_item": "", "home_assistant_item": "", + "homey_item": "", "openhab_items": [], "home_assistant_items": [], + "homey_items": [], } if data["entity_source"] == "home_assistant": - ha_items = web.home_assistant_api.get_all_home_assistant_items({"type": ["scene"]}) + ha_items = web.home_assistant_api.get_all_home_assistant_items( + {"type": ["scene"]} + ) if len(ha_items["errors"]) == 0: data["home_assistant_items"] = ha_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from Home Assistant!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from Home Assistant!"}, + status=500, + ) elif data["entity_source"] == "openhab": openhab_items = web.openhab_api.get_all_openhab_scenes() if len(openhab_items["errors"]) == 0: data["openhab_items"] = openhab_items["items"] else: - return JsonResponse({ - "status": "error", - "text": "Failed to get items from OpenHAB!" - }, status=500) + return JsonResponse( + {"status": "error", "text": "Failed to get items from OpenHAB!"}, + status=500, + ) + elif data["entity_source"] == "homey": + homey_flows = web.homey_api.get_all_homey_flows() + homey_moods = web.homey_api.get_all_homey_moods() + homey_items = [] + + if len(homey_flows["errors"]) == 0: + homey_items.extend(homey_flows["items"]) + if len(homey_moods["errors"]) == 0: + homey_items.extend(homey_moods["items"]) + + data["homey_items"] = homey_items else: logging.error("Unknown entity source! Source: " + data["entity_source"]) - return render(request, 'partial/select_entity/entity_add_or_edit_scene.html', data) + return render(request, "partial/select_entity/entity_add_or_edit_scene.html", data) @csrf_exempt @@ -707,12 +1037,12 @@ def partial_remove_entity_from_page_slot(request, page_id, slot_id): # Check for light in given slot entities = page.entity_set.filter(room_view_position=slot_id).all() if entities.count() > 0: - entities.delete(); + entities.delete() send_mqttmanager_reload_command() entities = page.scene_set.filter(room_view_position=slot_id).all() if entities.count() > 0: - entities.delete(); + entities.delete() send_mqttmanager_reload_command() room_id = 0 @@ -720,24 +1050,32 @@ def partial_remove_entity_from_page_slot(request, page_id, slot_id): room_id = page.room.id entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=room_id, is_scenes_pages=page.is_scenes_page, is_global_scenes_page=(page.room is None)) + return entities_pages.get( + request=request, + view="edit_room", + room_id=room_id, + is_scenes_pages=page.is_scenes_page, + is_global_scenes_page=(page.room is None), + ) @csrf_exempt -def partial_add_entities_page_to_room(request, room_id, is_scenes_page, is_global_scenes_page): +def partial_add_entities_page_to_room( + request, room_id, is_scenes_page, is_global_scenes_page +): data = { "room_id": room_id, "is_scenes_page": is_scenes_page, "is_global_scenes_page": is_global_scenes_page, } - return render(request, 'partial/add_entities_page_to_room.html', data) + return render(request, "partial/add_entities_page_to_room.html", data) def partial_edit_entities_page(request, page_id): data = { "page_id": page_id, } - return render(request, 'partial/edit_entities_page.html', data) + return render(request, "partial/edit_entities_page.html", data) def partial_save_edit_entities_page(request, page_id, page_type): @@ -750,7 +1088,13 @@ def partial_save_edit_entities_page(request, page_id, page_type): room_id = page.room.id send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=room_id, is_scenes_pages=page.is_scenes_page, is_global_scenes_page=(page.room is None)) + return entities_pages.get( + request=request, + view="edit_room", + room_id=room_id, + is_scenes_pages=page.is_scenes_page, + is_global_scenes_page=(page.room is None), + ) def get_entity_in_page_slot(page_id, slot_id): @@ -768,7 +1112,9 @@ def get_entity_in_page_slot(page_id, slot_id): @csrf_exempt def partial_move_entity(request): - existing_entity_in_slot = get_entity_in_page_slot(request.POST["page_id"], request.POST["slot_id"]) + existing_entity_in_slot = get_entity_in_page_slot( + request.POST["page_id"], request.POST["slot_id"] + ) new_entity_in_slot = None if request.POST["new_entity_type"] == "Scene": new_entity_in_slot = Scene.objects.get(id=request.POST["new_entity_id"]) @@ -776,18 +1122,21 @@ def partial_move_entity(request): try: new_entity_in_slot = Entity.objects.get(id=request.POST["new_entity_id"]) except Exception as e: - return JsonResponse({ - "status": "error", - "text": "Did not find existing entity to move!" - }) + return JsonResponse( + {"status": "error", "text": "Did not find existing entity to move!"} + ) if existing_entity_in_slot: # Swap the existing entity place with the new entity to be put on that slot existing_entity_in_slot.entities_page = new_entity_in_slot.entities_page - existing_entity_in_slot.room_view_position = new_entity_in_slot.room_view_position + existing_entity_in_slot.room_view_position = ( + new_entity_in_slot.room_view_position + ) existing_entity_in_slot.save() - new_entity_in_slot.entities_page = RoomEntitiesPage.objects.get(id=request.POST["page_id"]) + new_entity_in_slot.entities_page = RoomEntitiesPage.objects.get( + id=request.POST["page_id"] + ) new_entity_in_slot.room_view_position = request.POST["slot_id"] new_entity_in_slot.save() send_mqttmanager_reload_command() @@ -800,12 +1149,21 @@ def partial_move_entity(request): is_global_scenes_page = False entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request, view='edit_room', room_id=room_id, is_scenes_pages=new_entity_in_slot.entities_page.is_scenes_page, is_global_scenes_page=is_global_scenes_page) + return entities_pages.get( + request, + view="edit_room", + room_id=room_id, + is_scenes_pages=new_entity_in_slot.entities_page.is_scenes_page, + is_global_scenes_page=is_global_scenes_page, + ) + @csrf_exempt def partial_move_entities_pages(request): if "htmx_form_save_entities_pages_order_field" in request.POST: - json_data = json.loads(request.POST["htmx_form_save_entities_pages_order_field"]) + json_data = json.loads( + request.POST["htmx_form_save_entities_pages_order_field"] + ) if "pages" in json_data: if len(json_data["pages"]) > 0: entity_page = RoomEntitiesPage.objects.get(id=json_data["pages"][0]) @@ -821,70 +1179,107 @@ def partial_move_entities_pages(request): page.save() send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request, view='edit_room', room_id=room_id, is_scenes_pages=entity_page.is_scenes_page, is_global_scenes_page=is_global_scenes_pages) + return entities_pages.get( + request, + view="edit_room", + room_id=room_id, + is_scenes_pages=entity_page.is_scenes_page, + is_global_scenes_page=is_global_scenes_pages, + ) else: - return JsonResponse({ - "status": "error", - "text": "'pages' field empty in request POST-data." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "'pages' field empty in request POST-data.", + }, + status=500, + ) else: - return JsonResponse({ - "status": "error", - "text": "'pages' field not available in JSON-data." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "'pages' field not available in JSON-data.", + }, + status=500, + ) else: - return JsonResponse({ - "status": "error", - "text": "'htmx_form_save_entities_pages_order_field' field not available in request POST-data." - }, status=500) - + return JsonResponse( + { + "status": "error", + "text": "'htmx_form_save_entities_pages_order_field' field not available in request POST-data.", + }, + status=500, + ) @csrf_exempt -def partial_add_entity_to_entities_page_select_entity_type(request, action, action_args): - data = { - "action": action, - "action_args": action_args - } - return render(request, 'partial/add_entity_to_entities_page_select_entity_type.html', data) +def partial_add_entity_to_entities_page_select_entity_type( + request, action, action_args +): + data = {"action": action, "action_args": action_args} + return render( + request, "partial/add_entity_to_entities_page_select_entity_type.html", data + ) @csrf_exempt -def partial_add_entity_to_entities_page_select_entity_source(request, action, action_args): +def partial_add_entity_to_entities_page_select_entity_source( + request, action, action_args +): request.session["action"] = action request.session["action_args"] = action_args is_home_assistant_configured = False is_openhab_configured = False - if get_setting_with_default("home_assistant_address") != "" and get_setting_with_default("home_assistant_token") != "": + is_homey_configured = False + if ( + get_setting_with_default("home_assistant_address") != "" + and get_setting_with_default("home_assistant_token") != "" + ): is_home_assistant_configured = True - if get_setting_with_default("openhab_address") != "" and get_setting_with_default("openhab_token") != "": + if ( + get_setting_with_default("openhab_address") != "" + and get_setting_with_default("openhab_token") != "" + ): is_openhab_configured = True + if ( + get_setting_with_default("homey_address") != "" + and get_setting_with_default("homey_token") != "" + ): + is_homey_configured = True data = { "action": action, "action_args": action_args, "is_home_assistant_configured": is_home_assistant_configured, "is_openhab_configured": is_openhab_configured, + "is_homey_configured": is_homey_configured, "home_assistant_supported_entity_types": [ "ADD_LIGHT_TO_ROOM", "ADD_SWITCH_TO_ROOM", "ADD_BUTTON_TO_ROOM", "ADD_THERMOSTAT_TO_ROOM", - "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE" + "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE", ], "openhab_supported_entity_types": [ "ADD_LIGHT_TO_ROOM", "ADD_SWITCH_TO_ROOM", "ADD_THERMOSTAT_TO_ROOM", - "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE" + "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE", + ], + "homey_supported_entity_types": [ + "ADD_LIGHT_TO_ROOM", + "ADD_SWITCH_TO_ROOM", + "ADD_BUTTON_TO_ROOM", + "ADD_THERMOSTAT_TO_ROOM", + "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE", ], - "manual_supported_entity_types": [ - "ADD_BUTTON_TO_ROOM" - ] + "manual_supported_entity_types": ["ADD_BUTTON_TO_ROOM"], } - return render(request, 'partial/add_entity_to_entities_page_select_entity_source.html', data) + return render( + request, "partial/add_entity_to_entities_page_select_entity_source.html", data + ) @csrf_exempt @@ -901,10 +1296,13 @@ def partial_add_entity_to_entities_page_config_modal(request, entity_source): elif request.session["action"] == "ADD_SCENE_TO_NSPANEL_ENTITY_PAGE": return partial_entity_add_scene_entity(request) else: - return JsonResponse({ - "status": "error", - "text": "Unknown action! Action: " + request.session["action"] - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "Unknown action! Action: " + request.session["action"], + }, + status=500, + ) @csrf_exempt @@ -919,27 +1317,44 @@ def partial_delete_entities_page(request, page_id): page.delete() # Recalculate entity page order - for index, entity_page in enumerate(RoomEntitiesPage.objects.filter(room=page.room, is_scenes_page=page.is_scenes_page).order_by('display_order'), start=0): + for index, entity_page in enumerate( + RoomEntitiesPage.objects.filter( + room=page.room, is_scenes_page=page.is_scenes_page + ).order_by("display_order"), + start=0, + ): entity_page.display_order = index entity_page.save() send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=room_id, is_scenes_pages=page.is_scenes_page, is_global_scenes_page=is_global_scenes_page) - - -def create_entities_page_in_room(request, room_id, page_type, is_scenes_page, is_global_scenes_page): + return entities_pages.get( + request=request, + view="edit_room", + room_id=room_id, + is_scenes_pages=page.is_scenes_page, + is_global_scenes_page=is_global_scenes_page, + ) + + +def create_entities_page_in_room( + request, room_id, page_type, is_scenes_page, is_global_scenes_page +): entity_page = RoomEntitiesPage() entity_page.is_scenes_page = is_scenes_page == "True" entity_page.is_global_scenes_page = is_global_scenes_page == "True" if entity_page.is_global_scenes_page: entity_page.room = None - entity_page.display_order = RoomEntitiesPage.objects.filter(room=None, is_scenes_page=is_scenes_page).count() + entity_page.display_order = RoomEntitiesPage.objects.filter( + room=None, is_scenes_page=is_scenes_page + ).count() else: room = Room.objects.get(id=room_id) entity_page.room = room - entity_page.display_order = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=is_scenes_page).count() + entity_page.display_order = RoomEntitiesPage.objects.filter( + room=room, is_scenes_page=is_scenes_page + ).count() if page_type == 4: entity_page.page_type = 4 @@ -954,10 +1369,16 @@ def create_entities_page_in_room(request, room_id, page_type, is_scenes_page, is entity_page.save() send_mqttmanager_reload_command() else: - print(F"ERROR! Unknown page type {page_type}") + print(f"ERROR! Unknown page type {page_type}") # Return new partial HTMX update of all entities pages in this room entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=room_id, is_scenes_pages=entity_page.is_scenes_page, is_global_scenes_page=(entity_page.room == None)) + return entities_pages.get( + request=request, + view="edit_room", + room_id=room_id, + is_scenes_pages=entity_page.is_scenes_page, + is_global_scenes_page=(entity_page.room == None), + ) @csrf_exempt @@ -974,20 +1395,29 @@ def partial_reorder_rooms(request): rooms_list = RoomsList() return rooms_list.get(request) else: - return JsonResponse({ - "status": "error", - "text": "'pages' field empty in request POST-data." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "'pages' field empty in request POST-data.", + }, + status=500, + ) else: - return JsonResponse({ - "status": "error", - "text": "'rooms' field not available in JSON-data." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "'rooms' field not available in JSON-data.", + }, + status=500, + ) else: - return JsonResponse({ - "status": "error", - "text": "'htmx_form_save_rooms_order_field' field not available in request POST-data." - }, status=500) + return JsonResponse( + { + "status": "error", + "text": "'htmx_form_save_rooms_order_field' field not available in request POST-data.", + }, + status=500, + ) def partial_select_new_outside_temperature_sensor(request): @@ -995,28 +1425,35 @@ def partial_select_new_outside_temperature_sensor(request): data = { "entities": get_all_available_entities(request), } - return render(request, 'partial/select_entity/entity_list_select_outside_temperature_sensor.html', data) + return render( + request, + "partial/select_entity/entity_list_select_outside_temperature_sensor.html", + data, + ) # When creating a new or updating an existing light entity this will take care of the actual creation/updating of the model # in the database. def create_or_update_light_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity + action_args = json.loads( + request.session["action_args"] + ) # Loads arguments set when first starting process of adding/updating entity entity_data = { - 'controller': request.session["entity_source"], - 'home_assistant_name': '', - 'openhab_name': '', - 'openhab_control_mode': '', - 'openhab_item_switch': '', - 'openhab_item_dimmer': '', - 'openhab_item_color_temp': '', - 'openhab_item_rgb': '', - 'can_dim': False, - 'can_color_temperature': False, - 'can_rgb': False, - 'is_ceiling_light': False, - 'controlled_by_nspanel_main_page': True, + "controller": request.session["entity_source"], + "home_assistant_name": "", + "openhab_name": "", + "openhab_control_mode": "", + "openhab_item_switch": "", + "openhab_item_dimmer": "", + "openhab_item_color_temp": "", + "openhab_item_rgb": "", + "homey_device_id": "", + "can_dim": False, + "can_color_temperature": False, + "can_rgb": False, + "is_ceiling_light": False, + "controlled_by_nspanel_main_page": True, } if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: new_light = Entity.objects.get(id=int(action_args["entity_id"])) @@ -1026,58 +1463,84 @@ def create_or_update_light_entity(request): new_light.entity_type = Entity.EntityType.LIGHT if entity_data["controller"] == "home_assistant": - entity_data['home_assistant_name'] = request.POST["home_assistant_item"] + entity_data["home_assistant_name"] = request.POST["home_assistant_item"] + elif entity_data["controller"] == "homey": + # For Homey, just store the device ID + if "homey_item" in request.POST: + entity_data["homey_device_id"] = request.POST["homey_item"] + # Note: capabilities are determined from the form fields below + new_light.friendly_name = request.POST["add_new_light_name"] new_light.room = Room.objects.get(id=int(action_args["room_id"])) - new_light.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) + new_light.entities_page = RoomEntitiesPage.objects.get( + id=int(action_args["page_id"]) + ) new_light.room_view_position = int(action_args["page_slot"]) - entity_data['controlled_by_nspanel_main_page'] = "controlled_by_nspanel_main_page" in request.POST - entity_data['is_ceiling_light'] = request.POST["light_type"] == "ceiling" + entity_data["controlled_by_nspanel_main_page"] = ( + "controlled_by_nspanel_main_page" in request.POST + ) + entity_data["is_ceiling_light"] = request.POST["light_type"] == "ceiling" + # Process control mode for all controllers if request.POST["light_control_mode"] == "dimmer": - entity_data['can_dim'] = True - entity_data['openhab_control_mode'] = "dimmer" + entity_data["can_dim"] = True if entity_data["controller"] == "openhab": - entity_data['openhab_item_dimmer'] = request.POST["openhab_dimming_item"] + entity_data["openhab_control_mode"] = "dimmer" + entity_data["openhab_item_dimmer"] = request.POST["openhab_dimming_item"] else: - entity_data['openhab_control_mode'] = "switch" - entity_data['can_dim'] = False + entity_data["can_dim"] = False if entity_data["controller"] == "openhab": - entity_data['openhab_item_switch'] = request.POST["openhab_dimming_item"] + entity_data["openhab_control_mode"] = "switch" + entity_data["openhab_item_switch"] = request.POST["openhab_dimming_item"] + # Process color temperature capability for all controllers if "color_temperature" in request.POST: - entity_data['can_color_temperature'] = True + entity_data["can_color_temperature"] = True if entity_data["controller"] == "openhab": - entity_data['openhab_item_color_temp'] = request.POST["openhab_color_temperature_item"] + entity_data["openhab_item_color_temp"] = request.POST[ + "openhab_color_temperature_item" + ] else: - entity_data['can_color_temperature'] = False - entity_data['openhab_item_color_temp'] = "" + entity_data["can_color_temperature"] = False + if entity_data["controller"] == "openhab": + entity_data["openhab_item_color_temp"] = "" + # Process RGB capability for all controllers if "rgb" in request.POST: - entity_data['can_rgb'] = True + entity_data["can_rgb"] = True if entity_data["controller"] == "openhab": - entity_data['openhab_item_rgb'] = request.POST["openhab_rgb_item"] + entity_data["openhab_item_rgb"] = request.POST["openhab_rgb_item"] else: - entity_data['can_rgb'] = False - entity_data['openhab_item_rgb'] = "" + entity_data["can_rgb"] = False + if entity_data["controller"] == "openhab": + entity_data["openhab_item_rgb"] = "" new_light.entity_data = entity_data new_light.save() send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=new_light.room.id, is_scenes_pages=False, is_global_scenes_page=False) + return entities_pages.get( + request=request, + view="edit_room", + room_id=new_light.room.id, + is_scenes_pages=False, + is_global_scenes_page=False, + ) def create_or_update_switch_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity + action_args = json.loads( + request.session["action_args"] + ) # Loads arguments set when first starting process of adding/updating entity entity_data = { - 'controller': request.session["entity_source"], - 'home_assistant_name': '', - 'openhab_name': '', - 'openhab_item_switch': '', + "controller": request.session["entity_source"], + "home_assistant_name": "", + "openhab_name": "", + "openhab_item_switch": "", + "homey_device_id": "", } if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: new_switch = Entity.objects.get(id=int(action_args["entity_id"])) @@ -1087,12 +1550,17 @@ def create_or_update_switch_entity(request): new_switch.entity_type = Entity.EntityType.SWITCH # Only set once, during initial creation: new_switch.room = Room.objects.get(id=int(action_args["room_id"])) - new_switch.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) + new_switch.entities_page = RoomEntitiesPage.objects.get( + id=int(action_args["page_id"]) + ) new_switch.room_view_position = int(action_args["page_slot"]) - if entity_data['controller'] == "home_assistant": - entity_data['home_assistant_name'] = request.POST["backend_name"] - elif entity_data['controller'] == "openhab": - entity_data['openhab_item_switch'] = request.POST["backend_name"] + if entity_data["controller"] == "home_assistant": + entity_data["home_assistant_name"] = request.POST["backend_name"] + elif entity_data["controller"] == "openhab": + entity_data["openhab_item_switch"] = request.POST["backend_name"] + elif entity_data["controller"] == "homey": + if "homey_item" in request.POST: + entity_data["homey_device_id"] = request.POST["homey_item"] new_switch.friendly_name = request.POST["light_name"] new_switch.entity_data = entity_data @@ -1100,17 +1568,26 @@ def create_or_update_switch_entity(request): send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=new_switch.room.id, is_scenes_pages=False, is_global_scenes_page=False) + return entities_pages.get( + request=request, + view="edit_room", + room_id=new_switch.room.id, + is_scenes_pages=False, + is_global_scenes_page=False, + ) def create_or_update_button_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity + action_args = json.loads( + request.session["action_args"] + ) # Loads arguments set when first starting process of adding/updating entity entity_data = { - 'controller': request.session["entity_source"], - 'home_assistant_name': '', - 'mqtt_topic': '', # Used in case of controller = manual - 'mqtt_payload': '', # Used in case of controller = manual + "controller": request.session["entity_source"], + "home_assistant_name": "", + "homey_device_id": "", + "mqtt_topic": "", # Used in case of controller = manual + "mqtt_payload": "", # Used in case of controller = manual } if "entity_id" in action_args and int(action_args["entity_id"]): new_button = Entity.objects.get(id=int(action_args["entity_id"])) @@ -1120,28 +1597,43 @@ def create_or_update_button_entity(request): new_button.entity_type = Entity.EntityType.BUTTON # Only set once, during initial creation: new_button.room = Room.objects.get(id=int(action_args["room_id"])) - new_button.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) + new_button.entities_page = RoomEntitiesPage.objects.get( + id=int(action_args["page_id"]) + ) new_button.room_view_position = int(action_args["page_slot"]) - if entity_data['controller'] == "home_assistant": - entity_data['home_assistant_name'] = request.POST["backend_name"] - elif entity_data['controller'] == "nspm": - entity_data['mqtt_topic'] = request.POST["mqtt_topic"] - entity_data['mqtt_payload'] = request.POST["mqtt_payload"] + if entity_data["controller"] == "home_assistant": + entity_data["home_assistant_name"] = request.POST["backend_name"] + elif entity_data["controller"] == "homey": + if "homey_item" in request.POST: + entity_data["homey_device_id"] = request.POST["homey_item"] + elif entity_data["controller"] == "nspm": + entity_data["mqtt_topic"] = request.POST["mqtt_topic"] + entity_data["mqtt_payload"] = request.POST["mqtt_payload"] new_button.friendly_name = request.POST["light_name"] new_button.entity_data = entity_data new_button.save() send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=new_button.room.id, is_scenes_pages=False, is_global_scenes_page=False) + return entities_pages.get( + request=request, + view="edit_room", + room_id=new_button.room.id, + is_scenes_pages=False, + is_global_scenes_page=False, + ) + def create_or_update_thermostat_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity + action_args = json.loads( + request.session["action_args"] + ) # Loads arguments set when first starting process of adding/updating entity entity_data = { - 'controller': request.session["entity_source"], - 'home_assistant_name': '', + "controller": request.session["entity_source"], + "home_assistant_name": "", + "homey_device_id": "", } if "entity_id" in action_args and int(action_args["entity_id"]): new_thermostat = Entity.objects.get(id=int(action_args["entity_id"])) @@ -1151,74 +1643,109 @@ def create_or_update_thermostat_entity(request): new_thermostat.entity_type = Entity.EntityType.THERMOSTAT # Only set once, during initial creation: new_thermostat.room = Room.objects.get(id=int(action_args["room_id"])) - new_thermostat.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) + new_thermostat.entities_page = RoomEntitiesPage.objects.get( + id=int(action_args["page_id"]) + ) new_thermostat.room_view_position = int(action_args["page_slot"]) - if entity_data['controller'] == "home_assistant": - entity_data['home_assistant_name'] = request.POST["backend_name"] - elif entity_data['controller'] == "openhab": - entity_data['openhab_temperature_item'] = request.POST["temperature_item"] - entity_data['openhab_step_size'] = request.POST["step_size"] - entity_data['openhab_fan_mode_item'] = request.POST["fan_mode_item"] - entity_data['openhab_hvac_mode_item'] = request.POST["hvac_mode_item"] - entity_data['openhab_preset_item'] = request.POST["preset_item"] - entity_data['openhab_swing_item'] = request.POST["swing_item"] - entity_data['openhab_swingh_item'] = request.POST["swingh_item"] + if entity_data["controller"] == "home_assistant": + entity_data["home_assistant_name"] = request.POST["backend_name"] + elif entity_data["controller"] == "openhab": + entity_data["openhab_temperature_item"] = request.POST["temperature_item"] + entity_data["openhab_step_size"] = request.POST["step_size"] + entity_data["openhab_fan_mode_item"] = request.POST["fan_mode_item"] + entity_data["openhab_hvac_mode_item"] = request.POST["hvac_mode_item"] + entity_data["openhab_preset_item"] = request.POST["preset_item"] + entity_data["openhab_swing_item"] = request.POST["swing_item"] + entity_data["openhab_swingh_item"] = request.POST["swingh_item"] + elif entity_data["controller"] == "homey": + if "homey_item" in request.POST: + entity_data["homey_device_id"] = request.POST["homey_item"] # Loop over all options starting with fan_mode_option_ fan_modes = [] for option in request.POST: - if option.startswith("fan_mode_option_") and not option.endswith("_icon") and not option.endswith("_label"): - fan_modes.append({ - "value": request.POST[option], - "icon": request.POST[option + "_icon"], - "label": request.POST[option + "_label"] - }) - entity_data['fan_modes'] = fan_modes + if ( + option.startswith("fan_mode_option_") + and not option.endswith("_icon") + and not option.endswith("_label") + ): + fan_modes.append( + { + "value": request.POST[option], + "icon": request.POST[option + "_icon"], + "label": request.POST[option + "_label"], + } + ) + entity_data["fan_modes"] = fan_modes # Loop over all options starting with hvac_mode_option_ hvac_modes = [] for option in request.POST: - if option.startswith("hvac_mode_option_") and not option.endswith("_icon") and not option.endswith("_label"): - hvac_modes.append({ - "value": request.POST[option], - "icon": request.POST[option + "_icon"], - "label": request.POST[option + "_label"] - }) - entity_data['hvac_modes'] = hvac_modes + if ( + option.startswith("hvac_mode_option_") + and not option.endswith("_icon") + and not option.endswith("_label") + ): + hvac_modes.append( + { + "value": request.POST[option], + "icon": request.POST[option + "_icon"], + "label": request.POST[option + "_label"], + } + ) + entity_data["hvac_modes"] = hvac_modes # Loop over all options starting with preset_option_ preset_modes = [] for option in request.POST: - if option.startswith("preset_option_") and not option.endswith("_icon") and not option.endswith("_label"): - preset_modes.append({ - "value": request.POST[option], - "icon": request.POST[option + "_icon"], - "label": request.POST[option + "_label"] - }) - entity_data['preset_modes'] = preset_modes + if ( + option.startswith("preset_option_") + and not option.endswith("_icon") + and not option.endswith("_label") + ): + preset_modes.append( + { + "value": request.POST[option], + "icon": request.POST[option + "_icon"], + "label": request.POST[option + "_label"], + } + ) + entity_data["preset_modes"] = preset_modes # Loop over all options starting with swing_option swing_modes = [] for option in request.POST: - if option.startswith("swing_option_") and not option.endswith("_icon") and not option.endswith("_label"): - swing_modes.append({ - "value": request.POST[option], - "icon": request.POST[option + "_icon"], - "label": request.POST[option + "_label"] - }) - entity_data['swing_modes'] = swing_modes + if ( + option.startswith("swing_option_") + and not option.endswith("_icon") + and not option.endswith("_label") + ): + swing_modes.append( + { + "value": request.POST[option], + "icon": request.POST[option + "_icon"], + "label": request.POST[option + "_label"], + } + ) + entity_data["swing_modes"] = swing_modes # Loop over all options starting with swingh_option swingh_modes = [] for option in request.POST: - if option.startswith("swingh_option_") and not option.endswith("_icon") and not option.endswith("_label"): - swingh_modes.append({ - "value": request.POST[option], - "icon": request.POST[option + "_icon"], - "label": request.POST[option + "_label"] - }) - entity_data['swingh_modes'] = swingh_modes + if ( + option.startswith("swingh_option_") + and not option.endswith("_icon") + and not option.endswith("_label") + ): + swingh_modes.append( + { + "value": request.POST[option], + "icon": request.POST[option + "_icon"], + "label": request.POST[option + "_label"], + } + ) + entity_data["swingh_modes"] = swingh_modes new_thermostat.friendly_name = request.POST["friendly_name"] new_thermostat.entity_data = entity_data @@ -1226,11 +1753,19 @@ def create_or_update_thermostat_entity(request): send_mqttmanager_reload_command() entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=new_thermostat.room.id, is_scenes_pages=False, is_global_scenes_page=False) + return entities_pages.get( + request=request, + view="edit_room", + room_id=new_thermostat.room.id, + is_scenes_pages=False, + is_global_scenes_page=False, + ) def create_or_update_scene_entity(request): - action_args = json.loads(request.session["action_args"]) # Loads arguments set when first starting process of adding/updating entity + action_args = json.loads( + request.session["action_args"] + ) # Loads arguments set when first starting process of adding/updating entity if "entity_id" in action_args and int(action_args["entity_id"]) >= 0: new_scene = Scene.objects.get(id=int(action_args["entity_id"])) @@ -1240,11 +1775,13 @@ def create_or_update_scene_entity(request): new_scene.room = Room.objects.get(id=int(action_args["room_id"])) else: new_scene.room = None - new_scene.entities_page = RoomEntitiesPage.objects.get(id=int(action_args["page_id"])) + new_scene.entities_page = RoomEntitiesPage.objects.get( + id=int(action_args["page_id"]) + ) new_scene.room_view_position = int(action_args["page_slot"]) new_scene.scene_type = request.session["entity_source"] if new_scene.scene_type == "nspanelmanager": - pass # Do nothing, this is one of our own scenes. + pass # Do nothing, this is one of our own scenes. elif new_scene.scene_type == "home_assistant": new_scene.backend_name = request.POST["backend_name"] elif new_scene.scene_type == "openhab": @@ -1259,11 +1796,17 @@ def create_or_update_scene_entity(request): room_id = new_scene.room.id entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get(request=request, view="edit_room", room_id=room_id, is_scenes_pages=True, is_global_scenes_page=(new_scene.room == None)) + return entities_pages.get( + request=request, + view="edit_room", + room_id=room_id, + is_scenes_pages=True, + is_global_scenes_page=(new_scene.room == None), + ) def initial_setup_welcome(request): - return render(request, 'modals/initial_setup/welcome.html') + return render(request, "modals/initial_setup/welcome.html") @csrf_exempt @@ -1282,13 +1825,13 @@ def initial_setup_manager_settings(request): "mqtt_username": get_setting_with_default("mqtt_username"), "mqtt_password": get_setting_with_default("mqtt_password"), } - return render(request, 'modals/initial_setup/mqtt.html', data) + return render(request, "modals/initial_setup/mqtt.html", data) elif request.method == "GET": data = { "manager_address": get_setting_with_default("manager_address"), "manager_port": get_setting_with_default("manager_port"), } - return render(request, 'modals/initial_setup/manager_settings.html', data) + return render(request, "modals/initial_setup/manager_settings.html", data) @csrf_exempt @@ -1307,11 +1850,16 @@ def initial_setup_mqtt_settings(request): # Save settings succesfully, return the next view in the setup guide. Home Assistant: environment = environ.Env() data = { - "home_assistant_address": get_setting_with_default("home_assistant_address"), + "home_assistant_address": get_setting_with_default( + "home_assistant_address" + ), "home_assistant_token": get_setting_with_default("home_assistant_token"), - "is_home_assistant_addon": ("IS_HOME_ASSISTANT_ADDON" in environment and environment("IS_HOME_ASSISTANT_ADDON") == "true") + "is_home_assistant_addon": ( + "IS_HOME_ASSISTANT_ADDON" in environment + and environment("IS_HOME_ASSISTANT_ADDON") == "true" + ), } - return render(request, 'modals/initial_setup/home_assistant.html', data) + return render(request, "modals/initial_setup/home_assistant.html", data) elif request.method == "GET": data = { "mqtt_server": get_setting_with_default("mqtt_server"), @@ -1319,16 +1867,20 @@ def initial_setup_mqtt_settings(request): "mqtt_username": get_setting_with_default("mqtt_username"), "mqtt_password": get_setting_with_default("mqtt_password"), } - return render(request, 'modals/initial_setup/mqtt.html', data) + return render(request, "modals/initial_setup/mqtt.html", data) @csrf_exempt def initial_setup_home_assistant_settings(request): if request.method == "POST": if "home_assistant_address" in request.POST: - set_setting_value("home_assistant_address", request.POST["home_assistant_address"]) + set_setting_value( + "home_assistant_address", request.POST["home_assistant_address"] + ) if "home_assistant_token" in request.POST: - set_setting_value("home_assistant_token", request.POST["home_assistant_token"]) + set_setting_value( + "home_assistant_token", request.POST["home_assistant_token"] + ) send_mqttmanager_reload_command() # Save settings succesfully, return the next view in the setup guide. OpenHAB: @@ -1336,15 +1888,20 @@ def initial_setup_home_assistant_settings(request): "openhab_address": get_setting_with_default("openhab_address"), "openhab_token": get_setting_with_default("openhab_token"), } - return render(request, 'modals/initial_setup/openhab.html', data) + return render(request, "modals/initial_setup/openhab.html", data) elif request.method == "GET": environment = environ.Env() data = { - "home_assistant_address": get_setting_with_default("home_assistant_address"), + "home_assistant_address": get_setting_with_default( + "home_assistant_address" + ), "home_assistant_token": get_setting_with_default("home_assistant_token"), - "is_home_assistant_addon": ("IS_HOME_ASSISTANT_ADDON" in environment and environment("IS_HOME_ASSISTANT_ADDON") == "true") + "is_home_assistant_addon": ( + "IS_HOME_ASSISTANT_ADDON" in environment + and environment("IS_HOME_ASSISTANT_ADDON") == "true" + ), } - return render(request, 'modals/initial_setup/home_assistant.html', data) + return render(request, "modals/initial_setup/home_assistant.html", data) @csrf_exempt @@ -1356,24 +1913,48 @@ def initial_setup_openhab_settings(request): set_setting_value("openhab_token", request.POST["openhab_token"]) send_mqttmanager_reload_command() - # Save settings succesfully, return the next view in the setup guide. Finished: - return render(request, 'modals/initial_setup/finished.html') + # Save settings succesfully, return the next view in the setup guide. Homey: + data = { + "homey_address": get_setting_with_default("homey_address"), + "homey_token": get_setting_with_default("homey_token"), + } + return render(request, "modals/initial_setup/homey.html", data) elif request.method == "GET": data = { "openhab_address": get_setting_with_default("openhab_address"), "openhab_token": get_setting_with_default("openhab_token"), } - return render(request, 'modals/initial_setup/openhab.html', data) + return render(request, "modals/initial_setup/openhab.html", data) + + +@csrf_exempt +def initial_setup_homey_settings(request): + if request.method == "POST": + if "homey_address" in request.POST: + set_setting_value("homey_address", request.POST["homey_address"]) + if "homey_token" in request.POST: + set_setting_value("homey_token", request.POST["homey_token"]) + send_mqttmanager_reload_command() + + # Save settings succesfully, return the next view in the setup guide. Finished: + return render(request, "modals/initial_setup/finished.html") + elif request.method == "GET": + data = { + "homey_address": get_setting_with_default("homey_address"), + "homey_token": get_setting_with_default("homey_token"), + } + return render(request, "modals/initial_setup/homey.html", data) def initial_setup_finished(request): - return render(request, 'modals/initial_setup/finished.html') + return render(request, "modals/initial_setup/finished.html") def show_messages(request): data = get_base_data(request) data["messages"] = Message.objects.all() - return render(request, 'partial/show_messages.html', data) + return render(request, "partial/show_messages.html", data) + def mark_message_read(request, message_id): try: @@ -1382,7 +1963,4 @@ def mark_message_read(request, message_id): message.save() return show_messages(request) except Exception as e: - return JsonResponse({ - "status": "error", - "text": str(e) - }, status=500) + return JsonResponse({"status": "error", "text": str(e)}, status=500) diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/homey.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/homey.html new file mode 100644 index 00000000..0deb542d --- /dev/null +++ b/docker/web/nspanelmanager/web/templates/modals/initial_setup/homey.html @@ -0,0 +1,144 @@ +{% load static %} + + + + + + + + diff --git a/docker/web/nspanelmanager/web/templates/partial/add_entity_to_entities_page_select_entity_source.html b/docker/web/nspanelmanager/web/templates/partial/add_entity_to_entities_page_select_entity_source.html index 21b0b4cf..55c82401 100644 --- a/docker/web/nspanelmanager/web/templates/partial/add_entity_to_entities_page_select_entity_source.html +++ b/docker/web/nspanelmanager/web/templates/partial/add_entity_to_entities_page_select_entity_source.html @@ -1,52 +1,98 @@ - diff --git a/docker/web/nspanelmanager/web/templates/partial/select_entity/entity_add_or_edit_button_to_room.html b/docker/web/nspanelmanager/web/templates/partial/select_entity/entity_add_or_edit_button_to_room.html index d6a4fdd7..d6d544d8 100644 --- a/docker/web/nspanelmanager/web/templates/partial/select_entity/entity_add_or_edit_button_to_room.html +++ b/docker/web/nspanelmanager/web/templates/partial/select_entity/entity_add_or_edit_button_to_room.html @@ -1,7 +1,7 @@