diff --git a/src/alexa.zig b/src/alexa.zig new file mode 100644 index 0000000..b69a5f2 --- /dev/null +++ b/src/alexa.zig @@ -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); +} diff --git a/src/homeassistant.zig b/src/homeassistant.zig index 8f47a72..e837428 100644 --- a/src/homeassistant.zig +++ b/src/homeassistant.zig @@ -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); diff --git a/src/main.zig b/src/main.zig index 2948b9e..7c3bf0a 100644 --- a/src/main.zig +++ b/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(); } diff --git a/src/timezone.zig b/src/timezone.zig new file mode 100644 index 0000000..5691d54 --- /dev/null +++ b/src/timezone.zig @@ -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)); +}