This commit is contained in:
Emil Lerch 2026-02-02 20:06:54 -08:00
parent 363efa1cca
commit f25524791b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 370 additions and 86 deletions

217
README.md
View file

@ -21,110 +21,193 @@ zig build
# Release build (arm64) # Release build (arm64)
zig build -Doptimize=ReleaseFast zig build -Doptimize=ReleaseFast
# Create Lambda deployment package
zig build -Doptimize=ReleaseFast package
# Build for native target (e.g., for local testing) # Build for native target (e.g., for local testing)
zig build -Dtarget=native zig build -Dtarget=native
# Run tests
zig build test -Dtarget=native
``` ```
## Dependencies ## Dependencies
- [lambda-zig](../../lambda-zig) - AWS Lambda runtime for Zig - [lambda-zig](https://git.lerch.org/lobo/lambda-zig) - AWS Lambda runtime for Zig
- [controlr](../../controlr) - Rinnai API client (provides `rinnai` module) - [controlr](https://git.lerch.org/lobo/controlr) - Rinnai API client (provides `rinnai` module)
## Deployment Setup
Before deploying, you need to configure both AWS credentials and ASK CLI authentication.
### 1. AWS Credentials
AWS credentials are required for Lambda deployment. You can configure them in several ways:
#### Option A: AWS CLI Configuration (Recommended)
```bash
# Configure default profile
aws configure
# Or configure a named profile
aws configure --profile personal
```
This creates `~/.aws/credentials` and `~/.aws/config` files.
#### Option B: Environment Variables
```bash
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=us-west-2
```
#### Using a Non-Default AWS Profile
If your default AWS profile is your work account but you want to deploy to your personal account:
```bash
# Set the profile for the current shell session
export AWS_PROFILE=personal
# Or specify it per-command
zig build awslambda_deploy -Dprofile=personal
zig build deploy -Dprofile=personal
```
You can also set the region:
```bash
zig build awslambda_deploy -Dprofile=personal -Dregion=us-west-2
```
### 2. ASK CLI Authentication
The ASK CLI requires authentication with your Amazon Developer account to deploy Alexa skills.
#### First-Time Setup
```bash
# Install dependencies (if not already installed)
bun install
# Configure ASK CLI (opens browser for Amazon login). Note this takes an ungodly
# amount of time to do anything and it will look like everything is hung
bun x ask configure
```
This will:
1. Open your browser to sign in with your Amazon Developer account
2. Store credentials in `~/.ask/cli_config`
#### Verify Authentication
```bash
bun x ask smapi list-skills-for-vendor
```
### 3. Rinnai Credentials
The Lambda function needs your Rinnai account credentials to authenticate with the water heater API.
Create a `.env` file in the project root (this file is gitignored):
```bash
# .env
COGNITO_USERNAME=your@email.com
COGNITO_PASSWORD=your_password
```
These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option.
## Build Steps
| Step | Description |
|------|-------------|
| `zig build` | Build the bootstrap executable |
| `zig build test` | Run unit tests |
| `zig build awslambda_package` | Package Lambda function into zip |
| `zig build awslambda_iam` | Create/verify IAM role |
| `zig build awslambda_deploy` | Deploy Lambda function to AWS |
| `zig build awslambda_run` | Invoke the deployed Lambda function |
| `zig build ask_deploy` | Deploy Alexa skill metadata |
| `zig build deploy` | Deploy both Lambda and Alexa skill |
### Build Options
| Option | Description | Default |
|--------|-------------|---------|
| `-Doptimize=ReleaseFast` | Build with optimizations | Debug |
| `-Dtarget=native` | Build for local machine | aarch64-linux |
| `-Dfunction-name=NAME` | Lambda function name | zig-fn |
| `-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 ## Deployment
### Prerequisites ### Prerequisites
- AWS CLI configured with appropriate credentials Before deploying, ensure you have:
- mise (for zig and bun version management)
### 1. Build the Package 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))
### Full Deployment (Lambda + Alexa Skill)
```bash ```bash
mise exec -- zig build -Doptimize=ReleaseFast package zig build deploy -Doptimize=ReleaseFast \
-Dfunction-name=water-recirculation \
-Dprofile=personal \
-Dregion=us-west-2 \
-Denv-file=.env \
-Dallow-principal=alexa-appkit.amazon.com
``` ```
This creates `function.zip` containing the arm64 bootstrap executable. 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
### 2. Create Lambda Function (first time only) ### Lambda Only
```bash ```bash
aws lambda create-function \ zig build awslambda_deploy -Doptimize=ReleaseFast \
--function-name water-recirculation \ -Dfunction-name=water-recirculation \
--runtime provided.al2023 \ -Dprofile=personal \
--handler bootstrap \ -Dregion=us-west-2 \
--architectures arm64 \ -Denv-file=.env \
--role arn:aws:iam::ACCOUNT_ID:role/lambda_basic_execution \ -Dallow-principal=alexa-appkit.amazon.com
--zip-file fileb://function.zip \
--timeout 30 \
--memory-size 128
``` ```
### 3. Set Environment Variables ### Alexa Skill Only
```bash ```bash
aws lambda update-function-configuration \ zig build ask_deploy
--function-name water-recirculation \
--environment "Variables={COGNITO_USERNAME=your@email.com,COGNITO_PASSWORD=your_password}"
```
### 4. Update Function Code (subsequent deploys)
```bash
mise exec -- zig build -Doptimize=ReleaseFast package
aws lambda update-function-code \
--function-name water-recirculation \
--zip-file fileb://function.zip
```
### 5. Deploy Alexa Skill
First time setup - configure ASK CLI (opens browser for Amazon login):
```bash
mise exec -- bunx ask-cli configure
```
Deploy the skill:
```bash
mise exec -- bunx ask-cli deploy
```
This will:
- Create the Alexa skill in your developer account
- Upload the interaction model
- Link to the Lambda endpoint
After deployment, add the Alexa Skills Kit trigger permission to Lambda:
```bash
aws lambda add-permission \
--function-name water-recirculation \
--statement-id alexa-skill \
--action lambda:InvokeFunction \
--principal alexa-appkit.amazon.com \
--event-source-token amzn1.ask.skill.YOUR_SKILL_ID
``` ```
## Project Structure ## Project Structure
``` ```
water_recirculation/ water_recirculation/
├── build.zig # Build configuration (defaults to arm64-linux) ├── build.zig # Build configuration
├── build.zig.zon # Dependencies (lambda-zig, controlr) ├── build.zig.zon # Dependencies (lambda-zig, controlr)
├── .env # Rinnai credentials (gitignored, create locally)
├── ask-resources.json # ASK CLI deployment config ├── ask-resources.json # ASK CLI deployment config
├── package.json # Node.js deps for ASK CLI
├── src/ ├── src/
│ └── main.zig # Alexa request handler │ └── main.zig # Alexa request handler + tests
├── skill-package/ ├── skill-package/
│ ├── skill.json # Alexa skill manifest │ ├── skill.json # Alexa skill manifest
│ └── interactionModels/ │ └── interactionModels/
│ └── custom/ │ └── custom/
│ └── en-US.json # Interaction model │ └── en-US.json # Interaction model
└── function.zip # Lambda deployment package (after build)
``` ```
## Sample Utterances ## Sample Utterances

View file

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const lambda_zig = @import("lambda_zig");
pub fn build(b: *std.Build) !void { pub fn build(b: *std.Build) !void {
// Default to aarch64-linux for Lambda Graviton deployment // Default to aarch64-linux for Lambda Graviton deployment
@ -42,18 +43,24 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(exe); b.installArtifact(exe);
// Create a step to package for Lambda // Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.)
const package_step = b.step("package", "Package for AWS Lambda (arm64) deployment"); try lambda_zig.configureBuild(b, lambda_zig_dep, exe);
// After installing, create a zip // ASK CLI deploy step for Alexa skill metadata
const install_step = b.getInstallStep(); const ask_deploy_cmd = b.addSystemCommand(&.{
"bun", "x", "ask", "deploy", "--target", "skill-metadata",
// Add a system command to create zip (requires zip to be installed)
const zip_cmd = b.addSystemCommand(&.{
"zip", "-j", "function.zip", "zig-out/bin/bootstrap",
}); });
zip_cmd.step.dependOn(install_step); const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
package_step.dependOn(&zip_cmd.step); ask_deploy_step.dependOn(&ask_deploy_cmd.step);
// Full deploy step - deploys both Lambda function and Alexa skill
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
// Lambda deploy (awslambda_deploy) is added by lambda_zig.configureBuild
// We need to get a reference to it - it's registered as "awslambda_deploy"
if (b.top_level_steps.get("awslambda_deploy")) |lambda_deploy| {
full_deploy_step.dependOn(&lambda_deploy.step);
}
full_deploy_step.dependOn(&ask_deploy_cmd.step);
// Test step - reuses the same target query, tests run via emulation or on native arm64 // Test step - reuses the same target query, tests run via emulation or on native arm64
const test_module = b.createModule(.{ const test_module = b.createModule(.{

View file

@ -9,8 +9,8 @@
.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#183d2d912c41ca721c8d18e5c258e4472d38db70", .url = "git+https://git.lerch.org/lobo/lambda-zig#b420abb0a145e8c9bb151606c124ed380cb744e9",
.hash = "lambda_zig-0.1.0-_G43_6YQAQD-ahqtf3DQpJroP__spvt4U_uI5TtMZ4Xv", .hash = "lambda_zig-0.1.0-_G43_zRAAQA7RdBlHSxhmfSRrlvOOk3DJhfDJfimhneA",
}, },
}, },
.paths = .{ .paths = .{

View file

@ -2,6 +2,7 @@ const std = @import("std");
const json = std.json; const json = std.json;
const lambda = @import("lambda_runtime"); const lambda = @import("lambda_runtime");
const rinnai = @import("rinnai"); const rinnai = @import("rinnai");
const builtin = @import("builtin");
const log = std.log.scoped(.alexa); const log = std.log.scoped(.alexa);
@ -19,24 +20,24 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
// Parse the Alexa request // Parse the Alexa request
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| { const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
log.err("Failed to parse Alexa request: {}", .{err}); if (!builtin.is_test) log.err("Failed to parse Alexa request: {}", .{err});
return buildAlexaResponse(allocator, "I couldn't understand that request.", true); return buildAlexaResponse(allocator, "I couldn't understand that request.", true);
}; };
defer parsed.deinit(); defer parsed.deinit();
// Get request type // Get request type
const request_obj = parsed.value.object.get("request") orelse { const request_obj = parsed.value.object.get("request") orelse {
log.err("No 'request' field in Alexa event", .{}); if (!builtin.is_test) log.err("No 'request' field in Alexa event", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true); return buildAlexaResponse(allocator, "Invalid request format.", true);
}; };
const request_type = request_obj.object.get("type") orelse { const request_type = request_obj.object.get("type") orelse {
log.err("No 'type' field in request", .{}); if (!builtin.is_test) log.err("No 'type' field in request", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true); return buildAlexaResponse(allocator, "Invalid request format.", true);
}; };
const request_type_str = if (request_type == .string) request_type.string else { const request_type_str = if (request_type == .string) request_type.string else {
log.err("Request type is not a string", .{}); if (!builtin.is_test) log.err("Request type is not a string", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true); return buildAlexaResponse(allocator, "Invalid request format.", true);
}; };
@ -57,17 +58,17 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons
/// Handle Alexa intent requests /// 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) ![]const u8 {
const intent_obj = request_obj.object.get("intent") orelse { const intent_obj = request_obj.object.get("intent") orelse {
log.err("No 'intent' field in IntentRequest", .{}); if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
}; };
const intent_name_val = intent_obj.object.get("name") orelse { const intent_name_val = intent_obj.object.get("name") orelse {
log.err("No 'name' field in intent", .{}); if (!builtin.is_test) log.err("No 'name' field in intent", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
}; };
const intent_name = if (intent_name_val == .string) intent_name_val.string else { const intent_name = if (intent_name_val == .string) intent_name_val.string else {
log.err("Intent name is not a string", .{}); if (!builtin.is_test) log.err("Intent name is not a string", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
}; };
@ -163,3 +164,196 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
\\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}} \\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}}
, .{ escaped_speech.items, end_session_str }); , .{ escaped_speech.items, end_session_str });
} }
// =============================================================================
// Tests
// =============================================================================
test "buildAlexaResponse with speech and end session" {
const allocator = std.testing.allocator;
const response = try buildAlexaResponse(allocator, "Hello world", true);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
try std.testing.expectEqualStrings("1.0", parsed.value.object.get("version").?.string);
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
try std.testing.expectEqualStrings("PlainText", resp.get("outputSpeech").?.object.get("type").?.string);
try std.testing.expectEqualStrings("Hello world", resp.get("outputSpeech").?.object.get("text").?.string);
}
test "buildAlexaResponse with speech and keep session open" {
const allocator = std.testing.allocator;
const response = try buildAlexaResponse(allocator, "What can I help with?", false);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
}
test "buildAlexaResponse empty speech (SessionEndedRequest)" {
const allocator = std.testing.allocator;
const response = try buildAlexaResponse(allocator, "", true);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
try std.testing.expect(resp.get("outputSpeech") == null);
}
test "buildAlexaResponse escapes special characters" {
const allocator = std.testing.allocator;
const response = try buildAlexaResponse(allocator, "Say \"hello\"\nNew line", true);
defer allocator.free(response);
// Should parse as valid JSON (escaping worked)
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("Say \"hello\"\nNew line", text);
}
test "handler returns error response for invalid JSON" {
const allocator = std.testing.allocator;
const response = try handler(allocator, "not valid json");
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("I couldn't understand that request.", text);
}
test "handler returns error for missing request field" {
const allocator = std.testing.allocator;
const response = try handler(allocator, "{}");
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("Invalid request format.", text);
}
test "handler handles LaunchRequest" {
const allocator = std.testing.allocator;
const launch_request =
\\{"request":{"type":"LaunchRequest"}}
;
const response = try handler(allocator, launch_request);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
const text = resp.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expect(std.mem.indexOf(u8, text, "hot water") != null);
}
test "handler handles SessionEndedRequest" {
const allocator = std.testing.allocator;
const session_ended =
\\{"request":{"type":"SessionEndedRequest"}}
;
const response = try handler(allocator, session_ended);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
try std.testing.expect(resp.get("outputSpeech") == null);
}
test "handler handles AMAZON.HelpIntent" {
const allocator = std.testing.allocator;
const help_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.HelpIntent"}}}
;
const response = try handler(allocator, help_request);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == false);
const text = resp.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expect(std.mem.indexOf(u8, text, "recirculation") != null);
}
test "handler handles AMAZON.StopIntent" {
const allocator = std.testing.allocator;
const stop_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.StopIntent"}}}
;
const response = try handler(allocator, stop_request);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
const text = resp.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("Okay, goodbye.", text);
}
test "handler handles AMAZON.CancelIntent" {
const allocator = std.testing.allocator;
const cancel_request =
\\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.CancelIntent"}}}
;
const response = try handler(allocator, cancel_request);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const resp = parsed.value.object.get("response").?.object;
try std.testing.expect(resp.get("shouldEndSession").?.bool == true);
}
test "handler handles unknown intent" {
const allocator = std.testing.allocator;
const unknown_intent =
\\{"request":{"type":"IntentRequest","intent":{"name":"SomeRandomIntent"}}}
;
const response = try handler(allocator, unknown_intent);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("I don't know how to do that.", text);
}
test "handler handles unknown request type" {
const allocator = std.testing.allocator;
const unknown_request =
\\{"request":{"type":"SomeOtherRequest"}}
;
const response = try handler(allocator, unknown_request);
defer allocator.free(response);
const parsed = try json.parseFromSlice(json.Value, allocator, response, .{});
defer parsed.deinit();
const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string;
try std.testing.expectEqualStrings("I didn't understand that.", text);
}