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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1934a01 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.sourceDirectory": "/Users/kelly/Workspace/prefab/cpp-client" +} \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..60b78e6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "PrefabServer", + platforms: [ + .macOS(.v14_2) + ], + products: [ + .library( + name: "PrefabServer", + targets: ["PrefabServer"]), + ], + dependencies: [ + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "PrefabServer", + dependencies: [ + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + ]), + ] +) + diff --git a/README.md b/README.md index 419e17b..2fae606 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,12 @@ Prefab Prefab is an application that provides a simple HTTP interface to [HomeKit](https://developer.apple.com/documentation/homekit) data. As of this writing the native HomeKit APIs are only available on iOS based systems. The goal of this app is to provide HomeKit access to macOS. The Prefab application provides access to data provided by HomeKit while the prefab CLI tool provides a simple client to request HomeKit data and provide shell access. +This repository includes: +- **Prefab.app**: A standalone macOS application with HTTP server +- **prefab CLI**: Command-line tool for accessing HomeKit data +- **PrefabServer Swift Package**: Embed the HTTP server in your own macOS apps +- **C++ Client Library**: Access Prefab's API from C++ applications + ## Requirements - **Xcode**: Version 15.0 or later @@ -19,6 +25,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 +86,137 @@ 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) +### Swift Package (PrefabServer) + +This repository includes a Swift Package that allows other macOS apps to embed the Prefab HTTP server functionality: + +- **Location**: `Package.swift` and `Sources/PrefabServer/` directory +- **Purpose**: Embed HomeKit HTTP server in your own macOS applications +- **Target**: macOS 14.2+ applications +- **Features**: Full HTTP server, HomeKit integration, mDNS/Bonjour advertising, REST API endpoints + +#### Adding PrefabServer to Your Project + +**Option 1: Local Package (Development)** + +If you have the Prefab repository locally: + +1. In Xcode, select your project +2. Go to **File** → **Add Package Dependencies...** +3. Click **Add Local...** +4. Navigate to the Prefab repository directory +5. Select the directory and click **Add Package** + +**Option 2: Git Repository** + +If the package is hosted in a Git repository: + +1. In Xcode, select your project +2. Go to **File** → **Add Package Dependencies...** +3. Enter the repository URL: `https://github.com/kellyp/prefab.git` +4. Select the version or branch you want to use +5. Click **Add Package** + +#### Using PrefabServer in Your App + +1. **Import the package** in your Swift files: + ```swift + import PrefabServer + ``` + +2. **Configure your app target**: + - Enable **HomeKit** capability in **Signing & Capabilities** + - Add `NSHomeKitUsageDescription` to your `Info.plist`: + ```xml + NSHomeKitUsageDescription + This app needs access to HomeKit to control your smart home devices. + ``` + +3. **Create and start the server**: + ```swift + import PrefabServer + + class AppDelegate: NSObject, NSApplicationDelegate { + var server: PrefabServer? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Create server instance + server = PrefabServer() + + // Start the HTTP server + server?.start() + + // Server is now running on http://localhost:8080 + // It will automatically advertise via mDNS/Bonjour + } + + func applicationWillTerminate(_ notification: Notification) { + // Stop the server when app terminates + server?.stop() + } + } + ``` + +4. **Access HomeKit data**: + ```swift + // Access the HomeBase singleton + let homeBase = server?.homeBase + + // Monitor homes + homeBase?.$homes + .sink { homes in + print("Found \(homes.count) homes") + } + ``` + +#### Server Configuration + +The server runs with the following defaults: +- **Port**: 8080 +- **Host**: 0.0.0.0 (listens on all interfaces) +- **mDNS Service**: `_prefab._tcp.` +- **Service Name**: "Prefab HomeKit Bridge" + +#### API Endpoints + +Once started, the server provides the same REST API endpoints as the standalone Prefab app: + +- `GET /homes` - List all homes +- `GET /homes/:home` - Get specific home +- `GET /rooms/:home` - List rooms in a home +- `GET /rooms/:home/:room` - Get specific room +- `GET /accessories/:home/:room` - List accessories in a room +- `GET /accessories/:home/:room/:accessory` - Get accessory details +- `PUT /accessories/:home/:room/:accessory` - Update accessory +- `GET /scenes/:home` - List scenes in a home +- `GET /scenes/:home/:scene` - Get scene details +- `POST /scenes/:home/:scene/execute` - Execute a scene +- `GET /groups/:home` - List accessory groups +- `GET /groups/:home/:group` - Get group details +- `PUT /groups/:home/:group` - Update group + +#### Requirements for Consumers + +Apps using PrefabServer must: +- Target macOS 14.2 or later +- Have a valid Apple Developer account (paid membership required) +- Enable HomeKit capability in Xcode +- Include `NSHomeKitUsageDescription` in Info.plist +- Be properly code-signed (required for HomeKit APIs) + +See the [API Usage](#3-api-usage) section for examples of interacting with the HTTP endpoints. + +### 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 +340,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/Sources/PrefabServer/Data.swift b/Sources/PrefabServer/Data.swift new file mode 100644 index 0000000..d6d0d01 --- /dev/null +++ b/Sources/PrefabServer/Data.swift @@ -0,0 +1,255 @@ +// +// Data.swift +// PrefabServer +// +// Data models for API responses +// + +import Foundation + +public struct Home: Encodable, Decodable { + public var name: String + + public init(name: String) { + self.name = name + } +} + +public struct Room: Encodable, Decodable { + public var home: String + public var name: String + + public init(home: String, name: String) { + self.home = home + self.name = name + } +} + +public struct Accessory: Encodable, Decodable { + public var home: String + public var room: String + public var name: String + + public var category: String? + public var isReachable: Bool? + public var supportsIdentify: Bool? + public var isBridged: Bool? + + public var services: [Service]? + + public var firmwareVersion: String? + public var manufacturer: String? + public var model: String? + + public init(home: String, room: String, name: String, category: String? = nil, isReachable: Bool? = nil, supportsIdentify: Bool? = nil, isBridged: Bool? = nil, services: [Service]? = nil, firmwareVersion: String? = nil, manufacturer: String? = nil, model: String? = nil) { + self.home = home + self.room = room + self.name = name + self.category = category + self.isReachable = isReachable + self.supportsIdentify = supportsIdentify + self.isBridged = isBridged + self.services = services + self.firmwareVersion = firmwareVersion + self.manufacturer = manufacturer + self.model = model + } +} + +public struct Service: Encodable, Decodable { + public var uniqueIdentifier: UUID + public var name: String + public var typeName: String + public var type: String + public var isPrimary: Bool + public var isUserInteractive: Bool + public var associatedType: String? + + public var characteristics: [Characteristic] + + public init(uniqueIdentifier: UUID, name: String, typeName: String, type: String, isPrimary: Bool, isUserInteractive: Bool, associatedType: String? = nil, characteristics: [Characteristic]) { + self.uniqueIdentifier = uniqueIdentifier + self.name = name + self.typeName = typeName + self.type = type + self.isPrimary = isPrimary + self.isUserInteractive = isUserInteractive + self.associatedType = associatedType + self.characteristics = characteristics + } +} + +public struct Characteristic: Encodable, Decodable { + public var uniqueIdentifier: UUID + public var description: String + public var properties: [String] + public var typeName: String + public var type: String + public var metadata: CharacteristicMetadata? + public var value: String? + + public init(uniqueIdentifier: UUID, description: String, properties: [String], typeName: String, type: String, metadata: CharacteristicMetadata? = nil, value: String? = nil) { + self.uniqueIdentifier = uniqueIdentifier + self.description = description + self.properties = properties + self.typeName = typeName + self.type = type + self.metadata = metadata + self.value = value + } +} + +public struct CharacteristicMetadata: Encodable, Decodable { + public init(manufacturerDescription: String? = nil, validValues: [String]? = nil, minimumValue: String? = nil, maximumValue: String? = nil, stepValue: String? = nil, maxLength: String? = nil, format: String? = nil, units: String? = nil) { + self.manufacturerDescription = manufacturerDescription + self.validValues = validValues + self.minimumValue = minimumValue + self.maximumValue = maximumValue + self.stepValue = stepValue + self.maxLength = maxLength + self.format = format + self.units = units + } + public var manufacturerDescription: String? + public var validValues: [String]? + public var minimumValue: (String)? + public var maximumValue: (String)? + public var stepValue: (String)? + public var maxLength: (String)? + public var format: String? + public var units: String? +} + +public struct UpdateAccessoryInput: Encodable, Decodable { + public var serviceId: String + public var characteristicId: String + public var value: String + + public init(serviceId: String, characteristicId: String, value: String) { + self.serviceId = serviceId + self.characteristicId = characteristicId + self.value = value + } +} + +enum UnknownFormatError : Error { + case formatValue(format: String) +} + +func GetValue(value: String, format: String) throws -> Any { + switch format { + case "bool": + let trues: [String] = ["1", "true", "on"] + return trues.contains(where: { $0.lowercased() == value.lowercased() } ) + default: + throw UnknownFormatError.formatValue(format: format) + } +} + +// MARK: - Scenes + +/// Basic scene info (list view) +public struct HomeKitScene: Encodable, Decodable { + public var home: String + public var uniqueIdentifier: UUID + public var name: String + public var isBuiltIn: Bool + + public init(home: String, uniqueIdentifier: UUID, name: String, isBuiltIn: Bool) { + self.home = home + self.uniqueIdentifier = uniqueIdentifier + self.name = name + self.isBuiltIn = isBuiltIn + } +} + +/// Action within a scene +public struct SceneAction: Encodable, Decodable { + public var accessoryName: String + public var serviceName: String + public var characteristicType: String + public var targetValue: String + + public init(accessoryName: String, serviceName: String, characteristicType: String, targetValue: String) { + self.accessoryName = accessoryName + self.serviceName = serviceName + self.characteristicType = characteristicType + self.targetValue = targetValue + } +} + +/// Detailed scene info including actions +public struct SceneDetail: Encodable, Decodable { + public var home: String + public var uniqueIdentifier: UUID + public var name: String + public var isBuiltIn: Bool + public var actions: [SceneAction] + + public init(home: String, uniqueIdentifier: UUID, name: String, isBuiltIn: Bool, actions: [SceneAction]) { + self.home = home + self.uniqueIdentifier = uniqueIdentifier + self.name = name + self.isBuiltIn = isBuiltIn + self.actions = actions + } +} + +// MARK: - Accessory Groups + +/// Service within a group +public struct GroupService: Encodable, Decodable { + public var accessoryName: String + public var serviceName: String + public var serviceType: String + public var uniqueIdentifier: UUID + + public init(accessoryName: String, serviceName: String, serviceType: String, uniqueIdentifier: UUID) { + self.accessoryName = accessoryName + self.serviceName = serviceName + self.serviceType = serviceType + self.uniqueIdentifier = uniqueIdentifier + } +} + +/// Basic group info (list view) +public struct AccessoryGroup: Encodable, Decodable { + public var home: String + public var uniqueIdentifier: UUID + public var name: String + public var serviceCount: Int + + public init(home: String, uniqueIdentifier: UUID, name: String, serviceCount: Int) { + self.home = home + self.uniqueIdentifier = uniqueIdentifier + self.name = name + self.serviceCount = serviceCount + } +} + +/// Detailed group info including services +public struct AccessoryGroupDetail: Encodable, Decodable { + public var home: String + public var uniqueIdentifier: UUID + public var name: String + public var services: [GroupService] + + public init(home: String, uniqueIdentifier: UUID, name: String, services: [GroupService]) { + self.home = home + self.uniqueIdentifier = uniqueIdentifier + self.name = name + self.services = services + } +} + +/// Input for updating group characteristics +public struct UpdateGroupInput: Encodable, Decodable { + public var characteristicType: String + public var value: String + + public init(characteristicType: String, value: String) { + self.characteristicType = characteristicType + self.value = value + } +} + diff --git a/Sources/PrefabServer/HAPUUIDs.swift b/Sources/PrefabServer/HAPUUIDs.swift new file mode 100644 index 0000000..9bb3683 --- /dev/null +++ b/Sources/PrefabServer/HAPUUIDs.swift @@ -0,0 +1,866 @@ +import Foundation + +// MARK: - Core UUID Functions + +/// Helper to create Apple-defined HAP UUIDs from a base UUID + 16-bit identifier +public func hapUUIDCreateAppleDefined(_ identifier: UInt16) -> UUID { + // Format the string and create a UUID from it, which is more reliable + // than trying to create the byte array directly + let uuidString = String(format: "%08X-0000-1000-8000-0026BB765291", identifier) + return UUID(uuidString: uuidString)! +} + +/// Convert a 16-bit identifier to a string representation of a HAP UUID +public func hapUUIDAsString(_ identifier: UInt16) -> String { + return String(format: "%08X-0000-1000-8000-0026BB765291", identifier) +} + +// MARK: - HAP Characteristic Type + +/// HomeKit Accessory Protocol characteristic types +public enum HAPCharacteristicType: UInt16, CaseIterable { + // Basic characteristics + case administratorOnlyAccess = 0x01 + case audioFeedback = 0x05 + case brightness = 0x08 + case coolingThresholdTemperature = 0x0D + case currentDoorState = 0x0E + case currentHeatingCoolingState = 0x0F + case currentRelativeHumidity = 0x10 + case currentTemperature = 0x11 + case heatingThresholdTemperature = 0x12 + case hue = 0x13 + case identify = 0x14 + case lockControlPoint = 0x19 + case lockManagementAutoSecurityTimeout = 0x1A + case lockLastKnownAction = 0x1C + case lockCurrentState = 0x1D + case lockTargetState = 0x1E + case logs = 0x1F + case manufacturer = 0x20 + case model = 0x21 + case motionDetected = 0x22 + case name = 0x23 + case obstructionDetected = 0x24 + case on = 0x25 + case outletInUse = 0x26 + case rotationDirection = 0x28 + case rotationSpeed = 0x29 + case saturation = 0x2F + case serialNumber = 0x30 + case targetDoorState = 0x32 + case targetHeatingCoolingState = 0x33 + case targetRelativeHumidity = 0x34 + case targetTemperature = 0x35 + case temperatureDisplayUnits = 0x36 + case version = 0x37 + + // Security characteristics + case pairSetup = 0x4C + case pairVerify = 0x4E + case pairingFeatures = 0x4F + case pairingPairings = 0x50 + case firmwareRevision = 0x52 + case hardwareRevision = 0x53 + + // Sensor characteristics + case airParticulateDensity = 0x64 + case airParticulateSize = 0x65 + case securitySystemCurrentState = 0x66 + case securitySystemTargetState = 0x67 + case batteryLevel = 0x68 + case carbonMonoxideDetected = 0x69 + case contactSensorState = 0x6A + case currentAmbientLightLevel = 0x6B + case currentHorizontalTiltAngle = 0x6C + case currentPosition = 0x6D + case currentVerticalTiltAngle = 0x6E + case holdPosition = 0x6F + case leakDetected = 0x70 + case occupancyDetected = 0x71 + case positionState = 0x72 + case programmableSwitchEvent = 0x73 + case programmableSwitchOutputState = 0x74 + case statusActive = 0x75 + case smokeDetected = 0x76 + case statusFault = 0x77 + case statusJammed = 0x78 + case statusLowBattery = 0x79 + case statusTampered = 0x7A + case targetHorizontalTiltAngle = 0x7B + case targetPosition = 0x7C + case targetVerticalTiltAngle = 0x7D + + // Environmental characteristics + case securitySystemAlarmType = 0x8E + case chargingState = 0x8F + case carbonMonoxideLevel = 0x90 + case carbonMonoxidePeakLevel = 0x91 + case carbonDioxideDetected = 0x92 + case carbonDioxideLevel = 0x93 + case carbonDioxidePeakLevel = 0x94 + case airQuality = 0x95 + + // Air quality characteristics + case serviceSignature = 0xA5 + case accessoryFlags = 0xA6 + case lockPhysicalControls = 0xA7 + case targetAirPurifierState = 0xA8 + case currentAirPurifierState = 0xA9 + case currentSlatState = 0xAA + case filterLifeLevel = 0xAB + case filterChangeIndication = 0xAC + case resetFilterIndication = 0xAD + case currentFanState = 0xAF + + // Fan and climate characteristics + case active = 0xB0 + case currentHeaterCoolerState = 0xB1 + case targetHeaterCoolerState = 0xB2 + case currentHumidifierDehumidifierState = 0xB3 + case targetHumidifierDehumidifierState = 0xB4 + case waterLevel = 0xB5 + case swingMode = 0xB6 + case targetFanState = 0xBF + + // Slat characteristics + case slatType = 0xC0 + case currentTiltAngle = 0xC1 + case targetTiltAngle = 0xC2 + + // Air quality sensor characteristics + case ozoneDensity = 0xC3 + case nitrogenDioxideDensity = 0xC4 + case sulphurDioxideDensity = 0xC5 + case pm2_5Density = 0xC6 + case pm10Density = 0xC7 + case vocDensity = 0xC8 + case relativeHumidityDehumidifierThreshold = 0xC9 + case relativeHumidityHumidifierThreshold = 0xCA + case serviceLabelIndex = 0xCB + case serviceLabelNamespace = 0xCD + case colorTemperature = 0xCE + + // Irrigation characteristics + case programMode = 0xD1 + case inUse = 0xD2 + case setDuration = 0xD3 + case remainingDuration = 0xD4 + case valveType = 0xD5 + case isConfigured = 0xD6 + + // Media characteristics + case activeIdentifier = 0xE7 + case configuredName = 0xE3 + case currentMediaState = 0xE0 + case targetMediaState = 0xE2 + case pictureMode = 0xE4 + case powerModeSelection = 0x13D + case remoteKey = 0xE1 + case closedCaptions = 0x123 + case displayOrder = 0x136 + case inputSourceType = 0xDB + case volume = 0x119 + case mute = 0x11A + + // Camera characteristics + case streamingStatus = 0x120 + case supportedVideoStreamConfiguration = 0x114 + case supportedAudioStreamConfiguration = 0x115 + case supportedRTPConfiguration = 0x116 + case selectedRTPStreamConfiguration = 0x117 + case setupEndpoints = 0x118 + case nightVision = 0x11B + case opticalZoom = 0x11C + case digitalZoom = 0x11D + case imageRotation = 0x11E + case imageMirroring = 0x11F + + // New iOS 15+ characteristics + case buttonEvent = 0x126 + case selectedAudioStreamConfiguration = 0x128 + case supportedDataStreamTransportConfiguration = 0x130 + case setupDataStreamTransport = 0x131 + case siriInputType = 0x132 + + public var uuid: UUID { + return hapUUIDCreateAppleDefined(self.rawValue) + } + + public var uuidString: String { + return hapUUIDAsString(self.rawValue) + } + + public var description: String { + switch self { + // Basic characteristics + case .administratorOnlyAccess: return "Administrator Only Access" + case .audioFeedback: return "Audio Feedback" + case .brightness: return "Brightness" + case .coolingThresholdTemperature: return "Cooling Threshold Temperature" + case .currentDoorState: return "Current Door State" + case .currentHeatingCoolingState: return "Current Heating Cooling State" + case .currentRelativeHumidity: return "Current Relative Humidity" + case .currentTemperature: return "Current Temperature" + case .heatingThresholdTemperature: return "Heating Threshold Temperature" + case .hue: return "Hue" + case .identify: return "Identify" + case .lockControlPoint: return "Lock Control Point" + case .lockManagementAutoSecurityTimeout: return "Auto Security Timeout" + case .lockLastKnownAction: return "Last Known Action" + case .lockCurrentState: return "Current Lock State" + case .lockTargetState: return "Target Lock State" + case .logs: return "Logs" + case .manufacturer: return "Manufacturer" + case .model: return "Model" + case .motionDetected: return "Motion Detected" + case .name: return "Name" + case .obstructionDetected: return "Obstruction Detected" + case .on: return "On" + case .outletInUse: return "Outlet In Use" + case .rotationDirection: return "Rotation Direction" + case .rotationSpeed: return "Rotation Speed" + case .saturation: return "Saturation" + case .serialNumber: return "Serial Number" + case .targetDoorState: return "Target Door State" + case .targetHeatingCoolingState: return "Target Heating Cooling State" + case .targetRelativeHumidity: return "Target Relative Humidity" + case .targetTemperature: return "Target Temperature" + case .temperatureDisplayUnits: return "Temperature Display Units" + case .version: return "Version" + + // Security characteristics + case .pairSetup: return "Pair Setup" + case .pairVerify: return "Pair Verify" + case .pairingFeatures: return "Pairing Features" + case .pairingPairings: return "Pairing Pairings" + case .firmwareRevision: return "Firmware Revision" + case .hardwareRevision: return "Hardware Revision" + + // Sensor characteristics + case .airParticulateDensity: return "Air Particulate Density" + case .airParticulateSize: return "Air Particulate Size" + case .securitySystemCurrentState: return "Security System Current State" + case .securitySystemTargetState: return "Security System Target State" + case .batteryLevel: return "Battery Level" + case .carbonMonoxideDetected: return "Carbon Monoxide Detected" + case .contactSensorState: return "Contact Sensor State" + case .currentAmbientLightLevel: return "Current Ambient Light Level" + case .currentHorizontalTiltAngle: return "Current Horizontal Tilt Angle" + case .currentPosition: return "Current Position" + case .currentVerticalTiltAngle: return "Current Vertical Tilt Angle" + case .holdPosition: return "Hold Position" + case .leakDetected: return "Leak Detected" + case .occupancyDetected: return "Occupancy Detected" + case .positionState: return "Position State" + case .programmableSwitchEvent: return "Programmable Switch Event" + case .programmableSwitchOutputState: return "Programmable Switch Output State" + case .statusActive: return "Status Active" + case .smokeDetected: return "Smoke Detected" + case .statusFault: return "Status Fault" + case .statusJammed: return "Status Jammed" + case .statusLowBattery: return "Status Low Battery" + case .statusTampered: return "Status Tampered" + case .targetHorizontalTiltAngle: return "Target Horizontal Tilt Angle" + case .targetPosition: return "Target Position" + case .targetVerticalTiltAngle: return "Target Vertical Tilt Angle" + + // Environmental characteristics + case .securitySystemAlarmType: return "Security System Alarm Type" + case .chargingState: return "Charging State" + case .carbonMonoxideLevel: return "Carbon Monoxide Level" + case .carbonMonoxidePeakLevel: return "Carbon Monoxide Peak Level" + case .carbonDioxideDetected: return "Carbon Dioxide Detected" + case .carbonDioxideLevel: return "Carbon Dioxide Level" + case .carbonDioxidePeakLevel: return "Carbon Dioxide Peak Level" + case .airQuality: return "Air Quality" + + // Air quality characteristics + case .serviceSignature: return "Service Signature" + case .accessoryFlags: return "Accessory Flags" + case .lockPhysicalControls: return "Lock Physical Controls" + case .targetAirPurifierState: return "Target Air Purifier State" + case .currentAirPurifierState: return "Current Air Purifier State" + case .currentSlatState: return "Current Slat State" + case .filterLifeLevel: return "Filter Life Level" + case .filterChangeIndication: return "Filter Change Indication" + case .resetFilterIndication: return "Reset Filter Indication" + case .currentFanState: return "Current Fan State" + + // Fan and climate characteristics + case .active: return "Active" + case .currentHeaterCoolerState: return "Current Heater Cooler State" + case .targetHeaterCoolerState: return "Target Heater Cooler State" + case .currentHumidifierDehumidifierState: return "Current Humidifier Dehumidifier State" + case .targetHumidifierDehumidifierState: return "Target Humidifier Dehumidifier State" + case .waterLevel: return "Water Level" + case .swingMode: return "Swing Mode" + case .targetFanState: return "Target Fan State" + + // Slat characteristics + case .slatType: return "Slat Type" + case .currentTiltAngle: return "Current Tilt Angle" + case .targetTiltAngle: return "Target Tilt Angle" + + // Air quality sensor characteristics + case .ozoneDensity: return "Ozone Density" + case .nitrogenDioxideDensity: return "Nitrogen Dioxide Density" + case .sulphurDioxideDensity: return "Sulphur Dioxide Density" + case .pm2_5Density: return "PM2.5 Density" + case .pm10Density: return "PM10 Density" + case .vocDensity: return "VOC Density" + case .relativeHumidityDehumidifierThreshold: return "Relative Humidity Dehumidifier Threshold" + case .relativeHumidityHumidifierThreshold: return "Relative Humidity Humidifier Threshold" + case .serviceLabelIndex: return "Service Label Index" + case .serviceLabelNamespace: return "Service Label Namespace" + case .colorTemperature: return "Color Temperature" + + // Irrigation characteristics + case .programMode: return "Program Mode" + case .inUse: return "In Use" + case .setDuration: return "Set Duration" + case .remainingDuration: return "Remaining Duration" + case .valveType: return "Valve Type" + case .isConfigured: return "Is Configured" + + // Media characteristics + case .activeIdentifier: return "Active Identifier" + case .configuredName: return "Configured Name" + case .currentMediaState: return "Current Media State" + case .targetMediaState: return "Target Media State" + case .remoteKey: return "Remote Key" + case .closedCaptions: return "Closed Captions" + case .pictureMode: return "Picture Mode" + case .powerModeSelection: return "Power Mode Selection" + case .displayOrder: return "Display Order" + case .volume: return "Volume" + case .mute: return "Mute" + + // Stream characteristics + case .streamingStatus: return "Streaming Status" + case .digitalZoom: return "Digital Zoom" + case .opticalZoom: return "Optical Zoom" + case .imageMirroring: return "Image Mirroring" + case .imageRotation: return "Image Rotation" + case .nightVision: return "Night Vision" + case .supportedVideoStreamConfiguration: return "Supported Video Stream Configuration" + case .supportedAudioStreamConfiguration: return "Supported Audio Stream Configuration" + case .supportedRTPConfiguration: return "Supported RTP Configuration" + case .selectedRTPStreamConfiguration: return "Selected RTP Stream Configuration" + case .setupEndpoints: return "Setup Endpoints" + case .selectedAudioStreamConfiguration: return "Selected Audio Stream Configuration" + + // Control characteristics + case .buttonEvent: return "Button Event" +// case .tapType: return "Tap Type" +// case .targetControlList: return "Target Control List" +// case .targetControlSupportedConfiguration: return "Target Control Supported Configuration" +// case .inputDeviceType: return "Input Device Type" + case .inputSourceType: return "Input Source Type" + + // Network characteristics + case .setupDataStreamTransport: return "Setup Data Stream Transport" + case .supportedDataStreamTransportConfiguration: return "Supported Data Stream Transport Configuration" + case .siriInputType: return "Siri Input Type" +// case .wiFiCapabilities: return "WiFi Capabilities" +// case .wiFiConfigurationControl: return "WiFi Configuration Control" +// case .wakeConfiguration: return "Wake Configuration" + + // Additional characteristics + default: return "Characteristic \(String(format: "0x%X", self.rawValue))" + } + } +} + +// MARK: - HAP Service Type + +/// HomeKit Accessory Protocol service types +public enum HAPServiceType: UInt16, CaseIterable { + case accessoryInformation = 0x3E + case fan = 0x40 + case garageDoorOpener = 0x41 + case lightbulb = 0x43 + case lockManagement = 0x44 + case lockMechanism = 0x45 + case outlet = 0x47 + case `switch` = 0x49 + case thermostat = 0x4A + case pairing = 0x55 + case securitySystem = 0x7E + case carbonMonoxideSensor = 0x7F + case contactSensor = 0x80 + case door = 0x81 + case humiditySensor = 0x82 + case leakSensor = 0x83 + case lightSensor = 0x84 + case motionSensor = 0x85 + case occupancySensor = 0x86 + case smokeSensor = 0x87 + case statefulProgrammableSwitch = 0x88 + case statelessProgrammableSwitch = 0x89 + case temperatureSensor = 0x8A + case window = 0x8B + case windowCovering = 0x8C + case airQualitySensor = 0x8D + case battery = 0x96 + case carbonDioxideSensor = 0x97 + case fanV2 = 0xB7 + case slats = 0xB9 + case filterMaintenance = 0xBA + case airPurifier = 0xBB + case heaterCooler = 0xBC + case humidifierDehumidifier = 0xBD + case serviceLabel = 0xCC + case irrigationSystem = 0xCF + case valve = 0xD0 + case faucet = 0xD7 + case television = 0xD8 + + public var uuid: UUID { + return hapUUIDCreateAppleDefined(self.rawValue) + } + + public var uuidString: String { + return hapUUIDAsString(self.rawValue) + } + + public var description: String { + switch self { + // Basic services + case .accessoryInformation: return "Accessory Information" + case .airQualitySensor: return "Air Quality Sensor" + case .battery: return "Battery Service" + case .carbonDioxideSensor: return "Carbon Dioxide Sensor" + case .carbonMonoxideSensor: return "Carbon Monoxide Sensor" + case .contactSensor: return "Contact Sensor" + case .door: return "Door" + case .fan: return "Fan" + case .fanV2: return "Fan v2" + case .garageDoorOpener: return "Garage Door Opener" + case .humiditySensor: return "Humidity Sensor" + case .leakSensor: return "Leak Sensor" + case .lightSensor: return "Light Sensor" + case .lightbulb: return "Lightbulb" + case .lockManagement: return "Lock Management" + case .lockMechanism: return "Lock Mechanism" + case .motionSensor: return "Motion Sensor" + case .occupancySensor: return "Occupancy Sensor" + case .outlet: return "Outlet" + case .securitySystem: return "Security System" + case .smokeSensor: return "Smoke Sensor" + case .statefulProgrammableSwitch: return "Stateful Programmable Switch" + case .statelessProgrammableSwitch: return "Stateless Programmable Switch" + case .switch: return "Switch" + case .temperatureSensor: return "Temperature Sensor" + case .thermostat: return "Thermostat" + case .window: return "Window" + case .windowCovering: return "Window Covering" + + // Climate control services + case .airPurifier: return "Air Purifier" + case .heaterCooler: return "Heater Cooler" + case .humidifierDehumidifier: return "Humidifier Dehumidifier" + case .slats: return "Slats" + case .filterMaintenance: return "Filter Maintenance" + + // Additional services + case .faucet: return "Faucet" + case .valve: return "Valve" + case .irrigationSystem: return "Irrigation System" + case .serviceLabel: return "Service Label" + case .television: return "Television" + + // If we don't have a specific case, fall back to hex representation + default: return "Service \(String(format: "0x%X", self.rawValue))" + } + } +} + +// MARK: - Service-Characteristic Relationships + +/// Structure representing the characteristic requirements for a service +public struct HAPServiceCharacteristicRequirements { + let requiredCharacteristics: [HAPCharacteristicType] + let optionalCharacteristics: [HAPCharacteristicType] +} + +/// Dictionary mapping service types to their characteristic requirements +public let hapServiceCharacteristicRequirements: [HAPServiceType: HAPServiceCharacteristicRequirements] = [ + .accessoryInformation: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .identify, .manufacturer, .model, .name, .serialNumber + ], + optionalCharacteristics: [ + .firmwareRevision, .hardwareRevision + ] + ), + .lightbulb: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .on + ], + optionalCharacteristics: [ + .brightness, .colorTemperature, .hue, .name, .saturation + ] + ), + .switch: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .on + ], + optionalCharacteristics: [ + .name + ] + ), + .temperatureSensor: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .currentTemperature + ], + optionalCharacteristics: [ + .name, .statusActive, .statusFault, .statusLowBattery, .statusTampered + ] + ), + .thermostat: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .currentHeatingCoolingState, + .currentTemperature, + .targetHeatingCoolingState, + .targetTemperature + ], + optionalCharacteristics: [ + .coolingThresholdTemperature, + .heatingThresholdTemperature, + .name, + .temperatureDisplayUnits + ] + ), + .lockMechanism: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .lockCurrentState, + .lockTargetState + ], + optionalCharacteristics: [ + .name + ] + ), + .statelessProgrammableSwitch: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .programmableSwitchEvent + ], + optionalCharacteristics: [ + .name, + .serviceLabelIndex, + .statusActive + ] + ), + .statefulProgrammableSwitch: HAPServiceCharacteristicRequirements( + requiredCharacteristics: [ + .serviceLabelIndex + ], + optionalCharacteristics: [ + .name, + .programmableSwitchEvent, + .statusActive + ] + ), + + // Add more services as needed... +] + +// MARK: - Convenience Extensions + +extension HAPCharacteristicType { + /// Returns all characteristics for a given service, both required and optional + public static func allCharacteristicsForService(_ serviceType: HAPServiceType) -> [HAPCharacteristicType] { + guard let requirements = hapServiceCharacteristicRequirements[serviceType] else { + return [] + } + + return requirements.requiredCharacteristics + requirements.optionalCharacteristics + } +} + +extension HAPServiceType { + /// Returns the required characteristics for this service + public var requiredCharacteristics: [HAPCharacteristicType] { + return hapServiceCharacteristicRequirements[self]?.requiredCharacteristics ?? [] + } + + /// Returns the optional characteristics for this service + public var optionalCharacteristics: [HAPCharacteristicType] { + return hapServiceCharacteristicRequirements[self]?.optionalCharacteristics ?? [] + } +} + +// MARK: - Service Information Lookup + +/// Represents information about a HomeKit service +public struct HAPServiceInfo { + public let type: HAPServiceType + public let name: String + public let requiredCharacteristics: [HAPCharacteristicType] + public let optionalCharacteristics: [HAPCharacteristicType] + + /// All characteristics (required + optional) + public var allCharacteristics: [HAPCharacteristicType] { + return requiredCharacteristics + optionalCharacteristics + } +} + +/// Get information about a service from its UUID +/// - Parameter uuid: The service UUID +/// - Returns: Service information or nil if not recognized +public func getHAPServiceInfo(fromUUID uuid: UUID) -> HAPServiceInfo? { + // Find the service type that matches the UUID + guard let serviceType = HAPServiceType.allCases.first(where: { $0.uuid == uuid }) else { + return nil + } + + return getHAPServiceInfo(fromType: serviceType) +} + +/// Get information about a service from its UUID string +/// - Parameter uuidString: The service UUID as a string +/// - Returns: Service information or nil if not recognized +public func getHAPServiceInfo(fromUUIDString uuidString: String) -> HAPServiceInfo? { + // Find the service type that matches the UUID string + guard let serviceType = HAPServiceType.allCases.first(where: { $0.uuidString.lowercased() == uuidString.lowercased() }) else { + return nil + } + + return getHAPServiceInfo(fromType: serviceType) +} + +/// Get information about a service from its 16-bit identifier +/// - Parameter identifier: The 16-bit service identifier +/// - Returns: Service information or nil if not recognized +public func getHAPServiceInfo(fromIdentifier identifier: UInt16) -> HAPServiceInfo? { + // Find the service type that matches the identifier + guard let serviceType = HAPServiceType(rawValue: identifier) else { + return nil + } + + return getHAPServiceInfo(fromType: serviceType) +} + +/// Helper to create service info from a service type +private func getHAPServiceInfo(fromType serviceType: HAPServiceType) -> HAPServiceInfo { + return HAPServiceInfo( + type: serviceType, + name: serviceType.description, + requiredCharacteristics: serviceType.requiredCharacteristics, + optionalCharacteristics: serviceType.optionalCharacteristics + ) +} + +// Usage examples: +// let info1 = getHAPServiceInfo(fromUUID: someUUID) +// let info2 = getHAPServiceInfo(fromUUIDString: "00000043-0000-1000-8000-0026BB765291") +// let info3 = getHAPServiceInfo(fromIdentifier: 0x43) // Lightbulb + +// MARK: - Characteristic Information Lookup + +/// Represents information about a HomeKit characteristic +public struct HAPCharacteristicInfo { + public let type: HAPCharacteristicType + public let name: String + public let format: HAPCharacteristicFormat + public let permissions: HAPCharacteristicPermissions + public let unit: HAPCharacteristicUnit? + public let minValue: Any? + public let maxValue: Any? + public let stepValue: Any? + + /// Services that require this characteristic + public var requiredByServices: [HAPServiceType] { + return HAPServiceType.allCases.filter { serviceType in + serviceType.requiredCharacteristics.contains(type) + } + } + + /// Services that optionally include this characteristic + public var optionalForServices: [HAPServiceType] { + return HAPServiceType.allCases.filter { serviceType in + serviceType.optionalCharacteristics.contains(type) + } + } +} + +/// Format of characteristic values +public enum HAPCharacteristicFormat: String { + case bool + case uint8 + case uint16 + case uint32 + case uint64 + case int + case float + case string + case tlv8 + case data +} + +/// Permissions available for characteristics +public struct HAPCharacteristicPermissions: OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let read = HAPCharacteristicPermissions(rawValue: 1 << 0) + public static let write = HAPCharacteristicPermissions(rawValue: 1 << 1) + public static let events = HAPCharacteristicPermissions(rawValue: 1 << 2) // Supports notifications + + /// String representation of permissions + public var description: String { + var permissions: [String] = [] + if contains(.read) { permissions.append("read") } + if contains(.write) { permissions.append("write") } + if contains(.events) { permissions.append("events") } + return permissions.joined(separator: ", ") + } +} + +/// Units for characteristic values +public enum HAPCharacteristicUnit: String { + case celsius + case percentage + case arcdegrees + case lux + case seconds +} + +/// Get information about a characteristic from its UUID +/// - Parameter uuid: The characteristic UUID +/// - Returns: Characteristic information or nil if not recognized +public func getHAPCharacteristicInfo(fromUUID uuid: UUID) -> HAPCharacteristicInfo? { + // Find the characteristic type that matches the UUID + guard let characteristicType = HAPCharacteristicType.allCases.first(where: { $0.uuid == uuid }) else { + return nil + } + + return getHAPCharacteristicInfo(fromType: characteristicType) +} + +/// Get information about a characteristic from its UUID string +/// - Parameter uuidString: The characteristic UUID as a string +/// - Returns: Characteristic information or nil if not recognized +public func getHAPCharacteristicInfo(fromUUIDString uuidString: String) -> HAPCharacteristicInfo? { + // Find the characteristic type that matches the UUID string + guard let characteristicType = HAPCharacteristicType.allCases.first(where: { + $0.uuidString.lowercased() == uuidString.lowercased() + }) else { + return nil + } + + return getHAPCharacteristicInfo(fromType: characteristicType) +} + +/// Get information about a characteristic from its 16-bit identifier +/// - Parameter identifier: The 16-bit characteristic identifier +/// - Returns: Characteristic information or nil if not recognized +public func getHAPCharacteristicInfo(fromIdentifier identifier: UInt16) -> HAPCharacteristicInfo? { + // Find the characteristic type that matches the identifier + guard let characteristicType = HAPCharacteristicType(rawValue: identifier) else { + return nil + } + + return getHAPCharacteristicInfo(fromType: characteristicType) +} + +/// Helper to create characteristic info from a characteristic type +private func getHAPCharacteristicInfo(fromType characteristicType: HAPCharacteristicType) -> HAPCharacteristicInfo { + // Get format and other metadata based on characteristic type + let (format, permissions, unit, minValue, maxValue, stepValue) = getMetadataForCharacteristic(characteristicType) + + return HAPCharacteristicInfo( + type: characteristicType, + name: characteristicType.description, + format: format, + permissions: permissions, + unit: unit, + minValue: minValue, + maxValue: maxValue, + stepValue: stepValue + ) +} + +/// Get metadata for a characteristic based on its type +private func getMetadataForCharacteristic(_ type: HAPCharacteristicType) -> + (format: HAPCharacteristicFormat, permissions: HAPCharacteristicPermissions, unit: HAPCharacteristicUnit?, minValue: Any?, maxValue: Any?, stepValue: Any?) { + + // Default values + var format: HAPCharacteristicFormat = .string + var permissions: HAPCharacteristicPermissions = [.read] + var unit: HAPCharacteristicUnit? = nil + var minValue: Any? = nil + var maxValue: Any? = nil + var stepValue: Any? = nil + + // Determine format, permissions, and other metadata based on characteristic type + switch type { + case .on: + format = .bool + permissions = [.read, .write, .events] + + case .brightness: + format = .int + permissions = [.read, .write, .events] + unit = .percentage + minValue = 0 + maxValue = 100 + stepValue = 1 + + case .currentTemperature: + format = .float + permissions = [.read, .events] + unit = .celsius + minValue = -270.0 + maxValue = 100.0 + stepValue = 0.1 + + case .hue: + format = .float + permissions = [.read, .write, .events] + unit = .arcdegrees + minValue = 0.0 + maxValue = 360.0 + stepValue = 1.0 + + case .saturation: + format = .float + permissions = [.read, .write, .events] + unit = .percentage + minValue = 0.0 + maxValue = 100.0 + stepValue = 1.0 + + case .identify: + format = .bool + permissions = [.write] + + case .name, .manufacturer, .model, .serialNumber: + format = .string + permissions = [.read] + + // Add more cases as needed for other characteristic types + + default: + // Provide reasonable defaults for unknown characteristics + if type.rawValue >= 0x50 && type.rawValue <= 0x70 { + // Most sensor values are read-only with events + format = .bool + permissions = [.read, .events] + } else if type.rawValue >= 0x20 && type.rawValue <= 0x40 { + // Many standard characteristics are readable and writable + format = .string + permissions = [.read, .write] + } + } + + return (format, permissions, unit, minValue, maxValue, stepValue) +} + +// Usage examples: +// let info1 = getHAPCharacteristicInfo(fromUUID: someUUID) +// let info2 = getHAPCharacteristicInfo(fromUUIDString: "00000025-0000-1000-8000-0026BB765291") // On characteristic +// let info3 = getHAPCharacteristicInfo(fromIdentifier: 0x25) // On characteristic diff --git a/Sources/PrefabServer/HomeBase.swift b/Sources/PrefabServer/HomeBase.swift new file mode 100644 index 0000000..6f271e0 --- /dev/null +++ b/Sources/PrefabServer/HomeBase.swift @@ -0,0 +1,42 @@ +// +// HomeBase.swift +// PrefabServer +// +// HomeKit manager wrapper +// + +import Foundation +import Combine +import HomeKit +import OSLog + + +/// A container for the home manager that's accessible throughout the app. +public class HomeBase: NSObject, ObservableObject, HMHomeManagerDelegate { + /// A singleton that can be used anywhere in the app to access the home manager. + public static var shared = HomeBase() + + @Published public var homes: [HMHome] = [] + + public override init(){ + super.init() + homeManager.delegate = self + } + + /// The one and only home manager that belongs to the home store singleton. + @Published public var homeManager = HMHomeManager() + + /// A set of objects that want to receive accessory delegate callbacks. + @Published public var accessoryDelegates = Set() + + public func homeManagerDidUpdateHomes(_ manager: HMHomeManager) { + Logger().log("Manager: \(manager)") + Logger().log("Homes: \(manager.homes)") + homes = manager.homes + } + + public func getHomes() { + + } +} + diff --git a/Sources/PrefabServer/PrefabServer.swift b/Sources/PrefabServer/PrefabServer.swift new file mode 100644 index 0000000..a074daf --- /dev/null +++ b/Sources/PrefabServer/PrefabServer.swift @@ -0,0 +1,57 @@ +// +// PrefabServer.swift +// PrefabServer +// +// Public API for Prefab HTTP Server +// + +import Foundation +import HomeKit + +/// Main public interface for Prefab HTTP Server +/// +/// Use this class to start and stop the HomeKit HTTP server in your application. +/// +/// Example: +/// ```swift +/// let server = PrefabServer() +/// server.start() +/// // Server is now running on port 8080 +/// ``` +public class PrefabServer { + private let server: Server + private var serverThread: Thread? + + /// Initialize a new PrefabServer instance + public init() { + self.server = Server() + } + + /// Start the HTTP server on a background thread + /// + /// The server will: + /// - Listen on port 8080 at 0.0.0.0 + /// - Advertise via mDNS/Bonjour as "Prefab HomeKit Bridge" + /// - Provide REST API endpoints for HomeKit data + public func start() { + guard serverThread == nil else { + // Server already started + return + } + + serverThread = Thread(target: server, selector: #selector(Server.startServer), object: nil) + serverThread?.start() + } + + /// Stop the HTTP server and advertising + public func stop() { + server.stop() + serverThread = nil + } + + /// Access to the underlying HomeBase for HomeKit data + public var homeBase: HomeBase { + return server.homeBase + } +} + diff --git a/Sources/PrefabServer/Routes+Accessories.swift b/Sources/PrefabServer/Routes+Accessories.swift new file mode 100644 index 0000000..fc175a3 --- /dev/null +++ b/Sources/PrefabServer/Routes+Accessories.swift @@ -0,0 +1,177 @@ +// +// Routes+Accessories.swift +// PrefabServer +// +// Accessory routes +// + +import Foundation +import HomeKit +import Hummingbird +import OSLog + +extension Server { + func getAccessories(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let roomName = try getRequiredParam(param: "room", request: request) + let home = homeBase.homes.first(where: {$0.name == homeName.removingPercentEncoding}) + if (home == nil) { + throw HBHTTPError(.notFound) + } + let room = home?.rooms.first(where: {$0.name == roomName.removingPercentEncoding}) + if (room == nil) { + throw HBHTTPError(.notFound) + } + + 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) + + return json! + } + + + func getAccessory(_ request: HBRequest) throws -> String { + 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) { + throw HBHTTPError(.notFound) + } + let room = home?.rooms.first(where: {$0.name == roomName.removingPercentEncoding}) + if (room == nil) { + throw HBHTTPError(.notFound) + } + let hkAccessory = room?.accessories.first(where: { (hmAccessory: HMAccessory) -> Bool in hmAccessory.name == accessoryName.removingPercentEncoding}) + if (hkAccessory == nil) { + throw HBHTTPError(.notFound) + } + + let group = DispatchGroup() + for service in hkAccessory!.services { + for char in service.characteristics { + group.enter() + // Error handling for read + char.readValue{ (error: Error?) -> Void in group.leave() } + } + } + group.wait() + + let accessory = Accessory( + home: home!.name, room: room!.name, name: hkAccessory!.name, category: hkAccessory!.category.localizedDescription, isReachable: hkAccessory!.isReachable, supportsIdentify: hkAccessory!.supportsIdentify, isBridged: hkAccessory!.isBridged, services: hkAccessory!.services.map{ (service: HMService) -> Service in Service(uniqueIdentifier: service.uniqueIdentifier, name: service.name, typeName: getHAPServiceInfo(fromUUIDString: service.serviceType)?.name ?? "", type: service.serviceType, isPrimary: service.isPrimaryService, isUserInteractive: service.isUserInteractive, associatedType: service.associatedServiceType, characteristics: service.characteristics.map{ (char: HMCharacteristic) -> Characteristic in Characteristic(uniqueIdentifier: char.uniqueIdentifier, description: char.localizedDescription, properties: char.properties, typeName: getHAPCharacteristicInfo(fromUUIDString: char.characteristicType)?.name ?? "", type: char.characteristicType, metadata: CharacteristicMetadata(manufacturerDescription: char.metadata?.manufacturerDescription, validValues: char.metadata?.validValues?.map{ (number: NSNumber) -> String in return number.stringValue}, minimumValue: char.metadata?.minimumValue?.stringValue, maximumValue: char.metadata?.maximumValue?.stringValue, stepValue: char.metadata?.stepValue?.stringValue, maxLength: char.metadata?.maxLength?.stringValue, format: char.metadata?.format, units: char.metadata?.units), value: "\(char.value ?? "")" )}) }, firmwareVersion: hkAccessory!.firmwareVersion, manufacturer: hkAccessory!.manufacturer, model: hkAccessory!.model ) + + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(accessory) + let json = String(data: jsonData, encoding: String.Encoding.utf8) + + return json! + } + + func updateAccessory(_ request: HBRequest) throws -> String { + 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: bodyBuffer) + logger.debug("Decoded UpdateAccessoryInput: serviceId=\(updateAccessoryInput.serviceId, privacy: .public), characteristicId=\(updateAccessoryInput.characteristicId, privacy: .public), value=\(updateAccessoryInput.value, privacy: .public)") + } catch { + 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) + 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) + } + 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) + } + 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)") + + // 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) + } + 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)") + + let group = DispatchGroup() + group.enter() + 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 "" + } +} + diff --git a/Sources/PrefabServer/Routes+Groups.swift b/Sources/PrefabServer/Routes+Groups.swift new file mode 100644 index 0000000..61ba835 --- /dev/null +++ b/Sources/PrefabServer/Routes+Groups.swift @@ -0,0 +1,155 @@ +// +// Routes+Groups.swift +// PrefabServer +// +// Group routes +// + +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/Sources/PrefabServer/Routes+Homes.swift b/Sources/PrefabServer/Routes+Homes.swift new file mode 100644 index 0000000..15f00e4 --- /dev/null +++ b/Sources/PrefabServer/Routes+Homes.swift @@ -0,0 +1,33 @@ +// +// Routes+Homes.swift +// PrefabServer +// +// Home routes +// + +import Foundation +import Hummingbird + +extension Server { + func getHomes(_ request: HBRequest) throws -> String { + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(homeBase.homes.map{Home(name: $0.name)}) + let json = String(data: jsonData, encoding: String.Encoding.utf8) + + return json ?? "[]" + } + + func getHome(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let home = homeBase.homes.first(where: {$0.name == homeName.removingPercentEncoding}) + if (home == nil) { + throw HBHTTPError(.notFound) + } + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(Home(name: home!.name)) + let json = String(data: jsonData, encoding: String.Encoding.utf8) + + return json! + } +} + diff --git a/Sources/PrefabServer/Routes+Rooms.swift b/Sources/PrefabServer/Routes+Rooms.swift new file mode 100644 index 0000000..3a4efe3 --- /dev/null +++ b/Sources/PrefabServer/Routes+Rooms.swift @@ -0,0 +1,44 @@ +// +// Routes+Rooms.swift +// PrefabServer +// +// Room routes +// + +import Foundation +import Hummingbird + +extension Server { + func getRooms(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let home = homeBase.homes.first(where: {$0.name == homeName.removingPercentEncoding}) + if (home == nil) { + throw HBHTTPError(.notFound) + } + let rooms = home?.rooms.map{Room(home: home!.name, name: $0.name)} + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(rooms) + let json = String(data: jsonData, encoding: String.Encoding.utf8) + + return json! + } + + func getRoom(_ request: HBRequest) throws -> String { + let homeName = try getRequiredParam(param: "home", request: request) + let roomName = try getRequiredParam(param: "room", request: request) + let home = homeBase.homes.first(where: {$0.name == homeName.removingPercentEncoding}) + if (home == nil) { + throw HBHTTPError(.notFound) + } + let room = home?.rooms.first(where: {$0.name == roomName.removingPercentEncoding}) + if (room == nil) { + throw HBHTTPError(.notFound) + } + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(Room(home: home!.name, name: room!.name)) + let json = String(data: jsonData, encoding: String.Encoding.utf8) + + return json! + } +} + diff --git a/Sources/PrefabServer/Routes+Scenes.swift b/Sources/PrefabServer/Routes+Scenes.swift new file mode 100644 index 0000000..80a67eb --- /dev/null +++ b/Sources/PrefabServer/Routes+Scenes.swift @@ -0,0 +1,122 @@ +// +// Routes+Scenes.swift +// PrefabServer +// +// Scene routes +// + +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/Sources/PrefabServer/Routes.swift b/Sources/PrefabServer/Routes.swift new file mode 100644 index 0000000..50b432d --- /dev/null +++ b/Sources/PrefabServer/Routes.swift @@ -0,0 +1,18 @@ +// +// Routes.swift +// PrefabServer +// +// Base routes +// + +import Foundation +import HomeKit +import Hummingbird +import OSLog + +extension Server { + func getRoot(_ request: HBRequest) throws -> String { + return "" + } +} + diff --git a/Sources/PrefabServer/Server.swift b/Sources/PrefabServer/Server.swift new file mode 100644 index 0000000..16a6d87 --- /dev/null +++ b/Sources/PrefabServer/Server.swift @@ -0,0 +1,176 @@ +// +// Server.swift +// PrefabServer +// +// Internal server implementation +// + +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.shared + let stats = homebase.homeManager.authorizationStatus + Logger().log("HomeKit Authorization status is \(stats.rawValue)") + if !homebase.homeManager.authorizationStatus.contains(.authorized) { + let failure: EventLoopFuture = request.failure(.forbidden, message: "{\"error\": \"Prefab is not authorized to access your HomeKit data.\"}") + + return failure + } + return next.respond(to: request) + } +} + +class Server { + var homeBase: HomeBase + private var bonjourAdvertiser: BonjourAdvertiser? + private var app: HBApplication? + + init() { + self.homeBase = HomeBase.shared + } + + deinit { + stop() + } + + private func startAdvertising() { + // Create and configure the BonjourAdvertiser for mDNS advertising + let txtData: [String: String] = [ + "server": "prefab", + "version": "1.0", + "api": "homekit" + ] + + 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() { + bonjourAdvertiser?.stop() + bonjourAdvertiser = nil + Logger().info("Stopped mDNS advertising") + } + + func stop() { + stopAdvertising() + // Note: HBApplication doesn't have a direct stop method in this version + // The server will stop when the thread exits + app = nil + } + + func getRequiredParam(param: String, request: HBRequest) throws -> String { + guard let value = request.parameters[param] else { + throw HBHTTPError( + .badRequest, + message: "Invalid \(param) parameter." + ) + } + return value + } + + @objc + func startServer() { + Task{ + let application = HBApplication(configuration: .init(address: .hostname("0.0.0.0", port: 8080))) + self.app = application + application.logger.logLevel = .debug + application.middleware.add(HBLogRequestsMiddleware(.debug)) + application.middleware.add(HomeKitAuthLogger()) + application.router.get("homes", use: self.getHomes) + application.router.get("homes/:home", use: self.getHome) + + application.router.get("rooms/:home", use: self.getRooms) + application.router.get("rooms/:home/:room", use: self.getRoom) + + application.router.get("accessories/:home/:room", use: self.getAccessories) + application.router.get("accessories/:home/:room/:accessory", use: self.getAccessory) + application.router.put("accessories/:home/:room/:accessory", use: self.updateAccessory) + + application.router.get("scenes/:home", use: self.getScenes) + application.router.get("scenes/:home/:scene", use: self.getScene) + application.router.post("scenes/:home/:scene/execute", use: self.executeScene) + + application.router.get("groups/:home", use: self.getGroups) + application.router.get("groups/:home/:group", use: self.getGroup) + application.router.put("groups/:home/:group", use: self.updateGroup) + + // Start mDNS advertising + startAdvertising() + + try application.start() + RunLoop.current.add(Port(), forMode: .default) + while true { RunLoop.current.run(mode: .default, before: Date.distantFuture) } + await application.asyncWait() + } + } +} + 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)