diff --git a/README.md b/README.md index 6d9daa4..5fa811e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# Water Recirculation Alexa Skill +# Home control Alexa Skill -An Alexa skill that triggers water recirculation on Rinnai tankless water heaters. +An Alexa skill that triggers water recirculation on Rinnai tankless water heaters +and supports homeassistant device control ## Usage @@ -8,6 +9,11 @@ An Alexa skill that triggers water recirculation on Rinnai tankless water heater This will authenticate with the Rinnai API and start a 15-minute recirculation cycle. +> "Alexa, turn on the bedroom light" + +This will use the homeassistant REST API to find the bedroom device and turn it on. +You can turn on, turn off, toggle, and query status + ## Building Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management. @@ -21,15 +27,13 @@ zig build # Release build (arm64) zig build -Doptimize=ReleaseFast -# Build for native target (e.g., for local testing) -zig build -Dtarget=native - -# Run tests -zig build test -Dtarget=native +# Run tests (uses host CPU/OS) +zig build test ``` ## Dependencies +- [aws-sdk-for-zig](https://git.lerch.org/lobo/aws-sdk-for-zig) - AWS SDK for Zig - [lambda-zig](https://git.lerch.org/lobo/lambda-zig) - AWS Lambda runtime for Zig - [controlr](https://git.lerch.org/lobo/controlr) - Rinnai API client (provides `rinnai` module) @@ -105,18 +109,41 @@ This will: bun x ask smapi list-skills-for-vendor ``` -### 3. Rinnai Credentials +### 3. Environment Variables -The Lambda function needs your Rinnai account credentials to authenticate with the water heater API. +The Lambda function needs credentials for the services it interacts with. Create a `.env` file in the project root (this file is gitignored): ```bash # .env + +# Rinnai API credentials for water heater control COGNITO_USERNAME=your@email.com COGNITO_PASSWORD=your_password + +# Home Assistant configuration +HOME_ASSISTANT_URL=https://your-homeassistant.example.com +HOME_ASSISTANT_TOKEN=your_long_lived_access_token ``` +#### Rinnai Credentials + +The `COGNITO_USERNAME` and `COGNITO_PASSWORD` are your Rinnai app login credentials. +These are used to authenticate with the Rinnai API for water recirculation control. + +#### Home Assistant Token + +To generate a long-lived access token in Home Assistant: + +1. Go to your Home Assistant profile (click your username in the sidebar) +2. Scroll down to "Long-Lived Access Tokens" +3. Click "Create Token" +4. Give it a name (e.g., "Alexa Lambda") +5. Copy the token immediately (it won't be shown again) + +The `HOME_ASSISTANT_URL` should be the external URL of your Home Assistant instance. + These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option. ## Build Steps @@ -138,13 +165,12 @@ These credentials will be automatically deployed to Lambda when you use the `-De |--------|-------------|---------| | `-Doptimize=ReleaseFast` | Build with optimizations | Debug | | `-Dtarget=native` | Build for local machine | aarch64-linux | -| `-Dfunction-name=NAME` | Lambda function name | zig-fn | +| `-Dfunction-name=NAME` | Lambda function name | exe name (house-control) | | `-Dprofile=PROFILE` | AWS profile to use | default | | `-Dregion=REGION` | AWS region | from profile | | `-Drole-name=ROLE` | IAM role name | lambda_basic_execution | | `-Dpayload=JSON` | Payload for `awslambda_run` | {} | | `-Denv-file=PATH` | Environment variables file | none | -| `-Dallow-principal=PRINCIPAL` | AWS service principal to grant invoke permission | none | ## Deployment @@ -154,38 +180,44 @@ Before deploying, ensure you have: 1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup)) 2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`) -3. **Rinnai credentials** in `.env` file (see [Rinnai Credentials](#3-rinnai-credentials)) +3. **Credentials** in `.env` file (see [Environment Variables](#3-environment-variables)): + - Rinnai account credentials (for water recirculation) + - Home Assistant URL and long-lived access token (for device control) ### Full Deployment (Lambda + Alexa Skill) ```bash zig build deploy -Doptimize=ReleaseFast \ - -Dfunction-name=water-recirculation \ -Dprofile=personal \ -Dregion=us-west-2 \ - -Denv-file=.env \ - -Dallow-principal=alexa-appkit.amazon.com + -Denv-file=.env ``` -This command will: -1. Build the Lambda function for arm64 -2. Package it into a zip file -3. Create/update the Lambda function in AWS -4. Set environment variables from `.env` -5. Grant Alexa Skills Kit permission to invoke the function -6. Deploy the Alexa skill metadata via ASK CLI +This command orchestrates a multi-step deployment pipeline: + +1. **Build** - Compile Lambda function for arm64 +2. **Package** - Create deployment zip with bootstrap executable +3. **Deploy Lambda** - Create/update function in AWS, set env vars from `.env` +4. **Generate skill.json** - Inject Lambda ARN into `skill.template.json` +5. **ASK Deploy** - Deploy Alexa skill metadata and interaction model +6. **Add Permission** - Grant Alexa permission to invoke Lambda with skill-specific token + +The permission step uses the skill ID (from `.ask/ask-states.json`) as an +`event_source_token` condition, restricting invocation to only this specific +Alexa skill rather than allowing any Alexa skill to invoke the Lambda. ### Lambda Only ```bash zig build awslambda_deploy -Doptimize=ReleaseFast \ - -Dfunction-name=water-recirculation \ -Dprofile=personal \ -Dregion=us-west-2 \ - -Denv-file=.env \ - -Dallow-principal=alexa-appkit.amazon.com + -Denv-file=.env ``` +Note: This only deploys the Lambda function. To invoke the function from Alexa, +you must also deploy the skill (`zig build deploy`) to set up the permission. + ### Alexa Skill Only ```bash @@ -197,21 +229,29 @@ zig build ask_deploy ``` water_recirculation/ ├── build.zig # Build configuration -├── build.zig.zon # Dependencies (lambda-zig, controlr) -├── .env # Rinnai credentials (gitignored, create locally) +├── build.zig.zon # Dependencies +├── .env # Credentials (gitignored) +├── skill.template.json # Skill manifest template (Lambda ARN placeholder) ├── ask-resources.json # ASK CLI deployment config ├── package.json # Node.js deps for ASK CLI ├── src/ -│ └── main.zig # Alexa request handler + tests +│ ├── main.zig # Lambda entry point +│ └── homeassistant.zig # Home Assistant API client +├── tools/ +│ ├── gen-skill-json.zig # Generates skill.json from template +│ └── add-alexa-permission.zig # Adds skill-specific Lambda permission ├── skill-package/ -│ ├── skill.json # Alexa skill manifest +│ ├── skill.json # Generated (gitignored) │ └── interactionModels/ │ └── custom/ -│ └── en-US.json # Interaction model +│ └── en-US.json # Interaction model with intents/slots +└── .ask/ + └── ask-states.json # ASK CLI state (contains skill ID) ``` ## Sample Utterances +### Water Recirculation - "start the hot water" - "turn on the hot water" - "heat the water" @@ -219,17 +259,23 @@ water_recirculation/ - "start recirculation" - "warm up the water" +### Home Assistant Device Control +- "turn on the bedroom light" +- "turn off the kitchen" +- "toggle the basement fireplace" +- "is the deck on" +- "check the family room" + ## Lambda Details -- **Function**: `water-recirculation` +- **Function**: `house-control` - **Region**: us-west-2 - **Architecture**: arm64 (Graviton) - **Runtime**: provided.al2023 -- **ARN**: `arn:aws:lambda:us-west-2:932028523435:function:water-recirculation` ## Alexa Skill -- **Skill ID**: `amzn1.ask.skill.c373c562-d574-4f38-bd06-001e96426d12` +- **Skill ID**: `amzn1.ask.skill.5cc9bf04-8be9-4229-936d-49a22fae6a3e` - **Invocation**: "Alexa, ask house to..." ## License diff --git a/build.zig b/build.zig index f60fd97..9b707a1 100644 --- a/build.zig +++ b/build.zig @@ -47,9 +47,8 @@ pub fn build(b: *std.Build) !void { b.installArtifact(exe); // Configure Lambda build steps and get deployment info - const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{ - .default_function_name = "house-control", - }); + // Function name defaults to exe.name ("house-control") + const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{}); // Build the gen-skill-json tool (runs on host) const gen_skill_json_module = b.createModule(.{ @@ -65,7 +64,7 @@ pub fn build(b: *std.Build) !void { // Generate skill.json from template using Lambda ARN const gen_skill_cmd = b.addRunArtifact(gen_skill_json_exe); gen_skill_cmd.addFileArg(lambda.deploy_output); - gen_skill_cmd.addFileArg(b.path("skill-package/skill.template.json")); + gen_skill_cmd.addFileArg(b.path("skill.template.json")); gen_skill_cmd.step.dependOn(lambda.deploy_step); // Capture generated skill.json @@ -85,12 +84,43 @@ pub fn build(b: *std.Build) !void { // ASK deploy depends on skill.json being generated ask_deploy_cmd.step.dependOn(&write_skill_json.step); - const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI"); - ask_deploy_step.dependOn(&ask_deploy_cmd.step); + // Add Alexa skill-specific Lambda permission + // + // Alexa requires a skill-specific Lambda permission with the skill ID as an + // event_source_token condition. This is more secure than a generic principal-based + // permission (--allow-principal) and restricts invocation to only our specific skill. + // + // We use our own tool instead of lambda-zig's built-in --allow-principal because: + // 1. The skill ID isn't known until after ASK deploy runs + // 2. event_source_token is Alexa-specific (not supported by lambda-zig) + // + // Pipeline: Lambda deploy -> gen-skill-json -> ASK deploy -> add-alexa-permission + const aws_dep = b.dependency("aws", .{ + .target = native_target, + .optimize = .ReleaseFast, + }); + const add_alexa_perm_module = b.createModule(.{ + .root_source_file = b.path("tools/add-alexa-permission.zig"), + .target = native_target, + .optimize = .ReleaseFast, + }); + add_alexa_perm_module.addImport("aws", aws_dep.module("aws")); + const add_alexa_perm_exe = b.addExecutable(.{ + .name = "add-alexa-permission", + .root_module = add_alexa_perm_module, + }); + const add_alexa_perm_cmd = b.addRunArtifact(add_alexa_perm_exe); + add_alexa_perm_cmd.addFileArg(lambda.deploy_output); + add_alexa_perm_cmd.addFileArg(b.path(".ask/ask-states.json")); + // Must run after ASK deploy (which creates/updates skill ID) and Lambda deploy + add_alexa_perm_cmd.step.dependOn(&ask_deploy_cmd.step); - // Full deploy step - deploys Lambda, generates skill.json, deploys Alexa skill + const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI"); + ask_deploy_step.dependOn(&add_alexa_perm_cmd.step); + + // Full deploy step - deploys Lambda, generates skill.json, deploys Alexa skill, adds permission const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill"); - full_deploy_step.dependOn(&ask_deploy_cmd.step); + full_deploy_step.dependOn(&add_alexa_perm_cmd.step); // Test step - use native target for tests (not cross-compiled Lambda target) const lambda_zig_dep_native = b.dependency("lambda_zig", .{ @@ -119,6 +149,9 @@ 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); + // Also verify tools compile + test_step.dependOn(&gen_skill_json_exe.step); + test_step.dependOn(&add_alexa_perm_exe.step); // Run step for local testing (uses native target) const run_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon index e36c478..407acf2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,12 @@ .hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk", }, .lambda_zig = .{ - .url = "git+https://git.lerch.org/lobo/lambda-zig#56ac230e5e6c849376a72e12f9e65ea3800fe18e", - .hash = "lambda_zig-0.1.0-_G43_2ZbAQAirikqqFgrxNwwluSxOBzN4PnrRMr4IGNx", + .url = "git+https://git.lerch.org/lobo/lambda-zig#f444697d93244425fa799023d0e7adf80ecaa1de", + .hash = "lambda_zig-0.1.0-_G43_9VdAQBdYVii9jenUiYxPssqZudPv23LEJVA2W0b", + }, + .aws = .{ + .url = "git+https://git.lerch.org/lobo/aws-sdk-for-zig#5c7aed071f6251d53a1627080a21d604ff58f0a5", + .hash = "aws-0.0.1-SbsFcFE7CgDBilPa15i4gIB6Qr5ozBz328O63abDQDDk", }, }, .paths = .{ diff --git a/tools/add-alexa-permission.zig b/tools/add-alexa-permission.zig new file mode 100644 index 0000000..919a93a --- /dev/null +++ b/tools/add-alexa-permission.zig @@ -0,0 +1,101 @@ +//! Adds Alexa skill-specific Lambda permission. +//! Alexa requires the Lambda policy to include the skill ID as an event source token condition. + +const std = @import("std"); +const aws = @import("aws"); +const json = std.json; + +pub fn main() !u8 { + 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); + + if (args.len != 3) { + std.debug.print("Usage: {s} \n", .{args[0]}); + return 1; + } + + // Read deploy output to get function name and region + const deploy_output = std.fs.cwd().readFileAlloc(allocator, args[1], 1024 * 1024) catch |err| { + std.debug.print("Failed to read deploy output '{s}': {}\n", .{ args[1], err }); + return 1; + }; + defer allocator.free(deploy_output); + + const deploy_parsed = json.parseFromSlice(json.Value, allocator, deploy_output, .{}) catch |err| { + std.debug.print("Failed to parse deploy output: {}\n", .{err}); + return 1; + }; + defer deploy_parsed.deinit(); + + const function_name = deploy_parsed.value.object.get("function_name").?.string; + const region = deploy_parsed.value.object.get("region").?.string; + + // Read ask-states.json to get skill ID + const ask_states = std.fs.cwd().readFileAlloc(allocator, args[2], 1024 * 1024) catch |err| { + std.debug.print("Failed to read ask-states.json '{s}': {}\n", .{ args[2], err }); + return 1; + }; + defer allocator.free(ask_states); + + const ask_parsed = json.parseFromSlice(json.Value, allocator, ask_states, .{}) catch |err| { + std.debug.print("Failed to parse ask-states.json: {}\n", .{err}); + return 1; + }; + defer ask_parsed.deinit(); + + const skill_id = ask_parsed.value.object.get("profiles").?.object.get("default").?.object.get("skillId").?.string; + + std.debug.print("Adding Alexa permission for skill {s} to function {s} in {s}\n", .{ skill_id, function_name, region }); + + // Build statement ID from skill ID (use last 12 chars to keep it short but unique) + var statement_id_buf: [64]u8 = undefined; + const statement_id = std.fmt.bufPrint(&statement_id_buf, "alexa-skill-{s}", .{skill_id[skill_id.len - 12 ..]}) catch { + std.debug.print("Failed to build statement ID\n", .{}); + return 1; + }; + + // Create AWS client and options + var client = aws.Client.init(allocator, .{}); + defer client.deinit(); + + var diagnostics: aws.Diagnostics = .{ + .response_status = undefined, + .response_body = undefined, + .allocator = allocator, + }; + + const opts = aws.Options{ + .client = client, + .region = region, + .diagnostics = &diagnostics, + }; + + // Add permission with skill ID as event source token + const services = aws.Services(.{.lambda}){}; + + _ = aws.Request(services.lambda.add_permission).call(.{ + .function_name = function_name, + .statement_id = statement_id, + .action = "lambda:InvokeFunction", + .principal = "alexa-appkit.amazon.com", + .event_source_token = skill_id, + }, opts) catch |err| { + defer diagnostics.deinit(); + + // 409 Conflict means permission already exists - that's fine + if (diagnostics.response_status == .conflict) { + std.debug.print("Permission already exists for skill: {s}\n", .{skill_id}); + return 0; + } + + std.debug.print("AddPermission failed: {} (HTTP {})\n", .{ err, diagnostics.response_status }); + return 1; + }; + + std.debug.print("Added Alexa permission for skill: {s}\n", .{skill_id}); + return 0; +}