Init
2
src/eink_feed_render/Dimensions.zig
Normal file
|
@ -0,0 +1,2 @@
|
|||
width: u32,
|
||||
height: u32,
|
110
src/eink_feed_render/FeedClient.zig
Normal file
|
@ -0,0 +1,110 @@
|
|||
const std = @import("std");
|
||||
const wardrobe = @import("wardrobe");
|
||||
|
||||
const server = @import("eink_feed_server");
|
||||
const protocol = @import("eink_feed_protocol");
|
||||
|
||||
pub const Error = error{
|
||||
InvalidResponse,
|
||||
};
|
||||
|
||||
const Self = @This();
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
channel_url_str: []const u8,
|
||||
frame_url_str: []const u8,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, base_url: []const u8) !Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.channel_url_str = try std.fmt.allocPrint(allocator, "{s}/api/channel", .{base_url}),
|
||||
.frame_url_str = try std.fmt.allocPrint(allocator, "{s}/api/frame", .{base_url}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Self) void {
|
||||
self.allocator.free(self.channel_url_str);
|
||||
self.allocator.free(self.frame_url_str);
|
||||
}
|
||||
|
||||
pub fn getChannel(self: *const Self, channel_id: server.models.Channel.Id) !std.json.Parsed(server.web.api.models.Channel) {
|
||||
var client = std.http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
const url_str = try std.fmt.allocPrint(
|
||||
self.allocator,
|
||||
"{s}?id={d}",
|
||||
.{ self.channel_url_str, channel_id },
|
||||
);
|
||||
defer self.allocator.free(url_str);
|
||||
const url = try std.Uri.parse(url_str);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var req = try client.open(.GET, url, .{
|
||||
.server_header_buffer = &buf,
|
||||
});
|
||||
defer req.deinit();
|
||||
|
||||
try req.send();
|
||||
try req.finish();
|
||||
try req.wait();
|
||||
|
||||
if (req.response.status != .ok)
|
||||
return Error.InvalidResponse;
|
||||
|
||||
const body = try req.reader().readAllAlloc(self.allocator, 1 * 1024 * 1024);
|
||||
defer self.allocator.free(body);
|
||||
|
||||
const channel = try std.json.parseFromSlice(
|
||||
server.web.api.models.Channel,
|
||||
self.allocator,
|
||||
body,
|
||||
.{ .allocate = .alloc_always, .ignore_unknown_fields = true },
|
||||
);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
pub fn uploadFrame(self: *const Self, channel_id: u32, orientation: protocol.models.Orientation, data: []const u8, content_type: []const u8) !void {
|
||||
var client = std.http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
const url_str = try std.fmt.allocPrint(
|
||||
self.allocator,
|
||||
"{s}?id={d}&orientation={s}",
|
||||
.{ self.frame_url_str, channel_id, orientation.toString() },
|
||||
);
|
||||
defer self.allocator.free(url_str);
|
||||
const url = try std.Uri.parse(url_str);
|
||||
|
||||
var body = std.ArrayList(u8).init(self.allocator);
|
||||
defer body.deinit();
|
||||
|
||||
var prng = std.Random.DefaultPrng.init(@intCast(std.time.microTimestamp()));
|
||||
const boundary: wardrobe.Boundary = .entropy("EinkFeedRenderBoundary", prng.random());
|
||||
var write_stream = wardrobe.writeStream(boundary, body.writer());
|
||||
|
||||
try write_stream.beginFileEntry("img", content_type, "image.png");
|
||||
try write_stream.writer().writeAll(data);
|
||||
try write_stream.endEntry();
|
||||
|
||||
try write_stream.endEntries();
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var req = try client.open(.POST, url, .{
|
||||
.server_header_buffer = &buf,
|
||||
.headers = .{
|
||||
.content_type = .{ .override = boundary.contentType() },
|
||||
},
|
||||
});
|
||||
defer req.deinit();
|
||||
req.transfer_encoding = .{ .content_length = body.items.len };
|
||||
|
||||
try req.send();
|
||||
try req.writeAll(body.items);
|
||||
try req.finish();
|
||||
try req.wait();
|
||||
|
||||
if (req.response.status != .created)
|
||||
return Error.InvalidResponse;
|
||||
}
|
219
src/eink_feed_render/apps/Departures/efa.zig
Normal file
|
@ -0,0 +1,219 @@
|
|||
const std = @import("std");
|
||||
|
||||
const escaper = @import("../../escaper.zig");
|
||||
|
||||
const log = std.log.scoped(.efa);
|
||||
|
||||
pub const ApiTransportationProductClass = enum(u32) {
|
||||
zug = 0,
|
||||
s_bahn = 1,
|
||||
u_bahn = 2,
|
||||
stadtbahn = 3,
|
||||
strassen_trambahn = 4,
|
||||
stadtbus = 5,
|
||||
regionalbus = 6,
|
||||
schnellbus = 7,
|
||||
seil_zahnradbahn = 8,
|
||||
schiff = 9,
|
||||
anruf_sammel_taxi = 10,
|
||||
sonstige = 11,
|
||||
flugzeug = 12,
|
||||
zug_nv = 13,
|
||||
zug_fv = 14,
|
||||
zug_fv_m_zuschlag = 15,
|
||||
zug_fv_m_spez_fpr = 16,
|
||||
sev = 17,
|
||||
zug_shuttle = 18,
|
||||
buergerbus = 19,
|
||||
rufbus_liniengebunden = 20,
|
||||
rufbus = 21,
|
||||
_,
|
||||
|
||||
pub fn toString(self: ApiTransportationProductClass) ?[]const u8 {
|
||||
return switch (self) {
|
||||
.zug => "Zug",
|
||||
.s_bahn => "S-Bahn",
|
||||
.u_bahn => "U-Bahn",
|
||||
.stadtbahn => "Stadtbahn",
|
||||
.strassen_trambahn => "Straßen-/Trambahn",
|
||||
.stadtbus => "Stadtbus",
|
||||
.regionalbus => "Regionalbus",
|
||||
.schnellbus => "Schnellbus",
|
||||
.seil_zahnradbahn => "Seil-/Zahnradbahn",
|
||||
.schiff => "Schiff",
|
||||
.anruf_sammel_taxi => "Anruf-Sammel-Taxi",
|
||||
.sonstige => "Sonstige",
|
||||
.flugzeug => "Flugzeug",
|
||||
.zug_nv => "Zug (Nahverkehr)",
|
||||
.zug_fv => "Zug (Fernverkehr)",
|
||||
.zug_fv_m_zuschlag => "Zug (Fernverkehr) mit Zuschlag",
|
||||
.zug_fv_m_spez_fpr => "Zug (Fernverkehr) mit spezifischer Fpr",
|
||||
.sev => "SEV Schnienenersatzverkehr",
|
||||
.zug_shuttle => "Zug Shuttle",
|
||||
.buergerbus => "Bürgerbus",
|
||||
.rufbus_liniengebunden => "Rufbus (liniengebunden)",
|
||||
.rufbus => "Rufbus",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const ApiLocation = struct {
|
||||
id: []const u8,
|
||||
name: []const u8,
|
||||
properties: struct {
|
||||
platform: ?[]const u8 = null,
|
||||
},
|
||||
};
|
||||
|
||||
pub const ApiStopEvent = struct {
|
||||
location: ApiLocation,
|
||||
departureTimePlanned: []const u8,
|
||||
departureTimeBaseTimetable: []const u8,
|
||||
departureTimeEstimated: ?[]const u8 = null,
|
||||
transportation: struct {
|
||||
name: []const u8,
|
||||
product: struct {
|
||||
class: ApiTransportationProductClass,
|
||||
},
|
||||
operator: ?struct {
|
||||
name: []const u8,
|
||||
} = null,
|
||||
destination: struct {
|
||||
name: []const u8,
|
||||
},
|
||||
},
|
||||
hints: ?[]const struct {
|
||||
content: []const u8,
|
||||
type: []const u8,
|
||||
} = null,
|
||||
isCancelled: bool = false,
|
||||
};
|
||||
|
||||
pub const DmResponse = struct {
|
||||
locations: []const ApiLocation,
|
||||
stopEvents: []const ApiStopEvent,
|
||||
};
|
||||
|
||||
pub const Client = struct {
|
||||
pub const Error = error{
|
||||
InvalidResponse,
|
||||
};
|
||||
|
||||
const Self = @This();
|
||||
|
||||
const BodyLimit: usize = 10 * 1024 * 1024;
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
efa_url: []const u8,
|
||||
efa_dm_url_str: []const u8,
|
||||
efa_stopfinder_url_str: []const u8,
|
||||
|
||||
pub fn init(
|
||||
allocator: std.mem.Allocator,
|
||||
efa_url: []const u8,
|
||||
) !Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.efa_url = efa_url,
|
||||
.efa_dm_url_str = try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{s}/XML_DM_REQUEST?locationServerActive=1&stateless=1&sRaLP=1&itdLPxx_generalInfo=false&mode=direct&type_dm=any&itdLPxx_stopname=false&useRealtime=1&deleteAssignedStops_dm=1&depType=stopEvents&useAllStops=1&outputFormat=rapidJSON",
|
||||
.{efa_url},
|
||||
),
|
||||
.efa_stopfinder_url_str = try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{s}/XML_STOPFINDER_REQUEST?doNotSearchForStops_sf=1&locationInfoActive=0&locationServerActive=0&sl3plusStopFinderMacro=1&type_sf=any&outputFormat=rapidJSON",
|
||||
.{efa_url},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Self) void {
|
||||
self.allocator.free(self.efa_stopfinder_url_str);
|
||||
self.allocator.free(self.efa_dm_url_str);
|
||||
}
|
||||
|
||||
pub fn findStops(self: *const Self, query: []const u8) !void {
|
||||
const escaped_query = try escaper.escapeUriComponent(self.allocator, query);
|
||||
defer if (escaped_query) |s| self.allocator.free(s);
|
||||
const url_query = escaped_query orelse query;
|
||||
|
||||
var client = std.http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
const url_str = try std.fmt.allocPrint(
|
||||
self.allocator,
|
||||
"{s}&name_sf={s}",
|
||||
.{ self.efa_stopfinder_url_str, url_query },
|
||||
);
|
||||
defer self.allocator.free(url_str);
|
||||
const url = try std.Uri.parse(url_str);
|
||||
log.info("Finding stops ({s}): {s}", .{ query, url_str });
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var req = try client.open(.GET, url, .{
|
||||
.server_header_buffer = &buf,
|
||||
});
|
||||
defer req.deinit();
|
||||
|
||||
try req.send();
|
||||
try req.finish();
|
||||
try req.wait();
|
||||
|
||||
if (req.response.status != .ok)
|
||||
return Error.InvalidResponse;
|
||||
|
||||
const body = try req.reader().readAllAlloc(self.allocator, BodyLimit);
|
||||
defer self.allocator.free(body);
|
||||
|
||||
try std.io.getStdOut().writeAll(body);
|
||||
}
|
||||
|
||||
pub fn getDepartures(self: *const Self, stop_id: []const u8) !std.json.Parsed(DmResponse) {
|
||||
const escaped_stop_id = try escaper.escapeUriComponent(self.allocator, stop_id);
|
||||
defer if (escaped_stop_id) |s| self.allocator.free(s);
|
||||
const url_stop_id = escaped_stop_id orelse stop_id;
|
||||
|
||||
var client = std.http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
const url_str = try std.fmt.allocPrint(
|
||||
self.allocator,
|
||||
"{s}&name_dm={s}",
|
||||
.{ self.efa_dm_url_str, url_stop_id },
|
||||
);
|
||||
defer self.allocator.free(url_str);
|
||||
const url = try std.Uri.parse(url_str);
|
||||
log.info("Fetching departures: {s}", .{url_str});
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var req = try client.open(.GET, url, .{
|
||||
.server_header_buffer = &buf,
|
||||
});
|
||||
defer req.deinit();
|
||||
|
||||
try req.send();
|
||||
try req.finish();
|
||||
try req.wait();
|
||||
|
||||
if (req.response.status != .ok)
|
||||
return Error.InvalidResponse;
|
||||
|
||||
const body = try req.reader().readAllAlloc(self.allocator, BodyLimit);
|
||||
defer self.allocator.free(body);
|
||||
|
||||
const response = try std.json.parseFromSlice(
|
||||
DmResponse,
|
||||
self.allocator,
|
||||
body,
|
||||
.{ .allocate = .alloc_always, .ignore_unknown_fields = true },
|
||||
);
|
||||
|
||||
// const str = try std.json.stringifyAlloc(self.allocator, response.value, .{});
|
||||
// defer self.allocator.free(str);
|
||||
// try std.io.getStdOut().writeAll(str);
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
131
src/eink_feed_render/apps/Departures/root.zig
Normal file
|
@ -0,0 +1,131 @@
|
|||
const std = @import("std");
|
||||
const temp = @import("temp");
|
||||
const zdt = @import("zdt");
|
||||
|
||||
const Dimensions = @import("../../Dimensions.zig");
|
||||
const renderer = @import("../../renderer.zig");
|
||||
|
||||
pub const efa = @import("efa.zig");
|
||||
|
||||
const template = @import("template/root.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
dimensions: Dimensions,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, dimensions: Dimensions) Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.dimensions = dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
pub const RenderOptions = struct {
|
||||
now: *const zdt.Datetime,
|
||||
tz: *const zdt.Timezone,
|
||||
max_items: usize,
|
||||
show_operator: bool,
|
||||
};
|
||||
|
||||
pub fn render(self: *const Self, efa_dm_resp: *const efa.DmResponse, options: RenderOptions) ![]const u8 {
|
||||
const now_local = try options.now.tzConvert(.{ .tz = options.tz });
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const departures = try arena.allocator().alloc(template.Departure, efa_dm_resp.stopEvents.len);
|
||||
var dep_i: usize = 0;
|
||||
for (efa_dm_resp.stopEvents) |dep| {
|
||||
const dp_timetable = try zdt.Datetime.fromISO8601(dep.departureTimeBaseTimetable);
|
||||
const dp_timetable_local = try dp_timetable.tzConvert(.{ .tz = options.tz });
|
||||
|
||||
const dp_planned = try zdt.Datetime.fromISO8601(
|
||||
dep.departureTimeEstimated orelse dep.departureTimePlanned,
|
||||
);
|
||||
const dp_planned_local = try dp_planned.tzConvert(.{ .tz = options.tz });
|
||||
|
||||
const diff_min: i32 = @intFromFloat(dp_planned_local.diff(dp_timetable_local).totalMinutes());
|
||||
const on_time: ?u32 = if (diff_min < 0) @abs(diff_min) else null;
|
||||
const delayed: ?u32 = if (diff_min > 0) @abs(diff_min) else null;
|
||||
|
||||
const date: ?[]const u8 = if (dp_planned_local.dayOfYear() != now_local.dayOfYear())
|
||||
try std.fmt.allocPrint(arena.allocator(), "{d}.{d}.{d}", .{
|
||||
dp_planned_local.day,
|
||||
dp_planned_local.month,
|
||||
dp_planned_local.year,
|
||||
})
|
||||
else
|
||||
null;
|
||||
|
||||
var infos: ?[]const []const u8 = null;
|
||||
if (dep.hints) |hints| {
|
||||
const arr = try arena.allocator().alloc([]const u8, hints.len + 1);
|
||||
var i: usize = 0;
|
||||
for (hints) |hint| {
|
||||
if (std.mem.eql(u8, hint.type, "Timetable"))
|
||||
continue;
|
||||
|
||||
arr[i] = hint.content;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
infos = arr[0..i];
|
||||
}
|
||||
|
||||
departures[dep_i] = template.Departure{
|
||||
.time = try std.fmt.allocPrint(
|
||||
arena.allocator(),
|
||||
"{d}:{d:0>2}",
|
||||
.{ dp_timetable_local.hour, dp_timetable_local.minute },
|
||||
),
|
||||
.time_unix = dp_timetable_local.toUnix(.second),
|
||||
.line = dep.transportation.name,
|
||||
.transportation_product_class = dep.transportation.product.class,
|
||||
.operator = if (dep.transportation.operator) |op| op.name else null,
|
||||
.destination = dep.transportation.destination.name,
|
||||
.platform = dep.location.properties.platform,
|
||||
.date = date,
|
||||
.on_time = on_time,
|
||||
.delayed = delayed,
|
||||
.cancelled = dep.isCancelled,
|
||||
.information = infos,
|
||||
};
|
||||
|
||||
dep_i += 1;
|
||||
}
|
||||
|
||||
std.mem.sort(
|
||||
template.Departure,
|
||||
departures,
|
||||
{},
|
||||
template.Departure.cmpByTimeUnix,
|
||||
);
|
||||
|
||||
const context = template.Context{
|
||||
.station = efa_dm_resp.locations[0].name,
|
||||
.time = try std.fmt.allocPrint(
|
||||
arena.allocator(),
|
||||
"{d}:{d:0>2}",
|
||||
.{ now_local.hour, now_local.minute },
|
||||
),
|
||||
.departures = departures[0..@min(departures.len, options.max_items)],
|
||||
.show_operator = options.show_operator,
|
||||
};
|
||||
|
||||
const escaped_context = try context.escape(arena.allocator());
|
||||
|
||||
var tmp_file = try temp.TempFile.create(arena.allocator(), .{
|
||||
.pattern = "departures-*.html",
|
||||
});
|
||||
defer tmp_file.deinit();
|
||||
const path = try tmp_file.parent_dir.realpathAlloc(arena.allocator(), tmp_file.basename);
|
||||
{
|
||||
const file = try tmp_file.open(.{ .mode = .write_only });
|
||||
defer file.close();
|
||||
const writer = file.writer();
|
||||
try template.render(&escaped_context, writer);
|
||||
}
|
||||
|
||||
return try renderer.render(self.allocator, path, self.dimensions);
|
||||
}
|
194
src/eink_feed_render/apps/Departures/template/css/fonts.css
Normal file
104
src/eink_feed_render/apps/Departures/template/css/style.css
Normal file
|
@ -0,0 +1,104 @@
|
|||
body {
|
||||
width: 100vw;
|
||||
height: 100vw;
|
||||
margin: 0;
|
||||
/*font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;*/
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-variation-settings: "wdth" 100;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.monitor {
|
||||
max-width: 90vw;
|
||||
margin: 0 auto;
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1%;
|
||||
/* border-bottom: 0.2rem solid black; */
|
||||
/* padding-bottom: 0.25em; */
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-box-pack: justify;
|
||||
}
|
||||
|
||||
.header .header-title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 80%;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header .header-time {
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 20%;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.departures {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.departures th,
|
||||
.departures td {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.departures .data td {
|
||||
padding-top: 0.35em;
|
||||
}
|
||||
|
||||
.departures .border td {
|
||||
padding-top: 0.35em;
|
||||
padding-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.departures th {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.departures td {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.departures .border {
|
||||
border-bottom: 0.2rem solid black;
|
||||
}
|
||||
|
||||
.operator {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status-time {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.status-date {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
margin: auto;
|
||||
height: 2rem;
|
||||
}
|
BIN
src/eink_feed_render/apps/Departures/template/icons/img/boat.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
src/eink_feed_render/apps/Departures/template/icons/img/bus.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.5 KiB |
BIN
src/eink_feed_render/apps/Departures/template/icons/img/tram.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
63
src/eink_feed_render/apps/Departures/template/icons/root.zig
Normal file
|
@ -0,0 +1,63 @@
|
|||
const std = @import("std");
|
||||
|
||||
const efa = @import("../../efa.zig");
|
||||
|
||||
pub fn iconFromTransportationProductClass(class: efa.ApiTransportationProductClass) ?[]const u8 {
|
||||
return switch (class) {
|
||||
.zug,
|
||||
.s_bahn,
|
||||
.zug_nv,
|
||||
.zug_fv,
|
||||
.zug_fv_m_zuschlag,
|
||||
.zug_fv_m_spez_fpr,
|
||||
.zug_shuttle,
|
||||
=> train,
|
||||
|
||||
.u_bahn => subway,
|
||||
|
||||
.stadtbahn, .strassen_trambahn => tram,
|
||||
|
||||
.stadtbus,
|
||||
.regionalbus,
|
||||
.schnellbus,
|
||||
.buergerbus,
|
||||
.rufbus_liniengebunden,
|
||||
.rufbus,
|
||||
.anruf_sammel_taxi,
|
||||
=> bus,
|
||||
|
||||
.seil_zahnradbahn => gondola,
|
||||
|
||||
.schiff => boat,
|
||||
|
||||
.sonstige => null,
|
||||
|
||||
.flugzeug => flight,
|
||||
|
||||
.sev => no_transfer,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn encodePng(comptime path: []const u8) []const u8 {
|
||||
const raw = @embedFile(path);
|
||||
const len = std.base64.standard.Encoder.calcSize(raw.len);
|
||||
var buf: [len]u8 = undefined;
|
||||
{
|
||||
@setEvalBranchQuota(1_000_000);
|
||||
_ = std.base64.standard.Encoder.encode(&buf, raw);
|
||||
}
|
||||
const final = buf;
|
||||
return &final;
|
||||
}
|
||||
|
||||
pub const train = encodePng("img/train.png");
|
||||
pub const subway = encodePng("img/subway.png");
|
||||
pub const tram = encodePng("img/tram.png");
|
||||
pub const bus = encodePng("img/bus.png");
|
||||
pub const shuttle = encodePng("img/shuttle.png");
|
||||
pub const gondola = encodePng("img/gondola.png");
|
||||
pub const flight = encodePng("img/flight.png");
|
||||
pub const boat = encodePng("img/boat.png");
|
||||
pub const no_transfer = encodePng("img/no_transfer.png");
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M152-80h-32v-80h32q48 0 91.5-10.5T341-204q38 19 66.5 31.5T480-160q44 0 72.5-12.5T619-204q53 23 97.5 33.5T809-160h31v80h-31q-49 0-95.5-9T622-116q-40 19-73 27t-69 8q-36 0-68.5-8T339-116q-45 18-91.5 27T152-80Zm328-160q-60 0-105-40l-45-40q-27 27-60.5 46T198-247l-85-273q-5-17 3-31t25-19l59-16v-134q0-33 23.5-56.5T280-800h100v-80h200v80h100q33 0 56.5 23.5T760-720v134l59 16q17 5 25 19t3 31l-85 273q-38-8-71.5-27T630-320l-45 40q-45 40-105 40Zm2-80q31 0 55-20.5t44-43.5l46-53 41 42q11 11 22.5 20.5T713-355l46-149-279-73-278 73 46 149q11-10 22.5-19.5T293-395l41-42 46 53q20 24 45 44t57 20ZM280-607l200-53 200 53v-113H280v113Zm201 158Z"/></svg>
|
After Width: | Height: | Size: 751 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M240-120q-17 0-28.5-11.5T200-160v-82q-18-20-29-44.5T160-340v-380q0-83 77-121.5T480-880q172 0 246 37t74 123v380q0 29-11 53.5T760-242v82q0 17-11.5 28.5T720-120h-40q-17 0-28.5-11.5T640-160v-40H320v40q0 17-11.5 28.5T280-120h-40Zm242-640h224-448 224Zm158 280H240h480-80Zm-400-80h480v-120H240v120Zm100 240q25 0 42.5-17.5T400-380q0-25-17.5-42.5T340-440q-25 0-42.5 17.5T280-380q0 25 17.5 42.5T340-320Zm280 0q25 0 42.5-17.5T680-380q0-25-17.5-42.5T620-440q-25 0-42.5 17.5T560-380q0 25 17.5 42.5T620-320ZM258-760h448q-15-17-64.5-28.5T482-800q-107 0-156.5 12.5T258-760Zm62 480h320q33 0 56.5-23.5T720-360v-120H240v120q0 33 23.5 56.5T320-280Z"/></svg>
|
After Width: | Height: | Size: 753 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M280-80v-100l120-84v-144L80-280v-120l320-224v-176q0-33 23.5-56.5T480-880q33 0 56.5 23.5T560-800v176l320 224v120L560-408v144l120 84v100l-200-60-200 60Z"/></svg>
|
After Width: | Height: | Size: 275 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M200-120q-33 0-56.5-23.5T120-200v-240q0-66 47-113t113-47h160v-109L40-600v-80l205-56q-2-5-3.5-11t-1.5-13q0-25 17.5-42.5T300-820q23 0 40 15t19 38l81-22v-51h80v29l86-23q-3-6-4.5-12.5T600-860q0-25 17.5-42.5T660-920q23 0 40.5 16t19.5 39l200-55v80L520-731v131h160q66 0 113 47t47 113v240q0 33-23.5 56.5T760-120H200Zm0-80h560v-80H200v80Zm0-160h133v-160h-53q-33 0-56.5 23.5T200-440v80Zm213 0h133v-160H413v160Zm214 0h133v-80q0-33-23.5-56.5T680-520h-53v160ZM200-200v-80 80Z"/></svg>
|
After Width: | Height: | Size: 587 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M819-28 652-195h114v35q0 17-11.5 28.5T726-120h-46q-17 0-28.5-11.5T640-160v-40H320v40q0 17-11.5 28.5T280-120h-40q-17 0-28.5-11.5T200-160v-82q-18-20-29-44.5T160-340v-347L27-820l57-57L876-85l-57 57ZM320-280h247L367-480H240v120q0 33 23.5 56.5T320-280Zm469-6-69-69v-125H595l-80-80h205v-120H395l-80-80h391q-15-17-64.5-28.5T482-800q-71 0-115.5 6T296-779l-61-61q39-20 99.5-30T480-880q172 0 246 37t74 123v380q0 14-3 27.5t-8 26.5Zm-449-34q25 0 42.5-17.5T400-380q0-25-17.5-42.5T340-440q-25 0-42.5 17.5T280-380q0 25 17.5 42.5T340-320ZM240-560h47l-47-47v47Zm75-200h391-391Zm52 280Zm228 0Z"/></svg>
|
After Width: | Height: | Size: 700 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M240-200q-50 0-85-35t-35-85H40v-360q0-33 23.5-56.5T120-760h560l240 240v200h-80q0 50-35 85t-85 35q-50 0-85-35t-35-85H360q0 50-35 85t-85 35Zm360-360h160L640-680h-40v120Zm-240 0h160v-120H360v120Zm-240 0h160v-120H120v120Zm120 290q21 0 35.5-14.5T290-320q0-21-14.5-35.5T240-370q-21 0-35.5 14.5T190-320q0 21 14.5 35.5T240-270Zm480 0q21 0 35.5-14.5T770-320q0-21-14.5-35.5T720-370q-21 0-35.5 14.5T670-320q0 21 14.5 35.5T720-270ZM120-400h32q17-18 39-29t49-11q27 0 49 11t39 29h304q17-18 39-29t49-11q27 0 49 11t39 29h32v-80H120v80Zm720-80H120h720Z"/></svg>
|
After Width: | Height: | Size: 660 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M80-80v-526q0-85 44-147.5T248-848q54-21 115-26.5t117-5.5q56 0 117 5.5T712-848q80 32 124 94.5T880-606v526H80Zm284-80h230l-60-60H424l-60 60Zm-64-280h360v-160H300v160Zm320 140q17 0 28.5-11.5T660-340q0-17-11.5-28.5T620-380q-17 0-28.5 11.5T580-340q0 17 11.5 28.5T620-300Zm-280 0q17 0 28.5-11.5T380-340q0-17-11.5-28.5T340-380q-17 0-28.5 11.5T300-340q0 17 11.5 28.5T340-300ZM160-160h140v-20l42-42q-44-6-73-39.5T240-340v-260q0-78 74.5-99T480-720q100 0 170 21t70 99v260q0 45-29 78.5T618-222l42 42v20h140v-446q0-60-29.5-102.5T682-774q-44-17-97.5-21.5T480-800q-51 0-104.5 4.5T278-774q-59 23-88.5 65.5T160-606v446Zm0 0h640-640Z"/></svg>
|
After Width: | Height: | Size: 740 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M160-340v-380q0-53 27.5-84.5t72.5-48q45-16.5 102.5-22T480-880q66 0 124.5 5.5t102 22q43.5 16.5 68.5 48t25 84.5v380q0 59-40.5 99.5T660-200l60 60v20h-80l-80-80H400l-80 80h-80v-20l60-60q-59 0-99.5-40.5T160-340Zm320-460q-106 0-155 12.5T258-760h448q-15-17-64.5-28.5T480-800ZM240-560h200v-120H240v120Zm420 80H240h480-60Zm-140-80h200v-120H520v120ZM340-320q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm280 0q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm-320 40h360q26 0 43-17t17-43v-140H240v140q0 26 17 43t43 17Zm180-480h226-448 222Z"/></svg>
|
After Width: | Height: | Size: 703 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M160-260v-380q0-97 85-127t195-33l30-60H280v-60h400v60H550l-30 60q119 3 199.5 32.5T800-640v380q0 59-40.5 99.5T660-120l60 60v20h-80l-80-80H400l-80 80h-80v-20l60-60q-59 0-99.5-40.5T160-260Zm500-140H240h480-60ZM480-240q25 0 42.5-17.5T540-300q0-25-17.5-42.5T480-360q-25 0-42.5 17.5T420-300q0 25 17.5 42.5T480-240Zm-2-440h228-450 222ZM240-480h480v-120H240v120Zm60 280h360q26 0 43-17t17-43v-140H240v140q0 26 17 43t43 17Zm178-520q-134 0-172 14.5T256-680h450q-12-14-52-27t-176-13Z"/></svg>
|
After Width: | Height: | Size: 596 B |
264
src/eink_feed_render/apps/Departures/template/root.zig
Normal file
|
@ -0,0 +1,264 @@
|
|||
const std = @import("std");
|
||||
|
||||
const escaper = @import("../../../escaper.zig");
|
||||
const efa = @import("../efa.zig");
|
||||
|
||||
const icons = @import("icons/root.zig");
|
||||
|
||||
pub const Departure = struct {
|
||||
time: []const u8,
|
||||
time_unix: i128,
|
||||
line: []const u8,
|
||||
transportation_product_class: efa.ApiTransportationProductClass,
|
||||
operator: ?[]const u8,
|
||||
destination: []const u8,
|
||||
platform: ?[]const u8,
|
||||
date: ?[]const u8,
|
||||
on_time: ?u32,
|
||||
delayed: ?u32,
|
||||
cancelled: bool,
|
||||
information: ?[]const []const u8,
|
||||
|
||||
pub fn cmpByTimeUnix(context: void, a: Departure, b: Departure) bool {
|
||||
return std.sort.asc(i128)(context, a.time_unix, b.time_unix);
|
||||
}
|
||||
|
||||
pub fn escape(self: *const Departure, allocator: std.mem.Allocator) !Departure {
|
||||
var copy: Departure = undefined;
|
||||
|
||||
const time = try escaper.escapeHtml(allocator, self.time);
|
||||
errdefer if (time) |s| allocator.free(s);
|
||||
copy.time = time orelse self.time;
|
||||
|
||||
const line = try escaper.escapeHtml(allocator, self.line);
|
||||
errdefer if (line) |s| allocator.free(s);
|
||||
copy.line = line orelse self.line;
|
||||
|
||||
copy.transportation_product_class = self.transportation_product_class;
|
||||
|
||||
copy.operator = null;
|
||||
if (self.operator) |str| {
|
||||
const operator = try escaper.escapeHtml(allocator, str);
|
||||
errdefer if (operator) |s| allocator.free(s);
|
||||
copy.operator = operator orelse str;
|
||||
}
|
||||
|
||||
const destination = try escaper.escapeHtml(allocator, self.destination);
|
||||
errdefer if (destination) |s| allocator.free(s);
|
||||
copy.destination = destination orelse self.destination;
|
||||
|
||||
copy.platform = null;
|
||||
if (self.platform) |str| {
|
||||
const platform = try escaper.escapeHtml(allocator, str);
|
||||
errdefer if (platform) |s| allocator.free(s);
|
||||
copy.platform = platform orelse str;
|
||||
}
|
||||
|
||||
copy.date = null;
|
||||
if (self.date) |str| {
|
||||
const date = try escaper.escapeHtml(allocator, str);
|
||||
errdefer if (date) |s| allocator.free(s);
|
||||
copy.date = date orelse str;
|
||||
}
|
||||
|
||||
copy.on_time = self.on_time;
|
||||
copy.delayed = self.delayed;
|
||||
copy.cancelled = self.cancelled;
|
||||
|
||||
copy.information = null;
|
||||
if (self.information) |arr| {
|
||||
const infos = try allocator.alloc([]const u8, arr.len);
|
||||
for (arr, 0..) |str, i| {
|
||||
const information = try escaper.escapeHtml(allocator, str);
|
||||
errdefer if (information) |s| allocator.free(s);
|
||||
infos[i] = information orelse str;
|
||||
}
|
||||
copy.information = infos;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Context = struct {
|
||||
station: []const u8,
|
||||
time: []const u8,
|
||||
departures: []const Departure,
|
||||
show_operator: bool,
|
||||
|
||||
pub fn escape(self: *const Context, allocator: std.mem.Allocator) !Context {
|
||||
var copy: Context = undefined;
|
||||
|
||||
const station = try escaper.escapeHtml(allocator, self.station);
|
||||
errdefer if (station) |s| allocator.free(s);
|
||||
copy.station = station orelse self.station;
|
||||
|
||||
const time = try escaper.escapeHtml(allocator, self.time);
|
||||
errdefer if (time) |s| allocator.free(s);
|
||||
copy.time = time orelse self.time;
|
||||
|
||||
const departures = try allocator.alloc(Departure, self.departures.len);
|
||||
errdefer allocator.free(copy.departures);
|
||||
for (self.departures, 0..) |departure, i| {
|
||||
const escaped = try departure.escape(allocator);
|
||||
departures[i] = escaped;
|
||||
}
|
||||
copy.departures = departures;
|
||||
|
||||
copy.show_operator = self.show_operator;
|
||||
|
||||
return copy;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn render(context: *const Context, writer: anytype) !void {
|
||||
try writer.writeAll(
|
||||
\\<!DOCTYPE html>
|
||||
\\<html lang="de">
|
||||
\\ <head>
|
||||
\\ <meta charset="UTF-8" />
|
||||
\\ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
);
|
||||
try writer.print(
|
||||
\\<style>{[fonts_css]s}</style>
|
||||
\\<style>{[style_css]s}</style>
|
||||
,
|
||||
.{
|
||||
.fonts_css = @embedFile("css/fonts.css"),
|
||||
.style_css = @embedFile("css/style.css"),
|
||||
},
|
||||
);
|
||||
try writer.writeAll(
|
||||
\\</head>
|
||||
\\<body>
|
||||
\\ <div class="monitor">
|
||||
\\ <header class="header">
|
||||
);
|
||||
try writer.print(
|
||||
\\<h1 class="header-title">Abfahrten: {[station]s}</h1>
|
||||
\\<h2 class="header-time">{[time]s}</h2>
|
||||
, .{ .station = context.station, .time = context.time });
|
||||
try writer.writeAll(
|
||||
\\</header>
|
||||
\\ <table style="width: 100%" class="departures">
|
||||
\\ <colgroup>
|
||||
\\ <col span="1" style="width: 15%;">
|
||||
\\ <col span="1" style="width: 10%;">
|
||||
\\ <col span="1" style="width: 30%;">
|
||||
\\ <col span="1" style="width: 30%;">
|
||||
\\ <col span="1" style="width: 15%;">
|
||||
\\ </colgroup>
|
||||
\\ <thead>
|
||||
\\ <tr class="border">
|
||||
\\ <th>Zeit</th>
|
||||
\\ <th colspan="2">Linie</th>
|
||||
\\ <th>Ziel</th>
|
||||
\\ <th style="text-align: right;">Gleis/<wbr />Steig</th>
|
||||
\\ </tr>
|
||||
\\ </thead>
|
||||
\\ <tbody>
|
||||
);
|
||||
|
||||
for (context.departures) |departure| {
|
||||
try writer.print(
|
||||
\\<tr class="data {[cancelled]s} {[border]s}">
|
||||
,
|
||||
.{
|
||||
.cancelled = if (departure.cancelled) "status-cancelled" else "",
|
||||
.border = if (departure.information == null) "border" else "",
|
||||
},
|
||||
);
|
||||
|
||||
try writer.print("<td>{s}", .{departure.time});
|
||||
if (departure.on_time) |n| {
|
||||
try writer.print(
|
||||
\\ <wbr /><span class="status-time">(-{d})</span>
|
||||
, .{n});
|
||||
}
|
||||
if (departure.delayed) |n| {
|
||||
try writer.print(
|
||||
\\ <wbr /><span class="status-time">(+{d})</span>
|
||||
, .{n});
|
||||
}
|
||||
if (departure.date) |date| {
|
||||
try writer.print(
|
||||
\\<br /><span class="status-date">({s})</span>
|
||||
,
|
||||
.{date},
|
||||
);
|
||||
}
|
||||
|
||||
if (icons.iconFromTransportationProductClass(departure.transportation_product_class)) |img| {
|
||||
try writer.print(
|
||||
\\ <td>
|
||||
\\ <img class="icon" src="data:image/png;charset=utf-8;base64,{[img]s}" />
|
||||
\\ </td>
|
||||
,
|
||||
.{ .img = img },
|
||||
);
|
||||
} else {
|
||||
try writer.writeAll(
|
||||
\\<td></td>
|
||||
);
|
||||
}
|
||||
|
||||
try writer.print(
|
||||
\\<td><span>{[line]s}<span>
|
||||
,
|
||||
.{ .line = departure.line },
|
||||
);
|
||||
if (context.show_operator) {
|
||||
if (departure.operator) |operator| {
|
||||
try writer.print(
|
||||
\\<br /><span class="operator">{[operator]s}</span>
|
||||
,
|
||||
.{ .operator = operator },
|
||||
);
|
||||
}
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\</td>
|
||||
);
|
||||
|
||||
try writer.print(
|
||||
\\ <td>{[destination]s}</td>
|
||||
\\ <td style="text-align: right;">{[platform]s}</td>
|
||||
\\</tr>
|
||||
,
|
||||
.{
|
||||
.destination = departure.destination,
|
||||
.platform = departure.platform orelse "",
|
||||
},
|
||||
);
|
||||
|
||||
if (departure.information) |infos| {
|
||||
try writer.print(
|
||||
\\<tr class="border info {[cancelled]s}">
|
||||
\\ <td></td>
|
||||
\\ <td></td>
|
||||
\\ <td colspan="3">
|
||||
,
|
||||
.{
|
||||
.cancelled = if (departure.cancelled) "status-cancelled" else "",
|
||||
},
|
||||
);
|
||||
for (infos, 0..) |str, i| {
|
||||
try writer.print("<span>{s}</span>", .{str});
|
||||
if (i < infos.len - 1)
|
||||
try writer.writeAll("<br />");
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\ </td>
|
||||
\\</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll(
|
||||
\\ </tbody>
|
||||
\\ </table>
|
||||
\\ </div>
|
||||
\\ </body>
|
||||
\\</html>
|
||||
);
|
||||
}
|
1
src/eink_feed_render/apps/root.zig
Normal file
|
@ -0,0 +1 @@
|
|||
pub const Departures = @import("Departures/root.zig");
|
35
src/eink_feed_render/escaper.zig
Normal file
|
@ -0,0 +1,35 @@
|
|||
const std = @import("std");
|
||||
|
||||
const c = @cImport({
|
||||
@cInclude("houdini.h");
|
||||
});
|
||||
|
||||
pub fn escapeHtml(allocator: std.mem.Allocator, data: []const u8) !?[]const u8 {
|
||||
var buf: c.gh_buf = undefined;
|
||||
c.gh_buf_init(&buf, data.len);
|
||||
defer c.gh_buf_free(&buf);
|
||||
|
||||
const res = c.houdini_escape_html(&buf, @ptrCast(data), data.len);
|
||||
if (res == 0) return null;
|
||||
|
||||
const escaped = try allocator.alloc(u8, c.gh_buf_len(&buf));
|
||||
errdefer allocator.free(escaped);
|
||||
@memcpy(escaped, c.gh_buf_cstr(&buf)[0..escaped.len]);
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
pub fn escapeUriComponent(allocator: std.mem.Allocator, data: []const u8) !?[]const u8 {
|
||||
var buf: c.gh_buf = undefined;
|
||||
c.gh_buf_init(&buf, data.len);
|
||||
defer c.gh_buf_free(&buf);
|
||||
|
||||
const res = c.houdini_escape_uri_component(&buf, @ptrCast(data), data.len);
|
||||
if (res == 0) return null;
|
||||
|
||||
const escaped = try allocator.alloc(u8, c.gh_buf_len(&buf));
|
||||
errdefer allocator.free(escaped);
|
||||
@memcpy(escaped, c.gh_buf_cstr(&buf)[0..escaped.len]);
|
||||
|
||||
return escaped;
|
||||
}
|
101
src/eink_feed_render/main/departures.zig
Normal file
|
@ -0,0 +1,101 @@
|
|||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const clap = @import("clap");
|
||||
const zdt = @import("zdt");
|
||||
|
||||
const render = @import("eink_feed_render");
|
||||
|
||||
pub const std_options: std.Options = .{
|
||||
.log_level = if (builtin.mode == .Debug) .debug else .info,
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{
|
||||
.thread_safe = true,
|
||||
}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
{
|
||||
const params = comptime clap.parseParamsComptime(
|
||||
\\-h, --help Display this help and exit.
|
||||
\\--url <str> URL of eink-feed server.
|
||||
\\--channel <u32> Channel ID.
|
||||
\\--efa <str> URL of EFA server.
|
||||
\\--stop <str> Stop ID.
|
||||
\\--tz <str> Time zone.
|
||||
\\--max <usize> Max number of departures listed.
|
||||
\\--show-operator Show operator.
|
||||
\\--stopfinder <str> Search for stops on the EFA server with the given search query.
|
||||
);
|
||||
|
||||
var diag = clap.Diagnostic{};
|
||||
var res = clap.parse(clap.Help, ¶ms, clap.parsers.default, .{
|
||||
.diagnostic = &diag,
|
||||
.allocator = allocator,
|
||||
}) catch |err| {
|
||||
diag.report(std.io.getStdErr().writer(), err) catch {};
|
||||
return err;
|
||||
};
|
||||
defer res.deinit();
|
||||
|
||||
if (res.args.help != 0 or
|
||||
res.args.efa == null or
|
||||
((res.args.stop == null or
|
||||
res.args.tz == null or
|
||||
res.args.url == null or
|
||||
res.args.channel == null) and
|
||||
res.args.stopfinder == null))
|
||||
{
|
||||
const writer = std.io.getStdErr().writer();
|
||||
try writer.writeAll("eink-feed-render-departures\n");
|
||||
return clap.help(writer, clap.Help, ¶ms, .{});
|
||||
}
|
||||
|
||||
const efa = try render.apps.Departures.efa.Client.init(allocator, res.args.efa.?);
|
||||
defer efa.deinit();
|
||||
|
||||
if (res.args.stopfinder) |query| {
|
||||
try efa.findStops(query);
|
||||
return;
|
||||
}
|
||||
|
||||
var tz = try zdt.Timezone.fromTzdata(res.args.tz.?, allocator);
|
||||
defer tz.deinit();
|
||||
|
||||
render.renderer.init();
|
||||
defer render.renderer.deinit();
|
||||
|
||||
const feed_client = try render.FeedClient.init(allocator, res.args.url.?);
|
||||
defer feed_client.deinit();
|
||||
|
||||
const channel = try feed_client.getChannel(res.args.channel.?);
|
||||
defer channel.deinit();
|
||||
const dim = render.Dimensions{
|
||||
.width = channel.value.display.width,
|
||||
.height = channel.value.display.height,
|
||||
};
|
||||
const orientation = channel.value.display.orientation.toInternal();
|
||||
const departures = render.apps.Departures.init(allocator, dim);
|
||||
|
||||
const stop_id: []const u8 = res.args.stop.?;
|
||||
|
||||
const now = try zdt.Datetime.now(.{ .tz = &tz });
|
||||
const efa_departures = try efa.getDepartures(stop_id);
|
||||
defer efa_departures.deinit();
|
||||
|
||||
const png = try departures.render(&efa_departures.value, .{
|
||||
.now = &now,
|
||||
.tz = &tz,
|
||||
.max_items = res.args.max orelse 15,
|
||||
.show_operator = res.args.@"show-operator" != 0,
|
||||
});
|
||||
defer allocator.free(png);
|
||||
try feed_client.uploadFrame(
|
||||
channel.value.id,
|
||||
orientation,
|
||||
png,
|
||||
"image/png",
|
||||
);
|
||||
}
|
||||
}
|
90
src/eink_feed_render/renderer.zig
Normal file
|
@ -0,0 +1,90 @@
|
|||
const std = @import("std");
|
||||
|
||||
const Dimensions = @import("Dimensions.zig");
|
||||
|
||||
const c = @cImport({
|
||||
@cInclude("wkhtmltox/image.h");
|
||||
});
|
||||
|
||||
const log = std.log.scoped(.renderer);
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub const Error = error{
|
||||
Wkhtml,
|
||||
};
|
||||
|
||||
var wkhtmlInit = false;
|
||||
|
||||
pub fn init() void {
|
||||
if (!wkhtmlInit) {
|
||||
_ = c.wkhtmltoimage_init(0);
|
||||
wkhtmlInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit() void {
|
||||
_ = c.wkhtmltoimage_deinit();
|
||||
}
|
||||
|
||||
fn wkhtmlProgressChanged(converter: ?*c.wkhtmltoimage_converter, progress: c_int) callconv(.C) void {
|
||||
_ = converter;
|
||||
_ = progress;
|
||||
}
|
||||
|
||||
fn wkhtmlPhaseChanged(converter: ?*c.wkhtmltoimage_converter) callconv(.C) void {
|
||||
_ = converter;
|
||||
}
|
||||
|
||||
fn wkhtmlError(converter: ?*c.wkhtmltoimage_converter, msg: [*c]const u8) callconv(.C) void {
|
||||
_ = converter;
|
||||
log.err("Convert error: {s}", .{msg});
|
||||
}
|
||||
|
||||
fn wkhtmlWarning(converter: ?*c.wkhtmltoimage_converter, msg: [*c]const u8) callconv(.C) void {
|
||||
_ = converter;
|
||||
log.warn("Convert warning: {s}", .{msg});
|
||||
}
|
||||
|
||||
pub fn render(allocator: std.mem.Allocator, input_path: []const u8, dimensions: Dimensions) ![]const u8 {
|
||||
const input = try allocator.dupeZ(u8, input_path);
|
||||
defer allocator.free(input);
|
||||
|
||||
const width = try std.fmt.allocPrintZ(allocator, "{d}", .{dimensions.width});
|
||||
defer allocator.free(width);
|
||||
const height = try std.fmt.allocPrintZ(allocator, "{d}", .{dimensions.height});
|
||||
defer allocator.free(height);
|
||||
|
||||
const gs = c.wkhtmltoimage_create_global_settings().?;
|
||||
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "web.loadImages", "true");
|
||||
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "screenWidth", width);
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "smartWidth", "false");
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "crop.width", width);
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "crop.height", height);
|
||||
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "in", input);
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "fmt", "png");
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "transparent", "false");
|
||||
_ = c.wkhtmltoimage_set_global_setting(gs, "quality", "100");
|
||||
|
||||
const converter = c.wkhtmltoimage_create_converter(gs, 0).?;
|
||||
defer c.wkhtmltoimage_destroy_converter(converter);
|
||||
|
||||
c.wkhtmltoimage_set_progress_changed_callback(converter, wkhtmlProgressChanged);
|
||||
c.wkhtmltoimage_set_phase_changed_callback(converter, wkhtmlPhaseChanged);
|
||||
c.wkhtmltoimage_set_error_callback(converter, wkhtmlError);
|
||||
c.wkhtmltoimage_set_warning_callback(converter, wkhtmlWarning);
|
||||
|
||||
if (c.wkhtmltoimage_convert(converter) == 0) {
|
||||
log.err("Conversion failed", .{});
|
||||
return Error.Wkhtml;
|
||||
}
|
||||
|
||||
var data: [*c]const u8 = undefined;
|
||||
const len = c.wkhtmltoimage_get_output(converter, &data);
|
||||
const img = data[0..@intCast(len)];
|
||||
|
||||
return try allocator.dupe(u8, img);
|
||||
}
|
4
src/eink_feed_render/root.zig
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub const apps = @import("apps/root.zig");
|
||||
pub const renderer = @import("renderer.zig");
|
||||
pub const FeedClient = @import("FeedClient.zig");
|
||||
pub const Dimensions = @import("Dimensions.zig");
|