update query state code (AI yolo mode)
This commit is contained in:
parent
bfb066264d
commit
ba17d65d77
4 changed files with 613 additions and 45 deletions
175
src/alexa.zig
Normal file
175
src/alexa.zig
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! 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.
|
||||
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);
|
||||
}
|
||||
|
|
@ -610,7 +610,7 @@ pub fn findEntitiesByName(
|
|||
}
|
||||
|
||||
/// Format a detailed state response for an entity
|
||||
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const u8 {
|
||||
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offset: ?i32) ![]const u8 {
|
||||
var response: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer response.deinit(allocator);
|
||||
|
||||
|
|
@ -631,13 +631,29 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const
|
|||
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});
|
||||
} else if (std.mem.eql(u8, entity.domain, "binary_sensor") or
|
||||
}
|
||||
|
||||
// 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
|
||||
std.mem.eql(u8, entity.domain, "cover"))
|
||||
{
|
||||
// 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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll(".");
|
||||
|
|
@ -645,15 +661,119 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const
|
|||
return response.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// 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 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;
|
||||
|
||||
// For now, just return a generic message
|
||||
// TODO: Implement proper time parsing
|
||||
return "some time";
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/// Format the "which one?" clarification prompt
|
||||
|
|
@ -692,6 +812,7 @@ 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| {
|
||||
|
|
@ -822,7 +943,7 @@ pub fn handleDeviceAction(
|
|||
};
|
||||
},
|
||||
.query_state => {
|
||||
const response = try formatStateResponse(allocator, entity);
|
||||
const response = try formatStateResponse(allocator, entity, utc_offset);
|
||||
return ActionResult{
|
||||
.speech = response,
|
||||
.end_session = true,
|
||||
|
|
@ -986,7 +1107,7 @@ test "formatStateResponse light on with brightness" {
|
|||
.current_temperature = null,
|
||||
};
|
||||
|
||||
const response = try formatStateResponse(allocator, &entity);
|
||||
const response = try formatStateResponse(allocator, &entity, null);
|
||||
defer allocator.free(response);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null);
|
||||
|
|
@ -1007,7 +1128,7 @@ test "formatStateResponse light off" {
|
|||
.current_temperature = null,
|
||||
};
|
||||
|
||||
const response = try formatStateResponse(allocator, &entity);
|
||||
const response = try formatStateResponse(allocator, &entity, null);
|
||||
defer allocator.free(response);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, response, "off") != null);
|
||||
|
|
@ -1027,7 +1148,7 @@ test "formatStateResponse climate with temperatures" {
|
|||
.current_temperature = 68.0,
|
||||
};
|
||||
|
||||
const response = try formatStateResponse(allocator, &entity);
|
||||
const response = try formatStateResponse(allocator, &entity, null);
|
||||
defer allocator.free(response);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null);
|
||||
|
|
@ -1145,7 +1266,7 @@ test "handleDeviceAction turn on light success" {
|
|||
mock.interface(),
|
||||
);
|
||||
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null);
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null, null);
|
||||
defer allocator.free(result.speech);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null);
|
||||
|
|
@ -1172,7 +1293,7 @@ test "handleDeviceAction device not found" {
|
|||
mock.interface(),
|
||||
);
|
||||
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null);
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null, null);
|
||||
defer allocator.free(result.speech);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "couldn't find") != null);
|
||||
|
|
@ -1199,7 +1320,7 @@ test "handleDeviceAction multiple matches returns clarification" {
|
|||
mock.interface(),
|
||||
);
|
||||
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null);
|
||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null, null);
|
||||
defer allocator.free(result.speech);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null);
|
||||
|
|
@ -1227,7 +1348,7 @@ test "handleDeviceAction query state" {
|
|||
mock.interface(),
|
||||
);
|
||||
|
||||
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null);
|
||||
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null, null);
|
||||
defer allocator.free(result.speech);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null);
|
||||
|
|
@ -1256,7 +1377,7 @@ test "handleDeviceAction set brightness" {
|
|||
mock.interface(),
|
||||
);
|
||||
|
||||
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0);
|
||||
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0, null);
|
||||
defer allocator.free(result.speech);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null);
|
||||
|
|
|
|||
86
src/main.zig
86
src/main.zig
|
|
@ -3,6 +3,8 @@ 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");
|
||||
|
||||
|
|
@ -19,7 +21,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() catch return 1;
|
||||
printHelp(args) catch return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +86,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, config);
|
||||
return handleIntentRequest(allocator, request_obj, parsed.value, config);
|
||||
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
|
||||
return buildAlexaResponse(allocator, "", true);
|
||||
}
|
||||
|
|
@ -93,7 +95,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, config: Config) ![]const u8 {
|
||||
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, full_request: 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);
|
||||
|
|
@ -116,7 +118,7 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, co
|
|||
} else if (std.mem.eql(u8, intent_name, "HomeAssistantIntent") or
|
||||
std.mem.eql(u8, intent_name, "SetDeviceValueIntent"))
|
||||
{
|
||||
return handleHomeAssistantIntent(allocator, intent_obj, config);
|
||||
return handleHomeAssistantIntent(allocator, intent_obj, full_request, config);
|
||||
} else if (std.mem.eql(u8, intent_name, "WeezTheJuiceIntent")) {
|
||||
return handleWeezTheJuice(allocator, config);
|
||||
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
|
||||
|
|
@ -178,7 +180,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, config: Config) ![]const u8 {
|
||||
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, full_request: 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", .{});
|
||||
|
|
@ -208,6 +210,12 @@ 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,
|
||||
|
|
@ -215,6 +223,7 @@ 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);
|
||||
|
|
@ -248,6 +257,7 @@ 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);
|
||||
|
|
@ -343,6 +353,38 @@ 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: tries Alexa Settings API, then falls back to local timezone.
|
||||
/// In local mode: uses TZ env var or /etc/timezone.
|
||||
fn resolveTimezone(allocator: std.mem.Allocator, parsed_value: json.Value, config: Config) ?i32 {
|
||||
// First try to get timezone from Alexa context (Lambda mode)
|
||||
if (alexa.parseContext(parsed_value)) |context| {
|
||||
// Need HTTP client for Alexa API
|
||||
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.http_interface, context)) |tz_name| {
|
||||
defer allocator.free(tz_name);
|
||||
if (timezone.getUtcOffset(allocator, tz_name)) |offset| {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local timezone
|
||||
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
|
||||
|
|
@ -378,9 +420,13 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
|
|||
// Local Testing Mode
|
||||
// =============================================================================
|
||||
|
||||
fn printHelp() !void {
|
||||
const help =
|
||||
\\Usage: bootstrap [OPTIONS]
|
||||
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]
|
||||
\\
|
||||
\\Alexa skill handler for water recirculation and Home Assistant control.
|
||||
\\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API.
|
||||
|
|
@ -405,16 +451,16 @@ fn printHelp() !void {
|
|||
\\ AMAZON.CancelIntent Cancel/goodbye
|
||||
\\
|
||||
\\Examples:
|
||||
\\ 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"
|
||||
\\ {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"
|
||||
\\ (no action = query state)
|
||||
\\ bootstrap --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
||||
\\ bootstrap --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
||||
\\ bootstrap --intent=WeezTheJuiceIntent
|
||||
\\ {0s} --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
||||
\\ {0s} --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
||||
\\ {0s} --intent=WeezTheJuiceIntent
|
||||
\\
|
||||
\\Environment variables (or .env file in current directory):
|
||||
\\ COGNITO_USERNAME Rinnai account username
|
||||
|
|
@ -422,11 +468,7 @@ fn printHelp() !void {
|
|||
\\ HOME_ASSISTANT_URL Home Assistant URL (e.g., https://ha.example.com)
|
||||
\\ HOME_ASSISTANT_TOKEN Home Assistant long-lived access token
|
||||
\\
|
||||
;
|
||||
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});
|
||||
, .{prog});
|
||||
try stdout.flush();
|
||||
}
|
||||
|
||||
|
|
|
|||
230
src/timezone.zig
Normal file
230
src/timezone.zig
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
//! Timezone utilities: TZif parsing and local timezone resolution.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = std.log.scoped(.timezone);
|
||||
|
||||
/// 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 {
|
||||
log.warn("Failed to allocate timezone path", .{});
|
||||
return null;
|
||||
};
|
||||
defer allocator.free(path);
|
||||
|
||||
const file = std.fs.openFileAbsolute(path, .{}) catch {
|
||||
log.warn("Failed to open timezone file: {s}", .{path});
|
||||
return null;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const data = file.readToEndAlloc(allocator, 64 * 1024) catch {
|
||||
log.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 {
|
||||
log.warn("No TZ env var and failed to open /etc/timezone", .{});
|
||||
return null;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const content = file.readToEndAlloc(allocator, 256) catch {
|
||||
log.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) {
|
||||
log.warn("TZif file too small: {d} bytes", .{data.len});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check magic number "TZif"
|
||||
if (!std.mem.eql(u8, data[0..4], "TZif")) {
|
||||
log.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) {
|
||||
log.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) {
|
||||
log.warn("TZif v2 file truncated", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify v2 header magic
|
||||
if (!std.mem.eql(u8, data[v2_header_start..][0..4], "TZif")) {
|
||||
log.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) {
|
||||
log.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) {
|
||||
log.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) {
|
||||
log.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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue