update readme and build process

This commit is contained in:
Emil Lerch 2026-02-04 12:00:50 -08:00
parent d1e93d8529
commit bde519af0b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 227 additions and 43 deletions

112
README.md
View file

@ -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 ## 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. 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 ## Building
Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management. 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) # Release build (arm64)
zig build -Doptimize=ReleaseFast zig build -Doptimize=ReleaseFast
# Build for native target (e.g., for local testing) # Run tests (uses host CPU/OS)
zig build -Dtarget=native zig build test
# Run tests
zig build test -Dtarget=native
``` ```
## Dependencies ## 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 - [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) - [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 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): Create a `.env` file in the project root (this file is gitignored):
```bash ```bash
# .env # .env
# Rinnai API credentials for water heater control
COGNITO_USERNAME=your@email.com COGNITO_USERNAME=your@email.com
COGNITO_PASSWORD=your_password 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. These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option.
## Build Steps ## 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 | | `-Doptimize=ReleaseFast` | Build with optimizations | Debug |
| `-Dtarget=native` | Build for local machine | aarch64-linux | | `-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 | | `-Dprofile=PROFILE` | AWS profile to use | default |
| `-Dregion=REGION` | AWS region | from profile | | `-Dregion=REGION` | AWS region | from profile |
| `-Drole-name=ROLE` | IAM role name | lambda_basic_execution | | `-Drole-name=ROLE` | IAM role name | lambda_basic_execution |
| `-Dpayload=JSON` | Payload for `awslambda_run` | {} | | `-Dpayload=JSON` | Payload for `awslambda_run` | {} |
| `-Denv-file=PATH` | Environment variables file | none | | `-Denv-file=PATH` | Environment variables file | none |
| `-Dallow-principal=PRINCIPAL` | AWS service principal to grant invoke permission | none |
## Deployment ## Deployment
@ -154,38 +180,44 @@ Before deploying, ensure you have:
1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup)) 1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup))
2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`) 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) ### Full Deployment (Lambda + Alexa Skill)
```bash ```bash
zig build deploy -Doptimize=ReleaseFast \ zig build deploy -Doptimize=ReleaseFast \
-Dfunction-name=water-recirculation \
-Dprofile=personal \ -Dprofile=personal \
-Dregion=us-west-2 \ -Dregion=us-west-2 \
-Denv-file=.env \ -Denv-file=.env
-Dallow-principal=alexa-appkit.amazon.com
``` ```
This command will: This command orchestrates a multi-step deployment pipeline:
1. Build the Lambda function for arm64
2. Package it into a zip file 1. **Build** - Compile Lambda function for arm64
3. Create/update the Lambda function in AWS 2. **Package** - Create deployment zip with bootstrap executable
4. Set environment variables from `.env` 3. **Deploy Lambda** - Create/update function in AWS, set env vars from `.env`
5. Grant Alexa Skills Kit permission to invoke the function 4. **Generate skill.json** - Inject Lambda ARN into `skill.template.json`
6. Deploy the Alexa skill metadata via ASK CLI 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 ### Lambda Only
```bash ```bash
zig build awslambda_deploy -Doptimize=ReleaseFast \ zig build awslambda_deploy -Doptimize=ReleaseFast \
-Dfunction-name=water-recirculation \
-Dprofile=personal \ -Dprofile=personal \
-Dregion=us-west-2 \ -Dregion=us-west-2 \
-Denv-file=.env \ -Denv-file=.env
-Dallow-principal=alexa-appkit.amazon.com
``` ```
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 ### Alexa Skill Only
```bash ```bash
@ -197,21 +229,29 @@ zig build ask_deploy
``` ```
water_recirculation/ water_recirculation/
├── build.zig # Build configuration ├── build.zig # Build configuration
├── build.zig.zon # Dependencies (lambda-zig, controlr) ├── build.zig.zon # Dependencies
├── .env # Rinnai credentials (gitignored, create locally) ├── .env # Credentials (gitignored)
├── skill.template.json # Skill manifest template (Lambda ARN placeholder)
├── ask-resources.json # ASK CLI deployment config ├── ask-resources.json # ASK CLI deployment config
├── package.json # Node.js deps for ASK CLI ├── package.json # Node.js deps for ASK CLI
├── src/ ├── 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-package/
│ ├── skill.json # Alexa skill manifest │ ├── skill.json # Generated (gitignored)
│ └── interactionModels/ │ └── interactionModels/
│ └── custom/ │ └── 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 ## Sample Utterances
### Water Recirculation
- "start the hot water" - "start the hot water"
- "turn on the hot water" - "turn on the hot water"
- "heat the water" - "heat the water"
@ -219,17 +259,23 @@ water_recirculation/
- "start recirculation" - "start recirculation"
- "warm up the water" - "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 ## Lambda Details
- **Function**: `water-recirculation` - **Function**: `house-control`
- **Region**: us-west-2 - **Region**: us-west-2
- **Architecture**: arm64 (Graviton) - **Architecture**: arm64 (Graviton)
- **Runtime**: provided.al2023 - **Runtime**: provided.al2023
- **ARN**: `arn:aws:lambda:us-west-2:932028523435:function:water-recirculation`
## Alexa Skill ## 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..." - **Invocation**: "Alexa, ask house to..."
## License ## License

View file

@ -47,9 +47,8 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(exe); b.installArtifact(exe);
// Configure Lambda build steps and get deployment info // Configure Lambda build steps and get deployment info
const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{ // Function name defaults to exe.name ("house-control")
.default_function_name = "house-control", const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{});
});
// Build the gen-skill-json tool (runs on host) // Build the gen-skill-json tool (runs on host)
const gen_skill_json_module = b.createModule(.{ 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 // Generate skill.json from template using Lambda ARN
const gen_skill_cmd = b.addRunArtifact(gen_skill_json_exe); const gen_skill_cmd = b.addRunArtifact(gen_skill_json_exe);
gen_skill_cmd.addFileArg(lambda.deploy_output); 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); gen_skill_cmd.step.dependOn(lambda.deploy_step);
// Capture generated skill.json // 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 depends on skill.json being generated
ask_deploy_cmd.step.dependOn(&write_skill_json.step); ask_deploy_cmd.step.dependOn(&write_skill_json.step);
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI"); // Add Alexa skill-specific Lambda permission
ask_deploy_step.dependOn(&ask_deploy_cmd.step); //
// 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"); 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) // Test step - use native target for tests (not cross-compiled Lambda target)
const lambda_zig_dep_native = b.dependency("lambda_zig", .{ 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 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);
// 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) // Run step for local testing (uses native target)
const run_module = b.createModule(.{ const run_module = b.createModule(.{

View file

@ -9,8 +9,12 @@
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk", .hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
}, },
.lambda_zig = .{ .lambda_zig = .{
.url = "git+https://git.lerch.org/lobo/lambda-zig#56ac230e5e6c849376a72e12f9e65ea3800fe18e", .url = "git+https://git.lerch.org/lobo/lambda-zig#f444697d93244425fa799023d0e7adf80ecaa1de",
.hash = "lambda_zig-0.1.0-_G43_2ZbAQAirikqqFgrxNwwluSxOBzN4PnrRMr4IGNx", .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 = .{ .paths = .{

View file

@ -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} <deploy-output.json> <ask-states.json>\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;
}