first working thingy

This commit is contained in:
Emil Lerch 2026-02-02 12:20:27 -08:00
parent 0c56542913
commit 363efa1cca
Signed by: lobo
GPG key ID: A7B62D657EF764F8
14 changed files with 10038 additions and 0 deletions

View file

@ -0,0 +1,32 @@
name: Alexa skill build
run-name: ${{ github.actor }} building alexa skill
on:
push:
branches:
- '*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup Zig
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
- name: Build
run: zig build --summary all
- name: Run tests
run: zig build test --summary all
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always()
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Zig build artifacts
zig-out/
.zig-cache/
# Lambda deployment package
lambda.zip
function.zip
# Editor/IDE
.vscode/
.idea/
*.swp
*.swo
*~
# macOS
.DS_Store
# Credentials (never commit)
.credentials
.env
# Dependencies
node_modules/
# ASK CLI state (account-specific)
.ask/

6
.mise.toml Normal file
View file

@ -0,0 +1,6 @@
[tools]
zig = "0.15.2"
bun = "1.3.8"
pre-commit = "4.2.0"
"ubi:DonIsaac/zlint" = "0.7.6"
zls = "0.15.1"

36
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,36 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-fmt
- repo: local
hooks:
- id: zlint
name: Run zlint
entry: zlint
args: ["--deny-warnings", "--fix"]
language: system
types: [zig]
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-build
- repo: local
hooks:
- id: test
name: Run zig build test
entry: zig
# args: ["build", "coverage", "-Dcoverage-threshold=80"]
args: ["build", "test"]
language: system
types: [file]
pass_filenames: false

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Emil Lerch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

154
README.md Normal file
View file

@ -0,0 +1,154 @@
# Water Recirculation Alexa Skill
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters.
## Usage
> "Alexa, ask house to start the hot water"
This will authenticate with the Rinnai API and start a 15-minute recirculation cycle.
## Building
Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management.
The build defaults to `aarch64-linux` for AWS Lambda Graviton (arm64) deployment.
```bash
# Debug build (arm64)
zig build
# Release build (arm64)
zig build -Doptimize=ReleaseFast
# Create Lambda deployment package
zig build -Doptimize=ReleaseFast package
# Build for native target (e.g., for local testing)
zig build -Dtarget=native
```
## Dependencies
- [lambda-zig](../../lambda-zig) - AWS Lambda runtime for Zig
- [controlr](../../controlr) - Rinnai API client (provides `rinnai` module)
## Deployment
### Prerequisites
- AWS CLI configured with appropriate credentials
- mise (for zig and bun version management)
### 1. Build the Package
```bash
mise exec -- zig build -Doptimize=ReleaseFast package
```
This creates `function.zip` containing the arm64 bootstrap executable.
### 2. Create Lambda Function (first time only)
```bash
aws lambda create-function \
--function-name water-recirculation \
--runtime provided.al2023 \
--handler bootstrap \
--architectures arm64 \
--role arn:aws:iam::ACCOUNT_ID:role/lambda_basic_execution \
--zip-file fileb://function.zip \
--timeout 30 \
--memory-size 128
```
### 3. Set Environment Variables
```bash
aws lambda update-function-configuration \
--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
```
water_recirculation/
├── build.zig # Build configuration (defaults to arm64-linux)
├── build.zig.zon # Dependencies (lambda-zig, controlr)
├── ask-resources.json # ASK CLI deployment config
├── src/
│ └── main.zig # Alexa request handler
├── skill-package/
│ ├── skill.json # Alexa skill manifest
│ └── interactionModels/
│ └── custom/
│ └── en-US.json # Interaction model
└── function.zip # Lambda deployment package (after build)
```
## Sample Utterances
- "start the hot water"
- "turn on the hot water"
- "heat the water"
- "preheat the water"
- "start recirculation"
- "warm up the water"
## Lambda Details
- **Function**: `water-recirculation`
- **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`
- **Invocation**: "Alexa, ask house to..."
## License
MIT

10
ask-resources.json Normal file
View file

@ -0,0 +1,10 @@
{
"askcliResourcesVersion": "2020-03-31",
"profiles": {
"default": {
"skillMetadata": {
"src": "./skill-package"
}
}
}
}

75
build.zig Normal file
View file

@ -0,0 +1,75 @@
const std = @import("std");
pub fn build(b: *std.Build) !void {
// Default to aarch64-linux for Lambda Graviton deployment
const target = b.standardTargetOptions(.{
.default_target = .{
.cpu_arch = .aarch64,
.os_tag = .linux,
},
});
const optimize = b.standardOptimizeOption(.{});
// Get lambda-zig dependency
const lambda_zig_dep = b.dependency("lambda_zig", .{
.target = target,
.optimize = optimize,
});
// Get controlr dependency for rinnai module
const controlr_dep = b.dependency("controlr", .{
.target = target,
.optimize = optimize,
});
// Create the main module
const main_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Add lambda_runtime import
main_module.addImport("lambda_runtime", lambda_zig_dep.module("lambda_runtime"));
// Add rinnai import from controlr
main_module.addImport("rinnai", controlr_dep.module("rinnai"));
// Create executable
const exe = b.addExecutable(.{
.name = "bootstrap", // Lambda requires the executable to be named "bootstrap"
.root_module = main_module,
});
b.installArtifact(exe);
// Create a step to package for Lambda
const package_step = b.step("package", "Package for AWS Lambda (arm64) deployment");
// After installing, create a zip
const install_step = b.getInstallStep();
// 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);
package_step.dependOn(&zip_cmd.step);
// Test step - reuses the same target query, tests run via emulation or on native arm64
const test_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
test_module.addImport("lambda_runtime", lambda_zig_dep.module("lambda_runtime"));
test_module.addImport("rinnai", controlr_dep.module("rinnai"));
const main_tests = b.addTest(.{
.name = "test",
.root_module = test_module,
});
const run_main_tests = b.addRunArtifact(main_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_main_tests.step);
}

21
build.zig.zon Normal file
View file

@ -0,0 +1,21 @@
.{
.name = .water_recirculation_alexa,
.version = "0.1.0",
.fingerprint = 0xaff1623a413ed497,
.minimum_zig_version = "0.15.2",
.dependencies = .{
.controlr = .{
.url = "git+https://git.lerch.org/lobo/controlr#4f5bd5b0607f73c9975ca41246fbfd5836cdfb98",
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
},
.lambda_zig = .{
.url = "git+https://git.lerch.org/lobo/lambda-zig#183d2d912c41ca721c8d18e5c258e4472d38db70",
.hash = "lambda_zig-0.1.0-_G43_6YQAQD-ahqtf3DQpJroP__spvt4U_uI5TtMZ4Xv",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

9379
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"dependencies": {
"ask-cli": "^2.30.7"
}
}

View file

@ -0,0 +1,54 @@
{
"interactionModel": {
"languageModel": {
"invocationName": "house",
"intents": [
{
"name": "RecirculateWaterIntent",
"slots": [],
"samples": [
"start the hot water",
"start hot water",
"turn on the hot water",
"turn on hot water",
"heat the water",
"heat water",
"preheat the water",
"preheat water",
"start recirculation",
"start the recirculation",
"start water recirculation",
"run the hot water",
"run hot water",
"warm up the water",
"warm the water up",
"get hot water ready",
"make the water hot",
"I need hot water"
]
},
{
"name": "AMAZON.HelpIntent",
"samples": []
},
{
"name": "AMAZON.StopIntent",
"samples": []
},
{
"name": "AMAZON.CancelIntent",
"samples": []
},
{
"name": "AMAZON.NavigateHomeIntent",
"samples": []
},
{
"name": "AMAZON.FallbackIntent",
"samples": []
}
],
"types": []
}
}
}

53
skill-package/skill.json Normal file
View file

@ -0,0 +1,53 @@
{
"manifest": {
"apis": {
"custom": {
"endpoint": {
"uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation"
},
"interfaces": []
}
},
"manifestVersion": "1.0",
"publishingInformation": {
"locales": {
"en-US": {
"name": "House Water Control",
"summary": "Control water recirculation on your Rinnai tankless water heater",
"description": "This skill allows you to start water recirculation on your Rinnai tankless water heater by voice command. Just say \"Alexa, ask house to start the hot water\" to begin a 15-minute recirculation cycle.",
"examplePhrases": [
"Alexa, ask house to start the hot water",
"Alexa, ask house to heat the water",
"Alexa, ask house to start recirculation"
],
"keywords": [
"water heater",
"rinnai",
"recirculation",
"hot water",
"tankless"
]
}
},
"isAvailableWorldwide": false,
"testingInstructions": "Sample testing instructions.",
"category": "SMART_HOME",
"distributionCountries": [
"US"
]
},
"privacyAndCompliance": {
"allowsPurchases": false,
"usesPersonalInfo": false,
"isChildDirected": false,
"isExportCompliant": true,
"containsAds": false,
"locales": {
"en-US": {
"privacyPolicyUrl": "",
"termsOfUseUrl": ""
}
}
}
}
}

165
src/main.zig Normal file
View file

@ -0,0 +1,165 @@
const std = @import("std");
const json = std.json;
const lambda = @import("lambda_runtime");
const rinnai = @import("rinnai");
const log = std.log.scoped(.alexa);
pub fn main() !u8 {
lambda.run(null, handler) catch |err| {
log.err("Lambda runtime error: {}", .{err});
return 1;
};
return 0;
}
/// Main Alexa request handler
fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]const u8 {
log.info("Received Alexa request: {d} bytes", .{event_data.len});
// Parse the Alexa request
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
log.err("Failed to parse Alexa request: {}", .{err});
return buildAlexaResponse(allocator, "I couldn't understand that request.", true);
};
defer parsed.deinit();
// Get request type
const request_obj = parsed.value.object.get("request") orelse {
log.err("No 'request' field in Alexa event", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true);
};
const request_type = request_obj.object.get("type") orelse {
log.err("No 'type' field in request", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true);
};
const request_type_str = if (request_type == .string) request_type.string else {
log.err("Request type is not a string", .{});
return buildAlexaResponse(allocator, "Invalid request format.", true);
};
log.info("Request type: {s}", .{request_type_str});
// Handle different request types
if (std.mem.eql(u8, request_type_str, "LaunchRequest")) {
return buildAlexaResponse(allocator, "What would you like me to do? You can ask me to start the hot water.", false);
} else if (std.mem.eql(u8, request_type_str, "IntentRequest")) {
return handleIntentRequest(allocator, request_obj);
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
return buildAlexaResponse(allocator, "", true);
}
return buildAlexaResponse(allocator, "I didn't understand that.", true);
}
/// Handle Alexa intent requests
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![]const u8 {
const intent_obj = request_obj.object.get("intent") orelse {
log.err("No 'intent' field in IntentRequest", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
};
const intent_name_val = intent_obj.object.get("name") orelse {
log.err("No 'name' field in intent", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
};
const intent_name = if (intent_name_val == .string) intent_name_val.string else {
log.err("Intent name is not a string", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
};
log.info("Intent: {s}", .{intent_name});
if (std.mem.eql(u8, intent_name, "RecirculateWaterIntent")) {
return handleRecirculateWater(allocator);
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
return buildAlexaResponse(allocator, "You can ask me to start the hot water to begin recirculation. This will preheat your water for about 15 minutes.", false);
} else if (std.mem.eql(u8, intent_name, "AMAZON.StopIntent") or std.mem.eql(u8, intent_name, "AMAZON.CancelIntent")) {
return buildAlexaResponse(allocator, "Okay, goodbye.", true);
}
return buildAlexaResponse(allocator, "I don't know how to do that.", true);
}
/// Handle the main recirculate water intent
fn handleRecirculateWater(allocator: std.mem.Allocator) ![]const u8 {
// Get credentials from environment variables
const username = std.posix.getenv("COGNITO_USERNAME") orelse {
log.err("COGNITO_USERNAME environment variable not set", .{});
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true);
};
const password = std.posix.getenv("COGNITO_PASSWORD") orelse {
log.err("COGNITO_PASSWORD environment variable not set", .{});
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true);
};
// Authenticate with Cognito
log.info("Authenticating with Cognito...", .{});
var auth = rinnai.authenticate(allocator, username, password) catch |err| {
log.err("Authentication failed: {}", .{err});
return buildAlexaResponse(allocator, "I couldn't log in to your water heater account.", true);
};
defer auth.deinit();
log.info("Authenticated successfully", .{});
// Get device list
log.info("Fetching device list...", .{});
var devices = rinnai.getDevices(allocator, auth.id_token, username) catch |err| {
log.err("Failed to get devices: {}", .{err});
return buildAlexaResponse(allocator, "I couldn't find your water heater.", true);
};
defer devices.deinit();
if (devices.devices.len == 0) {
return buildAlexaResponse(allocator, "I couldn't find any water heaters on your account.", true);
}
const device = devices.devices[0];
if (device.thing_name == null) {
return buildAlexaResponse(allocator, "Your water heater isn't properly configured.", true);
}
// Start recirculation
log.info("Starting recirculation for device: {?s}", .{device.device_name});
rinnai.setRecirculation(allocator, auth.id_token, device.thing_name.?, 15) catch |err| {
log.err("Failed to start recirculation: {}", .{err});
return buildAlexaResponse(allocator, "I couldn't start the water recirculation. Please try again.", true);
};
return buildAlexaResponse(allocator, "Starting water recirculation. Hot water should be ready in about 2 minutes.", true);
}
/// Build an Alexa skill response JSON
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
// Escape speech for JSON
var escaped_speech: std.ArrayList(u8) = .{};
defer escaped_speech.deinit(allocator);
for (speech) |c| {
switch (c) {
'"' => try escaped_speech.appendSlice(allocator, "\\\""),
'\\' => try escaped_speech.appendSlice(allocator, "\\\\"),
'\n' => try escaped_speech.appendSlice(allocator, "\\n"),
'\r' => try escaped_speech.appendSlice(allocator, "\\r"),
'\t' => try escaped_speech.appendSlice(allocator, "\\t"),
else => try escaped_speech.append(allocator, c),
}
}
const end_session_str = if (end_session) "true" else "false";
if (speech.len == 0) {
// Empty response for SessionEndedRequest
return try std.fmt.allocPrint(allocator,
\\{{"version":"1.0","response":{{"shouldEndSession":{s}}}}}
, .{end_session_str});
}
return try std.fmt.allocPrint(allocator,
\\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}}
, .{ escaped_speech.items, end_session_str });
}