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
@ -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

View file

@ -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(.{

View file

@ -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 = .{

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