update readme and build process
This commit is contained in:
parent
d1e93d8529
commit
bde519af0b
4 changed files with 227 additions and 43 deletions
112
README.md
112
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
|
||||
|
|
|
|||
49
build.zig
49
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(.{
|
||||
|
|
|
|||
|
|
@ -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 = .{
|
||||
|
|
|
|||
101
tools/add-alexa-permission.zig
Normal file
101
tools/add-alexa-permission.zig
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue