diff --git a/.github/workflows/copilot-instructions.md b/.github/workflows/copilot-instructions.md new file mode 100644 index 0000000..5bc7c87 --- /dev/null +++ b/.github/workflows/copilot-instructions.md @@ -0,0 +1,197 @@ +# Copilot Instructions for Prefab + +## Project Overview + +Prefab is a macOS/iOS application that provides an HTTP interface to Apple HomeKit data. The project consists of two main components: + +1. **Prefab App**: A SwiftUI application that exposes HomeKit data via a REST API +2. **Prefab CLI Tool**: A command-line client for interacting with the Prefab server + +The goal is to make HomeKit functionality accessible to macOS systems through a simple HTTP interface, since native HomeKit APIs are primarily available on iOS. + +## Architecture + +### Core Components + +- **prefab/**: Main SwiftUI application + - `prefabApp.swift`: App entry point with server initialization + - `ContentView.swift`: Main UI displaying HomeKit homes + - `model/`: Data models and HomeKit integration + - `http/`: HTTP server implementation using Hummingbird + +- **prefab-client/**: Command-line tool + - `Root.swift`: CLI entry point using ArgumentParser + - `Client/`: HTTP client implementation + - `Command/`: CLI command definitions + +### Key Technologies + +- **SwiftUI**: User interface framework +- **HomeKit**: Apple's home automation framework +- **Hummingbird**: Swift HTTP server framework +- **ArgumentParser**: Command-line argument parsing +- **HTTPTypes**: Modern HTTP types for Swift + +## Development Guidelines + +### Code Style and Patterns + +1. **Swift Conventions** + - Use Swift naming conventions (camelCase for variables/functions, PascalCase for types) + - Prefer `struct` over `class` when possible + - Use `@StateObject` and `@Published` for SwiftUI state management + - Follow Apple's Swift API Design Guidelines + +2. **HomeKit Integration** + - Use `HomeBase` singleton for centralized HomeKit management + - Implement `HMHomeManagerDelegate` for HomeKit updates + - Use proper authorization checks before accessing HomeKit data + - Handle HomeKit permissions gracefully in the UI + +3. **HTTP Server Patterns** + - Use middleware for cross-cutting concerns (auth, logging) + - Implement proper error handling with meaningful HTTP status codes + - Structure routes in separate files by functionality (Homes, Rooms, Accessories) + - Use JSON for API responses + +4. **Error Handling** + - Use `throws` and `Result` types for error propagation + - Provide meaningful error messages to users + - Log errors appropriately using `OSLog` + +### File Organization + +``` +prefab/ +├── prefabApp.swift # App entry point +├── ContentView.swift # Main UI +├── model/ +│ ├── HomeBase.swift # HomeKit manager singleton +│ └── HAPUUIDs.swift # HomeKit UUID definitions +└── http/ + ├── Server.swift # HTTP server setup + ├── Routes.swift # Base route definitions + ├── Routes+*.swift # Feature-specific routes + └── Data.swift # Data models +``` + +### API Design + +- **Base URL**: `http://localhost:8080` +- **Authentication**: HomeKit authorization required +- **Response Format**: JSON +- **Error Format**: `{"error": "Error message"}` + +Common HTTP status codes: +- `200`: Success +- `400`: Bad Request (invalid parameters) +- `403`: Forbidden (HomeKit not authorized) +- `404`: Not Found +- `500`: Internal Server Error + +### Testing + +- Use XCTest for unit tests +- Tests are currently minimal - expand coverage for new features +- Test both the HTTP API and CLI functionality +- Mock HomeKit data for consistent testing + +### HomeKit Specifics + +1. **Authorization** + - Check `homeManager.authorizationStatus` before API calls + - Handle `.notDetermined`, `.restricted`, `.denied`, and `.authorized` states + - Prompt users for permission when needed + +2. **Data Models** + - `HMHome`: Represents a HomeKit home + - `HMRoom`: Rooms within a home + - `HMAccessory`: HomeKit accessories (lights, locks, etc.) + - Use HAP (HomeKit Accessory Protocol) UUIDs for characteristic identification + +3. **Real-time Updates** + - Implement delegate methods for HomeKit data changes + - Use `@Published` properties to update UI automatically + - Consider WebSocket connections for real-time API updates + +### CLI Tool Guidelines + +- Use ArgumentParser for command structure +- Implement subcommands for different operations (get, set, list) +- Provide helpful usage messages and examples +- Support JSON output for scripting +- Handle network errors gracefully + +### Dependencies Management + +The project uses Swift Package Manager through Xcode: +- **Hummingbird**: HTTP server framework +- **ArgumentParser**: CLI argument parsing +- **HTTPTypes**: HTTP type definitions + +### Build and Deployment + +- Target: macOS 11.0+ and iOS 14.0+ +- Uses GitHub Actions for CI/CD +- Supports code signing and provisioning profiles +- Includes both debug and release configurations + +### Security Considerations + +1. **HomeKit Privacy** + - Respect user privacy and HomeKit permissions + - Don't cache sensitive data unnecessarily + - Implement proper access controls + +2. **HTTP Security** + - Currently runs on localhost only + - Consider authentication for production use + - Validate all input parameters + +3. **Code Signing** + - Required for HomeKit entitlements + - Configured in GitHub Actions workflow + +### Common Patterns + +1. **Singleton Pattern**: `HomeBase.shared` for HomeKit access +2. **Delegate Pattern**: HomeKit delegate methods for updates +3. **MVVM**: SwiftUI views with Observable models +4. **Route Organization**: Separate route files by feature +5. **Middleware**: Cross-cutting concerns in HTTP pipeline + +### Development Workflow + +1. **Setup** + - Ensure Xcode 15.0+ is installed + - HomeKit simulator or physical HomeKit devices for testing + - Configure code signing for HomeKit entitlements + +2. **Running** + - Build and run the main app to start the HTTP server + - Use the CLI tool to test API endpoints + - Check logs in Console.app for debugging + +3. **Testing** + - Run unit tests in Xcode + - Test with real HomeKit accessories when possible + - Verify API responses with curl or the CLI tool + +### Future Considerations + +- WebSocket support for real-time updates +- Authentication and authorization for remote access +- Configuration file support +- Extended CLI functionality +- Docker container support +- Performance optimization for large HomeKit setups + +## Getting Started + +1. Clone the repository +2. Open `prefab.xcodeproj` in Xcode +3. Ensure HomeKit entitlements are properly configured +4. Build and run the project +5. Use the CLI tool to interact with the API + +For new features, follow the established patterns and maintain consistency with the existing codebase structure. \ No newline at end of file diff --git a/README.md b/README.md index 419e17b..3d1f81e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ This project uses Swift Package Manager through Xcode with the following depende - **[swift-http-types](https://github.com/apple/swift-http-types.git)**: Modern HTTP types for Swift - **[swift-argument-parser](https://github.com/apple/swift-argument-parser.git)**: Command-line argument parsing +### C++ Client Dependencies + +The C++ client library has its own dependencies: + +- **libcurl**: HTTP client library +- **nlohmann/json**: JSON parsing (automatically downloaded) +- **Avahi** (Linux, optional): For mDNS service discovery + ## Setup and Installation ### 1. Clone the Repository @@ -72,6 +80,17 @@ The build creates two main products: - **Prefab.app**: The main SwiftUI application with HTTP server - **prefab**: The command-line tool (embedded in the app bundle) +### C++ Client Library + +This repository also includes a C++ client library for accessing Prefab's HomeKit API from other systems, particularly Raspberry Pi and Linux devices: + +- **Location**: `cpp-client/` directory +- **Purpose**: Access HomeKit data from C++ applications +- **Target**: Raspberry Pi, Linux, and other embedded systems +- **Features**: HTTP client, automatic service discovery, type-safe API + +See [`cpp-client/README.md`](cpp-client/README.md) for detailed C++ client documentation. + ## Testing ### Run Tests in Xcode @@ -195,6 +214,25 @@ curl http://localhost:8080/homes/[HOME_ID]/accessories **mDNS/Bonjour Discovery**: Other devices can discover the service automatically and connect using the advertised hostname and port. +### 4. C++ Client Usage + +The C++ client library allows other systems (like Raspberry Pi) to access the Prefab API: + +```bash +# Build the C++ client +cd cpp-client +mkdir build && cd build +cmake .. +make + +# Run examples +./examples/simple_client +./examples/discovery_example +./examples/accessory_control "My Home" "Living Room" "Smart Light" +``` + +For detailed C++ usage, see [`cpp-client/README.md`](cpp-client/README.md). + ## Development Workflow ### First-Time Setup diff --git a/cpp-client/.gitignore b/cpp-client/.gitignore new file mode 100644 index 0000000..c586b64 --- /dev/null +++ b/cpp-client/.gitignore @@ -0,0 +1,32 @@ +# Build directories +build/ +*build*/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile +*.cmake + +# Compiled libraries +*.a +*.so +*.dylib + +# Executables +simple_client +discovery_example +accessory_control +test_models + +# IDE files +.vscode/ +.clangd/ +compile_commands.json + +# macOS +.DS_Store + +# Debug files +*.dSYM/ \ No newline at end of file diff --git a/cpp-client/CMakeLists.txt b/cpp-client/CMakeLists.txt new file mode 100644 index 0000000..d3d603f --- /dev/null +++ b/cpp-client/CMakeLists.txt @@ -0,0 +1,148 @@ +cmake_minimum_required(VERSION 3.16) +project(prefab-cpp-client VERSION 1.0.0 LANGUAGES CXX) + +# Set C++ standard +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Set build type to Release if not specified +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +# Compiler-specific options +set(CMAKE_CXX_FLAGS "-Wall -Wextra") +set(CMAKE_CXX_FLAGS_DEBUG "-g") +set(CMAKE_CXX_FLAGS_RELEASE "-O3") + +# Find required packages +find_package(PkgConfig REQUIRED) +find_package(CURL REQUIRED) + +# Try to find nlohmann_json +find_package(nlohmann_json 3.2.0 QUIET) +if(NOT nlohmann_json_FOUND) + message(STATUS "nlohmann_json not found, will use FetchContent") + include(FetchContent) + FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + ) + FetchContent_MakeAvailable(nlohmann_json) +endif() + +# Check for DNS-SD support (for mDNS discovery) +# On Raspberry Pi, this might require Avahi development packages +pkg_check_modules(AVAHI_CLIENT avahi-client) +if(AVAHI_CLIENT_FOUND) + add_definitions(-DHAVE_AVAHI=1) +endif() + +# Include directories +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) + +# Source files +set(SOURCES + src/client.cpp +) + +# Header files +set(HEADERS + include/prefab/models.h + include/prefab/client.h + include/prefab/prefab.h +) + +# Create the library +add_library(prefab-client STATIC ${SOURCES} ${HEADERS}) + +# Set target properties +set_target_properties(prefab-client PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION 1 + PUBLIC_HEADER "${HEADERS}" +) + +# Link libraries +target_link_libraries(prefab-client + PRIVATE + CURL::libcurl + nlohmann_json::nlohmann_json +) + +# Add Avahi libraries if available +if(AVAHI_CLIENT_FOUND) + target_link_libraries(prefab-client PRIVATE ${AVAHI_CLIENT_LIBRARIES}) + target_include_directories(prefab-client PRIVATE ${AVAHI_CLIENT_INCLUDE_DIRS}) + target_compile_options(prefab-client PRIVATE ${AVAHI_CLIENT_CFLAGS_OTHER}) +endif() + +# Include directories for the target +target_include_directories(prefab-client + PUBLIC + $ + $ +) + +# Installation +include(GNUInstallDirs) + +install(TARGETS prefab-client + EXPORT prefab-client-targets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/prefab +) + +install(EXPORT prefab-client-targets + FILE prefab-client-targets.cmake + NAMESPACE prefab:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/prefab-client +) + +# Create a config file +include(CMakePackageConfigHelpers) +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/prefab-client-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY AnyNewerVersion +) + +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/prefab-client-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/prefab-client-config.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/prefab-client +) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/prefab-client-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/prefab-client-config-version.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/prefab-client +) + +# Example programs (optional) +option(BUILD_EXAMPLES "Build example programs" ON) +if(BUILD_EXAMPLES) + add_subdirectory(examples) +endif() + +# Tests (optional) +option(BUILD_TESTS "Build tests" ON) +if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +# Print configuration summary +message(STATUS "") +message(STATUS "Prefab C++ Client Configuration Summary:") +message(STATUS " Version: ${PROJECT_VERSION}") +message(STATUS " Build type: ${CMAKE_BUILD_TYPE}") +message(STATUS " C++ standard: ${CMAKE_CXX_STANDARD}") +message(STATUS " Install prefix: ${CMAKE_INSTALL_PREFIX}") +message(STATUS " CURL found: ${CURL_FOUND}") +message(STATUS " nlohmann_json found: ${nlohmann_json_FOUND}") +message(STATUS " Avahi support: ${AVAHI_CLIENT_FOUND}") +message(STATUS " Build examples: ${BUILD_EXAMPLES}") +message(STATUS " Build tests: ${BUILD_TESTS}") +message(STATUS "") \ No newline at end of file diff --git a/cpp-client/README.md b/cpp-client/README.md new file mode 100644 index 0000000..11db11e --- /dev/null +++ b/cpp-client/README.md @@ -0,0 +1,404 @@ +# Prefab C++ Client Library + +A C++ client library for accessing HomeKit data through the Prefab HTTP server. This library is optimized for Raspberry Pi and other Linux systems. + +## Features + +- **Simple C++ API**: Easy-to-use interface for HomeKit data access +- **Automatic Service Discovery**: Find Prefab servers on the network using mDNS/Bonjour +- **Type Safety**: Strongly-typed data models with JSON serialization +- **Cross-Platform**: Works on Linux, macOS, and other Unix-like systems +- **Raspberry Pi Optimized**: Lightweight and efficient for embedded systems +- **HTTP Client**: Built-in HTTP client with proper error handling + +## Requirements + +### System Requirements +- **Linux**: Ubuntu 18.04+ or Raspberry Pi OS +- **macOS**: 10.14+ (for development/testing) +- **C++ Compiler**: GCC 7+ or Clang 8+ with C++17 support +- **CMake**: Version 3.16 or later + +### Dependencies +- **libcurl**: HTTP client library +- **nlohmann/json**: JSON parsing library (automatically downloaded if not found) +- **Avahi** (optional): For mDNS service discovery on Linux + +### Raspberry Pi Setup + +On Raspberry Pi OS, install the required dependencies: + +```bash +sudo apt update +sudo apt install -y \ + build-essential \ + cmake \ + libcurl4-openssl-dev \ + libavahi-client-dev \ + libavahi-common-dev \ + pkg-config \ + git +``` + +For other Linux distributions, install the equivalent packages. + +## Building + +### 1. Clone and Navigate + +```bash +cd /path/to/prefab/cpp-client +``` + +### 2. Create Build Directory + +```bash +mkdir build +cd build +``` + +### 3. Configure with CMake + +```bash +# Basic configuration +cmake .. + +# Or with custom options +cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_EXAMPLES=ON \ + -DBUILD_TESTS=ON \ + -DCMAKE_INSTALL_PREFIX=/usr/local +``` + +### 4. Build + +```bash +make -j$(nproc) +``` + +### 5. Install (Optional) + +```bash +sudo make install +``` + +### CMake Options + +- `BUILD_EXAMPLES` (default: ON): Build example programs +- `BUILD_TESTS` (default: ON): Build test programs +- `INSTALL_EXAMPLES` (default: OFF): Install example programs +- `CMAKE_BUILD_TYPE`: Debug, Release, RelWithDebInfo, MinSizeRel + +## Usage + +### Basic Example + +```cpp +#include +#include + +int main() { + try { + // Create client + prefab::PrefabClient client; + + // Test connection + if (!client.testConnection()) { + std::cout << "Cannot connect to Prefab server" << std::endl; + return 1; + } + + // Get all homes + auto homes = client.getHomes(); + for (const auto& home : homes) { + std::cout << "Home: " << home.name << std::endl; + + // Get rooms + auto rooms = client.getRooms(home.name); + for (const auto& room : rooms) { + std::cout << " Room: " << room.name << std::endl; + + // Get accessories + auto accessories = client.getAccessories(home.name, room.name); + for (const auto& accessory : accessories) { + std::cout << " Accessory: " << accessory.name << std::endl; + } + } + } + + } catch (const prefab::PrefabException& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} +``` + +### Service Discovery + +```cpp +#include + +int main() { + prefab::ClientConfig config; + config.enableMdnsDiscovery = true; + prefab::PrefabClient client(config); + + // Discover services on the network + bool found = client.discoverServices([](const std::string& hostname, int port) { + std::cout << "Found Prefab server at: " << hostname << ":" << port << std::endl; + }, 5000); // 5 second timeout + + if (found) { + std::cout << "Using server: " << client.getBaseUrl() << std::endl; + } else { + std::cout << "No servers found, using default" << std::endl; + client.setBaseUrl("http://192.168.1.100:8080"); + } + + return 0; +} +``` + +### Controlling Accessories + +```cpp +#include + +int main() { + prefab::PrefabClient client; + + // Get detailed accessory information + auto accessory = client.getAccessory("My Home", "Living Room", "Smart Light"); + + // Print details + if (accessory.services.has_value()) { + for (const auto& service : accessory.services.value()) { + std::cout << "Service: " << service.typeName << std::endl; + for (const auto& characteristic : service.characteristics) { + std::cout << " " << characteristic.typeName + << " = " << characteristic.value << std::endl; + } + } + } + + // Update a characteristic (turn on light) + try { + auto result = client.updateCharacteristicByType( + "My Home", "Living Room", "Smart Light", + "00000025-0000-1000-8000-0026BB765291", // On/Off characteristic + "1" // Turn on + ); + std::cout << "Light turned on: " << result << std::endl; + } catch (const prefab::PrefabException& e) { + std::cerr << "Failed to turn on light: " << e.what() << std::endl; + } + + return 0; +} +``` + +## API Reference + +### PrefabClient Class + +#### Constructor +```cpp +PrefabClient(const ClientConfig& config = ClientConfig()) +``` + +#### Configuration +```cpp +void setBaseUrl(const std::string& baseUrl) +std::string getBaseUrl() const +bool testConnection() +``` + +#### Service Discovery +```cpp +bool discoverServices(ServiceDiscoveryCallback callback, int timeoutMs = 5000) +``` + +#### HomeKit Data Access +```cpp +std::vector getHomes() +Home getHome(const std::string& homeName) +std::vector getRooms(const std::string& homeName) +Room getRoom(const std::string& homeName, const std::string& roomName) +std::vector getAccessories(const std::string& homeName, const std::string& roomName) +Accessory getAccessory(const std::string& homeName, const std::string& roomName, const std::string& accessoryName) +``` + +#### Accessory Control +```cpp +std::string updateAccessory(const std::string& homeName, const std::string& roomName, + const std::string& accessoryName, const UpdateAccessoryInput& update) +std::string updateCharacteristicByType(const std::string& homeName, const std::string& roomName, + const std::string& accessoryName, const std::string& characteristicType, + const std::string& value) +``` + +### Data Models + +#### Home +```cpp +struct Home { + std::string name; +}; +``` + +#### Room +```cpp +struct Room { + std::string home; + std::string name; +}; +``` + +#### Accessory +```cpp +struct Accessory { + std::string home; + std::string room; + std::string name; + + // Optional detailed properties + std::optional category; + std::optional isReachable; + std::optional supportsIdentify; + std::optional isBridged; + std::optional> services; + std::optional firmwareVersion; + std::optional manufacturer; + std::optional model; +}; +``` + +## Building Applications + +### Using CMake + +Create a `CMakeLists.txt` for your application: + +```cmake +cmake_minimum_required(VERSION 3.16) +project(my_prefab_app) + +set(CMAKE_CXX_STANDARD 17) + +# Find the prefab-client library +find_package(prefab-client REQUIRED) + +# Create your executable +add_executable(my_app main.cpp) + +# Link with prefab-client +target_link_libraries(my_app prefab::prefab-client) +``` + +### Manual Compilation + +If you prefer manual compilation: + +```bash +g++ -std=c++17 -I/usr/local/include main.cpp -lprefab-client -lcurl -o my_app +``` + +## Running Examples + +After building, you can run the example programs: + +```bash +# Simple client example +./examples/simple_client + +# Service discovery example +./examples/discovery_example + +# Accessory control example +./examples/accessory_control + +# Control specific accessory +./examples/accessory_control "My Home" "Living Room" "Smart Light" + +# Update characteristic +./examples/accessory_control "My Home" "Living Room" "Smart Light" \ + "00000025-0000-1000-8000-0026BB765291" "1" +``` + +## Common HomeKit Characteristic Types + +- **On/Off**: `00000025-0000-1000-8000-0026BB765291` +- **Brightness**: `00000008-0000-1000-8000-0026BB765291` +- **Hue**: `00000013-0000-1000-8000-0026BB765291` +- **Saturation**: `0000002F-0000-1000-8000-0026BB765291` +- **Current Temperature**: `00000011-0000-1000-8000-0026BB765291` +- **Target Temperature**: `00000035-0000-1000-8000-0026BB765291` + +## Raspberry Pi Deployment + +### Cross-Compilation + +For cross-compilation from a development machine: + +```bash +# Install cross-compilation tools +sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + +# Configure for ARM64 +cmake .. \ + -DCMAKE_TOOLCHAIN_FILE=../cmake/raspberry-pi.cmake \ + -DCMAKE_BUILD_TYPE=Release + +make -j$(nproc) +``` + +### Running on Raspberry Pi + +1. Copy the built library and examples to your Raspberry Pi +2. Install runtime dependencies: + ```bash + sudo apt install libcurl4 libavahi-client3 libavahi-common3 + ``` +3. Run your application: + ```bash + ./my_prefab_app + ``` + +## Troubleshooting + +### Connection Issues +- Ensure Prefab server is running and accessible +- Check firewall settings (port 8080) +- Verify network connectivity between devices + +### Build Issues +- Ensure all dependencies are installed +- Check CMake version (3.16+ required) +- Verify C++17 compiler support + +### mDNS Discovery Issues +- Install Avahi on Linux: `sudo apt install libavahi-client-dev libavahi-common-dev` +- Enable Avahi daemon: `sudo systemctl enable avahi-daemon` +- Start Avahi daemon: `sudo systemctl start avahi-daemon` +- Check network allows multicast traffic + +### Raspberry Pi Performance +- Use Release build for better performance +- Consider using static linking for deployment +- Monitor memory usage with complex HomeKit setups + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](../LICENSE) file for details. \ No newline at end of file diff --git a/cpp-client/build.sh b/cpp-client/build.sh new file mode 100755 index 0000000..6b5c3a4 --- /dev/null +++ b/cpp-client/build.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Build script for Prefab C++ Client Library + +set -e + +echo "Prefab C++ Client Build Script" +echo "==============================" + +# Check if we're in the right directory +if [ ! -f "CMakeLists.txt" ]; then + echo "Error: Please run this script from the cpp-client directory" + exit 1 +fi + +# Create build directory +BUILD_DIR="build" +if [ -d "$BUILD_DIR" ]; then + echo "Removing existing build directory..." + rm -rf "$BUILD_DIR" +fi + +echo "Creating build directory..." +mkdir "$BUILD_DIR" +cd "$BUILD_DIR" + +# Default build type +BUILD_TYPE=${1:-Release} + +echo "Configuring CMake (Build Type: $BUILD_TYPE)..." +cmake .. \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_EXAMPLES=ON \ + -DBUILD_TESTS=ON + +echo "Building..." +make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + +echo "Running tests..." +if make test; then + echo "✓ All tests passed!" +else + echo "⚠ Some tests failed" +fi + +echo "" +echo "Build completed successfully!" +echo "Examples are available in: $BUILD_DIR/examples/" +echo "Library is available in: $BUILD_DIR/libprefab-client.a" +echo "" +echo "To install system-wide, run:" +echo " sudo make install" +echo "" +echo "To run examples:" +echo " ./examples/simple_client" +echo " ./examples/discovery_example" +echo " ./examples/accessory_control" \ No newline at end of file diff --git a/cpp-client/cmake/prefab-client-config.cmake.in b/cpp-client/cmake/prefab-client-config.cmake.in new file mode 100644 index 0000000..35726d7 --- /dev/null +++ b/cpp-client/cmake/prefab-client-config.cmake.in @@ -0,0 +1,18 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +# Find dependencies +find_dependency(CURL REQUIRED) + +# Try to find nlohmann_json +find_dependency(nlohmann_json 3.2.0 QUIET) +if(NOT nlohmann_json_FOUND) + # If not found, the parent project should handle this with FetchContent + message(STATUS "nlohmann_json not found in config, parent project should provide it") +endif() + +# Include targets +include("${CMAKE_CURRENT_LIST_DIR}/prefab-client-targets.cmake") + +check_required_components(prefab-client) \ No newline at end of file diff --git a/cpp-client/examples/CMakeLists.txt b/cpp-client/examples/CMakeLists.txt new file mode 100644 index 0000000..43024a8 --- /dev/null +++ b/cpp-client/examples/CMakeLists.txt @@ -0,0 +1,21 @@ +# Examples CMakeLists.txt + +# Simple client example +add_executable(simple_client simple_client.cpp) +target_link_libraries(simple_client prefab-client) + +# Discovery example +add_executable(discovery_example discovery_example.cpp) +target_link_libraries(discovery_example prefab-client) + +# Accessory control example +add_executable(accessory_control accessory_control.cpp) +target_link_libraries(accessory_control prefab-client) + +# Install examples (optional) +option(INSTALL_EXAMPLES "Install example programs" OFF) +if(INSTALL_EXAMPLES) + install(TARGETS simple_client discovery_example accessory_control + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}/prefab-examples + ) +endif() \ No newline at end of file diff --git a/cpp-client/examples/accessory_control.cpp b/cpp-client/examples/accessory_control.cpp new file mode 100644 index 0000000..c198f41 --- /dev/null +++ b/cpp-client/examples/accessory_control.cpp @@ -0,0 +1,137 @@ +#include +#include +#include + +void printAccessoryDetails(const prefab::Accessory& accessory) { + std::cout << "Accessory: " << accessory.name << std::endl; + + if (accessory.manufacturer.has_value()) { + std::cout << " Manufacturer: " << accessory.manufacturer.value() << std::endl; + } + if (accessory.model.has_value()) { + std::cout << " Model: " << accessory.model.value() << std::endl; + } + if (accessory.isReachable.has_value()) { + std::cout << " Reachable: " << (accessory.isReachable.value() ? "Yes" : "No") << std::endl; + } + + if (accessory.services.has_value()) { + std::cout << " Services:" << std::endl; + for (const auto& service : accessory.services.value()) { + std::cout << " - " << service.typeName << " (" << service.name << ")" << std::endl; + + if (!service.characteristics.empty()) { + std::cout << " Characteristics:" << std::endl; + for (const auto& characteristic : service.characteristics) { + std::cout << " * " << characteristic.typeName + << " = " << characteristic.value + << " [" << characteristic.uniqueIdentifier << "]" << std::endl; + } + } + } + } +} + +int main(int argc, char* argv[]) { + try { + prefab::PrefabClient client; + + std::cout << "Prefab C++ Client - Accessory Control Example" << std::endl; + std::cout << "==============================================" << std::endl; + + // Test connection + if (!client.testConnection()) { + std::cout << "Cannot connect to Prefab server at " << client.getBaseUrl() << std::endl; + return 1; + } + + std::cout << "Connected to: " << client.getBaseUrl() << std::endl; + std::cout << std::endl; + + // If specific accessory is provided as command line arguments + if (argc >= 4) { + std::string homeName = argv[1]; + std::string roomName = argv[2]; + std::string accessoryName = argv[3]; + + std::cout << "Getting details for accessory: " << accessoryName << std::endl; + std::cout << "In room: " << roomName << ", Home: " << homeName << std::endl; + std::cout << std::endl; + + try { + auto accessory = client.getAccessory(homeName, roomName, accessoryName); + printAccessoryDetails(accessory); + + // If we have 5 arguments, try to update a characteristic + if (argc >= 6) { + std::string characteristicType = argv[4]; + std::string newValue = argv[5]; + + std::cout << std::endl; + std::cout << "Attempting to update characteristic " << characteristicType + << " to value: " << newValue << std::endl; + + try { + auto result = client.updateCharacteristicByType( + homeName, roomName, accessoryName, characteristicType, newValue); + std::cout << "Update result: " << result << std::endl; + } catch (const prefab::PrefabException& e) { + std::cout << "Update failed: " << e.what() << std::endl; + } + } + + } catch (const prefab::PrefabException& e) { + std::cout << "Error getting accessory details: " << e.what() << std::endl; + return 1; + } + + } else { + // Interactive mode - list all accessories + auto homes = client.getHomes(); + + for (const auto& home : homes) { + std::cout << "Home: " << home.name << std::endl; + + try { + auto rooms = client.getRooms(home.name); + for (const auto& room : rooms) { + std::cout << " Room: " << room.name << std::endl; + + try { + auto accessories = client.getAccessories(home.name, room.name); + for (const auto& accessory : accessories) { + std::cout << " Accessory: " << accessory.name << std::endl; + } + } catch (const prefab::PrefabException& e) { + std::cout << " Error getting accessories: " << e.what() << std::endl; + } + } + } catch (const prefab::PrefabException& e) { + std::cout << " Error getting rooms: " << e.what() << std::endl; + } + std::cout << std::endl; + } + + std::cout << "Usage for accessory control:" << std::endl; + std::cout << " " << argv[0] << " [characteristic_type] [new_value]" << std::endl; + std::cout << std::endl; + std::cout << "Example HomeKit characteristic types:" << std::endl; + std::cout << " 00000025-0000-1000-8000-0026BB765291 (On/Off)" << std::endl; + std::cout << " 00000008-0000-1000-8000-0026BB765291 (Brightness)" << std::endl; + std::cout << " 0000000A-0000-1000-8000-0026BB765291 (Current Temperature)" << std::endl; + } + + } catch (const prefab::PrefabException& e) { + std::cerr << "Prefab Error: " << e.what(); + if (e.getHttpCode() > 0) { + std::cerr << " (HTTP " << e.getHttpCode() << ")"; + } + std::cerr << std::endl; + return 1; + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} \ No newline at end of file diff --git a/cpp-client/examples/discovery_example.cpp b/cpp-client/examples/discovery_example.cpp new file mode 100644 index 0000000..5db5d62 --- /dev/null +++ b/cpp-client/examples/discovery_example.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include + +int main() { + try { + std::cout << "Prefab C++ Client - Service Discovery Example" << std::endl; + std::cout << "=============================================" << std::endl; + + // Create a client with mDNS discovery enabled + prefab::ClientConfig config; + config.enableMdnsDiscovery = true; + prefab::PrefabClient client(config); + + std::cout << "Searching for Prefab servers on the network..." << std::endl; + + bool foundService = false; + + // Try to discover services + foundService = client.discoverServices([&](const std::string& hostname, int port) { + std::cout << "Found Prefab server at: " << hostname << ":" << port << std::endl; + + // Set the discovered URL + std::string url = "http://" + hostname + ":" + std::to_string(port); + client.setBaseUrl(url); + + // Test the connection + if (client.testConnection()) { + std::cout << "Successfully connected to: " << url << std::endl; + } else { + std::cout << "Found server but connection test failed: " << url << std::endl; + } + }, 5000); // 5 second timeout + + if (!foundService) { + std::cout << "No Prefab servers found on the network." << std::endl; + std::cout << "Trying common local addresses..." << std::endl; + + // Try some common addresses manually + std::vector commonUrls = { + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://192.168.1.100:8080", + "http://192.168.0.100:8080" + }; + + for (const auto& url : commonUrls) { + std::cout << "Trying: " << url << "... "; + client.setBaseUrl(url); + + if (client.testConnection()) { + std::cout << "SUCCESS!" << std::endl; + foundService = true; + break; + } else { + std::cout << "failed" << std::endl; + } + } + } + + if (!foundService) { + std::cout << std::endl; + std::cout << "No Prefab servers could be reached." << std::endl; + std::cout << "Make sure:" << std::endl; + std::cout << "1. Prefab server is running" << std::endl; + std::cout << "2. Server is accessible from this machine" << std::endl; + std::cout << "3. No firewall is blocking port 8080" << std::endl; + return 1; + } + + std::cout << std::endl; + std::cout << "Using Prefab server at: " << client.getBaseUrl() << std::endl; + + // Now try to get some basic data + try { + auto homes = client.getHomes(); + std::cout << "Successfully retrieved " << homes.size() << " home(s) from the server." << std::endl; + + for (const auto& home : homes) { + std::cout << " - " << home.name << std::endl; + } + } catch (const prefab::PrefabException& e) { + std::cout << "Error retrieving homes: " << e.what() << std::endl; + } + + } catch (const prefab::PrefabException& e) { + std::cerr << "Prefab Error: " << e.what(); + if (e.getHttpCode() > 0) { + std::cerr << " (HTTP " << e.getHttpCode() << ")"; + } + std::cerr << std::endl; + return 1; + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} \ No newline at end of file diff --git a/cpp-client/examples/simple_client.cpp b/cpp-client/examples/simple_client.cpp new file mode 100644 index 0000000..8bb2cf7 --- /dev/null +++ b/cpp-client/examples/simple_client.cpp @@ -0,0 +1,70 @@ +#include +#include + +int main() { + try { + // Create a Prefab client with default configuration + prefab::PrefabClient client; + + std::cout << "Prefab C++ Client - Simple Example" << std::endl; + std::cout << "==================================" << std::endl; + + // Test connection first + if (!client.testConnection()) { + std::cout << "Cannot connect to Prefab server at " << client.getBaseUrl() << std::endl; + std::cout << "Make sure the Prefab server is running and accessible." << std::endl; + return 1; + } + + std::cout << "Connected to Prefab server at: " << client.getBaseUrl() << std::endl; + std::cout << std::endl; + + // Get all homes + auto homes = client.getHomes(); + std::cout << "Found " << homes.size() << " home(s):" << std::endl; + + for (const auto& home : homes) { + std::cout << std::endl; + std::cout << "Home: " << home.name << std::endl; + std::cout << "-----" << std::string(home.name.length(), '-') << std::endl; + + try { + // Get rooms in this home + auto rooms = client.getRooms(home.name); + std::cout << " Rooms (" << rooms.size() << "):" << std::endl; + + for (const auto& room : rooms) { + std::cout << " - " << room.name << std::endl; + + try { + // Get accessories in this room + auto accessories = client.getAccessories(home.name, room.name); + if (!accessories.empty()) { + std::cout << " Accessories (" << accessories.size() << "):" << std::endl; + for (const auto& accessory : accessories) { + std::cout << " * " << accessory.name << std::endl; + } + } + } catch (const prefab::PrefabException& e) { + std::cout << " Error getting accessories: " << e.what() << std::endl; + } + } + } catch (const prefab::PrefabException& e) { + std::cout << " Error getting rooms: " << e.what() << std::endl; + } + } + + } catch (const prefab::PrefabException& e) { + std::cerr << "Prefab Error: " << e.what(); + if (e.getHttpCode() > 0) { + std::cerr << " (HTTP " << e.getHttpCode() << ")"; + } + std::cerr << std::endl; + return 1; + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} \ No newline at end of file diff --git a/cpp-client/include/prefab/client.h b/cpp-client/include/prefab/client.h new file mode 100644 index 0000000..45f3ab9 --- /dev/null +++ b/cpp-client/include/prefab/client.h @@ -0,0 +1,261 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "models.h" + +namespace prefab { + + /** + * @brief Exception class for Prefab client errors + */ + class PrefabException : public std::exception { + private: + std::string message_; + int httpCode_; + + public: + PrefabException(const std::string& message, int httpCode = 0) + : message_(message), httpCode_(httpCode) {} + + const char* what() const noexcept override { + return message_.c_str(); + } + + int getHttpCode() const { return httpCode_; } + }; + + /** + * @brief Configuration for the Prefab client + */ + struct ClientConfig { + std::string baseUrl = "http://localhost:8080"; + std::string serviceName = "_prefab._tcp."; + int timeoutSeconds = 30; + bool enableMdnsDiscovery = true; + + ClientConfig() = default; + ClientConfig(const std::string& url) : baseUrl(url) {} + }; + + /** + * @brief Callback function type for mDNS service discovery + */ + using ServiceDiscoveryCallback = std::function; + + /** + * @brief C++ client for the Prefab HomeKit HTTP API + * + * This client provides access to HomeKit data through the Prefab server's REST API. + * It can automatically discover Prefab servers on the network using mDNS/Bonjour + * or connect to a specific server URL. + */ + class PrefabClient { + private: + ClientConfig config_; + std::string discoveredBaseUrl_; + + // Internal HTTP methods + std::string makeHttpRequest(const std::string& method, const std::string& path, + const std::string& body = "") const; + std::string urlEncode(const std::string& value) const; + + // mDNS discovery implementation + bool discoverService(); + + public: + /** + * @brief Construct a new Prefab Client + * + * @param config Client configuration including base URL and discovery options + */ + explicit PrefabClient(const ClientConfig& config = ClientConfig()); + + /** + * @brief Destructor + */ + ~PrefabClient(); + + /** + * @brief Discover Prefab servers on the network using mDNS + * + * @param callback Function called when a service is discovered + * @param timeoutMs Discovery timeout in milliseconds + * @return true if at least one service was discovered + */ + bool discoverServices(ServiceDiscoveryCallback callback, int timeoutMs = 5000); + + /** + * @brief Set the base URL for the Prefab server + * + * @param baseUrl The base URL (e.g., "http://192.168.1.100:8080") + */ + void setBaseUrl(const std::string& baseUrl); + + /** + * @brief Get the current base URL + * + * @return std::string The current base URL + */ + std::string getBaseUrl() const; + + /** + * @brief Test connectivity to the Prefab server + * + * @return true if the server is reachable + */ + bool testConnection(); + + // HomeKit API methods + + /** + * @brief Get all available homes + * + * @return std::vector List of homes + */ + std::vector getHomes(); + + /** + * @brief Get a specific home by name + * + * @param homeName Name of the home + * @return Home The requested home + */ + Home getHome(const std::string& homeName); + + /** + * @brief Get all rooms in a home + * + * @param homeName Name of the home + * @return std::vector List of rooms + */ + std::vector getRooms(const std::string& homeName); + + /** + * @brief Get a specific room in a home + * + * @param homeName Name of the home + * @param roomName Name of the room + * @return Room The requested room + */ + Room getRoom(const std::string& homeName, const std::string& roomName); + + /** + * @brief Get all accessories in a room + * + * @param homeName Name of the home + * @param roomName Name of the room + * @return std::vector List of accessories (basic info only) + */ + std::vector getAccessories(const std::string& homeName, + const std::string& roomName); + + /** + * @brief Get detailed information about a specific accessory + * + * @param homeName Name of the home + * @param roomName Name of the room + * @param accessoryName Name of the accessory + * @return Accessory Detailed accessory information including services and characteristics + */ + Accessory getAccessory(const std::string& homeName, + const std::string& roomName, + const std::string& accessoryName); + + /** + * @brief Update an accessory's characteristic value + * + * @param homeName Name of the home + * @param roomName Name of the room + * @param accessoryName Name of the accessory + * @param update Update information containing characteristic ID and new value + * @return std::string Response from the server + */ + std::string updateAccessory(const std::string& homeName, + const std::string& roomName, + const std::string& accessoryName, + const UpdateAccessoryInput& update); + + /** + * @brief Find and update a characteristic by type in an accessory + * + * This is a convenience method that finds a characteristic by its type UUID + * and updates its value without needing to know the exact characteristic UUID. + * + * @param homeName Name of the home + * @param roomName Name of the room + * @param accessoryName Name of the accessory + * @param characteristicType The HomeKit characteristic type UUID + * @param value The new value to set + * @return std::string Response from the server + */ + std::string updateCharacteristicByType(const std::string& homeName, + const std::string& roomName, + const std::string& accessoryName, + const std::string& characteristicType, + const std::string& value); + + // Scene API methods + + /** + * @brief Get all scenes in a home + * + * @param homeName Name of the home + * @return std::vector List of scenes + */ + std::vector getScenes(const std::string& homeName); + + /** + * @brief Get detailed scene info + * + * @param homeName Name of the home + * @param sceneId UUID of the scene + * @return SceneDetail Detailed scene information including actions + */ + SceneDetail getScene(const std::string& homeName, const std::string& sceneId); + + /** + * @brief Execute a scene + * + * @param homeName Name of the home + * @param sceneId UUID of the scene + * @return std::string Response from the server + */ + std::string executeScene(const std::string& homeName, const std::string& sceneId); + + // Accessory Group API methods + + /** + * @brief Get all accessory groups in a home + * + * @param homeName Name of the home + * @return std::vector List of groups + */ + std::vector getGroups(const std::string& homeName); + + /** + * @brief Get detailed group info + * + * @param homeName Name of the home + * @param groupId UUID of the group + * @return AccessoryGroupDetail Detailed group information including services + */ + AccessoryGroupDetail getGroup(const std::string& homeName, const std::string& groupId); + + /** + * @brief Update all accessories in a group + * + * @param homeName Name of the home + * @param groupId UUID of the group + * @param update Update information containing characteristic type and value + * @return std::string Response from the server + */ + std::string updateGroup(const std::string& homeName, + const std::string& groupId, + const UpdateGroupInput& update); + }; + +} // namespace prefab \ No newline at end of file diff --git a/cpp-client/include/prefab/models.h b/cpp-client/include/prefab/models.h new file mode 100644 index 0000000..f0953a4 --- /dev/null +++ b/cpp-client/include/prefab/models.h @@ -0,0 +1,320 @@ +#pragma once + +#include +#include +#include +#include + +namespace prefab { + + /** + * @brief Represents a HomeKit Home + */ + struct Home { + std::string name; + + // JSON serialization + NLOHMANN_DEFINE_TYPE_INTRUSIVE(Home, name) + }; + + /** + * @brief Represents a HomeKit Room within a Home + */ + struct Room { + std::string home; + std::string name; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(Room, home, name) + }; + + /** + * @brief Metadata for HomeKit Characteristics + */ + struct CharacteristicMetadata { + std::optional manufacturerDescription; + std::optional> validValues; + std::optional minimumValue; + std::optional maximumValue; + std::optional stepValue; + std::optional maxLength; + std::optional format; + std::optional units; + + // Custom JSON serialization for optional fields + friend void to_json(nlohmann::json& j, const CharacteristicMetadata& m) { + j = nlohmann::json{}; + if (m.manufacturerDescription.has_value()) j["manufacturerDescription"] = m.manufacturerDescription.value(); + if (m.validValues.has_value()) j["validValues"] = m.validValues.value(); + if (m.minimumValue.has_value()) j["minimumValue"] = m.minimumValue.value(); + if (m.maximumValue.has_value()) j["maximumValue"] = m.maximumValue.value(); + if (m.stepValue.has_value()) j["stepValue"] = m.stepValue.value(); + if (m.maxLength.has_value()) j["maxLength"] = m.maxLength.value(); + if (m.format.has_value()) j["format"] = m.format.value(); + if (m.units.has_value()) j["units"] = m.units.value(); + } + + friend void from_json(const nlohmann::json& j, CharacteristicMetadata& m) { + if (j.contains("manufacturerDescription") && !j["manufacturerDescription"].is_null()) { + m.manufacturerDescription = j["manufacturerDescription"].get(); + } + if (j.contains("validValues") && !j["validValues"].is_null()) { + m.validValues = j["validValues"].get>(); + } + if (j.contains("minimumValue") && !j["minimumValue"].is_null()) { + m.minimumValue = j["minimumValue"].get(); + } + if (j.contains("maximumValue") && !j["maximumValue"].is_null()) { + m.maximumValue = j["maximumValue"].get(); + } + if (j.contains("stepValue") && !j["stepValue"].is_null()) { + m.stepValue = j["stepValue"].get(); + } + if (j.contains("maxLength") && !j["maxLength"].is_null()) { + m.maxLength = j["maxLength"].get(); + } + if (j.contains("format") && !j["format"].is_null()) { + m.format = j["format"].get(); + } + if (j.contains("units") && !j["units"].is_null()) { + m.units = j["units"].get(); + } + } + }; + + /** + * @brief Represents a HomeKit Characteristic + */ + struct Characteristic { + std::string uniqueIdentifier; + std::string description; + std::vector properties; + std::string typeName; + std::string type; + CharacteristicMetadata metadata; + std::string value; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(Characteristic, + uniqueIdentifier, description, properties, typeName, type, metadata, value) + }; + + /** + * @brief Represents a HomeKit Service + */ + struct Service { + std::string uniqueIdentifier; + std::string name; + std::string typeName; + std::string type; + bool isPrimary; + bool isUserInteractive; + std::optional associatedType; + std::vector characteristics; + + // Custom JSON serialization for optional fields + friend void to_json(nlohmann::json& j, const Service& s) { + j = nlohmann::json{ + {"uniqueIdentifier", s.uniqueIdentifier}, + {"name", s.name}, + {"typeName", s.typeName}, + {"type", s.type}, + {"isPrimary", s.isPrimary}, + {"isUserInteractive", s.isUserInteractive}, + {"characteristics", s.characteristics} + }; + if (s.associatedType.has_value()) { + j["associatedType"] = s.associatedType.value(); + } + } + + friend void from_json(const nlohmann::json& j, Service& s) { + j.at("uniqueIdentifier").get_to(s.uniqueIdentifier); + j.at("name").get_to(s.name); + j.at("typeName").get_to(s.typeName); + j.at("type").get_to(s.type); + j.at("isPrimary").get_to(s.isPrimary); + j.at("isUserInteractive").get_to(s.isUserInteractive); + j.at("characteristics").get_to(s.characteristics); + + if (j.contains("associatedType") && !j["associatedType"].is_null()) { + s.associatedType = j["associatedType"].get(); + } + } + }; + + /** + * @brief Represents a HomeKit Accessory + */ + struct Accessory { + std::string home; + std::string room; + std::string name; + + // Optional detailed properties + std::optional category; + std::optional isReachable; + std::optional supportsIdentify; + std::optional isBridged; + std::optional> services; + std::optional firmwareVersion; + std::optional manufacturer; + std::optional model; + + // Custom JSON serialization for optional fields + friend void to_json(nlohmann::json& j, const Accessory& a) { + j = nlohmann::json{ + {"home", a.home}, + {"room", a.room}, + {"name", a.name} + }; + if (a.category.has_value()) j["category"] = a.category.value(); + if (a.isReachable.has_value()) j["isReachable"] = a.isReachable.value(); + if (a.supportsIdentify.has_value()) j["supportsIdentify"] = a.supportsIdentify.value(); + if (a.isBridged.has_value()) j["isBridged"] = a.isBridged.value(); + if (a.services.has_value()) j["services"] = a.services.value(); + if (a.firmwareVersion.has_value()) j["firmwareVersion"] = a.firmwareVersion.value(); + if (a.manufacturer.has_value()) j["manufacturer"] = a.manufacturer.value(); + if (a.model.has_value()) j["model"] = a.model.value(); + } + + friend void from_json(const nlohmann::json& j, Accessory& a) { + j.at("home").get_to(a.home); + j.at("room").get_to(a.room); + j.at("name").get_to(a.name); + + if (j.contains("category") && !j["category"].is_null()) { + a.category = j["category"].get(); + } + if (j.contains("isReachable") && !j["isReachable"].is_null()) { + a.isReachable = j["isReachable"].get(); + } + if (j.contains("supportsIdentify") && !j["supportsIdentify"].is_null()) { + a.supportsIdentify = j["supportsIdentify"].get(); + } + if (j.contains("isBridged") && !j["isBridged"].is_null()) { + a.isBridged = j["isBridged"].get(); + } + if (j.contains("services") && !j["services"].is_null()) { + a.services = j["services"].get>(); + } + if (j.contains("firmwareVersion") && !j["firmwareVersion"].is_null()) { + a.firmwareVersion = j["firmwareVersion"].get(); + } + if (j.contains("manufacturer") && !j["manufacturer"].is_null()) { + a.manufacturer = j["manufacturer"].get(); + } + if (j.contains("model") && !j["model"].is_null()) { + a.model = j["model"].get(); + } + } + }; + + /** + * @brief Input structure for updating accessory characteristics + * Matches the Swift server API: {serviceId, characteristicId, value} + */ + struct UpdateAccessoryInput { + std::string serviceId; + std::string characteristicId; + std::string value; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(UpdateAccessoryInput, + serviceId, characteristicId, value) + }; + + // ======================================================================== + // Scenes + // ======================================================================== + + /** + * @brief Basic scene info (list view) + */ + struct HomeKitScene { + std::string home; + std::string uniqueIdentifier; + std::string name; + bool isBuiltIn = false; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(HomeKitScene, home, uniqueIdentifier, name, isBuiltIn) + }; + + /** + * @brief Action within a scene + */ + struct SceneAction { + std::string accessoryName; + std::string serviceName; + std::string characteristicType; + std::string targetValue; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(SceneAction, + accessoryName, serviceName, characteristicType, targetValue) + }; + + /** + * @brief Detailed scene info including actions + */ + struct SceneDetail { + std::string home; + std::string uniqueIdentifier; + std::string name; + bool isBuiltIn = false; + std::vector actions; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(SceneDetail, + home, uniqueIdentifier, name, isBuiltIn, actions) + }; + + // ======================================================================== + // Accessory Groups + // ======================================================================== + + /** + * @brief Service within a group + */ + struct GroupService { + std::string accessoryName; + std::string serviceName; + std::string serviceType; + std::string uniqueIdentifier; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(GroupService, + accessoryName, serviceName, serviceType, uniqueIdentifier) + }; + + /** + * @brief Basic group info (list view) + */ + struct AccessoryGroup { + std::string home; + std::string uniqueIdentifier; + std::string name; + int serviceCount = 0; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(AccessoryGroup, + home, uniqueIdentifier, name, serviceCount) + }; + + /** + * @brief Detailed group info including services + */ + struct AccessoryGroupDetail { + std::string home; + std::string uniqueIdentifier; + std::string name; + std::vector services; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(AccessoryGroupDetail, + home, uniqueIdentifier, name, services) + }; + + /** + * @brief Input for updating group characteristics + */ + struct UpdateGroupInput { + std::string characteristicType; + std::string value; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(UpdateGroupInput, characteristicType, value) + }; + +} // namespace prefab \ No newline at end of file diff --git a/cpp-client/include/prefab/prefab.h b/cpp-client/include/prefab/prefab.h new file mode 100644 index 0000000..9661571 --- /dev/null +++ b/cpp-client/include/prefab/prefab.h @@ -0,0 +1,66 @@ +#pragma once + +/** + * @file prefab.h + * @brief Main header file for the Prefab C++ client library + * + * This header provides access to HomeKit data through the Prefab HTTP API. + * The library is designed to work on Raspberry Pi and other Linux systems. + * + * @author Prefab Team + * @version 1.0.0 + */ + +#include "models.h" +#include "client.h" + +/** + * @brief Prefab C++ client library for HomeKit data access + * + * This library provides a simple C++ interface to communicate with the Prefab + * HomeKit HTTP server. It supports automatic service discovery using mDNS/Bonjour + * and provides strongly-typed access to HomeKit homes, rooms, and accessories. + * + * Example usage: + * @code + * #include + * + * int main() { + * try { + * prefab::PrefabClient client; + * + * // Auto-discover Prefab server on network + * if (!client.discoverServices([](const std::string& host, int port) { + * std::cout << "Found Prefab server at " << host << ":" << port << std::endl; + * })) { + * // Fallback to manual configuration + * client.setBaseUrl("http://192.168.1.100:8080"); + * } + * + * // Get all homes + * auto homes = client.getHomes(); + * for (const auto& home : homes) { + * std::cout << "Home: " << home.name << std::endl; + * + * // Get rooms in this home + * auto rooms = client.getRooms(home.name); + * for (const auto& room : rooms) { + * std::cout << " Room: " << room.name << std::endl; + * + * // Get accessories in this room + * auto accessories = client.getAccessories(home.name, room.name); + * for (const auto& accessory : accessories) { + * std::cout << " Accessory: " << accessory.name << std::endl; + * } + * } + * } + * + * } catch (const prefab::PrefabException& e) { + * std::cerr << "Error: " << e.what() << std::endl; + * return 1; + * } + * + * return 0; + * } + * @endcode + */ \ No newline at end of file diff --git a/cpp-client/src/client.cpp b/cpp-client/src/client.cpp new file mode 100644 index 0000000..e78506d --- /dev/null +++ b/cpp-client/src/client.cpp @@ -0,0 +1,567 @@ +#include "prefab/client.h" +#include +#include +#include +#include +// threading/sync for non-blocking Avahi discovery +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace prefab { + + // Callback function for curl to write response data + static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) { + userp->append((char*)contents, size * nmemb); + return size * nmemb; + } + + PrefabClient::PrefabClient(const ClientConfig& config) : config_(config) { + // Initialize curl + curl_global_init(CURL_GLOBAL_DEFAULT); + + // If mDNS discovery is enabled and no specific URL provided, try to discover + if (config_.enableMdnsDiscovery && config_.baseUrl == "http://localhost:8080") { + discoverService(); + } + } + + PrefabClient::~PrefabClient() { + curl_global_cleanup(); + } + + std::string PrefabClient::makeHttpRequest(const std::string& method, const std::string& path, const std::string& body) const { + CURL* curl; + CURLcode res; + std::string response; + + curl = curl_easy_init(); + if (!curl) { + throw PrefabException("Failed to initialize CURL"); + } + + std::string url = getBaseUrl() + path; + // Log the outgoing request for diagnostics + + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, config_.timeoutSeconds); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + // Set HTTP method and body + if (method == "POST" || method == "PUT") { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body.length()); + + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + if (method == "PUT") { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + } + } + + res = curl_easy_perform(curl); + + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + throw PrefabException("CURL request failed: " + std::string(curl_easy_strerror(res))); + } + + if (httpCode >= 400) { + // Log a short snippet of the response to help debugging + std::string resp_snip = response.size() > 200 ? response.substr(0, 200) + "..." : response; + + throw PrefabException("HTTP error: " + response, (int)httpCode); + } else { + + } + + return response; + } + + std::string PrefabClient::urlEncode(const std::string& value) const { + CURL* curl = curl_easy_init(); + if (!curl) { + return value; // Fallback to unencoded value + } + + char* encoded = curl_easy_escape(curl, value.c_str(), value.length()); + if (!encoded) { + curl_easy_cleanup(curl); + return value; + } + + std::string result(encoded); + curl_free(encoded); + curl_easy_cleanup(curl); + + return result; + } + + void PrefabClient::setBaseUrl(const std::string& baseUrl) { + config_.baseUrl = baseUrl; + discoveredBaseUrl_ = baseUrl; + } + + std::string PrefabClient::getBaseUrl() const { + return discoveredBaseUrl_.empty() ? config_.baseUrl : discoveredBaseUrl_; + } + + bool PrefabClient::testConnection() { + try { + makeHttpRequest("GET", "/homes"); + return true; + } catch (const PrefabException&) { + return false; + } + } + + std::vector PrefabClient::getHomes() { + std::string response = makeHttpRequest("GET", "/homes"); + + try { + json j = json::parse(response); + return j.get>(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse homes response: " + std::string(e.what())); + } + } + + Home PrefabClient::getHome(const std::string& homeName) { + std::string path = "/homes/" + urlEncode(homeName); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse home response: " + std::string(e.what())); + } + } + + std::vector PrefabClient::getRooms(const std::string& homeName) { + std::string path = "/rooms/" + urlEncode(homeName); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get>(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse rooms response: " + std::string(e.what())); + } + } + + Room PrefabClient::getRoom(const std::string& homeName, const std::string& roomName) { + std::string path = "/rooms/" + urlEncode(homeName) + "/" + urlEncode(roomName); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse room response: " + std::string(e.what())); + } + } + + std::vector PrefabClient::getAccessories(const std::string& homeName, const std::string& roomName) { + std::string path = "/accessories/" + urlEncode(homeName) + "/" + urlEncode(roomName); + // Diagnostic log: show constructed path and source parameters so we can detect empty room names + try { + std::cerr << "PrefabClient: getAccessories called home=\"" << homeName + << "\" room=\"" << roomName << "\" path=\"" << path << "\"" << std::endl; + } catch (...) { + // best-effort logging + } + + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get>(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse accessories response: " + std::string(e.what())); + } + } + + Accessory PrefabClient::getAccessory(const std::string& homeName, const std::string& roomName, const std::string& accessoryName) { + std::string path = "/accessories/" + urlEncode(homeName) + "/" + urlEncode(roomName) + "/" + urlEncode(accessoryName); + // Diagnostic log: show constructed path and parameters so callers can see when roomName is empty + try { + std::cerr << "PrefabClient: getAccessory called home=\"" << homeName + << "\" room=\"" << roomName << "\" accessory=\"" << accessoryName + << "\" path=\"" << path << "\"" << std::endl; + } catch (...) {} + + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse accessory response: " + std::string(e.what())); + } + } + + std::string PrefabClient::updateAccessory(const std::string& homeName, + const std::string& roomName, + const std::string& accessoryName, + const UpdateAccessoryInput& update) { + std::string path = "/accessories/" + urlEncode(homeName) + "/" + urlEncode(roomName) + "/" + urlEncode(accessoryName); + // Diagnostic log: update path and params + try { + std::cerr << "PrefabClient: updateAccessory called home=\"" << homeName + << "\" room=\"" << roomName << "\" accessory=\"" << accessoryName + << "\" path=\"" << path << "\"" << std::endl; + } catch (...) {} + + try { + json j = update; + std::string body = j.dump(); + return makeHttpRequest("PUT", path, body); + } catch (const json::exception& e) { + throw PrefabException("Failed to serialize update request: " + std::string(e.what())); + } + } + + std::string PrefabClient::updateCharacteristicByType(const std::string& homeName, + const std::string& roomName, + const std::string& accessoryName, + const std::string& characteristicType, + const std::string& value) { + // First, get the accessory details to find the characteristic + Accessory accessory = getAccessory(homeName, roomName, accessoryName); + + if (!accessory.services.has_value()) { + throw PrefabException("Accessory has no services"); + } + + // Find the characteristic with the matching type (UUID or typeName) and get both service and characteristic IDs + std::string serviceId; + std::string characteristicId; + for (const auto& service : accessory.services.value()) { + for (const auto& characteristic : service.characteristics) { + // Match by UUID (type field) or by typeName + if (characteristic.type == characteristicType || + characteristic.typeName == characteristicType) { + serviceId = service.uniqueIdentifier; + characteristicId = characteristic.uniqueIdentifier; + break; + } + } + if (!characteristicId.empty()) break; + } + + if (characteristicId.empty()) { + throw PrefabException("Characteristic type not found: " + characteristicType); + } + + // Create update request with both serviceId and characteristicId to match Swift server API + UpdateAccessoryInput update; + update.serviceId = serviceId; + update.characteristicId = characteristicId; + update.value = value; + + return updateAccessory(homeName, roomName, accessoryName, update); + } + + // Avahi discovery implementation (always compiled) + struct AvahiDiscoveryData { + ServiceDiscoveryCallback callback; + bool foundService; + AvahiSimplePoll* simple_poll; + std::mutex mutex; + std::condition_variable cv; + }; + + static void resolve_callback( + AvahiServiceResolver *r, + [[maybe_unused]] AvahiIfIndex interface, + [[maybe_unused]] AvahiProtocol protocol, + AvahiResolverEvent event, + [[maybe_unused]] const char *name, + [[maybe_unused]] const char *type, + [[maybe_unused]] const char *domain, + [[maybe_unused]] const char *host_name, + const AvahiAddress *address, + uint16_t port, + [[maybe_unused]] AvahiStringList *txt, + [[maybe_unused]] AvahiLookupResultFlags flags, + void* userdata) { + + AvahiDiscoveryData* data = static_cast(userdata); + + switch (event) { + case AVAHI_RESOLVER_FAILURE: + break; + + case AVAHI_RESOLVER_FOUND: { + char addr_str[AVAHI_ADDRESS_STR_MAX]; + avahi_address_snprint(addr_str, sizeof(addr_str), address); + + data->callback(std::string(addr_str), port); + { + std::lock_guard lk(data->mutex); + data->foundService = true; + } + data->cv.notify_one(); + // Stop polling after first service found + avahi_simple_poll_quit(data->simple_poll); + break; + } + } + + avahi_service_resolver_free(r); + } + + static void browse_callback( + AvahiServiceBrowser *b, + AvahiIfIndex interface, + AvahiProtocol protocol, + AvahiBrowserEvent event, + const char *name, + const char *type, + const char *domain, + [[maybe_unused]] AvahiLookupResultFlags flags, + void* userdata) { + + AvahiDiscoveryData* data = static_cast(userdata); + AvahiClient* client = avahi_service_browser_get_client(b); + + switch (event) { + case AVAHI_BROWSER_FAILURE: + avahi_simple_poll_quit(data->simple_poll); + break; + + case AVAHI_BROWSER_NEW: + // Resolve the service + if (!(avahi_service_resolver_new(client, interface, protocol, name, type, domain, + AVAHI_PROTO_UNSPEC, static_cast(0), + resolve_callback, userdata))) { + avahi_simple_poll_quit(data->simple_poll); + } + break; + + case AVAHI_BROWSER_REMOVE: + case AVAHI_BROWSER_ALL_FOR_NOW: + case AVAHI_BROWSER_CACHE_EXHAUSTED: + break; + } + } + + static void client_callback([[maybe_unused]] AvahiClient *c, AvahiClientState state, void *userdata) { + AvahiDiscoveryData* data = static_cast(userdata); + if (!data) return; + if (state == AVAHI_CLIENT_FAILURE) { + { + std::lock_guard lk(data->mutex); + data->foundService = false; + } + data->cv.notify_one(); + avahi_simple_poll_quit(data->simple_poll); + } + } + + bool PrefabClient::discoverService() { + AvahiSimplePoll *simple_poll = avahi_simple_poll_new(); + if (!simple_poll) return false; + + // Prepare discovery data BEFORE creating the Avahi client so the client callback + // can safely access the userdata pointer. + AvahiDiscoveryData data; + data.foundService = false; + data.simple_poll = simple_poll; + data.callback = [this](const std::string& hostname, int port) { + std::string url = "http://" + hostname + ":" + std::to_string(port); + this->discoveredBaseUrl_ = url; + }; + + int error = 0; + AvahiClient *client = avahi_client_new(avahi_simple_poll_get(simple_poll), + static_cast(0), + client_callback, + &data, + &error); + if (!client) { + avahi_simple_poll_free(simple_poll); + return false; + } + + AvahiServiceBrowser *sb = avahi_service_browser_new(client, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, + "_prefab._tcp", nullptr, + static_cast(0), + browse_callback, &data); + if (!sb) { + avahi_client_free(client); + avahi_simple_poll_free(simple_poll); + return false; + } + + // Run the Avahi poll loop on a background thread and wait for a discovery + // event or timeout so this function returns instead of blocking forever. + std::thread poller([simple_poll]() { + avahi_simple_poll_loop(simple_poll); + }); + + // Wait up to 5 seconds for discovery + { + std::unique_lock lk(data.mutex); + data.cv.wait_for(lk, std::chrono::seconds(5), [&data]() { return data.foundService; }); + } + + // Ensure the poll loop stops (resolve_callback may have already called quit) + avahi_simple_poll_quit(simple_poll); + if (poller.joinable()) poller.join(); + + avahi_service_browser_free(sb); + avahi_client_free(client); + avahi_simple_poll_free(simple_poll); + + return data.foundService; + } + + bool PrefabClient::discoverServices(ServiceDiscoveryCallback callback, [[maybe_unused]] int timeoutMs) { + AvahiSimplePoll *simple_poll = avahi_simple_poll_new(); + if (!simple_poll) return false; + + AvahiDiscoveryData data; + data.foundService = false; + data.simple_poll = simple_poll; + data.callback = callback; + + int error = 0; + AvahiClient *client = avahi_client_new(avahi_simple_poll_get(simple_poll), + static_cast(0), + client_callback, + &data, + &error); + if (!client) { + avahi_simple_poll_free(simple_poll); + return false; + } + + AvahiServiceBrowser *sb = avahi_service_browser_new(client, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, + "_prefab._tcp", nullptr, + static_cast(0), + browse_callback, &data); + if (!sb) { + avahi_client_free(client); + avahi_simple_poll_free(simple_poll); + return false; + } + + // Run poll loop on a background thread and wait for discovery or timeout + std::thread poller([simple_poll]() { + avahi_simple_poll_loop(simple_poll); + }); + + int waitMs = (timeoutMs > 0) ? timeoutMs : 5000; + { + std::unique_lock lk(data.mutex); + data.cv.wait_for(lk, std::chrono::milliseconds(waitMs), [&data]() { return data.foundService; }); + } + + avahi_simple_poll_quit(simple_poll); + if (poller.joinable()) poller.join(); + + avahi_service_browser_free(sb); + avahi_client_free(client); + avahi_simple_poll_free(simple_poll); + + return data.foundService; + } + + // ======================================================================== + // Scene API methods + // ======================================================================== + + std::vector PrefabClient::getScenes(const std::string& homeName) { + std::string path = "/scenes/" + urlEncode(homeName); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get>(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse scenes response: " + std::string(e.what())); + } + } + + SceneDetail PrefabClient::getScene(const std::string& homeName, const std::string& sceneId) { + std::string path = "/scenes/" + urlEncode(homeName) + "/" + urlEncode(sceneId); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse scene response: " + std::string(e.what())); + } + } + + std::string PrefabClient::executeScene(const std::string& homeName, const std::string& sceneId) { + std::string path = "/scenes/" + urlEncode(homeName) + "/" + urlEncode(sceneId) + "/execute"; + return makeHttpRequest("POST", path); + } + + // ======================================================================== + // Accessory Group API methods + // ======================================================================== + + std::vector PrefabClient::getGroups(const std::string& homeName) { + std::string path = "/groups/" + urlEncode(homeName); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get>(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse groups response: " + std::string(e.what())); + } + } + + AccessoryGroupDetail PrefabClient::getGroup(const std::string& homeName, const std::string& groupId) { + std::string path = "/groups/" + urlEncode(homeName) + "/" + urlEncode(groupId); + std::string response = makeHttpRequest("GET", path); + + try { + json j = json::parse(response); + return j.get(); + } catch (const json::exception& e) { + throw PrefabException("Failed to parse group response: " + std::string(e.what())); + } + } + + std::string PrefabClient::updateGroup(const std::string& homeName, + const std::string& groupId, + const UpdateGroupInput& update) { + std::string path = "/groups/" + urlEncode(homeName) + "/" + urlEncode(groupId); + + try { + json j = update; + std::string body = j.dump(); + return makeHttpRequest("PUT", path, body); + } catch (const json::exception& e) { + throw PrefabException("Failed to serialize group update request: " + std::string(e.what())); + } + } + +} // namespace prefab \ No newline at end of file diff --git a/cpp-client/tests/CMakeLists.txt b/cpp-client/tests/CMakeLists.txt new file mode 100644 index 0000000..6fecac3 --- /dev/null +++ b/cpp-client/tests/CMakeLists.txt @@ -0,0 +1,8 @@ +# Tests CMakeLists.txt + +# Simple test +add_executable(test_models test_models.cpp) +target_link_libraries(test_models prefab-client) + +# Add test +add_test(NAME test_models COMMAND test_models) \ No newline at end of file diff --git a/cpp-client/tests/test_models.cpp b/cpp-client/tests/test_models.cpp new file mode 100644 index 0000000..4fea8fc --- /dev/null +++ b/cpp-client/tests/test_models.cpp @@ -0,0 +1,68 @@ +#include +#include +#include + +int main() { + std::cout << "Testing Prefab C++ Models..." << std::endl; + + try { + // Test Home serialization + prefab::Home home; + home.name = "Test Home"; + + nlohmann::json j = home; + auto home2 = j.get(); + assert(home.name == home2.name); + std::cout << "✓ Home serialization test passed" << std::endl; + + // Test Room serialization + prefab::Room room; + room.home = "Test Home"; + room.name = "Living Room"; + + nlohmann::json j2 = room; + auto room2 = j2.get(); + assert(room.home == room2.home); + assert(room.name == room2.name); + std::cout << "✓ Room serialization test passed" << std::endl; + + // Test Accessory basic serialization + prefab::Accessory accessory; + accessory.home = "Test Home"; + accessory.room = "Living Room"; + accessory.name = "Smart Light"; + accessory.manufacturer = "Test Manufacturer"; + accessory.isReachable = true; + + nlohmann::json j3 = accessory; + auto accessory2 = j3.get(); + assert(accessory.home == accessory2.home); + assert(accessory.room == accessory2.room); + assert(accessory.name == accessory2.name); + assert(accessory.manufacturer == accessory2.manufacturer); + assert(accessory.isReachable == accessory2.isReachable); + std::cout << "✓ Accessory serialization test passed" << std::endl; + + // Test UpdateAccessoryInput + prefab::UpdateAccessoryInput update; + update.serviceId = "test-service-uuid"; + update.characteristicId = "test-characteristic-uuid"; + update.value = "50"; + + nlohmann::json j4 = update; + auto update2 = j4.get(); + assert(update.serviceId == update2.serviceId); + assert(update.characteristicId == update2.characteristicId); + assert(update.value == update2.value); + std::cout << "✓ UpdateAccessoryInput serialization test passed" << std::endl; + + std::cout << std::endl; + std::cout << "All model tests passed!" << std::endl; + + } catch (const std::exception& e) { + std::cerr << "Test failed: " << e.what() << std::endl; + return 1; + } + + return 0; +} \ No newline at end of file diff --git a/prefab-client/Client/Client.swift b/prefab-client/Client/Client.swift index 2a8a1e9..9b4f480 100644 --- a/prefab-client/Client/Client.swift +++ b/prefab-client/Client/Client.swift @@ -32,7 +32,17 @@ class ServiceDiscoveryDelegate: NSObject, NetServiceBrowserDelegate, NetServiceD self.discoveryCompletionHandler = { result in continuation.resume(with: result) } - serviceBrowser.searchForServices(ofType: "_http._tcp.", inDomain: "") + serviceBrowser.searchForServices(ofType: "_http._tcp.", inDomain: "local.") + + // Pump the run loop so NetServiceBrowser can deliver callbacks in this async context + DispatchQueue.global().async { + let end = Date().addingTimeInterval(5.0) + let runLoop = RunLoop.current + runLoop.add(Port(), forMode: .default) + while Date() < end && self.discoveryCompletionHandler != nil { + runLoop.run(mode: .default, before: Date().addingTimeInterval(0.1)) + } + } // Set a timeout to prevent hanging indefinitely DispatchQueue.global().asyncAfter(deadline: .now() + 5.0) { @@ -70,7 +80,8 @@ class ServiceDiscoveryDelegate: NSObject, NetServiceBrowserDelegate, NetServiceD serviceBrowser.stop() if let hostName = sender.hostName { - discoveryCompletionHandler?(.success(Client.initShared(host: hostName, port: String(sender.port), scheme: "http"))) + let cleanHost = hostName.hasSuffix(".") ? String(hostName.dropLast()) : hostName + discoveryCompletionHandler?(.success(Client.initShared(host: cleanHost, port: String(sender.port), scheme: "http"))) } else { discoveryCompletionHandler?(.failure(.invalidServiceData)) } diff --git a/prefab.xcodeproj/project.pbxproj b/prefab.xcodeproj/project.pbxproj index af0f73d..7e71432 100644 --- a/prefab.xcodeproj/project.pbxproj +++ b/prefab.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ A2EF401D2D71362000CFB0C5 /* HAPUUIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2EF401C2D71362000CFB0C5 /* HAPUUIDs.swift */; }; A2EF401E2D71362000CFB0C5 /* HAPUUIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2EF401C2D71362000CFB0C5 /* HAPUUIDs.swift */; }; A2EF40202D713C0600CFB0C5 /* HAPUUIDsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2EF401F2D713C0600CFB0C5 /* HAPUUIDsTests.swift */; }; + A2F2CD382EE3B0F200D189DC /* Routes+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2F2CD362EE3B0F200D189DC /* Routes+Groups.swift */; }; + A2F2CD392EE3B0F200D189DC /* Routes+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2F2CD372EE3B0F200D189DC /* Routes+Scenes.swift */; }; CB78182B2B7D802B0077671A /* prefabApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB78182A2B7D802B0077671A /* prefabApp.swift */; }; CB78182D2B7D802B0077671A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB78182C2B7D802B0077671A /* ContentView.swift */; }; CB78182F2B7D802B0077671A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB78182E2B7D802B0077671A /* Assets.xcassets */; }; @@ -94,6 +96,8 @@ A2EF401C2D71362000CFB0C5 /* HAPUUIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAPUUIDs.swift; sourceTree = ""; }; A2EF401F2D713C0600CFB0C5 /* HAPUUIDsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAPUUIDsTests.swift; sourceTree = ""; }; A2EF40212D713C5700CFB0C5 /* Prefab.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Prefab.xctestplan; sourceTree = ""; }; + A2F2CD362EE3B0F200D189DC /* Routes+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Groups.swift"; sourceTree = ""; }; + A2F2CD372EE3B0F200D189DC /* Routes+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Scenes.swift"; sourceTree = ""; }; CB7818272B7D802B0077671A /* Prefab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Prefab.app; sourceTree = BUILT_PRODUCTS_DIR; }; CB78182A2B7D802B0077671A /* prefabApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = prefabApp.swift; sourceTree = ""; }; CB78182C2B7D802B0077671A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -232,6 +236,8 @@ CB9C51862B7D8493007C1AD4 /* http */ = { isa = PBXGroup; children = ( + A2F2CD362EE3B0F200D189DC /* Routes+Groups.swift */, + A2F2CD372EE3B0F200D189DC /* Routes+Scenes.swift */, CB9C51872B7D8493007C1AD4 /* Data.swift */, CB9C51882B7D8493007C1AD4 /* Server.swift */, CB9C51892B7D8493007C1AD4 /* Routes.swift */, @@ -459,6 +465,8 @@ CB9C518B2B7D8493007C1AD4 /* Server.swift in Sources */, CB9C518F2B7D849D007C1AD4 /* HomeBase.swift in Sources */, CB78182B2B7D802B0077671A /* prefabApp.swift in Sources */, + A2F2CD382EE3B0F200D189DC /* Routes+Groups.swift in Sources */, + A2F2CD392EE3B0F200D189DC /* Routes+Scenes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -668,8 +676,9 @@ PRODUCT_BUNDLE_IDENTIFIER = com.kellyp.prefab; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -708,8 +717,9 @@ PRODUCT_BUNDLE_IDENTIFIER = com.kellyp.prefab; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/prefab/http/Data.swift b/prefab/http/Data.swift index af9491e..128d549 100644 --- a/prefab/http/Data.swift +++ b/prefab/http/Data.swift @@ -95,3 +95,62 @@ func GetValue(value: String, format: String) throws -> Any { throw UnknownFormatError.formatValue(format: format) } } + +// MARK: - Scenes + +/// Basic scene info (list view) +struct HomeKitScene: Encodable, Decodable { + var home: String + var uniqueIdentifier: UUID + var name: String + var isBuiltIn: Bool +} + +/// Action within a scene +struct SceneAction: Encodable, Decodable { + var accessoryName: String + var serviceName: String + var characteristicType: String + var targetValue: String +} + +/// Detailed scene info including actions +struct SceneDetail: Encodable, Decodable { + var home: String + var uniqueIdentifier: UUID + var name: String + var isBuiltIn: Bool + var actions: [SceneAction] +} + +// MARK: - Accessory Groups + +/// Service within a group +struct GroupService: Encodable, Decodable { + var accessoryName: String + var serviceName: String + var serviceType: String + var uniqueIdentifier: UUID +} + +/// Basic group info (list view) +struct AccessoryGroup: Encodable, Decodable { + var home: String + var uniqueIdentifier: UUID + var name: String + var serviceCount: Int +} + +/// Detailed group info including services +struct AccessoryGroupDetail: Encodable, Decodable { + var home: String + var uniqueIdentifier: UUID + var name: String + var services: [GroupService] +} + +/// Input for updating group characteristics +struct UpdateGroupInput: Encodable, Decodable { + var characteristicType: String + var value: String +} diff --git a/prefab/http/Routes+Accessories.swift b/prefab/http/Routes+Accessories.swift index 3d1a327..3d7d939 100644 --- a/prefab/http/Routes+Accessories.swift +++ b/prefab/http/Routes+Accessories.swift @@ -23,7 +23,9 @@ extension Server { throw HBHTTPError(.notFound) } - let accessories = room?.accessories.map{ (hmAccessory: HMAccessory) -> Accessory in Accessory(home: home!.name, room: room!.name, name: hmAccessory.name)} + let accessories = room?.accessories.map{ (hmAccessory: HMAccessory) -> Accessory in + Accessory(home: home!.name, room: room!.name, name: hmAccessory.name, category: hmAccessory.category.localizedDescription) + } let jsonEncoder = JSONEncoder() let jsonData = try jsonEncoder.encode(accessories) let json = String(data: jsonData, encoding: String.Encoding.utf8) @@ -71,53 +73,105 @@ extension Server { } func updateAccessory(_ request: HBRequest) throws -> String { - var updateAccessoryInput: UpdateAccessoryInput + let logger = Logger(subsystem: "app.prefab", category: "updateAccessory") + logger.debug("updateAccessory called") + + // Ensure request body exists to avoid force-unwrapping crashes + guard let bodyBuffer = request.body.buffer else { + logger.error("Request body is missing.") + throw HBHTTPError(.badRequest, message: "Missing request body.") + } + + // Log raw request body for debugging + if let rawJSON = bodyBuffer.getString(at: bodyBuffer.readerIndex, length: bodyBuffer.readableBytes) { + logger.debug("Raw request body: \(rawJSON, privacy: .public)") + } else { + logger.debug("Raw request body could not be decoded as UTF-8. Byte count: \(bodyBuffer.readableBytes, privacy: .public)") + } + + // Decode input + let updateAccessoryInput: UpdateAccessoryInput do { - updateAccessoryInput = try JSONDecoder().decode(UpdateAccessoryInput.self, from: request.body.buffer!) + updateAccessoryInput = try JSONDecoder().decode(UpdateAccessoryInput.self, from: bodyBuffer) + logger.debug("Decoded UpdateAccessoryInput: serviceId=\(updateAccessoryInput.serviceId, privacy: .public), characteristicId=\(updateAccessoryInput.characteristicId, privacy: .public), value=\(updateAccessoryInput.value, privacy: .public)") } catch { - throw HBHTTPError( - .badRequest, - message: "Invalid update object." - ) + logger.error("Failed to decode UpdateAccessoryInput: \(error.localizedDescription, privacy: .public)") + throw HBHTTPError(.badRequest, message: "Invalid update object.") } - + + // Extract params let homeName = try getRequiredParam(param: "home", request: request) let roomName = try getRequiredParam(param: "room", request: request) let accessoryName = try getRequiredParam(param: "accessory", request: request) - - let home = homeBase.homes.first(where: {$0.name == homeName.removingPercentEncoding}) - if (home == nil) { + logger.debug("Params home=\(homeName, privacy: .public), room=\(roomName, privacy: .public), accessory=\(accessoryName, privacy: .public)") + + // Locate Home + let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) + guard let home else { + logger.error("Home not found: \(homeName, privacy: .public)") throw HBHTTPError(.notFound) } - let room = home?.rooms.first(where: {$0.name == roomName.removingPercentEncoding}) - if (room == nil) { + logger.debug("Found home: \(home.name, privacy: .public)") + + // Locate Room + let room = home.rooms.first(where: { $0.name == roomName.removingPercentEncoding }) + guard let room else { + logger.error("Room not found: \(roomName, privacy: .public)") throw HBHTTPError(.notFound) } - let hkAccessory = room?.accessories.first(where: { (hmAccessory: HMAccessory) -> Bool in hmAccessory.name == accessoryName.removingPercentEncoding}) - if (hkAccessory == nil) { + logger.debug("Found room: \(room.name, privacy: .public)") + + // Locate Accessory + let hkAccessory = room.accessories.first(where: { $0.name == accessoryName.removingPercentEncoding }) + guard let hkAccessory else { + logger.error("Accessory not found: \(accessoryName, privacy: .public)") throw HBHTTPError(.notFound) } + logger.debug("Found accessory: \(hkAccessory.name, privacy: .public) reachable=\(hkAccessory.isReachable, privacy: .public)") - let hkService = hkAccessory?.services.first(where: { (hmService: HMService) -> Bool in hmService.uniqueIdentifier.uuidString == updateAccessoryInput.serviceId}) - if (hkAccessory == nil) { - Logger().debug("Service not found \(updateAccessoryInput.serviceId)") + // Locate Service + let hkService = hkAccessory.services.first(where: { $0.uniqueIdentifier.uuidString == updateAccessoryInput.serviceId }) + guard let hkService else { + logger.error("Service not found: \(updateAccessoryInput.serviceId, privacy: .public)") throw HBHTTPError(.notFound) } - - let hkChar = hkService?.characteristics.first(where: { (hmChar: HMCharacteristic) -> Bool in hmChar.uniqueIdentifier.uuidString == updateAccessoryInput.characteristicId}) - if (hkAccessory == nil) { - Logger().debug("Characteristic not found \(updateAccessoryInput.characteristicId)") + logger.debug("Found service: \(hkService.name, privacy: .public) type=\(hkService.serviceType, privacy: .public)") + + // Locate Characteristic + let hkChar = hkService.characteristics.first(where: { $0.uniqueIdentifier.uuidString == updateAccessoryInput.characteristicId }) + guard let hkChar else { + logger.error("Characteristic not found: \(updateAccessoryInput.characteristicId, privacy: .public)") throw HBHTTPError(.notFound) } - + logger.debug("Found characteristic: \(hkChar.localizedDescription, privacy: .public) type=\(hkChar.characteristicType, privacy: .public) format=\(hkChar.metadata?.format ?? "nil", privacy: .public) properties=\(hkChar.properties.joined(separator: ","), privacy: .public)") + + // Prepare value for write + let valueToWrite: Any + do { + valueToWrite = try GetValue(value: updateAccessoryInput.value, format: hkChar.metadata?.format ?? "") + logger.debug("Prepared value to write: \(String(describing: valueToWrite), privacy: .public)") + } catch { + logger.error("Failed to convert value '\(updateAccessoryInput.value, privacy: .public)' with format '\(hkChar.metadata?.format ?? "nil", privacy: .public)': \(error.localizedDescription, privacy: .public)") + throw error + } + + logger.debug("Attempting write to characteristic \(hkChar.uniqueIdentifier.uuidString, privacy: .public)") - Logger().debug("Writing \(updateAccessoryInput.value) to \(hkChar)") - let group = DispatchGroup() group.enter() - hkChar?.writeValue(try GetValue(value: updateAccessoryInput.value, format: hkChar?.metadata?.format ?? ""), completionHandler: { (error: Error?) -> Void in defer {group.leave()}; Logger().error("\(String(describing: error))") }) + hkChar.writeValue(valueToWrite) { error in + if let error { + logger.error("writeValue completion with error: \(error.localizedDescription, privacy: .public)") + } else { + logger.debug("writeValue completed successfully.") + } + group.leave() + } + group.wait() + logger.debug("writeValue wait completed.") - return "" //json! + return "" } } + diff --git a/prefab/http/Routes+Groups.swift b/prefab/http/Routes+Groups.swift new file mode 100644 index 0000000..c230603 --- /dev/null +++ b/prefab/http/Routes+Groups.swift @@ -0,0 +1,154 @@ +// +// Routes+Groups.swift +// Prefab +// +// Created by Copilot on 2025. +// + +import Foundation +import HomeKit +import Hummingbird +import OSLog + +extension Server { + + /// GET /groups/:home - List all accessory groups in a home + func getGroups(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + throw HBHTTPError(.notFound) + } + + let groups = home.serviceGroups.map { serviceGroup in + AccessoryGroup( + home: home.name, + uniqueIdentifier: serviceGroup.uniqueIdentifier, + name: serviceGroup.name, + serviceCount: serviceGroup.services.count + ) + } + + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(groups) + let json = String(data: jsonData, encoding: .utf8) + + return json! + } + + /// GET /groups/:home/:group - Get detailed group info + func getGroup(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let groupId = try getRequiredParam(param: "group", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + throw HBHTTPError(.notFound) + } + + guard let groupUUID = UUID(uuidString: groupId), + let serviceGroup = home.serviceGroups.first(where: { $0.uniqueIdentifier == groupUUID }) else { + throw HBHTTPError(.notFound) + } + + let services = serviceGroup.services.map { service in + GroupService( + accessoryName: service.accessory?.name ?? "", + serviceName: service.name, + serviceType: service.serviceType, + uniqueIdentifier: service.uniqueIdentifier + ) + } + + let groupDetail = AccessoryGroupDetail( + home: home.name, + uniqueIdentifier: serviceGroup.uniqueIdentifier, + name: serviceGroup.name, + services: services + ) + + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(groupDetail) + let json = String(data: jsonData, encoding: .utf8) + + return json! + } + + /// PUT /groups/:home/:group - Update all accessories in a group + func updateGroup(_ request: HBRequest) throws -> String { + let logger = Logger(subsystem: "app.prefab", category: "updateGroup") + + guard let bodyBuffer = request.body.buffer else { + logger.error("Request body is missing.") + throw HBHTTPError(.badRequest, message: "Missing request body.") + } + + let updateInput: UpdateGroupInput + do { + updateInput = try JSONDecoder().decode(UpdateGroupInput.self, from: bodyBuffer) + logger.debug("Decoded UpdateGroupInput: characteristicType=\(updateInput.characteristicType, privacy: .public), value=\(updateInput.value, privacy: .public)") + } catch { + logger.error("Failed to decode UpdateGroupInput: \(error.localizedDescription, privacy: .public)") + throw HBHTTPError(.badRequest, message: "Invalid update object.") + } + + let homeName = try getRequiredParam(param: "home", request: request) + let groupId = try getRequiredParam(param: "group", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + logger.error("Home not found: \(homeName, privacy: .public)") + throw HBHTTPError(.notFound) + } + + guard let groupUUID = UUID(uuidString: groupId), + let serviceGroup = home.serviceGroups.first(where: { $0.uniqueIdentifier == groupUUID }) else { + logger.error("Group not found: \(groupId, privacy: .public)") + throw HBHTTPError(.notFound) + } + + logger.debug("Updating group: \(serviceGroup.name, privacy: .public) with \(serviceGroup.services.count) services") + + // Find all characteristics of the requested type and update them + var successCount = 0 + var failCount = 0 + let group = DispatchGroup() + + for service in serviceGroup.services { + for characteristic in service.characteristics { + if characteristic.characteristicType == updateInput.characteristicType { + // Convert value based on format + let valueToWrite: Any + do { + valueToWrite = try GetValue(value: updateInput.value, format: characteristic.metadata?.format ?? "") + } catch { + logger.error("Failed to convert value for characteristic: \(error.localizedDescription, privacy: .public)") + failCount += 1 + continue + } + + group.enter() + characteristic.writeValue(valueToWrite) { error in + if let error = error { + logger.error("Write failed for \(service.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + failCount += 1 + } else { + logger.debug("Write succeeded for \(service.name, privacy: .public)") + successCount += 1 + } + group.leave() + } + } + } + } + + group.wait() + + let response: [String: Any] = [ + "success": failCount == 0, + "group": serviceGroup.name, + "updated": successCount, + "failed": failCount + ] + let jsonData = try JSONSerialization.data(withJSONObject: response) + return String(data: jsonData, encoding: .utf8)! + } +} diff --git a/prefab/http/Routes+Scenes.swift b/prefab/http/Routes+Scenes.swift new file mode 100644 index 0000000..8e4d139 --- /dev/null +++ b/prefab/http/Routes+Scenes.swift @@ -0,0 +1,121 @@ +// +// Routes+Scenes.swift +// Prefab +// +// Created by Copilot on 2025. +// + +import Foundation +import HomeKit +import Hummingbird +import OSLog + +extension Server { + + /// GET /scenes/:home - List all scenes in a home + func getScenes(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + throw HBHTTPError(.notFound) + } + + let scenes = home.actionSets.map { actionSet in + HomeKitScene( + home: home.name, + uniqueIdentifier: actionSet.uniqueIdentifier, + name: actionSet.name, + isBuiltIn: actionSet.actionSetType != HMActionSetTypeUserDefined + ) + } + + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(scenes) + let json = String(data: jsonData, encoding: .utf8) + + return json! + } + + /// GET /scenes/:home/:scene - Get detailed scene info + func getScene(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let sceneId = try getRequiredParam(param: "scene", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + throw HBHTTPError(.notFound) + } + + guard let sceneUUID = UUID(uuidString: sceneId), + let actionSet = home.actionSets.first(where: { $0.uniqueIdentifier == sceneUUID }) else { + throw HBHTTPError(.notFound) + } + + let actions = actionSet.actions.compactMap { action -> SceneAction? in + guard let charAction = action as? HMCharacteristicWriteAction else { + return nil + } + return SceneAction( + accessoryName: charAction.characteristic.service?.accessory?.name ?? "", + serviceName: charAction.characteristic.service?.name ?? "", + characteristicType: charAction.characteristic.characteristicType, + targetValue: "\(charAction.targetValue)" + ) + } + + let sceneDetail = SceneDetail( + home: home.name, + uniqueIdentifier: actionSet.uniqueIdentifier, + name: actionSet.name, + isBuiltIn: actionSet.actionSetType != HMActionSetTypeUserDefined, + actions: actions + ) + + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(sceneDetail) + let json = String(data: jsonData, encoding: .utf8) + + return json! + } + + /// POST /scenes/:home/:scene/execute - Execute a scene + func executeScene(_ request: HBRequest) throws -> String { + let logger = Logger(subsystem: "app.prefab", category: "executeScene") + let homeName = try getRequiredParam(param: "home", request: request) + let sceneId = try getRequiredParam(param: "scene", request: request) + + guard let home = homeBase.homes.first(where: { $0.name == homeName.removingPercentEncoding }) else { + logger.error("Home not found: \(homeName, privacy: .public)") + throw HBHTTPError(.notFound) + } + + guard let sceneUUID = UUID(uuidString: sceneId), + let actionSet = home.actionSets.first(where: { $0.uniqueIdentifier == sceneUUID }) else { + logger.error("Scene not found: \(sceneId, privacy: .public)") + throw HBHTTPError(.notFound) + } + + logger.debug("Executing scene: \(actionSet.name, privacy: .public)") + + var executeError: Error? + let group = DispatchGroup() + group.enter() + home.executeActionSet(actionSet) { error in + if let error = error { + logger.error("Scene execution failed: \(error.localizedDescription, privacy: .public)") + executeError = error + } else { + logger.debug("Scene executed successfully") + } + group.leave() + } + group.wait() + + if let error = executeError { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) + } + + let response = ["success": true, "scene": actionSet.name] as [String: Any] + let jsonData = try JSONSerialization.data(withJSONObject: response) + return String(data: jsonData, encoding: .utf8)! + } +} diff --git a/prefab/http/Server.swift b/prefab/http/Server.swift index 2449e7a..0131a98 100644 --- a/prefab/http/Server.swift +++ b/prefab/http/Server.swift @@ -9,6 +9,71 @@ import Foundation import OSLog import Hummingbird +// BonjourAdvertiser: manage NetService creation, TXT record, publish, retries on name conflict. +final class BonjourAdvertiser: NSObject, NetServiceDelegate { + private var service: NetService? + private let baseName: String + private let serviceType: String + private let port: Int32 + private let txtData: [String: String] + private var attempt = 0 + + init(name: String, type: String, port: Int32, txt: [String:String]) { + self.baseName = name + self.serviceType = type + self.port = port + self.txtData = txt + super.init() + createService(name: name) + } + + private func createService(name: String) { + let cleanType = serviceType.trimmingCharacters(in: .whitespacesAndNewlines) + let svc = NetService(domain: "", type: cleanType, name: name, port: port) + // Don't set peer-to-peer for initial testing + // svc.includesPeerToPeer = true + let txtDict = txtData.reduce(into: [String: Data]()) { $0[$1.key] = $1.value.data(using: .utf8) } + svc.setTXTRecord(NetService.data(fromTXTRecord: txtDict)) + svc.delegate = self + self.service = svc + } + + func publish() { + let cleanType = serviceType.trimmingCharacters(in: .whitespacesAndNewlines) + print("🔎 Publishing Bonjour service: \(service?.name ?? "unknown") type: \(cleanType) port: \(service?.port ?? 0)") + service?.publish() + } + + func stop() { + service?.stop() + service = nil + } + + // Optional: accept connections if using NetService sockets; here we only advertise HTTP on port. + func netServiceDidPublish(_ sender: NetService) { + let cleanType = sender.type.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanDomain = sender.domain.trimmingCharacters(in: .whitespacesAndNewlines) + print("✅ Bonjour published: \(cleanType) \(sender.name).\(cleanDomain)") + } + func netService(_ sender: NetService, didNotPublish errorDict: [String : NSNumber]) { + if let errorCode = errorDict["NSNetServicesErrorCode"]?.intValue { + switch errorCode { + case -72003: // kDNSServiceErr_NameConflict + attempt += 1 + let newName = attempt == 1 ? "\(baseName) (2)" : "\(baseName) (\(attempt + 1))" + print("⚠️ Name conflict, retrying as '\(newName)'") + createService(name: newName) + publish() + return + default: + print("❌ Bonjour error: \(errorDict)") + } + } else { + print("❌ Bonjour error: \(errorDict)") + } + } +} + struct HomeKitAuthLogger: HBMiddleware { func apply(to request: HBRequest, next: HBResponder) -> EventLoopFuture { let homebase = HomeBase() @@ -25,11 +90,12 @@ struct HomeKitAuthLogger: HBMiddleware { class Server { var homeBase: HomeBase - private var netService: NetService? + private var bonjourAdvertiser: BonjourAdvertiser? init() { - homeBase = HomeBase() - let serverThread = Thread.init(target: self, selector: #selector(startServer), object: HomeBase()) + self.homeBase = HomeBase.shared + // Start server and mDNS on a background thread with a run loop + let serverThread = Thread(target: self, selector: #selector(startServer), object: nil) serverThread.start() } @@ -38,30 +104,21 @@ class Server { } private func startAdvertising() { - // Create and configure the NetService for mDNS advertising - let txtData: [String: Data] = [ - "server": "prefab".data(using: .utf8)!, - "version": "1.0".data(using: .utf8)!, - "api": "homekit".data(using: .utf8)! + // Create and configure the BonjourAdvertiser for mDNS advertising + let txtData: [String: String] = [ + "server": "prefab", + "version": "1.0", + "api": "homekit" ] - netService = NetService(domain: "", type: "_http._tcp.", name: "Prefab HomeKit Bridge", port: 8080) - let txtRecord = NetService.data(fromTXTRecord: txtData) - netService?.setTXTRecord(txtRecord) - - guard let service = netService else { - Logger().error("Failed to create NetService") - return - } - - // Start advertising - service.publish() + bonjourAdvertiser = BonjourAdvertiser(name: "Prefab HomeKit Bridge", type: "_prefab._tcp.", port: 8080, txt: txtData) + bonjourAdvertiser?.publish() Logger().info("Started mDNS advertising for Prefab HomeKit Server on port 8080") } private func stopAdvertising() { - netService?.stop() - netService = nil + bonjourAdvertiser?.stop() + bonjourAdvertiser = nil Logger().info("Stopped mDNS advertising") } @@ -76,7 +133,7 @@ class Server { } @objc - func startServer(homeStore: HomeBase) { + func startServer() { Task{ let app = HBApplication(configuration: .init(address: .hostname("0.0.0.0", port: 8080))) app.logger.logLevel = .debug @@ -92,10 +149,20 @@ class Server { app.router.get("accessories/:home/:room/:accessory", use: self.getAccessory) app.router.put("accessories/:home/:room/:accessory", use: self.updateAccessory) + app.router.get("scenes/:home", use: self.getScenes) + app.router.get("scenes/:home/:scene", use: self.getScene) + app.router.post("scenes/:home/:scene/execute", use: self.executeScene) + + app.router.get("groups/:home", use: self.getGroups) + app.router.get("groups/:home/:group", use: self.getGroup) + app.router.put("groups/:home/:group", use: self.updateGroup) + // Start mDNS advertising startAdvertising() try app.start() + RunLoop.current.add(Port(), forMode: .default) + while true { RunLoop.current.run(mode: .default, before: Date.distantFuture) } await app.asyncWait() } } diff --git a/prefab/prefabApp.swift b/prefab/prefabApp.swift index 949e85a..3cbbcc0 100644 --- a/prefab/prefabApp.swift +++ b/prefab/prefabApp.swift @@ -10,11 +10,9 @@ import SwiftUI @main struct prefabApp: App { + private let server = Server() @State var displayInstall: Bool = false - init() { - let _ = Server() - } var body: some Scene { WindowGroup { ContentView(homebase: HomeBase.shared)