add home assistant functionality

This commit is contained in:
Emil Lerch 2026-02-03 14:05:35 -08:00
parent f25524791b
commit c1a5720eb5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 1996 additions and 29 deletions

View file

@ -62,14 +62,26 @@ pub fn build(b: *std.Build) !void {
} }
full_deploy_step.dependOn(&ask_deploy_cmd.step); full_deploy_step.dependOn(&ask_deploy_cmd.step);
// Test step - reuses the same target query, tests run via emulation or on native arm64 // Test step - use native target for tests (not cross-compiled Lambda target)
const test_module = b.createModule(.{ const native_target = b.resolveTargetQuery(.{});
.root_source_file = b.path("src/main.zig"),
.target = target, const lambda_zig_dep_native = b.dependency("lambda_zig", .{
.target = native_target,
.optimize = optimize, .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(.{ const main_tests = b.addTest(.{
.name = "test", .name = "test",
@ -79,4 +91,26 @@ pub fn build(b: *std.Build) !void {
const run_main_tests = b.addRunArtifact(main_tests); const run_main_tests = b.addRunArtifact(main_tests);
const test_step = b.step("test", "Run unit tests"); const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_main_tests.step); 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);
} }

View file

@ -27,6 +27,62 @@
"I need hot water" "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", "name": "AMAZON.HelpIntent",
"samples": [] "samples": []
@ -46,9 +102,41 @@
{ {
"name": "AMAZON.FallbackIntent", "name": "AMAZON.FallbackIntent",
"samples": [] "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"]
}
}
]
}
]
} }
} }
} }

93
src/Config.zig Normal file
View file

@ -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);
}
}
}
}

1263
src/homeassistant.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,49 @@ const std = @import("std");
const json = std.json; const json = std.json;
const lambda = @import("lambda_runtime"); const lambda = @import("lambda_runtime");
const rinnai = @import("rinnai"); const rinnai = @import("rinnai");
const homeassistant = @import("homeassistant.zig");
const Config = @import("Config.zig");
const builtin = @import("builtin"); const builtin = @import("builtin");
const log = std.log.scoped(.alexa); const log = std.log.scoped(.alexa);
pub fn main() !u8 { 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}); log.err("Lambda runtime error: {}", .{err});
return 1; return 1;
}; };
@ -15,7 +52,7 @@ pub fn main() !u8 {
} }
/// Main Alexa request handler /// 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}); log.info("Received Alexa request: {d} bytes", .{event_data.len});
// Parse the Alexa request // Parse the Alexa request
@ -45,9 +82,9 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
// Handle different request types // Handle different request types
if (std.mem.eql(u8, request_type_str, "LaunchRequest")) { 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")) { } 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")) { } else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
return buildAlexaResponse(allocator, "", true); return buildAlexaResponse(allocator, "", true);
} }
@ -56,7 +93,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
} }
/// Handle Alexa intent requests /// 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 { const intent_obj = request_obj.object.get("intent") orelse {
if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{}); if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); 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}); log.info("Intent: {s}", .{intent_name});
if (std.mem.eql(u8, intent_name, "RecirculateWaterIntent")) { 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")) { } 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")) { } 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); 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 /// Handle the main recirculate water intent
fn handleRecirculateWater(allocator: std.mem.Allocator) ![]const u8 { fn handleRecirculateWater(allocator: std.mem.Allocator, config: Config) ![]const u8 {
// Get credentials from environment variables // Get credentials from config
const username = std.posix.getenv("COGNITO_USERNAME") orelse { const username = config.cognito_username orelse {
log.err("COGNITO_USERNAME environment variable not set", .{}); log.err("COGNITO_USERNAME not configured", .{});
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true); return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true);
}; };
const password = std.posix.getenv("COGNITO_PASSWORD") orelse { const password = config.cognito_password orelse {
log.err("COGNITO_PASSWORD environment variable not set", .{}); log.err("COGNITO_PASSWORD not configured", .{});
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true); 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); 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 /// Build an Alexa skill response JSON
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 { fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
// Escape speech for JSON // 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 }); , .{ 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 // 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" { test "buildAlexaResponse with speech and end session" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const response = try buildAlexaResponse(allocator, "Hello world", true); 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" { test "handler returns error response for invalid JSON" {
const allocator = std.testing.allocator; 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); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, 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" { test "handler returns error for missing request field" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const response = try handler(allocator, "{}"); const config = testConfig();
const response = try handler(allocator, "{}", config);
defer allocator.free(response); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, 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" { test "handler handles LaunchRequest" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const launch_request = const launch_request =
\\{"request":{"type":"LaunchRequest"}} \\{"request":{"type":"LaunchRequest"}}
; ;
const response = try handler(allocator, launch_request); const response = try handler(allocator, launch_request, config);
defer allocator.free(response); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
@ -265,10 +630,11 @@ test "handler handles LaunchRequest" {
test "handler handles SessionEndedRequest" { test "handler handles SessionEndedRequest" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const session_ended = const session_ended =
\\{"request":{"type":"SessionEndedRequest"}} \\{"request":{"type":"SessionEndedRequest"}}
; ;
const response = try handler(allocator, session_ended); const response = try handler(allocator, session_ended, config);
defer allocator.free(response); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
@ -281,10 +647,11 @@ test "handler handles SessionEndedRequest" {
test "handler handles AMAZON.HelpIntent" { test "handler handles AMAZON.HelpIntent" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const help_request = const help_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.HelpIntent"}}} \\{"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); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
@ -298,10 +665,11 @@ test "handler handles AMAZON.HelpIntent" {
test "handler handles AMAZON.StopIntent" { test "handler handles AMAZON.StopIntent" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const stop_request = const stop_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.StopIntent"}}} \\{"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); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
@ -315,10 +683,11 @@ test "handler handles AMAZON.StopIntent" {
test "handler handles AMAZON.CancelIntent" { test "handler handles AMAZON.CancelIntent" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const cancel_request = const cancel_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.CancelIntent"}}} \\{"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); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
@ -330,10 +699,11 @@ test "handler handles AMAZON.CancelIntent" {
test "handler handles unknown intent" { test "handler handles unknown intent" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const unknown_intent = const unknown_intent =
\\{"request":{"type":"IntentRequest","intent":{"name":"SomeRandomIntent"}}} \\{"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); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, 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" { test "handler handles unknown request type" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = testConfig();
const unknown_request = const unknown_request =
\\{"request":{"type":"SomeOtherRequest"}} \\{"request":{"type":"SomeOtherRequest"}}
; ;
const response = try handler(allocator, unknown_request); const response = try handler(allocator, unknown_request, config);
defer allocator.free(response); defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, 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; const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("I didn't understand that.", text); 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);
}