Compare commits

..

No commits in common. "37dcb8fd6cae6cb94084c4f43ae2a9055ce8d528" and "d77e59360789e986c9cc80357309c6994158f0f9" have entirely different histories.

12 changed files with 234 additions and 1179 deletions

3
.gitignore vendored
View file

@ -25,6 +25,3 @@ node_modules/
# ASK CLI state (account-specific)
.ask/
# Generated skill manifest (contains account-specific Lambda ARN)
skill-package/skill.json

112
README.md
View file

@ -1,7 +1,6 @@
# Home control Alexa Skill
# Water Recirculation Alexa Skill
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters
and supports homeassistant device control
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters.
## Usage
@ -9,11 +8,6 @@ and supports homeassistant device control
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.
@ -27,13 +21,15 @@ zig build
# Release build (arm64)
zig build -Doptimize=ReleaseFast
# Run tests (uses host CPU/OS)
zig build test
# Build for native target (e.g., for local testing)
zig build -Dtarget=native
# Run tests
zig build test -Dtarget=native
```
## 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)
@ -109,41 +105,18 @@ This will:
bun x ask smapi list-skills-for-vendor
```
### 3. Environment Variables
### 3. Rinnai Credentials
The Lambda function needs credentials for the services it interacts with.
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
# 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
@ -165,12 +138,13 @@ 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 | exe name (house-control) |
| `-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
@ -180,44 +154,38 @@ 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. **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)
3. **Rinnai credentials** in `.env` file (see [Rinnai Credentials](#3-rinnai-credentials))
### Full Deployment (Lambda + Alexa Skill)
```bash
zig build deploy -Doptimize=ReleaseFast \
-Dfunction-name=water-recirculation \
-Dprofile=personal \
-Dregion=us-west-2 \
-Denv-file=.env
-Denv-file=.env \
-Dallow-principal=alexa-appkit.amazon.com
```
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.
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
### Lambda Only
```bash
zig build awslambda_deploy -Doptimize=ReleaseFast \
-Dfunction-name=water-recirculation \
-Dprofile=personal \
-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
```bash
@ -229,29 +197,21 @@ zig build ask_deploy
```
water_recirculation/
├── build.zig # Build configuration
├── build.zig.zon # Dependencies
├── .env # Credentials (gitignored)
├── skill.template.json # Skill manifest template (Lambda ARN placeholder)
├── build.zig.zon # Dependencies (lambda-zig, controlr)
├── .env # Rinnai credentials (gitignored, create locally)
├── ask-resources.json # ASK CLI deployment config
├── package.json # Node.js deps for ASK CLI
├── src/
│ ├── 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
│ └── main.zig # Alexa request handler + tests
├── skill-package/
│ ├── skill.json # Generated (gitignored)
│ ├── skill.json # Alexa skill manifest
│ └── interactionModels/
│ └── custom/
│ └── en-US.json # Interaction model with intents/slots
└── .ask/
└── ask-states.json # ASK CLI state (contains skill ID)
│ └── en-US.json # Interaction model
```
## Sample Utterances
### Water Recirculation
- "start the hot water"
- "turn on the hot water"
- "heat the water"
@ -259,23 +219,17 @@ 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**: `house-control`
- **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.5cc9bf04-8be9-4229-936d-49a22fae6a3e`
- **Skill ID**: `amzn1.ask.skill.c373c562-d574-4f38-bd06-001e96426d12`
- **Invocation**: "Alexa, ask house to..."
## License

107
build.zig
View file

@ -11,9 +11,6 @@ pub fn build(b: *std.Build) !void {
});
const optimize = b.standardOptimizeOption(.{});
// Native target for build tools
const native_target = b.resolveTargetQuery(.{});
// Get lambda-zig dependency
const lambda_zig_dep = b.dependency("lambda_zig", .{
.target = target,
@ -46,103 +43,30 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(exe);
// Configure Lambda build steps and get deployment info
// Function name defaults to exe.name ("house-control")
const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{});
// Get AWS profile option (already declared by lambda-zig)
const profile = b.user_input_options.get("profile");
const profile_str: ?[]const u8 = if (profile) |p| switch (p.value) {
.scalar => |s| s,
else => null,
} else null;
// Get AWS region option (already declared by lambda-zig)
const region = b.user_input_options.get("region");
const region_str: ?[]const u8 = if (region) |r| switch (r.value) {
.scalar => |s| s,
else => null,
} else null;
// Build the gen-skill-json tool (runs on host)
const gen_skill_json_module = b.createModule(.{
.root_source_file = b.path("tools/gen-skill-json.zig"),
.target = native_target,
.optimize = .ReleaseSafe,
// Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.)
try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
.default_function_name = "house-control",
});
const gen_skill_json_exe = b.addExecutable(.{
.name = "gen-skill-json",
.root_module = gen_skill_json_module,
});
// 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.template.json"));
gen_skill_cmd.step.dependOn(lambda.deploy_step);
// Capture generated skill.json
const skill_json = gen_skill_cmd.captureStdOut();
// Write skill.json to skill-package directory (updates source files, necessary for the ask deploy)
const write_skill_json = b.addUpdateSourceFiles();
write_skill_json.addCopyFileToSource(skill_json, "skill-package/skill.json");
const gen_skill_step = b.step("gen_skill_json", "Generate skill.json from template (will deploy function)");
gen_skill_step.dependOn(&write_skill_json.step);
// ASK CLI deploy step for Alexa skill metadata
const ask_deploy_cmd = b.addSystemCommand(&.{
"bun", "x", "ask", "deploy", "--target", "skill-metadata",
});
// ASK deploy depends on skill.json being generated
ask_deploy_cmd.step.dependOn(&write_skill_json.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 = .ReleaseSafe,
});
const add_alexa_perm_module = b.createModule(.{
.root_source_file = b.path("tools/add-alexa-permission.zig"),
.target = native_target,
.optimize = .ReleaseSafe,
});
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"));
if (profile_str) |p| {
add_alexa_perm_cmd.addArgs(&.{ "--profile", p });
}
if (region_str) |r| {
add_alexa_perm_cmd.addArgs(&.{ "--region", r });
}
// Must run after ASK deploy (which creates/updates skill ID) and Lambda deploy
add_alexa_perm_cmd.step.dependOn(&ask_deploy_cmd.step);
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
ask_deploy_step.dependOn(&add_alexa_perm_cmd.step);
ask_deploy_step.dependOn(&ask_deploy_cmd.step);
// Full deploy step - deploys Lambda, generates skill.json, deploys Alexa skill, adds permission
// Full deploy step - deploys both Lambda function and Alexa skill
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
full_deploy_step.dependOn(&add_alexa_perm_cmd.step);
// 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 - use native target for tests (not cross-compiled Lambda target)
const native_target = b.resolveTargetQuery(.{});
const lambda_zig_dep_native = b.dependency("lambda_zig", .{
.target = native_target,
.optimize = optimize,
@ -169,9 +93,6 @@ 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(.{
@ -183,7 +104,7 @@ pub fn build(b: *std.Build) !void {
run_module.addImport("rinnai", controlr_dep_native.module("rinnai"));
const run_exe = b.addExecutable(.{
.name = exe.name,
.name = "bootstrap",
.root_module = run_module,
});

View file

@ -9,12 +9,8 @@
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
},
.lambda_zig = .{
.url = "git+https://git.lerch.org/lobo/lambda-zig#2bdf23f375c1453cb471e8603b3cdbb6616aba28",
.hash = "lambda_zig-0.1.0-_G43_x1fAQD4mNzDFE-Iz9lSrwQctI6V6510luXlJAkc",
},
.aws = .{
.url = "git+https://git.lerch.org/lobo/aws-sdk-for-zig#5c7aed071f6251d53a1627080a21d604ff58f0a5",
.hash = "aws-0.0.1-SbsFcFE7CgDBilPa15i4gIB6Qr5ozBz328O63abDQDDk",
.url = "git+https://git.lerch.org/lobo/lambda-zig#ed9c7ced6c23426c062a46a77f9dead9eb708550",
.hash = "lambda_zig-0.1.0-_G43_2hEAQC6_qXjFpEt2kAjCK4cBYIqkF3E-yl9S_dA",
},
},
.paths = .{

View file

@ -32,20 +32,20 @@
"slots": [
{
"name": "device",
"type": "DEVICE_NAME"
},
{
"name": "action",
"type": "DEVICE_ACTION"
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"{action} {device}",
"{action} the {device}",
"{device}",
"the {device}",
"about {device}",
"about the {device}",
"turn on {device}",
"turn on the {device}",
"turn {device} on",
"switch on {device}",
"switch on the {device}",
"turn off {device}",
"turn off the {device}",
"turn {device} off",
"switch off {device}",
"switch off the {device}",
"is {device} on",
"is the {device} on",
"is {device} off",
@ -56,17 +56,12 @@
"what is the state of the {device}",
"what is {device} set to",
"what is the {device} set to",
"what about {device}",
"what about the {device}",
"how is {device}",
"how is the {device}",
"status of {device}",
"status of the {device}",
"{device} status",
"check {device}",
"check the {device}",
"check on {device}",
"check on the {device}"
"check on the {device}",
"toggle {device}",
"toggle the {device}"
]
},
{
@ -155,30 +150,26 @@
{
"name": "DEVICE_NAME",
"values": [
{"name": {"value": "bar"}},
{"name": {"value": "bar light"}},
{"name": {"value": "basement"}},
{"name": {"value": "basement light"}},
{"name": {"value": "basement fireplace"}},
{"name": {"value": "fireplace"}},
{"name": {"value": "deck"}},
{"name": {"value": "deck light"}},
{"name": {"value": "family room"}},
{"name": {"value": "family room light"}},
{"name": {"value": "jack bedroom light"}},
{"name": {"value": "jack bedroom"}},
{"name": {"value": "jack's bedroom"}},
{"name": {"value": "kitchen"}},
{"name": {"value": "kitchen light"}},
{"name": {"value": "kris bedroom"}},
{"name": {"value": "kris bedroom light"}},
{"name": {"value": "kris's bedroom"}},
{"name": {"value": "bedroom"}},
{"name": {"value": "bedroom light"}},
{"name": {"value": "master bedroom"}},
{"name": {"value": "master bedroom light"}},
{"name": {"value": "emil light"}},
{"name": {"value": "emil's light"}}
{"name": {"value": "kitchen light"}},
{"name": {"value": "living room light"}},
{"name": {"value": "bathroom light"}},
{"name": {"value": "hallway light"}},
{"name": {"value": "office light"}},
{"name": {"value": "garage light"}},
{"name": {"value": "porch light"}},
{"name": {"value": "front porch"}},
{"name": {"value": "back porch"}},
{"name": {"value": "bedroom lamp"}},
{"name": {"value": "desk lamp"}},
{"name": {"value": "floor lamp"}},
{"name": {"value": "thermostat"}},
{"name": {"value": "downstairs thermostat"}},
{"name": {"value": "upstairs thermostat"}},
{"name": {"value": "bedroom fan"}},
{"name": {"value": "ceiling fan"}},
{"name": {"value": "living room fan"}},
{"name": {"value": "kitchen fan"}}
]
}
]

View file

@ -3,7 +3,7 @@
"apis": {
"custom": {
"endpoint": {
"uri": "{{LAMBDA_ARN}}"
"uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation"
},
"interfaces": []
}

View file

@ -1,178 +0,0 @@
//! Alexa-specific utilities: context parsing and Settings API.
const std = @import("std");
const json = std.json;
const http = std.http;
const Allocator = std.mem.Allocator;
const homeassistant = @import("homeassistant.zig");
const log = std.log.scoped(.alexa);
/// Context extracted from Alexa request
pub const Context = struct {
api_endpoint: []const u8,
api_access_token: []const u8,
device_id: []const u8,
};
/// Parse Alexa context from request JSON.
/// Returns null if context is missing or malformed.
pub fn parseContext(json_value: json.Value) ?Context {
const obj = switch (json_value) {
.object => |o| o,
else => return null,
};
const context_obj = switch (obj.get("context") orelse return null) {
.object => |o| o,
else => return null,
};
const system_obj = switch (context_obj.get("System") orelse return null) {
.object => |o| o,
else => return null,
};
const api_endpoint = switch (system_obj.get("apiEndpoint") orelse return null) {
.string => |s| s,
else => return null,
};
const api_access_token = switch (system_obj.get("apiAccessToken") orelse return null) {
.string => |s| s,
else => return null,
};
const device_obj = switch (system_obj.get("device") orelse return null) {
.object => |o| o,
else => return null,
};
const device_id = switch (device_obj.get("deviceId") orelse return null) {
.string => |s| s,
else => return null,
};
return Context{
.api_endpoint = api_endpoint,
.api_access_token = api_access_token,
.device_id = device_id,
};
}
/// Fetch user's timezone from Alexa Settings API.
/// GET {apiEndpoint}/v2/devices/{deviceId}/settings/System.timeZone
/// Returns timezone name like "America/Los_Angeles" or null on failure.
/// Caller owns the returned memory.
///
/// NOTE: This function is currently not used because the API call takes ~1 second
/// which is too slow for our 3 second Lambda timeout. See resolveTimezone() in main.zig.
pub fn fetchTimezone(
allocator: Allocator,
http_interface: homeassistant.HttpClientInterface,
context: Context,
) ?[]const u8 {
const url = std.fmt.allocPrint(
allocator,
"{s}/v2/devices/{s}/settings/System.timeZone",
.{ context.api_endpoint, context.device_id },
) catch {
log.warn("Failed to allocate Alexa Settings API URL", .{});
return null;
};
defer allocator.free(url);
const auth_header = std.fmt.allocPrint(
allocator,
"Bearer {s}",
.{context.api_access_token},
) catch {
log.warn("Failed to allocate auth header", .{});
return null;
};
defer allocator.free(auth_header);
const headers = [_]http.Header{
.{ .name = "Authorization", .value = auth_header },
};
const result = http_interface.fetch(allocator, .{
.url = url,
.method = .GET,
.headers = &headers,
.body = null,
}) catch |err| {
log.warn("Alexa Settings API request failed: {}", .{err});
return null;
};
defer allocator.free(result.body);
if (result.status != .ok) {
log.warn("Alexa Settings API returned status: {}", .{result.status});
return null;
}
// Response is a JSON string like "America/Los_Angeles" (with quotes)
const parsed = json.parseFromSlice(json.Value, allocator, result.body, .{}) catch {
log.warn("Failed to parse Alexa Settings API response", .{});
return null;
};
defer parsed.deinit();
const timezone = switch (parsed.value) {
.string => |s| s,
else => {
log.warn("Alexa Settings API response is not a string", .{});
return null;
},
};
return allocator.dupe(u8, timezone) catch {
log.warn("Failed to allocate timezone string", .{});
return null;
};
}
// ============================================================================
// Tests
// ============================================================================
test "parseContext with valid context" {
const json_str =
\\{
\\ "context": {
\\ "System": {
\\ "apiEndpoint": "https://api.amazonalexa.com",
\\ "apiAccessToken": "test-token-123",
\\ "device": {
\\ "deviceId": "amzn1.ask.device.XXXX"
\\ }
\\ }
\\ },
\\ "request": {}
\\}
;
const parsed = try json.parseFromSlice(json.Value, std.testing.allocator, json_str, .{});
defer parsed.deinit();
const ctx = parseContext(parsed.value);
try std.testing.expect(ctx != null);
try std.testing.expectEqualStrings("https://api.amazonalexa.com", ctx.?.api_endpoint);
try std.testing.expectEqualStrings("test-token-123", ctx.?.api_access_token);
try std.testing.expectEqualStrings("amzn1.ask.device.XXXX", ctx.?.device_id);
}
test "parseContext with missing context" {
const json_str =
\\{
\\ "request": {}
\\}
;
const parsed = try json.parseFromSlice(json.Value, std.testing.allocator, json_str, .{});
defer parsed.deinit();
const ctx = parseContext(parsed.value);
try std.testing.expect(ctx == null);
}

View file

@ -251,7 +251,7 @@ pub const Client = struct {
base_url: []const u8,
token: []const u8,
http_interface: HttpClientInterface,
client: ?*HttpClient = null,
client: ?HttpClient = null,
/// Normal initialization. If you are writing tests and need a mock
/// implemenation for unit tests, use MockHttpClient with the initInterface
@ -261,8 +261,7 @@ pub const Client = struct {
base_url: []const u8,
token: []const u8,
) Client {
const client = allocator.create(HttpClient) catch @panic("OOM");
client.* = HttpClient.init(allocator);
var client = HttpClient.init(allocator);
return .{
.allocator = allocator,
.base_url = base_url,
@ -289,10 +288,7 @@ pub const Client = struct {
}
pub fn deinit(self: Client) void {
if (self.client) |c| {
c.deinit();
self.allocator.destroy(c);
}
if (self.client) |c| c.deinit();
}
/// Fetch all entity states from Home Assistant
pub fn getStates(self: *Client) !struct { entities: []Entity, parsed: json.Parsed(json.Value) } {
@ -514,22 +510,8 @@ fn searchWordsInName(friendly_name: []const u8, search: []const u8) usize {
return important_matches;
}
/// Count important (non-noise) words in a friendly name
fn countImportantWords(friendly_name: []const u8) usize {
var name_buf: [256]u8 = undefined;
const name_len = @min(friendly_name.len, name_buf.len);
const name_lower = std.ascii.lowerString(name_buf[0..name_len], friendly_name[0..name_len]);
var count: usize = 0;
var words = std.mem.tokenizeAny(u8, name_lower, " _-");
while (words.next()) |word| {
if (!isNoiseWord(word)) count += 1;
}
return count;
}
/// Find entities matching the given name
/// Priority: 1) Exact match, 2) All search words in friendly name (prefer higher match ratio), 3) Fuzzy match
/// Priority: 1) Exact match, 2) All search words in friendly name (prefer more matches, then shorter name), 3) Fuzzy match
pub fn findEntitiesByName(
allocator: Allocator,
entities: []const Entity,
@ -569,26 +551,27 @@ pub fn findEntitiesByName(
}
}
// If we have ties, prefer entities with fewer important words (higher match ratio)
// e.g., "bedroom" (1 word) over "Jack Bedroom Light" (2 important words) when searching "bedroom"
// If we have ties, prefer shorter friendly names (more specific/direct match)
if (matches.items.len > 1) {
var best_idx: usize = matches.items[0];
var fewest_words: usize = countImportantWords(entities[best_idx].friendly_name);
var unique_best = true;
var shortest_idx: usize = matches.items[0];
var shortest_len: usize = entities[shortest_idx].friendly_name.len;
for (matches.items[1..]) |idx| {
const word_count = countImportantWords(entities[idx].friendly_name);
if (word_count < fewest_words) {
best_idx = idx;
fewest_words = word_count;
unique_best = true;
} else if (word_count == fewest_words) {
unique_best = false;
if (entities[idx].friendly_name.len < shortest_len) {
shortest_idx = idx;
shortest_len = entities[idx].friendly_name.len;
}
}
if (unique_best)
return .{ .single = best_idx };
// Check if there's a unique shortest
var count_at_shortest: usize = 0;
for (matches.items) |idx| {
if (entities[idx].friendly_name.len == shortest_len)
count_at_shortest += 1;
}
if (count_at_shortest == 1)
return .{ .single = shortest_idx };
}
if (matches.items.len == 1)
@ -614,7 +597,7 @@ pub fn findEntitiesByName(
}
/// Format a detailed state response for an entity
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offset: ?i32) ![]const u8 {
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const u8 {
var response: std.ArrayListUnmanaged(u8) = .empty;
errdefer response.deinit(allocator);
@ -635,29 +618,13 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offs
try writer.print(". Target temperature is {d:.0} degrees", .{target});
if (entity.current_temperature) |current|
try writer.print(". Current temperature is {d:.0} degrees", .{current});
}
// Add time since last change for lights, switches, binary sensors, and covers
if (std.mem.eql(u8, entity.domain, "light") or
std.mem.eql(u8, entity.domain, "switch") or
std.mem.eql(u8, entity.domain, "binary_sensor") or
} else if (std.mem.eql(u8, entity.domain, "binary_sensor") or
std.mem.eql(u8, entity.domain, "cover"))
{
if (entity.last_changed) |lc| {
if (parseTimestamp(lc)) |seconds| {
const duration = formatDuration(allocator, seconds) catch null;
const time_str = formatTimeFromTimestamp(allocator, lc, utc_offset) catch null;
if (duration) |dur| {
defer allocator.free(dur);
if (time_str) |ts| {
defer allocator.free(ts);
try writer.print(", and has been {s} for {s}, since {s}", .{ entity.state, dur, ts });
} else {
try writer.print(", and has been {s} for {s}", .{ entity.state, dur });
}
}
}
}
// Add time since last change if available
if (entity.last_changed) |lc|
if (parseTimeAgo(lc)) |ago|
try writer.print(" and has been {s} for {s}", .{ entity.state, ago });
}
try writer.writeAll(".");
@ -665,119 +632,15 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offs
return response.toOwnedSlice(allocator);
}
/// Parse ISO timestamp and return duration in seconds since that time
fn parseTimestamp(iso_timestamp: []const u8) ?i64 {
// Expected format: 2024-01-15T10:30:45.123456+00:00 or similar
if (iso_timestamp.len < 19) return null;
/// Parse ISO timestamp and return human-readable "time ago" string
fn parseTimeAgo(iso_timestamp: []const u8) ?[]const u8 {
// Simple heuristic: check if it looks like an ISO timestamp
// In production, you'd parse this properly
if (iso_timestamp.len < 10) return null;
// Parse date components
const year = std.fmt.parseInt(u16, iso_timestamp[0..4], 10) catch return null;
if (iso_timestamp[4] != '-') return null;
const month = std.fmt.parseInt(u4, iso_timestamp[5..7], 10) catch return null;
if (iso_timestamp[7] != '-') return null;
const day = std.fmt.parseInt(u5, iso_timestamp[8..10], 10) catch return null;
if (iso_timestamp[10] != 'T') return null;
const hour = std.fmt.parseInt(u5, iso_timestamp[11..13], 10) catch return null;
if (iso_timestamp[13] != ':') return null;
const minute = std.fmt.parseInt(u6, iso_timestamp[14..16], 10) catch return null;
if (iso_timestamp[16] != ':') return null;
const second = std.fmt.parseInt(u6, iso_timestamp[17..19], 10) catch return null;
// Calculate days from epoch (1970-01-01)
// Count days for complete years
var days: i64 = 0;
var y: u16 = 1970;
while (y < year) : (y += 1) {
days += if (std.time.epoch.isLeapYear(y)) 366 else 365;
}
// Add days for complete months in current year
const months = [_]u5{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
var m: u4 = 1;
while (m < month) : (m += 1) {
days += months[m - 1];
if (m == 2 and std.time.epoch.isLeapYear(year)) days += 1;
}
// Add days in current month
days += day - 1;
const timestamp_epoch = days * std.time.s_per_day +
@as(i64, hour) * 3600 + @as(i64, minute) * 60 + @as(i64, second);
// Get current time
const now = std.time.timestamp();
return now - timestamp_epoch;
}
/// Format duration as human-readable string
fn formatDuration(allocator: Allocator, seconds: i64) ![]const u8 {
if (seconds < 0) return try allocator.dupe(u8, "just now");
const minutes = @divFloor(seconds, 60);
const hours = @divFloor(minutes, 60);
const days = @divFloor(hours, 24);
if (days > 0) {
if (days == 1) {
const remaining_hours = @mod(hours, 24);
if (remaining_hours > 0) {
return try std.fmt.allocPrint(allocator, "1 day and {d} hour{s}", .{
remaining_hours,
if (remaining_hours == 1) "" else "s",
});
}
return try allocator.dupe(u8, "1 day");
}
return try std.fmt.allocPrint(allocator, "{d} days", .{days});
}
if (hours > 0) {
const remaining_minutes = @mod(minutes, 60);
if (remaining_minutes > 0) {
return try std.fmt.allocPrint(allocator, "{d} hour{s} and {d} minute{s}", .{
hours,
if (hours == 1) "" else "s",
remaining_minutes,
if (remaining_minutes == 1) "" else "s",
});
}
return try std.fmt.allocPrint(allocator, "{d} hour{s}", .{ hours, if (hours == 1) "" else "s" });
}
if (minutes > 0) {
return try std.fmt.allocPrint(allocator, "{d} minute{s}", .{ minutes, if (minutes == 1) "" else "s" });
}
if (seconds < 10) {
return try allocator.dupe(u8, "just now");
}
return try std.fmt.allocPrint(allocator, "{d} seconds", .{seconds});
}
/// Format time from ISO timestamp as "11:30 AM" style, adjusted for timezone.
/// If utc_offset is null, returns error (caller should omit "since" time).
fn formatTimeFromTimestamp(allocator: Allocator, iso_timestamp: []const u8, utc_offset: ?i32) ![]const u8 {
const offset = utc_offset orelse return error.NoTimezone;
if (iso_timestamp.len < 16) return error.InvalidTimestamp;
const hour_24_utc = std.fmt.parseInt(i32, iso_timestamp[11..13], 10) catch return error.InvalidTimestamp;
const minute = std.fmt.parseInt(u6, iso_timestamp[14..16], 10) catch return error.InvalidTimestamp;
// Apply timezone offset (offset is in seconds, convert to hours)
const offset_hours = @divTrunc(offset, 3600);
const hour_24_local = @mod(hour_24_utc + offset_hours + 24, 24);
const am_pm: []const u8 = if (hour_24_local < 12) "AM" else "PM";
const hour_12: i32 = if (hour_24_local == 0) 12 else if (hour_24_local > 12) hour_24_local - 12 else hour_24_local;
if (minute == 0) {
return try std.fmt.allocPrint(allocator, "{d} {s}", .{ hour_12, am_pm });
}
return try std.fmt.allocPrint(allocator, "{d}:{d:0>2} {s}", .{ hour_12, minute, am_pm });
// For now, just return a generic message
// TODO: Implement proper time parsing
return "some time";
}
/// Format the "which one?" clarification prompt
@ -816,7 +679,6 @@ pub fn handleDeviceAction(
action: Action,
device_name: []const u8,
value: ?f32,
utc_offset: ?i32,
) !ActionResult {
// Fetch all entities
const states_result = client.getStates() catch |err| {
@ -947,7 +809,7 @@ pub fn handleDeviceAction(
};
},
.query_state => {
const response = try formatStateResponse(allocator, entity, utc_offset);
const response = try formatStateResponse(allocator, entity);
return ActionResult{
.speech = response,
.end_session = true,
@ -1009,93 +871,104 @@ test "fuzzyMatchWord handles underscores and hyphens" {
try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room"));
}
test "findEntitiesByName - spoken phrase resolution" {
test "findEntitiesByName single match" {
const allocator = std.testing.allocator;
// Test entities representing a typical Home Assistant setup
const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.jack_bedroom", .state = "off", .friendly_name = "Jack Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.living_room", .state = "off", .friendly_name = "Living Room Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "climate.thermostat", .state = "heat", .friendly_name = "Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 72, .current_temperature = 68 },
.{ .entity_id = "climate.upstairs", .state = "cool", .friendly_name = "Upstairs Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 70, .current_temperature = 72 },
};
// Expected result type for test cases
const Expected = union(enum) {
single: []const u8, // friendly_name of expected match
multiple: usize, // number of matches expected
none,
const result = try findEntitiesByName(allocator, &entities, "bedroom");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 0), result.single);
}
test "findEntitiesByName multiple matches" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
const TestCase = struct {
spoken: []const u8,
expected: Expected,
};
const test_cases = [_]TestCase{
// Exact matches
.{ .spoken = "bedroom", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "Bedroom", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "BEDROOM", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "thermostat", .expected = .{ .single = "Thermostat" } },
.{ .spoken = "kitchen light", .expected = .{ .single = "Kitchen Light" } },
// Specific name with multiple words matches over generic
.{ .spoken = "jack bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .spoken = "jack bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .spoken = "jack's bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .spoken = "jack's bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .spoken = "upstairs thermostat", .expected = .{ .single = "Upstairs Thermostat" } },
.{ .spoken = "living room", .expected = .{ .single = "Living Room Light" } },
.{ .spoken = "living room light", .expected = .{ .single = "Living Room Light" } },
// Ambiguous spoken phrase resolves to simpler entity name
.{ .spoken = "bedroom light", .expected = .{ .single = "Bedroom" } }, // "Bedroom" has 1 important word, "Jack Bedroom Light" has 2
.{ .spoken = "light", .expected = .{ .multiple = 3 } }, // fuzzy matches entities with "Light" in name (not "Bedroom" which has no "Light")
// No match
.{ .spoken = "garage", .expected = .none },
.{ .spoken = "garage door", .expected = .none },
.{ .spoken = "front porch", .expected = .none },
};
for (test_cases) |tc| {
const result = try findEntitiesByName(allocator, &entities, tc.spoken);
switch (tc.expected) {
.single => |expected_name| {
if (result != .single) {
std.debug.print("FAIL: '{s}' expected single match '{s}', got {}\n", .{ tc.spoken, expected_name, result });
return error.TestUnexpectedResult;
}
const actual_name = entities[result.single].friendly_name;
if (!std.mem.eql(u8, actual_name, expected_name)) {
std.debug.print("FAIL: '{s}' expected '{s}', got '{s}'\n", .{ tc.spoken, expected_name, actual_name });
return error.TestUnexpectedResult;
}
},
.multiple => |expected_count| {
if (result != .multiple) {
std.debug.print("FAIL: '{s}' expected {d} matches, got {}\n", .{ tc.spoken, expected_count, result });
return error.TestUnexpectedResult;
}
const result = try findEntitiesByName(allocator, &entities, "light");
try std.testing.expect(result == .multiple);
defer allocator.free(result.multiple);
if (result.multiple.len != expected_count) {
std.debug.print("FAIL: '{s}' expected {d} matches, got {d}\n", .{ tc.spoken, expected_count, result.multiple.len });
return error.TestUnexpectedResult;
}
},
.none => {
if (result != .none) {
std.debug.print("FAIL: '{s}' expected no match, got {}\n", .{ tc.spoken, result });
if (result == .multiple) allocator.free(result.multiple);
return error.TestUnexpectedResult;
}
},
}
}
try std.testing.expectEqual(@as(usize, 2), result.multiple.len);
}
test "findEntitiesByName no match" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
const result = try findEntitiesByName(allocator, &entities, "garage");
try std.testing.expect(result == .none);
}
test "findEntitiesByName exact match takes priority" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "bedroom" exactly matches "Bedroom"
const result = try findEntitiesByName(allocator, &entities, "bedroom");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 0), result.single);
}
test "findEntitiesByName noise words dont override important matches" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "bedroom light" should still match "Bedroom" - "light" is a noise word
const result = try findEntitiesByName(allocator, &entities, "bedroom light");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 0), result.single);
}
test "findEntitiesByName specific name matches over generic" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "jack bedroom" should match "Jack Bedroom Light" - has both important words
const result = try findEntitiesByName(allocator, &entities, "jack bedroom");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 1), result.single);
}
test "findEntitiesByName handles possessives" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "jack's bedroom" should match "Jack Bedroom Light"
const result = try findEntitiesByName(allocator, &entities, "jack's bedroom");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 1), result.single);
}
test "findEntitiesByName full specific match" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "jack bedroom light" should match "Jack Bedroom Light"
const result = try findEntitiesByName(allocator, &entities, "jack bedroom light");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 1), result.single);
}
test "formatStateResponse light on with brightness" {
@ -1111,7 +984,7 @@ test "formatStateResponse light on with brightness" {
.current_temperature = null,
};
const response = try formatStateResponse(allocator, &entity, null);
const response = try formatStateResponse(allocator, &entity);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null);
@ -1132,7 +1005,7 @@ test "formatStateResponse light off" {
.current_temperature = null,
};
const response = try formatStateResponse(allocator, &entity, null);
const response = try formatStateResponse(allocator, &entity);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "off") != null);
@ -1152,7 +1025,7 @@ test "formatStateResponse climate with temperatures" {
.current_temperature = 68.0,
};
const response = try formatStateResponse(allocator, &entity, null);
const response = try formatStateResponse(allocator, &entity);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null);
@ -1270,7 +1143,7 @@ test "handleDeviceAction turn on light success" {
mock.interface(),
);
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null, null);
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null);
defer allocator.free(result.speech);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null);
@ -1297,7 +1170,7 @@ test "handleDeviceAction device not found" {
mock.interface(),
);
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null, null);
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null);
defer allocator.free(result.speech);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "couldn't find") != null);
@ -1324,7 +1197,7 @@ test "handleDeviceAction multiple matches returns clarification" {
mock.interface(),
);
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null, null);
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null);
defer allocator.free(result.speech);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null);
@ -1352,7 +1225,7 @@ test "handleDeviceAction query state" {
mock.interface(),
);
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null, null);
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null);
defer allocator.free(result.speech);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null);
@ -1381,7 +1254,7 @@ test "handleDeviceAction set brightness" {
mock.interface(),
);
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0, null);
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0);
defer allocator.free(result.speech);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null);

View file

@ -3,8 +3,6 @@ const json = std.json;
const lambda = @import("lambda_runtime");
const rinnai = @import("rinnai");
const homeassistant = @import("homeassistant.zig");
const alexa = @import("alexa.zig");
const timezone = @import("timezone.zig");
const Config = @import("Config.zig");
const builtin = @import("builtin");
@ -21,7 +19,7 @@ pub fn main() !u8 {
// Check for --help first (no config needed)
for (args) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
printHelp(args) catch return 1;
printHelp() catch return 1;
return 0;
}
}
@ -55,6 +53,7 @@ pub fn main() !u8 {
/// Main Alexa request handler
fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config) 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| {
@ -85,7 +84,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config)
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, or control your smart home devices.", false);
} else if (std.mem.eql(u8, request_type_str, "IntentRequest")) {
return handleIntentRequest(allocator, request_obj, parsed.value, config);
return handleIntentRequest(allocator, request_obj, config);
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
return buildAlexaResponse(allocator, "", true);
}
@ -94,7 +93,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config)
}
/// Handle Alexa intent requests
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, full_request: json.Value, config: Config) ![]const u8 {
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, config: Config) ![]const u8 {
const intent_obj = request_obj.object.get("intent") orelse {
if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{});
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
@ -117,7 +116,7 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, fu
} else if (std.mem.eql(u8, intent_name, "HomeAssistantIntent") or
std.mem.eql(u8, intent_name, "SetDeviceValueIntent"))
{
return handleHomeAssistantIntent(allocator, intent_obj, full_request, config);
return handleHomeAssistantIntent(allocator, intent_obj, config);
} else if (std.mem.eql(u8, intent_name, "WeezTheJuiceIntent")) {
return handleWeezTheJuice(allocator, config);
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
@ -179,7 +178,7 @@ fn handleRecirculateWater(allocator: std.mem.Allocator, config: Config) ![]const
}
/// Handle the Home Assistant device control intent
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, full_request: json.Value, config: Config) ![]const u8 {
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, config: Config) ![]const u8 {
// Get Home Assistant credentials from config
const ha_url = config.home_assistant_url orelse {
log.err("HOME_ASSISTANT_URL not configured", .{});
@ -209,12 +208,6 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
);
defer client.deinit();
// Only resolve timezone for state queries (avoids unnecessary Alexa API calls)
const utc_offset: ?i32 = if (params.action == .query_state)
resolveTimezone(allocator, full_request, config)
else
null;
// Execute the action
const result = homeassistant.handleDeviceAction(
allocator,
@ -222,7 +215,6 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
params.action,
params.device_name,
params.value,
utc_offset,
) catch |err| {
log.err("Home Assistant error: {}", .{err});
return buildAlexaResponse(allocator, "I had trouble communicating with Home Assistant. Please try again.", true);
@ -256,7 +248,6 @@ fn handleWeezTheJuice(allocator: std.mem.Allocator, config: Config) ![]const u8
.toggle,
"bedroom",
null,
null, // No timezone needed for toggle
) catch |err| {
log.err("Home Assistant error: {}", .{err});
return buildAlexaResponse(allocator, "I had trouble weezin' the juice.", true);
@ -352,42 +343,6 @@ fn extractSlotNumber(slots: ?json.Value, slot_name: []const u8) ?f32 {
return std.fmt.parseFloat(f32, value.string) catch null;
}
/// Resolve the UTC offset for timezone conversion.
/// In Lambda mode: uses TZ env var (Alexa API lookup disabled - too slow ~1s).
/// In local mode: uses TZ env var or /etc/timezone.
fn resolveTimezone(allocator: std.mem.Allocator, parsed_value: json.Value, config: Config) ?i32 {
_ = parsed_value;
_ = config;
// NOTE: Alexa Settings API timezone lookup is disabled because it takes ~1 second
// which is too much of our 3 second Lambda timeout budget. Instead, we use the
// TZ environment variable set in Lambda configuration.
//
// To re-enable Alexa timezone lookup, uncomment the following block:
//
// if (alexa.parseContext(parsed_value)) |context| {
// const ha_url = config.home_assistant_url orelse return resolveLocalTimezone(allocator);
// const ha_token = config.home_assistant_token orelse return resolveLocalTimezone(allocator);
// var ha_client = homeassistant.Client.init(allocator, ha_url, ha_token);
// defer ha_client.deinit();
// if (alexa.fetchTimezone(allocator, ha_client.httpInterface(), context)) |tz_name| {
// defer allocator.free(tz_name);
// if (timezone.getUtcOffset(allocator, tz_name)) |offset| {
// return offset;
// }
// }
// }
return resolveLocalTimezone(allocator);
}
/// Resolve timezone from local system (TZ env var or /etc/timezone)
fn resolveLocalTimezone(allocator: std.mem.Allocator) ?i32 {
const tz_name = timezone.getLocalTimezone(allocator) orelse return null;
defer allocator.free(tz_name);
return timezone.getUtcOffset(allocator, tz_name);
}
/// Build an Alexa skill response JSON
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
// Escape speech for JSON
@ -423,13 +378,9 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
// Local Testing Mode
// =============================================================================
fn printHelp(args: []const []const u8) !void {
const prog = std.fs.path.basename(args[0]);
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print(
\\Usage: {0s} [OPTIONS]
fn printHelp() !void {
const help =
\\Usage: bootstrap [OPTIONS]
\\
\\Alexa skill handler for water recirculation and Home Assistant control.
\\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API.
@ -454,16 +405,16 @@ fn printHelp(args: []const []const u8) !void {
\\ AMAZON.CancelIntent Cancel/goodbye
\\
\\Examples:
\\ {0s} --type=launch
\\ {0s} --intent=RecirculateWaterIntent
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light"
\\ bootstrap --type=launch
\\ bootstrap --intent=RecirculateWaterIntent
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light"
\\ (no action = query state)
\\ {0s} --intent=SetDeviceValueIntent --device="bedroom light" --value=50
\\ {0s} --intent=SetDeviceValueIntent --device="thermostat" --value=72
\\ {0s} --intent=WeezTheJuiceIntent
\\ bootstrap --intent=SetDeviceValueIntent --device="bedroom light" --value=50
\\ bootstrap --intent=SetDeviceValueIntent --device="thermostat" --value=72
\\ bootstrap --intent=WeezTheJuiceIntent
\\
\\Environment variables (or .env file in current directory):
\\ COGNITO_USERNAME Rinnai account username
@ -471,7 +422,11 @@ fn printHelp(args: []const []const u8) !void {
\\ HOME_ASSISTANT_URL Home Assistant URL (e.g., https://ha.example.com)
\\ HOME_ASSISTANT_TOKEN Home Assistant long-lived access token
\\
, .{prog});
;
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("{s}", .{help});
try stdout.flush();
}

View file

@ -1,238 +0,0 @@
//! Timezone utilities: TZif parsing and local timezone resolution.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.timezone);
// Suppress warnings during tests
fn warn(comptime fmt: []const u8, args: anytype) void {
if (!builtin.is_test) {
log.warn(fmt, args);
}
}
/// Get UTC offset in seconds for a timezone name.
/// Reads /usr/share/zoneinfo/{name} and parses TZif format.
/// Returns null on any error (file not found, parse error, etc.)
pub fn getUtcOffset(allocator: Allocator, timezone_name: []const u8) ?i32 {
const path = std.fmt.allocPrint(allocator, "/usr/share/zoneinfo/{s}", .{timezone_name}) catch {
warn("Failed to allocate timezone path", .{});
return null;
};
defer allocator.free(path);
const file = std.fs.openFileAbsolute(path, .{}) catch {
warn("Failed to open timezone file: {s}", .{path});
return null;
};
defer file.close();
const data = file.readToEndAlloc(allocator, 64 * 1024) catch {
warn("Failed to read timezone file: {s}", .{path});
return null;
};
defer allocator.free(data);
return parseTzif(data);
}
/// Get local timezone name from environment.
/// Checks TZ env var first, then reads /etc/timezone.
/// Returns owned slice that caller must free, or null if not found.
pub fn getLocalTimezone(allocator: Allocator) ?[]const u8 {
// First check TZ environment variable
if (std.process.getEnvVarOwned(allocator, "TZ")) |tz| {
// TZ can be a path like ":/etc/localtime" or just a name like "America/Los_Angeles"
// Strip leading colon if present
if (tz.len > 0 and tz[0] == ':') {
const stripped = allocator.dupe(u8, tz[1..]) catch {
allocator.free(tz);
return null;
};
allocator.free(tz);
return stripped;
}
return tz;
} else |_| {}
// Fall back to /etc/timezone
const file = std.fs.openFileAbsolute("/etc/timezone", .{}) catch {
warn("No TZ env var and failed to open /etc/timezone", .{});
return null;
};
defer file.close();
const content = file.readToEndAlloc(allocator, 256) catch {
warn("Failed to read /etc/timezone", .{});
return null;
};
// Trim trailing newline
const trimmed = std.mem.trimRight(u8, content, "\n\r \t");
if (trimmed.len == content.len) {
return content;
}
const result = allocator.dupe(u8, trimmed) catch {
allocator.free(content);
return null;
};
allocator.free(content);
return result;
}
/// Parse TZif binary file format.
/// Returns most recent UTC offset in seconds, or null on error.
fn parseTzif(data: []const u8) ?i32 {
// TZif header is 44 bytes minimum
if (data.len < 44) {
warn("TZif file too small: {d} bytes", .{data.len});
return null;
}
// Check magic number "TZif"
if (!std.mem.eql(u8, data[0..4], "TZif")) {
warn("Invalid TZif magic number", .{});
return null;
}
const version = data[4];
// Parse header counts (big-endian)
// Header layout:
// 0-3: magic "TZif"
// 4: version (0, '2', or '3')
// 5-19: reserved (15 bytes)
// 20-23: tzh_ttisutcnt
// 24-27: tzh_ttisstdcnt
// 28-31: tzh_leapcnt
// 32-35: tzh_timecnt
// 36-39: tzh_typecnt
// 40-43: tzh_charcnt
const tzh_ttisutcnt = std.mem.readInt(u32, data[20..24], .big);
const tzh_ttisstdcnt = std.mem.readInt(u32, data[24..28], .big);
const tzh_leapcnt = std.mem.readInt(u32, data[28..32], .big);
const tzh_timecnt = std.mem.readInt(u32, data[32..36], .big);
const tzh_typecnt = std.mem.readInt(u32, data[36..40], .big);
const tzh_charcnt = std.mem.readInt(u32, data[40..44], .big);
if (tzh_typecnt == 0) {
warn("TZif file has no time types", .{});
return null;
}
// For v2/v3 files, skip v1 data and parse v2 header
// v1 uses 4-byte transition times, v2/v3 use 8-byte
if (version == '2' or version == '3') {
// Calculate size of v1 data block to skip
const v1_data_size = tzh_timecnt * 4 + // transition times (4 bytes each in v1)
tzh_timecnt + // transition types (1 byte each)
tzh_typecnt * 6 + // ttinfos (6 bytes each)
tzh_charcnt + // timezone abbreviations
tzh_leapcnt * 8 + // leap second records (4+4 in v1)
tzh_ttisstdcnt + // std/wall indicators
tzh_ttisutcnt; // ut/local indicators
const v2_header_start = 44 + v1_data_size;
if (data.len < v2_header_start + 44) {
warn("TZif v2 file truncated", .{});
return null;
}
// Verify v2 header magic
if (!std.mem.eql(u8, data[v2_header_start..][0..4], "TZif")) {
warn("Invalid TZif v2 header magic", .{});
return null;
}
// Parse v2 header
const v2_timecnt = std.mem.readInt(u32, data[v2_header_start + 32 ..][0..4], .big);
const v2_typecnt = std.mem.readInt(u32, data[v2_header_start + 36 ..][0..4], .big);
if (v2_typecnt == 0) {
warn("TZif v2 has no time types", .{});
return null;
}
// Calculate offset to ttinfo structures in v2 data
const v2_data_start = v2_header_start + 44;
const ttinfo_offset = v2_data_start +
v2_timecnt * 8 + // transition times (8 bytes each in v2)
v2_timecnt; // transition types (1 byte each)
return readLastTtinfoOffset(data, ttinfo_offset, v2_typecnt);
}
// v1 format: ttinfos start after transition times and types
const ttinfo_offset = 44 +
tzh_timecnt * 4 + // transition times
tzh_timecnt; // transition types
return readLastTtinfoOffset(data, ttinfo_offset, tzh_typecnt);
}
/// Read the UTC offset from ttinfo structures.
/// Finds the first non-DST entry with a "normal" offset (multiple of 15 min), or falls back to first entry.
/// Each ttinfo is 6 bytes: i32 offset, u8 is_dst, u8 abbr_idx
fn readLastTtinfoOffset(data: []const u8, ttinfo_offset: usize, typecnt: u32) ?i32 {
if (data.len < ttinfo_offset + 6) {
warn("TZif file truncated at ttinfo", .{});
return null;
}
// Look for a non-DST entry (standard time) with a "normal" offset
// Skip historical LMT entries which often have unusual offsets
var i: usize = 0;
while (i < typecnt) : (i += 1) {
const off = ttinfo_offset + i * 6;
if (data.len < off + 6) break;
const offset = std.mem.readInt(i32, data[off..][0..4], .big);
const is_dst = data[off + 4];
// Skip DST entries
if (is_dst != 0) continue;
// Skip unusual offsets (not multiples of 15 minutes = 900 seconds)
// This filters out historical LMT entries
if (@mod(offset, 900) != 0) continue;
// Sanity check: offset should be reasonable (-14 to +14 hours)
if (offset >= -14 * 3600 and offset <= 14 * 3600) {
return offset;
}
}
// Fall back to first entry if no suitable entry found
const offset = std.mem.readInt(i32, data[ttinfo_offset..][0..4], .big);
// Sanity check
if (offset < -14 * 3600 or offset > 14 * 3600) {
warn("TZif offset out of range: {d}", .{offset});
return null;
}
return offset;
}
// ============================================================================
// Tests
// ============================================================================
test "getLocalTimezone with TZ env var" {
// This test depends on environment, just verify it doesn't crash
const allocator = std.testing.allocator;
if (getLocalTimezone(allocator)) |tz| {
defer allocator.free(tz);
try std.testing.expect(tz.len > 0);
}
}
test "parseTzif with invalid data" {
try std.testing.expectEqual(null, parseTzif(""));
try std.testing.expectEqual(null, parseTzif("short"));
try std.testing.expectEqual(null, parseTzif("NotTZif" ++ "\x00" ** 37));
}

View file

@ -1,153 +0,0 @@
//! 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();
var stdout_buf: [4096]u8 = undefined;
var stderr_buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
var stderr_writer = std.fs.File.stderr().writer(&stderr_buf);
const stdout = &stdout_writer.interface;
const stderr = &stderr_writer.interface;
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 3) {
try stderr.print("Usage: {s} <deploy-output.json> <ask-states.json> [--profile <profile>] [--region <region>]\n", .{args[0]});
try stderr.flush();
return 1;
}
// Parse optional arguments
var profile: ?[]const u8 = null;
var region_override: ?[]const u8 = null;
var i: usize = 3;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--profile") and i + 1 < args.len) {
profile = args[i + 1];
i += 1;
} else if (std.mem.eql(u8, args[i], "--region") and i + 1 < args.len) {
region_override = args[i + 1];
i += 1;
}
}
// Read deploy output to get function name and region
const deploy_output = std.fs.cwd().readFileAlloc(allocator, args[1], 1024 * 1024) catch |err| {
try stderr.print("Failed to read deploy output '{s}': {}\n", .{ args[1], err });
try stderr.flush();
return 1;
};
defer allocator.free(deploy_output);
const deploy_parsed = json.parseFromSlice(json.Value, allocator, deploy_output, .{}) catch |err| {
try stderr.print("Failed to parse deploy output: {}\n", .{err});
try stderr.flush();
return 1;
};
defer deploy_parsed.deinit();
const function_name = deploy_parsed.value.object.get("function_name").?.string;
const region = region_override orelse 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| {
try stderr.print("Failed to read ask-states.json '{s}': {}\n", .{ args[2], err });
try stderr.flush();
return 1;
};
defer allocator.free(ask_states);
const ask_parsed = json.parseFromSlice(json.Value, allocator, ask_states, .{}) catch |err| {
try stderr.print("Failed to parse ask-states.json: {}\n", .{err});
try stderr.flush();
return 1;
};
defer ask_parsed.deinit();
// Skill ID is always under "default" profile in ask-states.json
// (ASK CLI profile is separate from AWS profile)
const profiles_obj = ask_parsed.value.object.get("profiles") orelse {
try stderr.print("No 'profiles' field in ask-states.json\n", .{});
try stderr.flush();
return 1;
};
const profile_obj = profiles_obj.object.get("default") orelse {
try stderr.print("No 'default' profile in ask-states.json\n", .{});
try stderr.flush();
return 1;
};
const skill_id = profile_obj.object.get("skillId").?.string;
try stdout.print("Adding Alexa permission for skill {s} to function {s} in {s}", .{ skill_id, function_name, region });
if (profile) |p| try stdout.print(" (profile: {s})", .{p});
try stdout.print("\n", .{});
try stdout.flush();
// 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 {
try stderr.print("Failed to build statement ID\n", .{});
try stderr.flush();
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,
.credential_options = .{
.profile = .{
.profile_name = profile,
.prefer_profile_from_file = profile != null,
},
},
};
// Add permission with skill ID as event source token
const services = aws.Services(.{.lambda}){};
const result = 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) {
try stdout.print("Permission already exists for skill: {s}\n", .{skill_id});
try stdout.flush();
return 0;
}
try stderr.print("AddPermission failed: {} (HTTP {})\n", .{ err, diagnostics.response_status });
try stderr.flush();
return 1;
};
result.deinit();
try stdout.print("Added Alexa permission for skill: {s}\n", .{skill_id});
try stdout.flush();
return 0;
}

View file

@ -1,63 +0,0 @@
//! Generate skill.json from template by substituting Lambda ARN
//!
//! Usage: gen-skill-json <deploy-output.json> <skill.template.json>
//! Outputs the generated skill.json to stdout.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
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> <skill.template.json>\n", .{args[0]});
std.process.exit(1);
}
const deploy_output_path = args[1];
const template_path = args[2];
// Read deploy output JSON
const deploy_output = try readFile(allocator, deploy_output_path);
defer allocator.free(deploy_output);
// Parse to extract ARN
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, deploy_output, .{});
defer parsed.deinit();
const arn = parsed.value.object.get("arn") orelse {
std.debug.print("Error: deploy output missing 'arn' field\n", .{});
std.process.exit(1);
};
const arn_str = switch (arn) {
.string => |s| s,
else => {
std.debug.print("Error: 'arn' field is not a string\n", .{});
std.process.exit(1);
},
};
// Read template
const template = try readFile(allocator, template_path);
defer allocator.free(template);
// Replace placeholder with ARN
const result = try std.mem.replaceOwned(u8, allocator, template, "{{LAMBDA_ARN}}", arn_str);
defer allocator.free(result);
// Write to stdout
const stdout = std.fs.File.stdout();
try stdout.writeAll(result);
}
fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
return try file.readToEndAlloc(allocator, 1024 * 1024);
}