diff --git a/build.zig b/build.zig index ce340c6..64c414e 100644 --- a/build.zig +++ b/build.zig @@ -62,14 +62,26 @@ pub fn build(b: *std.Build) !void { } full_deploy_step.dependOn(&ask_deploy_cmd.step); - // Test step - reuses the same target query, tests run via emulation or on native arm64 - const test_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, + // Test step - use native target for tests (not cross-compiled Lambda target) + const native_target = b.resolveTargetQuery(.{}); + + const lambda_zig_dep_native = b.dependency("lambda_zig", .{ + .target = native_target, .optimize = optimize, }); - test_module.addImport("lambda_runtime", lambda_zig_dep.module("lambda_runtime")); - test_module.addImport("rinnai", controlr_dep.module("rinnai")); + + const controlr_dep_native = b.dependency("controlr", .{ + .target = native_target, + .optimize = optimize, + }); + + const test_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = native_target, + .optimize = optimize, + }); + test_module.addImport("lambda_runtime", lambda_zig_dep_native.module("lambda_runtime")); + test_module.addImport("rinnai", controlr_dep_native.module("rinnai")); const main_tests = b.addTest(.{ .name = "test", @@ -79,4 +91,26 @@ pub fn build(b: *std.Build) !void { const run_main_tests = b.addRunArtifact(main_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_main_tests.step); + + // Run step for local testing (uses native target) + const run_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = native_target, + .optimize = optimize, + }); + run_module.addImport("lambda_runtime", lambda_zig_dep_native.module("lambda_runtime")); + run_module.addImport("rinnai", controlr_dep_native.module("rinnai")); + + const run_exe = b.addExecutable(.{ + .name = "bootstrap", + .root_module = run_module, + }); + + const run_cmd = b.addRunArtifact(run_exe); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run locally for testing"); + run_step.dependOn(&run_cmd.step); } diff --git a/skill-package/interactionModels/custom/en-US.json b/skill-package/interactionModels/custom/en-US.json index 7ae0139..40a0d17 100644 --- a/skill-package/interactionModels/custom/en-US.json +++ b/skill-package/interactionModels/custom/en-US.json @@ -27,6 +27,62 @@ "I need hot water" ] }, + { + "name": "HomeAssistantIntent", + "slots": [ + { + "name": "action", + "type": "DEVICE_ACTION" + }, + { + "name": "device", + "type": "AMAZON.SearchQuery" + }, + { + "name": "value", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "turn on {device}", + "turn on the {device}", + "turn {device} on", + "switch on {device}", + "switch on the {device}", + "turn off {device}", + "turn off the {device}", + "turn {device} off", + "switch off {device}", + "switch off the {device}", + "set {device} to {value}", + "set {device} to {value} percent", + "set the {device} to {value}", + "set the {device} to {value} percent", + "set {device} to {value} degrees", + "set the {device} to {value} degrees", + "dim {device} to {value}", + "dim the {device} to {value}", + "dim {device} to {value} percent", + "is {device} on", + "is the {device} on", + "is {device} off", + "is the {device} off", + "is the {device} open", + "is the {device} closed", + "what is the state of {device}", + "what is the state of the {device}", + "what is {device} set to", + "what is the {device} set to", + "check {device}", + "check the {device}", + "check on {device}", + "check on the {device}", + "{action} {device}", + "{action} the {device}", + "toggle {device}", + "toggle the {device}" + ] + }, { "name": "AMAZON.HelpIntent", "samples": [] @@ -46,9 +102,41 @@ { "name": "AMAZON.FallbackIntent", "samples": [] + }, + { + "name": "WeezTheJuiceIntent", + "slots": [], + "samples": [ + "weez the juice", + "wheeze the juice" + ] } ], - "types": [] + "types": [ + { + "name": "DEVICE_ACTION", + "values": [ + { + "name": { + "value": "turn on", + "synonyms": ["switch on", "enable", "activate", "power on"] + } + }, + { + "name": { + "value": "turn off", + "synonyms": ["switch off", "disable", "deactivate", "power off"] + } + }, + { + "name": { + "value": "toggle", + "synonyms": ["flip", "switch"] + } + } + ] + } + ] } } } diff --git a/src/Config.zig b/src/Config.zig new file mode 100644 index 0000000..4769b09 --- /dev/null +++ b/src/Config.zig @@ -0,0 +1,93 @@ +//! Configuration management - loads from .env file and environment variables. +//! Environment variables take precedence over .env file values. + +const std = @import("std"); +const Config = @This(); + +allocator: std.mem.Allocator, + +cognito_username: ?[]const u8, +cognito_password: ?[]const u8, +home_assistant_url: ?[]const u8, +home_assistant_token: ?[]const u8, +is_lambda: bool, + +pub fn init(allocator: std.mem.Allocator) !Config { + var self = Config{ + .allocator = allocator, + .cognito_username = null, + .cognito_password = null, + .home_assistant_url = null, + .home_assistant_token = null, + .is_lambda = false, + }; + + // Load .env file first (if present) + try self.loadEnvFile(); + + // Environment variables override .env + var env_map = try std.process.getEnvMap(allocator); + defer env_map.deinit(); + + if (env_map.get("COGNITO_USERNAME")) |v| { + if (self.cognito_username) |old| allocator.free(old); + self.cognito_username = try allocator.dupe(u8, v); + } + if (env_map.get("COGNITO_PASSWORD")) |v| { + if (self.cognito_password) |old| allocator.free(old); + self.cognito_password = try allocator.dupe(u8, v); + } + if (env_map.get("HOME_ASSISTANT_URL")) |v| { + if (self.home_assistant_url) |old| allocator.free(old); + self.home_assistant_url = try allocator.dupe(u8, v); + } + if (env_map.get("HOME_ASSISTANT_TOKEN")) |v| { + if (self.home_assistant_token) |old| allocator.free(old); + self.home_assistant_token = try allocator.dupe(u8, v); + } + + // Detect Lambda environment + self.is_lambda = env_map.get("AWS_LAMBDA_RUNTIME_API") != null; + + return self; +} + +pub fn deinit(self: *Config) void { + if (self.cognito_username) |v| self.allocator.free(v); + if (self.cognito_password) |v| self.allocator.free(v); + if (self.home_assistant_url) |v| self.allocator.free(v); + if (self.home_assistant_token) |v| self.allocator.free(v); + self.cognito_username = null; + self.cognito_password = null; + self.home_assistant_url = null; + self.home_assistant_token = null; +} + +fn loadEnvFile(self: *Config) !void { + const file = std.fs.cwd().openFile(".env", .{}) catch return; + defer file.close(); + + const content = try file.readToEndAlloc(self.allocator, 1024 * 1024); + defer self.allocator.free(content); + + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + + if (std.mem.indexOf(u8, trimmed, "=")) |eq_idx| { + const key = trimmed[0..eq_idx]; + const val = trimmed[eq_idx + 1 ..]; + + if (std.mem.eql(u8, key, "COGNITO_USERNAME")) { + self.cognito_username = try self.allocator.dupe(u8, val); + } else if (std.mem.eql(u8, key, "COGNITO_PASSWORD")) { + self.cognito_password = try self.allocator.dupe(u8, val); + } else if (std.mem.eql(u8, key, "HOME_ASSISTANT_URL")) { + self.home_assistant_url = try self.allocator.dupe(u8, val); + } else if (std.mem.eql(u8, key, "HOME_ASSISTANT_TOKEN")) { + self.home_assistant_token = try self.allocator.dupe(u8, val); + } + } + } +} diff --git a/src/homeassistant.zig b/src/homeassistant.zig new file mode 100644 index 0000000..ba316b6 --- /dev/null +++ b/src/homeassistant.zig @@ -0,0 +1,1263 @@ +//! Home Assistant REST API client for Alexa skill integration. +//! Provides device discovery, control, and state querying via the Home Assistant REST API. +//! Designed for testability with injectable HTTP client interface. + +const std = @import("std"); +const http = std.http; +const json = std.json; +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.homeassistant); + +// ============================================================================ +// Types +// ============================================================================ + +/// Supported device actions +pub const Action = enum { + turn_on, + turn_off, + toggle, + set_value, + query_state, + + /// Parse action from user speech/slot value + pub fn fromString(s: []const u8) ?Action { + var lower_buf: [64]u8 = undefined; + const len = @min(s.len, lower_buf.len); + const lower = std.ascii.lowerString(lower_buf[0..len], s[0..len]); + + if (std.mem.indexOf(u8, lower, "toggle") != null) { + return .toggle; + } + if (std.mem.indexOf(u8, lower, "off") != null) { + return .turn_off; + } + if (std.mem.indexOf(u8, lower, "on") != null) { + return .turn_on; + } + return null; + } +}; + +/// Represents a Home Assistant entity +pub const Entity = struct { + entity_id: []const u8, + state: []const u8, + friendly_name: []const u8, + domain: []const u8, + last_changed: ?[]const u8, + brightness: ?u8, // 0-255 for lights + temperature: ?f32, // Target temperature for climate + current_temperature: ?f32, // Current temperature for climate + + /// Parse entity from Home Assistant JSON response + pub fn fromJson(allocator: Allocator, value: json.Value) !Entity { + const obj = value.object; + + const entity_id = obj.get("entity_id").?.string; + + // Extract domain from entity_id (e.g., "light" from "light.bedroom") + const domain = if (std.mem.indexOf(u8, entity_id, ".")) |idx| + entity_id[0..idx] + else + "unknown"; + + const state = obj.get("state").?.string; + + // Get attributes + const attrs = if (obj.get("attributes")) |a| a.object else null; + + const friendly_name = if (attrs) |a| + if (a.get("friendly_name")) |fn_val| + if (fn_val == .string) fn_val.string else entity_id + else + entity_id + else + entity_id; + + const last_changed = if (obj.get("last_changed")) |lc| + if (lc == .string) lc.string else null + else + null; + + // Light brightness (0-255) + const brightness: ?u8 = if (attrs) |a| + if (a.get("brightness")) |b| + switch (b) { + .integer => |i| @intCast(@min(255, @max(0, i))), + .float => |f| @intFromFloat(@min(255.0, @max(0.0, f))), + else => null, + } + else + null + else + null; + + // Climate temperatures + const temperature: ?f32 = if (attrs) |a| + if (a.get("temperature")) |t| + switch (t) { + .integer => |i| @floatFromInt(i), + .float => |f| @floatCast(f), + else => null, + } + else + null + else + null; + + const current_temperature: ?f32 = if (attrs) |a| + if (a.get("current_temperature")) |t| + switch (t) { + .integer => |i| @floatFromInt(i), + .float => |f| @floatCast(f), + else => null, + } + else + null + else + null; + + _ = allocator; // Entity doesn't own memory, references JSON strings + + return Entity{ + .entity_id = entity_id, + .state = state, + .friendly_name = friendly_name, + .domain = domain, + .last_changed = last_changed, + .brightness = brightness, + .temperature = temperature, + .current_temperature = current_temperature, + }; + } +}; + +/// Result of a device action - includes speech and session state +pub const ActionResult = struct { + speech: []const u8, + end_session: bool, +}; + +/// Result of entity name matching +pub const MatchResult = union(enum) { + single: usize, // Index into entities slice + multiple: []usize, // Indices into entities slice (caller owns memory) + none: void, +}; + +/// Errors that can occur when communicating with Home Assistant +pub const HomeAssistantError = error{ + ConnectionFailed, + Unauthorized, + NotFound, + InvalidResponse, + ServiceCallFailed, + OutOfMemory, + HttpError, + InvalidUri, +}; + +// ============================================================================ +// HTTP Client Abstraction (for testability) +// ============================================================================ + +pub const FetchOptions = struct { + url: []const u8, + method: http.Method, + headers: []const http.Header, + body: ?[]const u8, +}; + +pub const FetchResult = struct { + status: http.Status, + body: []const u8, +}; + +/// Interface for HTTP operations - allows mocking in tests +pub const HttpClientInterface = struct { + ptr: *anyopaque, + fetchFn: *const fn (ptr: *anyopaque, allocator: Allocator, options: FetchOptions) anyerror!FetchResult, + + pub fn fetch(self: HttpClientInterface, allocator: Allocator, options: FetchOptions) !FetchResult { + return self.fetchFn(self.ptr, allocator, options); + } +}; + +/// Real HTTP client implementation using std.http.Client +pub const HttpClient = struct { + allocator: Allocator, + client: *http.Client, + + pub fn init(allocator: Allocator) HttpClient { + const client = allocator.create(std.http.Client) catch @panic("OOM"); + client.* = .{ .allocator = allocator }; + return .{ + .allocator = allocator, + .client = client, + }; + } + + pub fn deinit(self: HttpClient) void { + const allocator = self.client.allocator; + self.client.deinit(); + allocator.destroy(self.client); + } + + pub fn interface(self: *HttpClient) HttpClientInterface { + return .{ + .ptr = self, + .fetchFn = fetch, + }; + } + + fn fetch(ptr: *anyopaque, allocator: Allocator, options: FetchOptions) anyerror!FetchResult { + const self: *HttpClient = @ptrCast(@alignCast(ptr)); + + const uri = std.Uri.parse(options.url) catch return HomeAssistantError.InvalidUri; + + // Use a fixed buffer for the response + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + + const result = self.client.fetch(.{ + .location = .{ .uri = uri }, + .method = options.method, + .extra_headers = options.headers, + .payload = options.body, + .response_writer = &writer, + .headers = .{ + .accept_encoding = .{ .override = "identity" }, // No compression + }, + }) catch return HomeAssistantError.ConnectionFailed; + + // Copy response body + const response_body = response_buf[0..writer.end]; + const body = allocator.dupe(u8, response_body) catch return HomeAssistantError.OutOfMemory; + + return FetchResult{ + .status = result.status, + .body = body, + }; + } +}; + +/// Home Assistant Client - this is the primary structure used for home +/// assistant communciations +pub const Client = struct { + allocator: Allocator, + base_url: []const u8, + token: []const u8, + http_interface: HttpClientInterface, + client: ?HttpClient = null, + + /// Normal initialization. If you are writing tests and need a mock + /// implemenation for unit tests, use MockHttpClient with the initInterface + /// method + pub fn init( + allocator: Allocator, + base_url: []const u8, + token: []const u8, + ) Client { + var client = HttpClient.init(allocator); + return .{ + .allocator = allocator, + .base_url = base_url, + .token = token, + .http_interface = client.interface(), + .client = client, + }; + } + + /// initInterface. This is intended only to support unit tests through + /// the MockHttpClient + pub fn initInterface( + allocator: Allocator, + base_url: []const u8, + token: []const u8, + http_interface: HttpClientInterface, + ) Client { + return .{ + .allocator = allocator, + .base_url = base_url, + .token = token, + .http_interface = http_interface, + }; + } + + pub fn deinit(self: Client) void { + if (self.client) |c| c.deinit(); + } + /// Fetch all entity states from Home Assistant + pub fn getStates(self: *Client) !struct { entities: []Entity, parsed: json.Parsed(json.Value) } { + const url = try std.fmt.allocPrint(self.allocator, "{s}/api/states", .{self.base_url}); + defer self.allocator.free(url); + + const auth_header = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{self.token}); + defer self.allocator.free(auth_header); + + const headers = [_]http.Header{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + }; + + const result = try self.http_interface.fetch(self.allocator, .{ + .url = url, + .method = .GET, + .headers = &headers, + .body = null, + }); + + if (result.status == .unauthorized) { + self.allocator.free(result.body); + return HomeAssistantError.Unauthorized; + } + + if (result.status != .ok) { + self.allocator.free(result.body); + return HomeAssistantError.HttpError; + } + + // Parse JSON response + const parsed = json.parseFromSlice(json.Value, self.allocator, result.body, .{}) catch { + self.allocator.free(result.body); + return HomeAssistantError.InvalidResponse; + }; + self.allocator.free(result.body); + + if (parsed.value != .array) { + parsed.deinit(); + return HomeAssistantError.InvalidResponse; + } + + // Convert to Entity structs + var entities: std.ArrayListUnmanaged(Entity) = .empty; + for (parsed.value.array.items) |item| { + if (item != .object) continue; + + // Skip entities without entity_id + if (item.object.get("entity_id") == null) continue; + + const entity = Entity.fromJson(self.allocator, item) catch continue; + + // Only include controllable domains + if (std.mem.eql(u8, entity.domain, "light") or + std.mem.eql(u8, entity.domain, "switch") or + std.mem.eql(u8, entity.domain, "climate") or + std.mem.eql(u8, entity.domain, "cover") or + std.mem.eql(u8, entity.domain, "fan") or + std.mem.eql(u8, entity.domain, "lock") or + std.mem.eql(u8, entity.domain, "binary_sensor") or + std.mem.eql(u8, entity.domain, "sensor")) + { + entities.append(self.allocator, entity) catch continue; + } + } + + return .{ + .entities = entities.toOwnedSlice(self.allocator) catch return HomeAssistantError.OutOfMemory, + .parsed = parsed, + }; + } + + /// Call a Home Assistant service + pub fn callService( + self: *Client, + domain: []const u8, + service: []const u8, + entity_id: []const u8, + extra_data: ?[]const u8, + ) !void { + const url = try std.fmt.allocPrint( + self.allocator, + "{s}/api/services/{s}/{s}", + .{ self.base_url, domain, service }, + ); + defer self.allocator.free(url); + + const auth_header = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{self.token}); + defer self.allocator.free(auth_header); + + const headers = [_]http.Header{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + }; + + // Build request body + const body = if (extra_data) |extra| + try std.fmt.allocPrint( + self.allocator, + \\{{"entity_id":"{s}",{s}}} + , + .{ entity_id, extra }, + ) + else + try std.fmt.allocPrint( + self.allocator, + \\{{"entity_id":"{s}"}} + , + .{entity_id}, + ); + defer self.allocator.free(body); + + const result = try self.http_interface.fetch(self.allocator, .{ + .url = url, + .method = .POST, + .headers = &headers, + .body = body, + }); + defer self.allocator.free(result.body); + + if (result.status == .unauthorized) { + return HomeAssistantError.Unauthorized; + } + + if (result.status != .ok and result.status != .created) { + if (!builtin.is_test) log.err("Service call failed with status: {}", .{result.status}); + return HomeAssistantError.ServiceCallFailed; + } + } +}; + +// ============================================================================ +// Core Logic Functions +// ============================================================================ + +/// Check if search_term fuzzy-matches a word in friendly_name. +/// Requires search_term to be at least half the length of the matched word. +pub fn fuzzyMatchWord(friendly_name: []const u8, search_term: []const u8) bool { + if (search_term.len == 0) return false; + + // Convert both to lowercase for comparison + var name_lower_buf: [256]u8 = undefined; + var search_lower_buf: [128]u8 = undefined; + + const name_len = @min(friendly_name.len, name_lower_buf.len); + const search_len = @min(search_term.len, search_lower_buf.len); + + const name_lower = std.ascii.lowerString(name_lower_buf[0..name_len], friendly_name[0..name_len]); + const search_lower = std.ascii.lowerString(search_lower_buf[0..search_len], search_term[0..search_len]); + + // Try to match against each word in the friendly name + var words = std.mem.tokenizeAny(u8, name_lower, " _-"); + while (words.next()) |word| { + // Check if search term is a prefix of this word + if (std.mem.startsWith(u8, word, search_lower)) { + // Require search term to be at least half the word length + const min_len = (word.len + 1) / 2; // Round up + if (search_lower.len >= min_len) { + return true; + } + } + } + + // Also check if the entire name contains the search term as a substring + // but only if the search term is substantial (at least 4 chars) + if (search_lower.len >= 4 and std.mem.indexOf(u8, name_lower, search_lower) != null) { + return true; + } + + return false; +} + +/// Words that are common device suffixes and carry less weight in matching +const noise_words = [_][]const u8{ "light", "lights", "lamp", "switch", "sensor", "plug", "outlet", "fan", "the" }; + +fn isNoiseWord(word: []const u8) bool { + for (noise_words) |noise| { + if (std.mem.eql(u8, word, noise)) return true; + } + return false; +} + +/// Check if search words appear in the friendly name +/// Important words must all match. Noise words are optional and don't affect score. +/// Single-character words (like 's from possessives) are ignored. +/// Returns the count of important word matches, or 0 if any important word is missing +fn searchWordsInName(friendly_name: []const u8, search: []const u8) usize { + var name_buf: [256]u8 = undefined; + var search_buf: [256]u8 = undefined; + + const name_len = @min(friendly_name.len, name_buf.len); + const search_len = @min(search.len, search_buf.len); + + const name_lower = std.ascii.lowerString(name_buf[0..name_len], friendly_name[0..name_len]); + const search_lower = std.ascii.lowerString(search_buf[0..search_len], search[0..search_len]); + + var important_matches: usize = 0; + var search_words = std.mem.tokenizeAny(u8, search_lower, " _-'"); + while (search_words.next()) |search_word| { + // Skip single-character words (artifacts from possessives like "'s") + if (search_word.len <= 1) continue; + + const is_noise = isNoiseWord(search_word); + var found = false; + + var name_words = std.mem.tokenizeAny(u8, name_lower, " _-"); + while (name_words.next()) |name_word| { + if (std.mem.eql(u8, name_word, search_word)) { + found = true; + if (!is_noise) important_matches += 1; + break; + } + } + + // Important words must be found; noise words are optional + if (!found and !is_noise) return 0; + } + return important_matches; +} + +/// Find entities matching the given name +/// Priority: 1) Exact match, 2) All search words in friendly name (prefer more matches, then shorter name), 3) Fuzzy match +pub fn findEntitiesByName( + allocator: Allocator, + entities: []const Entity, + name: []const u8, +) !MatchResult { + // Lowercase the search term once + var search_buf: [256]u8 = undefined; + const search_len = @min(name.len, search_buf.len); + const search_lower = std.ascii.lowerString(search_buf[0..search_len], name[0..search_len]); + + // First pass: look for exact match (case-insensitive) + for (entities, 0..) |entity, i| { + var name_buf: [256]u8 = undefined; + const name_len = @min(entity.friendly_name.len, name_buf.len); + const name_lower = std.ascii.lowerString(name_buf[0..name_len], entity.friendly_name[0..name_len]); + + if (std.mem.eql(u8, name_lower, search_lower)) + return .{ .single = i }; + } + + // Second pass: find friendly names that contain all important search words + // Track all matches and their scores + var matches: std.ArrayListUnmanaged(usize) = .empty; + defer matches.deinit(allocator); + var best_match_count: usize = 0; + + for (entities, 0..) |entity, i| { + const match_count = searchWordsInName(entity.friendly_name, name); + if (match_count > 0) { + if (match_count > best_match_count) { + // New best - clear previous matches + matches.clearRetainingCapacity(); + best_match_count = match_count; + } + if (match_count == best_match_count) + try matches.append(allocator, i); + } + } + + // If we have ties, prefer shorter friendly names (more specific/direct match) + if (matches.items.len > 1) { + var shortest_idx: usize = matches.items[0]; + var shortest_len: usize = entities[shortest_idx].friendly_name.len; + + for (matches.items[1..]) |idx| { + if (entities[idx].friendly_name.len < shortest_len) { + shortest_idx = idx; + shortest_len = entities[idx].friendly_name.len; + } + } + + // Check if there's a unique shortest + var count_at_shortest: usize = 0; + for (matches.items) |idx| { + if (entities[idx].friendly_name.len == shortest_len) + count_at_shortest += 1; + } + + if (count_at_shortest == 1) + return .{ .single = shortest_idx }; + } + + if (matches.items.len == 1) + return .{ .single = matches.items[0] }; + + if (matches.items.len > 1) + return .{ .multiple = try matches.toOwnedSlice(allocator) }; + + // Third pass: fuzzy matching + matches.clearRetainingCapacity(); + + for (entities, 0..) |entity, i| + if (fuzzyMatchWord(entity.friendly_name, name)) + try matches.append(allocator, i); + + if (matches.items.len == 0) + return .none; + + if (matches.items.len == 1) + return .{ .single = matches.items[0] }; + + return .{ .multiple = try matches.toOwnedSlice(allocator) }; +} + +/// Format a detailed state response for an entity +pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const u8 { + var response: std.ArrayListUnmanaged(u8) = .empty; + errdefer response.deinit(allocator); + + const writer = response.writer(allocator); + + try writer.print("The {s} is {s}", .{ entity.friendly_name, entity.state }); + + // Add domain-specific details + if (std.mem.eql(u8, entity.domain, "light")) { + if (std.mem.eql(u8, entity.state, "on")) { + if (entity.brightness) |b| { + const pct = @as(u32, b) * 100 / 255; + try writer.print(" at {d}% brightness", .{pct}); + } + } + } else if (std.mem.eql(u8, entity.domain, "climate")) { + if (entity.temperature) |target| + try writer.print(". Target temperature is {d:.0} degrees", .{target}); + if (entity.current_temperature) |current| + try writer.print(". Current temperature is {d:.0} degrees", .{current}); + } else if (std.mem.eql(u8, entity.domain, "binary_sensor") or + std.mem.eql(u8, entity.domain, "cover")) + { + // Add time since last change if available + if (entity.last_changed) |lc| + if (parseTimeAgo(lc)) |ago| + try writer.print(" and has been {s} for {s}", .{ entity.state, ago }); + } + + try writer.writeAll("."); + + return response.toOwnedSlice(allocator); +} + +/// Parse ISO timestamp and return human-readable "time ago" string +fn parseTimeAgo(iso_timestamp: []const u8) ?[]const u8 { + // Simple heuristic: check if it looks like an ISO timestamp + // In production, you'd parse this properly + if (iso_timestamp.len < 10) return null; + + // For now, just return a generic message + // TODO: Implement proper time parsing + return "some time"; +} + +/// Format the "which one?" clarification prompt +pub fn formatClarificationPrompt( + allocator: Allocator, + entities: []const Entity, + indices: []const usize, +) ![]const u8 { + var response: std.ArrayListUnmanaged(u8) = .empty; + errdefer response.deinit(allocator); + + const writer = response.writer(allocator); + + try writer.writeAll("Which one did you mean? I found "); + + for (indices, 0..) |idx, i| { + if (i > 0) { + if (i == indices.len - 1) { + try writer.writeAll(", or "); + } else { + try writer.writeAll(", "); + } + } + try writer.writeAll(entities[idx].friendly_name); + } + + try writer.writeAll("."); + + return response.toOwnedSlice(allocator); +} + +/// Main entry point: handle a device action request +pub fn handleDeviceAction( + allocator: Allocator, + client: *Client, + action: Action, + device_name: []const u8, + value: ?f32, +) !ActionResult { + // Fetch all entities + const states_result = client.getStates() catch |err| { + if (!builtin.is_test) log.err("Failed to get states: {}", .{err}); + return ActionResult{ + .speech = try std.fmt.allocPrint(allocator, "I couldn't connect to Home Assistant.", .{}), + .end_session = true, + }; + }; + defer states_result.parsed.deinit(); + defer allocator.free(states_result.entities); + + const entities = states_result.entities; + + // Find matching entities + const match_result = try findEntitiesByName(allocator, entities, device_name); + + switch (match_result) { + .none => { + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "I couldn't find a device called {s}.", + .{device_name}, + ), + .end_session = true, + }; + }, + .multiple => |indices| { + defer allocator.free(indices); + const prompt = try formatClarificationPrompt(allocator, entities, indices); + return ActionResult{ + .speech = prompt, + .end_session = false, // Keep session open for clarification + }; + }, + .single => |idx| { + const entity = &entities[idx]; + + // Execute the action + switch (action) { + .turn_on => { + try client.callService(entity.domain, "turn_on", entity.entity_id, null); + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "Turned on the {s}.", + .{entity.friendly_name}, + ), + .end_session = true, + }; + }, + .turn_off => { + try client.callService(entity.domain, "turn_off", entity.entity_id, null); + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "Turned off the {s}.", + .{entity.friendly_name}, + ), + .end_session = true, + }; + }, + .toggle => { + try client.callService(entity.domain, "toggle", entity.entity_id, null); + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "Toggled the {s}.", + .{entity.friendly_name}, + ), + .end_session = true, + }; + }, + .set_value => { + if (value) |v| { + // Domain-specific handling + if (std.mem.eql(u8, entity.domain, "climate")) { + const extra = try std.fmt.allocPrint( + allocator, + \\"temperature":{d} + , + .{v}, + ); + defer allocator.free(extra); + + try client.callService("climate", "set_temperature", entity.entity_id, extra); + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "Set {s} to {d} degrees.", + .{ entity.friendly_name, v }, + ), + .end_session = true, + }; + } else { + // Default: treat as brightness (lights, etc.) + // Convert percentage to 0-255 range + const brightness: u8 = @intFromFloat(@min(255.0, @max(0.0, v * 255.0 / 100.0))); + const extra = try std.fmt.allocPrint( + allocator, + \\"brightness":{d} + , + .{brightness}, + ); + defer allocator.free(extra); + + try client.callService(entity.domain, "turn_on", entity.entity_id, extra); + // Display as integer percentage for cleaner speech + const display_pct: u8 = @intFromFloat(@min(100.0, @max(0.0, v))); + return ActionResult{ + .speech = try std.fmt.allocPrint( + allocator, + "Set {s} to {d}%.", + .{ entity.friendly_name, display_pct }, + ), + .end_session = true, + }; + } + } else + // No value provided - ask for it + return ActionResult{ + .speech = try std.fmt.allocPrint(allocator, "What {s} should I set {s} to?", .{ + if (std.mem.eql(u8, entity.domain, "climate")) "temperature" else "level", + entity.friendly_name, + }), + .end_session = false, + }; + }, + .query_state => { + const response = try formatStateResponse(allocator, entity); + return ActionResult{ + .speech = response, + .end_session = true, + }; + }, + } + }, + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "fuzzyMatchWord exact match" { + try std.testing.expect(fuzzyMatchWord("Bedroom Light", "bedroom")); + try std.testing.expect(fuzzyMatchWord("Bedroom Light", "light")); +} + +test "fuzzyMatchWord prefix half length" { + // "bedr" (4 chars) vs "bedroom" (7 chars) -> 4 >= 4 (ceil(7/2)) -> MATCH + try std.testing.expect(fuzzyMatchWord("Bedroom Light", "bedr")); + // "lig" (3 chars) vs "light" (5 chars) -> 3 >= 3 (ceil(5/2)) -> MATCH + try std.testing.expect(fuzzyMatchWord("Bedroom Light", "lig")); +} + +test "fuzzyMatchWord prefix too short" { + // "bed" (3 chars) vs "bedroom" (7 chars) -> 3 < 4 (ceil(7/2)) -> NO MATCH + try std.testing.expect(!fuzzyMatchWord("Bedroom Light", "bed")); + // "li" (2 chars) vs "light" (5 chars) -> 2 < 3 (ceil(5/2)) -> NO MATCH + try std.testing.expect(!fuzzyMatchWord("Bedroom Light", "li")); +} + +test "fuzzyMatchWord case insensitive" { + try std.testing.expect(fuzzyMatchWord("Bedroom Light", "BEDROOM")); + try std.testing.expect(fuzzyMatchWord("BEDROOM LIGHT", "bedroom")); + try std.testing.expect(fuzzyMatchWord("bedroom light", "Bedroom")); +} + +test "fuzzyMatchWord multi-word friendly name" { + try std.testing.expect(fuzzyMatchWord("Living Room Lamp", "living")); + try std.testing.expect(fuzzyMatchWord("Living Room Lamp", "room")); + try std.testing.expect(fuzzyMatchWord("Living Room Lamp", "lamp")); + // "liv" (3 chars) vs "Living" (6 chars) -> 3 >= 3 (ceil(6/2)) -> MATCH + try std.testing.expect(fuzzyMatchWord("Living Room Lamp", "liv")); + // "li" (2 chars) vs "Living" (6 chars) -> 2 < 3 (ceil(6/2)) -> NO MATCH + try std.testing.expect(!fuzzyMatchWord("Living Room Lamp", "li")); +} + +test "fuzzyMatchWord substring match for long search terms" { + // "bedroom" is 7 chars, so it qualifies for substring matching (>= 4 chars) + try std.testing.expect(fuzzyMatchWord("Master Bedroom", "bedroom")); + // "room" is 4 chars, qualifies for substring matching + try std.testing.expect(fuzzyMatchWord("Living Room Lamp", "room")); +} + +test "fuzzyMatchWord handles underscores and hyphens" { + try std.testing.expect(fuzzyMatchWord("living_room_lamp", "living")); + try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room")); +} + +test "findEntitiesByName single match" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + const result = try findEntitiesByName(allocator, &entities, "bedroom"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 0), result.single); +} + +test "findEntitiesByName multiple matches" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + const result = try findEntitiesByName(allocator, &entities, "light"); + try std.testing.expect(result == .multiple); + defer allocator.free(result.multiple); + try std.testing.expectEqual(@as(usize, 2), result.multiple.len); +} + +test "findEntitiesByName no match" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + const result = try findEntitiesByName(allocator, &entities, "garage"); + try std.testing.expect(result == .none); +} + +test "findEntitiesByName exact match takes priority" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + // "bedroom" exactly matches "Bedroom" + const result = try findEntitiesByName(allocator, &entities, "bedroom"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 0), result.single); +} + +test "findEntitiesByName noise words dont override important matches" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + // "bedroom light" should still match "Bedroom" - "light" is a noise word + const result = try findEntitiesByName(allocator, &entities, "bedroom light"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 0), result.single); +} + +test "findEntitiesByName specific name matches over generic" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + // "jack bedroom" should match "Jack Bedroom Light" - has both important words + const result = try findEntitiesByName(allocator, &entities, "jack bedroom"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 1), result.single); +} + +test "findEntitiesByName handles possessives" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + // "jack's bedroom" should match "Jack Bedroom Light" + const result = try findEntitiesByName(allocator, &entities, "jack's bedroom"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 1), result.single); +} + +test "findEntitiesByName full specific match" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + // "jack bedroom light" should match "Jack Bedroom Light" + const result = try findEntitiesByName(allocator, &entities, "jack bedroom light"); + try std.testing.expect(result == .single); + try std.testing.expectEqual(@as(usize, 1), result.single); +} + +test "formatStateResponse light on with brightness" { + const allocator = std.testing.allocator; + const entity = Entity{ + .entity_id = "light.bedroom", + .state = "on", + .friendly_name = "Bedroom Light", + .domain = "light", + .last_changed = null, + .brightness = 191, // ~75% + .temperature = null, + .current_temperature = null, + }; + + const response = try formatStateResponse(allocator, &entity); + defer allocator.free(response); + + try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null); + try std.testing.expect(std.mem.indexOf(u8, response, "on") != null); + try std.testing.expect(std.mem.indexOf(u8, response, "74%") != null or std.mem.indexOf(u8, response, "75%") != null); +} + +test "formatStateResponse light off" { + const allocator = std.testing.allocator; + const entity = Entity{ + .entity_id = "light.bedroom", + .state = "off", + .friendly_name = "Bedroom Light", + .domain = "light", + .last_changed = null, + .brightness = null, + .temperature = null, + .current_temperature = null, + }; + + const response = try formatStateResponse(allocator, &entity); + defer allocator.free(response); + + try std.testing.expect(std.mem.indexOf(u8, response, "off") != null); + try std.testing.expect(std.mem.indexOf(u8, response, "brightness") == null); +} + +test "formatStateResponse climate with temperatures" { + const allocator = std.testing.allocator; + const entity = Entity{ + .entity_id = "climate.thermostat", + .state = "heat", + .friendly_name = "Thermostat", + .domain = "climate", + .last_changed = null, + .brightness = null, + .temperature = 72.0, + .current_temperature = 68.0, + }; + + const response = try formatStateResponse(allocator, &entity); + defer allocator.free(response); + + try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null); + try std.testing.expect(std.mem.indexOf(u8, response, "72") != null); + try std.testing.expect(std.mem.indexOf(u8, response, "68") != null); +} + +test "formatClarificationPrompt" { + const allocator = std.testing.allocator; + const entities = [_]Entity{ + .{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + .{ .entity_id = "light.living", .state = "on", .friendly_name = "Living Room Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, + }; + + const indices = [_]usize{ 0, 1, 2 }; + const prompt = try formatClarificationPrompt(allocator, &entities, &indices); + defer allocator.free(prompt); + + try std.testing.expect(std.mem.indexOf(u8, prompt, "Which one") != null); + try std.testing.expect(std.mem.indexOf(u8, prompt, "Bedroom Light") != null); + try std.testing.expect(std.mem.indexOf(u8, prompt, "Kitchen Light") != null); + try std.testing.expect(std.mem.indexOf(u8, prompt, "Living Room Light") != null); + try std.testing.expect(std.mem.indexOf(u8, prompt, ", or ") != null); +} + +test "Action.fromString parsing" { + // turn on variants + try std.testing.expectEqual(Action.turn_on, Action.fromString("turn on").?); + try std.testing.expectEqual(Action.turn_on, Action.fromString("on").?); + try std.testing.expectEqual(Action.turn_on, Action.fromString("Turn On").?); + try std.testing.expectEqual(Action.turn_on, Action.fromString("ON").?); + + // turn off variants (checked before "on" due to containing "on") + try std.testing.expectEqual(Action.turn_off, Action.fromString("turn off").?); + try std.testing.expectEqual(Action.turn_off, Action.fromString("off").?); + try std.testing.expectEqual(Action.turn_off, Action.fromString("Turn Off").?); + try std.testing.expectEqual(Action.turn_off, Action.fromString("OFF").?); + + // toggle + try std.testing.expectEqual(Action.toggle, Action.fromString("toggle").?); + try std.testing.expectEqual(Action.toggle, Action.fromString("Toggle").?); + + // unrecognized returns null + try std.testing.expect(Action.fromString("blah") == null); + try std.testing.expect(Action.fromString("something") == null); +} + +// ============================================================================ +// Mock HTTP Client for Integration Tests +// ============================================================================ + +const MockHttpClient = struct { + responses: []const MockResponse, + call_index: usize, + + const MockResponse = struct { + status: http.Status, + body: []const u8, + }; + + pub fn init(responses: []const MockResponse) MockHttpClient { + return .{ + .responses = responses, + .call_index = 0, + }; + } + + pub fn interface(self: *MockHttpClient) HttpClientInterface { + return .{ + .ptr = self, + .fetchFn = fetch, + }; + } + + fn fetch(ptr: *anyopaque, allocator: Allocator, options: FetchOptions) anyerror!FetchResult { + _ = options; + const self: *MockHttpClient = @ptrCast(@alignCast(ptr)); + + if (self.call_index >= self.responses.len) { + return HomeAssistantError.ConnectionFailed; + } + + const response = self.responses[self.call_index]; + self.call_index += 1; + + // Duplicate the body so caller can free it + const body = try allocator.dupe(u8, response.body); + + return FetchResult{ + .status = response.status, + .body = body, + }; + } +}; + +test "handleDeviceAction turn on light success" { + const allocator = std.testing.allocator; + + const states_response = + \\[{"entity_id":"light.bedroom","state":"off","attributes":{"friendly_name":"Bedroom Light"}}] + ; + const service_response = "[]"; + + var responses = [_]MockHttpClient.MockResponse{ + .{ .status = .ok, .body = states_response }, + .{ .status = .ok, .body = service_response }, + }; + + var mock = MockHttpClient.init(&responses); + var client = Client.initInterface( + allocator, + "http://test", + "token", + mock.interface(), + ); + + const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null); + defer allocator.free(result.speech); + + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null); + try std.testing.expect(result.end_session); +} + +test "handleDeviceAction device not found" { + const allocator = std.testing.allocator; + + const states_response = + \\[{"entity_id":"light.bedroom","state":"off","attributes":{"friendly_name":"Bedroom Light"}}] + ; + + var responses = [_]MockHttpClient.MockResponse{ + .{ .status = .ok, .body = states_response }, + }; + + var mock = MockHttpClient.init(&responses); + var client = Client.initInterface( + allocator, + "http://test", + "token", + mock.interface(), + ); + + const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null); + defer allocator.free(result.speech); + + try std.testing.expect(std.mem.indexOf(u8, result.speech, "couldn't find") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "garage") != null); + try std.testing.expect(result.end_session); +} + +test "handleDeviceAction multiple matches returns clarification" { + const allocator = std.testing.allocator; + + const states_response = + \\[{"entity_id":"light.bedroom","state":"off","attributes":{"friendly_name":"Bedroom Light"}},{"entity_id":"light.kitchen","state":"on","attributes":{"friendly_name":"Kitchen Light"}}] + ; + + var responses = [_]MockHttpClient.MockResponse{ + .{ .status = .ok, .body = states_response }, + }; + + var mock = MockHttpClient.init(&responses); + var client = Client.initInterface( + allocator, + "http://test", + "token", + mock.interface(), + ); + + const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null); + defer allocator.free(result.speech); + + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Kitchen Light") != null); + try std.testing.expect(!result.end_session); // Session stays open for clarification +} + +test "handleDeviceAction query state" { + const allocator = std.testing.allocator; + + const states_response = + \\[{"entity_id":"light.bedroom","state":"on","attributes":{"friendly_name":"Bedroom Light","brightness":191}}] + ; + + var responses = [_]MockHttpClient.MockResponse{ + .{ .status = .ok, .body = states_response }, + }; + + var mock = MockHttpClient.init(&responses); + var client = Client.initInterface( + allocator, + "http://test", + "token", + mock.interface(), + ); + + const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null); + defer allocator.free(result.speech); + + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "on") != null); + try std.testing.expect(result.end_session); +} + +test "handleDeviceAction set brightness" { + const allocator = std.testing.allocator; + + const states_response = + \\[{"entity_id":"light.bedroom","state":"off","attributes":{"friendly_name":"Bedroom Light"}}] + ; + const service_response = "[]"; + + var responses = [_]MockHttpClient.MockResponse{ + .{ .status = .ok, .body = states_response }, + .{ .status = .ok, .body = service_response }, + }; + + var mock = MockHttpClient.init(&responses); + var client = Client.initInterface( + allocator, + "http://test", + "token", + mock.interface(), + ); + + const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0); + defer allocator.free(result.speech); + + try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null); + try std.testing.expect(std.mem.indexOf(u8, result.speech, "50%") != null); + try std.testing.expect(result.end_session); +} diff --git a/src/main.zig b/src/main.zig index 77527f3..0c6f1ec 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,12 +2,49 @@ const std = @import("std"); const json = std.json; const lambda = @import("lambda_runtime"); const rinnai = @import("rinnai"); +const homeassistant = @import("homeassistant.zig"); +const Config = @import("Config.zig"); const builtin = @import("builtin"); const log = std.log.scoped(.alexa); pub fn main() !u8 { - lambda.run(null, handler) catch |err| { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + // Check for --help first (no config needed) + for (args) |arg| { + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + printHelp() catch return 1; + return 0; + } + } + + // Initialize config (loads .env + environment, detects Lambda) + var config = Config.init(allocator) catch |err| { + std.debug.print("Failed to initialize config: {}\n", .{err}); + return 1; + }; + defer config.deinit(); + + // Run in Lambda mode or local mode based on environment + if (!config.is_lambda) + return runLocal(allocator, config, args); + + const Handler = struct { + var c: Config = undefined; + + pub fn lambda_handler(alloc: std.mem.Allocator, event_data: []const u8) anyerror![]const u8 { + return handler(alloc, event_data, c); + } + }; + Handler.c = config; + + lambda.run(null, Handler.lambda_handler) catch |err| { log.err("Lambda runtime error: {}", .{err}); return 1; }; @@ -15,7 +52,7 @@ pub fn main() !u8 { } /// Main Alexa request handler -fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]const u8 { +fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config) anyerror![]const u8 { log.info("Received Alexa request: {d} bytes", .{event_data.len}); // Parse the Alexa request @@ -45,9 +82,9 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons // Handle different request types if (std.mem.eql(u8, request_type_str, "LaunchRequest")) { - return buildAlexaResponse(allocator, "What would you like me to do? You can ask me to start the hot water.", false); + return buildAlexaResponse(allocator, "What would you like me to do? You can ask me to start the hot water, or control your smart home devices.", false); } else if (std.mem.eql(u8, request_type_str, "IntentRequest")) { - return handleIntentRequest(allocator, request_obj); + return handleIntentRequest(allocator, request_obj, config); } else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) { return buildAlexaResponse(allocator, "", true); } @@ -56,7 +93,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons } /// Handle Alexa intent requests -fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![]const u8 { +fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, config: Config) ![]const u8 { const intent_obj = request_obj.object.get("intent") orelse { if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{}); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); @@ -75,9 +112,13 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![ log.info("Intent: {s}", .{intent_name}); if (std.mem.eql(u8, intent_name, "RecirculateWaterIntent")) { - return handleRecirculateWater(allocator); + return handleRecirculateWater(allocator, config); + } else if (std.mem.eql(u8, intent_name, "HomeAssistantIntent")) { + return handleHomeAssistantIntent(allocator, intent_obj, config); + } else if (std.mem.eql(u8, intent_name, "WeezTheJuiceIntent")) { + return handleWeezTheJuice(allocator, config); } else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) { - return buildAlexaResponse(allocator, "You can ask me to start the hot water to begin recirculation. This will preheat your water for about 15 minutes.", false); + return buildAlexaResponse(allocator, "You can ask me to start the hot water to begin recirculation, or control your smart home devices like lights and thermostats.", false); } else if (std.mem.eql(u8, intent_name, "AMAZON.StopIntent") or std.mem.eql(u8, intent_name, "AMAZON.CancelIntent")) { return buildAlexaResponse(allocator, "Okay, goodbye.", true); } @@ -86,15 +127,15 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![ } /// Handle the main recirculate water intent -fn handleRecirculateWater(allocator: std.mem.Allocator) ![]const u8 { - // Get credentials from environment variables - const username = std.posix.getenv("COGNITO_USERNAME") orelse { - log.err("COGNITO_USERNAME environment variable not set", .{}); +fn handleRecirculateWater(allocator: std.mem.Allocator, config: Config) ![]const u8 { + // Get credentials from config + const username = config.cognito_username orelse { + log.err("COGNITO_USERNAME not configured", .{}); return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true); }; - const password = std.posix.getenv("COGNITO_PASSWORD") orelse { - log.err("COGNITO_PASSWORD environment variable not set", .{}); + const password = config.cognito_password orelse { + log.err("COGNITO_PASSWORD not configured", .{}); return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true); }; @@ -134,6 +175,169 @@ fn handleRecirculateWater(allocator: std.mem.Allocator) ![]const u8 { return buildAlexaResponse(allocator, "Starting water recirculation. Hot water should be ready in about 2 minutes.", true); } +/// Handle the Home Assistant device control intent +fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, config: Config) ![]const u8 { + // Get Home Assistant credentials from config + const ha_url = config.home_assistant_url orelse { + log.err("HOME_ASSISTANT_URL not configured", .{}); + return buildAlexaResponse(allocator, "Home Assistant is not configured. Please set up the URL.", true); + }; + + const ha_token = config.home_assistant_token orelse { + log.err("HOME_ASSISTANT_TOKEN not configured", .{}); + return buildAlexaResponse(allocator, "Home Assistant is not configured. Please set up the access token.", true); + }; + + // Extract and parse slots from intent + const slots = intent_obj.object.get("slots"); + const params = parseHomeAssistantSlots(slots) orelse { + return buildAlexaResponse(allocator, "I didn't catch which device you want to control. Please try again.", true); + }; + + log.info( + "Home Assistant action: {} for device: {s}, value: {?}", + .{ params.action, params.device_name, params.value }, + ); + + var client = homeassistant.Client.init( + allocator, + ha_url, + ha_token, + ); + defer client.deinit(); + + // Execute the action + const result = homeassistant.handleDeviceAction( + allocator, + &client, + params.action, + params.device_name, + params.value, + ) catch |err| { + log.err("Home Assistant error: {}", .{err}); + return buildAlexaResponse(allocator, "I had trouble communicating with Home Assistant. Please try again.", true); + }; + defer allocator.free(result.speech); + + return buildAlexaResponse(allocator, result.speech, result.end_session); +} + +/// Handle the "weez the juice" Easter egg - toggles bedroom light +fn handleWeezTheJuice(allocator: std.mem.Allocator, config: Config) ![]const u8 { + const ha_url = config.home_assistant_url orelse { + log.err("HOME_ASSISTANT_URL not configured", .{}); + return buildAlexaResponse(allocator, "Home Assistant is not configured.", true); + }; + + const ha_token = config.home_assistant_token orelse { + log.err("HOME_ASSISTANT_TOKEN not configured", .{}); + return buildAlexaResponse(allocator, "Home Assistant is not configured.", true); + }; + + log.info("Weez the juice! Toggling bedroom light", .{}); + + var client = homeassistant.Client.init(allocator, ha_url, ha_token); + defer client.deinit(); + + // We don't care about the result speech - we have our own response + const result = homeassistant.handleDeviceAction( + allocator, + &client, + .toggle, + "bedroom", + null, + ) catch |err| { + log.err("Home Assistant error: {}", .{err}); + return buildAlexaResponse(allocator, "I had trouble weezin' the juice.", true); + }; + defer allocator.free(result.speech); + + return buildAlexaResponse(allocator, "No weezin' the juice!", true); +} + +/// Parsed intent parameters for Home Assistant commands +const IntentParams = struct { + action: homeassistant.Action, + device_name: []const u8, + value: ?f32, +}; + +/// Parse the action, device name, and value from Alexa slots. +/// Returns null if device_name is missing (required field). +fn parseHomeAssistantSlots(slots: ?json.Value) ?IntentParams { + // Get device name (required) + const device_name = extractSlotValue(slots, "device") orelse return null; + + // Get action (optional - may be inferred from utterance) + const action_slot = extractSlotValue(slots, "action"); + + // Get numeric value (optional - for brightness/temperature) + const value = extractSlotNumber(slots, "value"); + + const action = determineAction(action_slot, value); + + return .{ + .action = action, + .device_name = device_name, + .value = value, + }; +} + +/// Determine the action based on action slot text and presence of a value. +/// Logic: +/// - If value is present: set_value (value takes precedence) +/// - If action_slot present: parse with Action.fromString +/// - If no action_slot and no value: query_state +fn determineAction(action_slot: ?[]const u8, value: ?f32) homeassistant.Action { + // If we have a value, it's a set command + if (value != null) return .set_value; + + // If we have an action slot, parse it + if (action_slot) |action_text| + return homeassistant.Action.fromString(action_text) orelse .toggle; + + // No action slot and no value - default to query + return .query_state; +} + +/// Extract a string slot value from Alexa slots object +fn extractSlotValue(slots: ?json.Value, slot_name: []const u8) ?[]const u8 { + const s = slots orelse return null; + if (s != .object) return null; + + const slot = s.object.get(slot_name) orelse return null; + if (slot != .object) return null; + + // Try to get resolved value first (from slot resolution) + if (slot.object.get("resolutions")) |resolutions| + if (resolutions.object.get("resolutionsPerAuthority")) |rpa| + if (rpa.array.items.len > 0) + if (rpa.array.items[0].object.get("values")) |values| + if (values.array.items.len > 0) + if (values.array.items[0].object.get("value")) |val| + if (val.object.get("name")) |name| + if (name == .string) return name.string; + + // Fall back to raw value + const value = slot.object.get("value") orelse return null; + if (value != .string) return null; + return value.string; +} + +/// Extract a numeric slot value from Alexa slots object +fn extractSlotNumber(slots: ?json.Value, slot_name: []const u8) ?f32 { + const s = slots orelse return null; + if (s != .object) return null; + + const slot = s.object.get(slot_name) orelse return null; + if (slot != .object) return null; + + const value = slot.object.get("value") orelse return null; + if (value != .string) return null; + + return std.fmt.parseFloat(f32, value.string) catch null; +} + /// Build an Alexa skill response JSON fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 { // Escape speech for JSON @@ -165,10 +369,168 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess , .{ escaped_speech.items, end_session_str }); } +// ============================================================================= +// Local Testing Mode +// ============================================================================= + +fn printHelp() !void { + const help = + \\Usage: bootstrap [OPTIONS] + \\ + \\Alexa skill handler for water recirculation and Home Assistant control. + \\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API. + \\ + \\Options: + \\ --help, -h Show this help message + \\ + \\Local mode options (when not running in Lambda): + \\ --type=TYPE Request type: launch, intent, session_ended (default: intent) + \\ --intent=NAME Intent name (e.g., HomeAssistantIntent, RecirculateWaterIntent) + \\ --device=NAME Device name for HomeAssistantIntent + \\ --action=ACTION Action: "turn on", "turn off", "toggle" + \\ --value=NUM Numeric value (brightness %, temperature) + \\ + \\Examples: + \\ bootstrap --type=launch + \\ bootstrap --intent=RecirculateWaterIntent + \\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn on" + \\ bootstrap --intent=HomeAssistantIntent --device="thermostat" --value=72 + \\ bootstrap --intent=WeezTheJuiceIntent + \\ + \\Environment variables (or .env file in current directory): + \\ COGNITO_USERNAME Rinnai account username + \\ COGNITO_PASSWORD Rinnai account password + \\ HOME_ASSISTANT_URL Home Assistant URL (e.g., https://ha.example.com) + \\ HOME_ASSISTANT_TOKEN Home Assistant long-lived access token + \\ + ; + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + try stdout.print("{s}", .{help}); + try stdout.flush(); +} + +fn runLocal(allocator: std.mem.Allocator, config: Config, args: []const []const u8) !u8 { + // Parse arguments + var request_type: []const u8 = "intent"; + var intent_name: ?[]const u8 = null; + var device: ?[]const u8 = null; + var action: ?[]const u8 = null; + var value: ?[]const u8 = null; + + for (args) |arg| { + if (parseArgValue(arg, "--type=")) |v| { + request_type = v; + } else if (parseArgValue(arg, "--intent=")) |v| { + intent_name = v; + } else if (parseArgValue(arg, "--device=")) |v| { + device = v; + } else if (parseArgValue(arg, "--action=")) |v| { + action = v; + } else if (parseArgValue(arg, "--value=")) |v| { + value = v; + } + } + + // Build request JSON + const event_json = try buildLocalRequest(allocator, request_type, intent_name, device, action, value); + defer allocator.free(event_json); + + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + try stdout.print("Request: {s}\n\n", .{event_json}); + + // Call handler + const response = handler(allocator, event_json, config) catch |err| { + try stdout.print("Handler error: {t}\n", .{err}); + try stdout.flush(); + return 1; + }; + defer allocator.free(response); + + // Pretty print response JSON + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + try stdout.print("{f}\n", .{json.fmt(parsed.value, .{ .whitespace = .indent_2 })}); + try stdout.flush(); + return 0; +} + +fn parseArgValue(arg: []const u8, prefix: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, arg, prefix)) + return arg[prefix.len..]; + return null; +} + +fn buildLocalRequest( + allocator: std.mem.Allocator, + request_type: []const u8, + intent_name: ?[]const u8, + device: ?[]const u8, + action: ?[]const u8, + value: ?[]const u8, +) ![]const u8 { + if (std.mem.eql(u8, request_type, "launch")) { + return try std.fmt.allocPrint(allocator, + \\{{"request":{{"type":"LaunchRequest"}}}} + , .{}); + } + + if (std.mem.eql(u8, request_type, "session_ended")) { + return try std.fmt.allocPrint(allocator, + \\{{"request":{{"type":"SessionEndedRequest"}}}} + , .{}); + } + + // Intent request + const intent = intent_name orelse "AMAZON.HelpIntent"; + + // Build slots JSON + var slots_buf: [512]u8 = undefined; + var slots_stream = std.io.fixedBufferStream(&slots_buf); + const slots_writer = slots_stream.writer(); + + try slots_writer.writeAll("{"); + var has_slot = false; + + if (device) |d| { + try slots_writer.print("\"device\":{{\"value\":\"{s}\"}}", .{d}); + has_slot = true; + } + if (action) |a| { + if (has_slot) try slots_writer.writeAll(","); + try slots_writer.print("\"action\":{{\"value\":\"{s}\"}}", .{a}); + has_slot = true; + } + if (value) |v| { + if (has_slot) try slots_writer.writeAll(","); + try slots_writer.print("\"value\":{{\"value\":\"{s}\"}}", .{v}); + } + try slots_writer.writeAll("}"); + + return try std.fmt.allocPrint(allocator, + \\{{"request":{{"type":"IntentRequest","intent":{{"name":"{s}","slots":{s}}}}}}} + , .{ intent, slots_stream.getWritten() }); +} + // ============================================================================= // Tests // ============================================================================= +fn testConfig() Config { + return .{ + .allocator = std.testing.allocator, + .cognito_username = null, + .cognito_password = null, + .home_assistant_url = null, + .home_assistant_token = null, + .is_lambda = false, + }; +} + test "buildAlexaResponse with speech and end session" { const allocator = std.testing.allocator; const response = try buildAlexaResponse(allocator, "Hello world", true); @@ -224,7 +586,8 @@ test "buildAlexaResponse escapes special characters" { test "handler returns error response for invalid JSON" { const allocator = std.testing.allocator; - const response = try handler(allocator, "not valid json"); + const config = testConfig(); + const response = try handler(allocator, "not valid json", config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -236,7 +599,8 @@ test "handler returns error response for invalid JSON" { test "handler returns error for missing request field" { const allocator = std.testing.allocator; - const response = try handler(allocator, "{}"); + const config = testConfig(); + const response = try handler(allocator, "{}", config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -248,10 +612,11 @@ test "handler returns error for missing request field" { test "handler handles LaunchRequest" { const allocator = std.testing.allocator; + const config = testConfig(); const launch_request = \\{"request":{"type":"LaunchRequest"}} ; - const response = try handler(allocator, launch_request); + const response = try handler(allocator, launch_request, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -265,10 +630,11 @@ test "handler handles LaunchRequest" { test "handler handles SessionEndedRequest" { const allocator = std.testing.allocator; + const config = testConfig(); const session_ended = \\{"request":{"type":"SessionEndedRequest"}} ; - const response = try handler(allocator, session_ended); + const response = try handler(allocator, session_ended, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -281,10 +647,11 @@ test "handler handles SessionEndedRequest" { test "handler handles AMAZON.HelpIntent" { const allocator = std.testing.allocator; + const config = testConfig(); const help_request = \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.HelpIntent"}}} ; - const response = try handler(allocator, help_request); + const response = try handler(allocator, help_request, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -298,10 +665,11 @@ test "handler handles AMAZON.HelpIntent" { test "handler handles AMAZON.StopIntent" { const allocator = std.testing.allocator; + const config = testConfig(); const stop_request = \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.StopIntent"}}} ; - const response = try handler(allocator, stop_request); + const response = try handler(allocator, stop_request, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -315,10 +683,11 @@ test "handler handles AMAZON.StopIntent" { test "handler handles AMAZON.CancelIntent" { const allocator = std.testing.allocator; + const config = testConfig(); const cancel_request = \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.CancelIntent"}}} ; - const response = try handler(allocator, cancel_request); + const response = try handler(allocator, cancel_request, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -330,10 +699,11 @@ test "handler handles AMAZON.CancelIntent" { test "handler handles unknown intent" { const allocator = std.testing.allocator; + const config = testConfig(); const unknown_intent = \\{"request":{"type":"IntentRequest","intent":{"name":"SomeRandomIntent"}}} ; - const response = try handler(allocator, unknown_intent); + const response = try handler(allocator, unknown_intent, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -345,10 +715,11 @@ test "handler handles unknown intent" { test "handler handles unknown request type" { const allocator = std.testing.allocator; + const config = testConfig(); const unknown_request = \\{"request":{"type":"SomeOtherRequest"}} ; - const response = try handler(allocator, unknown_request); + const response = try handler(allocator, unknown_request, config); defer allocator.free(response); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); @@ -357,3 +728,121 @@ test "handler handles unknown request type" { const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; try std.testing.expectEqualStrings("I didn't understand that.", text); } + +// ============================================================================= +// determineAction tests +// ============================================================================= + +test "determineAction - no action slot, no value returns query_state" { + const action = determineAction(null, null); + try std.testing.expectEqual(homeassistant.Action.query_state, action); +} + +test "determineAction - turn on returns turn_on" { + try std.testing.expectEqual(homeassistant.Action.turn_on, determineAction("turn on", null)); + try std.testing.expectEqual(homeassistant.Action.turn_on, determineAction("on", null)); + try std.testing.expectEqual(homeassistant.Action.turn_on, determineAction("Turn On", null)); + try std.testing.expectEqual(homeassistant.Action.turn_on, determineAction("ON", null)); +} + +test "determineAction - turn off returns turn_off" { + try std.testing.expectEqual(homeassistant.Action.turn_off, determineAction("turn off", null)); + try std.testing.expectEqual(homeassistant.Action.turn_off, determineAction("off", null)); + try std.testing.expectEqual(homeassistant.Action.turn_off, determineAction("Turn Off", null)); + try std.testing.expectEqual(homeassistant.Action.turn_off, determineAction("OFF", null)); +} + +test "determineAction - toggle returns toggle" { + try std.testing.expectEqual(homeassistant.Action.toggle, determineAction("toggle", null)); + try std.testing.expectEqual(homeassistant.Action.toggle, determineAction("Toggle", null)); + try std.testing.expectEqual(homeassistant.Action.toggle, determineAction("TOGGLE", null)); +} + +test "determineAction - value present returns set_value" { + // Value takes precedence over action slot + try std.testing.expectEqual(homeassistant.Action.set_value, determineAction(null, 50.0)); + try std.testing.expectEqual(homeassistant.Action.set_value, determineAction("turn on", 75.5)); + try std.testing.expectEqual(homeassistant.Action.set_value, determineAction("turn off", 100.0)); +} + +test "determineAction - unrecognized action defaults to toggle" { + try std.testing.expectEqual(homeassistant.Action.toggle, determineAction("something weird", null)); + try std.testing.expectEqual(homeassistant.Action.toggle, determineAction("blah", null)); +} + +// ============================================================================= +// parseHomeAssistantSlots tests +// ============================================================================= + +test "parseHomeAssistantSlots - missing device returns null" { + const allocator = std.testing.allocator; + const slots_json = + \\{"action": {"value": "turn on"}} + ; + const parsed = try json.parseFromSlice(json.Value, allocator, slots_json, .{}); + defer parsed.deinit(); + + const params = parseHomeAssistantSlots(parsed.value); + try std.testing.expect(params == null); +} + +test "parseHomeAssistantSlots - device only returns query_state" { + const allocator = std.testing.allocator; + const slots_json = + \\{"device": {"value": "bedroom light"}} + ; + const parsed = try json.parseFromSlice(json.Value, allocator, slots_json, .{}); + defer parsed.deinit(); + + const params = parseHomeAssistantSlots(parsed.value).?; + try std.testing.expectEqualStrings("bedroom light", params.device_name); + try std.testing.expectEqual(homeassistant.Action.query_state, params.action); + try std.testing.expect(params.value == null); +} + +test "parseHomeAssistantSlots - device with turn on action" { + const allocator = std.testing.allocator; + const slots_json = + \\{"device": {"value": "kitchen light"}, "action": {"value": "turn on"}} + ; + const parsed = try json.parseFromSlice(json.Value, allocator, slots_json, .{}); + defer parsed.deinit(); + + const params = parseHomeAssistantSlots(parsed.value).?; + try std.testing.expectEqualStrings("kitchen light", params.device_name); + try std.testing.expectEqual(homeassistant.Action.turn_on, params.action); + try std.testing.expect(params.value == null); +} + +test "parseHomeAssistantSlots - device with turn off action" { + const allocator = std.testing.allocator; + const slots_json = + \\{"device": {"value": "living room"}, "action": {"value": "turn off"}} + ; + const parsed = try json.parseFromSlice(json.Value, allocator, slots_json, .{}); + defer parsed.deinit(); + + const params = parseHomeAssistantSlots(parsed.value).?; + try std.testing.expectEqualStrings("living room", params.device_name); + try std.testing.expectEqual(homeassistant.Action.turn_off, params.action); + try std.testing.expect(params.value == null); +} + +test "parseHomeAssistantSlots - device with value returns set_value" { + const allocator = std.testing.allocator; + const slots_json = + \\{"device": {"value": "bedroom"}, "action": {"value": "turn on"}, "value": {"value": "75"}} + ; + const parsed = try json.parseFromSlice(json.Value, allocator, slots_json, .{}); + defer parsed.deinit(); + + const params = parseHomeAssistantSlots(parsed.value).?; + try std.testing.expectEqualStrings("bedroom", params.device_name); + try std.testing.expectEqual(homeassistant.Action.set_value, params.action); + try std.testing.expectEqual(@as(f32, 75.0), params.value.?); +} + +test "parseHomeAssistantSlots - null slots returns null" { + const params = parseHomeAssistantSlots(null); + try std.testing.expect(params == null); +}