add home assistant functionality
This commit is contained in:
parent
f25524791b
commit
c1a5720eb5
5 changed files with 1996 additions and 29 deletions
46
build.zig
46
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
src/Config.zig
Normal file
93
src/Config.zig
Normal 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
1263
src/homeassistant.zig
Normal file
File diff suppressed because it is too large
Load diff
533
src/main.zig
533
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue