From a18a2a5435d25f4dc1c16a4c3009757b3937972f Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 27 Sep 2023 09:42:45 +0200 Subject: [PATCH 1/7] lndhttp: remove nonexistent field from pending chan list funding_expiry_blocks is present in lightning.proto but a REST API call shows no such field, at least in lnd v0.16.4, leading to json parsing error. the field is unused at the moment anyway. --- src/lndhttp.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lndhttp.zig b/src/lndhttp.zig index db517f6..e57dd9a 100644 --- a/src/lndhttp.zig +++ b/src/lndhttp.zig @@ -279,7 +279,6 @@ pub const PendingList = struct { pending_open_channels: []struct { channel: PendingChannel, commit_fee: i64, - funding_expiry_blocks: i32, }, pending_force_closing_channels: []struct { channel: PendingChannel, -- 2.41.0 From aca7eb1165827f238ba07ee446b58200bcc728da Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 27 Sep 2023 15:52:22 +0200 Subject: [PATCH 2/7] comm: introduce a simpler way to read/write this builds on top of the main read and write fn, setting up a global structure to allow module users imply comm.pipeWrite(msg) and comm.pipeRead() without providing an allocator or reader/writer on each call. the advantage is simplification in the gui functions because they don't have access to an allocator or the nd process read/write pipe. disadvantage is in testing because it requires a global, "before all tests" setup. at the moment, only ngui is modified to use the new pipeRead/Write. the daemon would suffer too many changes, especially in tests, due to the global state. --- src/comm.zig | 38 ++++++++++++++++++++++++++++++++++++-- src/nd.zig | 4 +++- src/ngui.zig | 29 ++++++++++++----------------- src/test.zig | 24 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/comm.zig b/src/comm.zig index 987dfed..c8b8bbb 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -1,14 +1,48 @@ //! daemon/gui communication. //! the protocol is a simple TLV construct: MessageTag(u16), length(u64), json-marshalled Message; //! little endian. + const std = @import("std"); const json = std.json; const mem = std.mem; -const ByteArrayList = @import("types.zig").ByteArrayList; +const types = @import("types.zig"); const logger = std.log.scoped(.comm); +var plumb: struct { + a: std.mem.Allocator, + r: std.fs.File.Reader, + w: std.fs.File.Writer, + + fn pipeRead(self: @This()) !ParsedMessage { + return read(self.a, self.r); + } + + fn pipeWrite(self: @This(), m: Message) !void { + return write(self.a, self.w, m); + } +} = undefined; + +/// initializes a global comm pipe, making `pipeRead` and `pipeWrite` ready to use from any module. +/// a message sent with `pipeWrite` can be subsequently read with `pipeRead`. +pub fn initPipe(a: std.mem.Allocator, p: types.IoPipe) void { + plumb = .{ .a = a, .r = p.r.reader(), .w = p.w.writer() }; +} + +/// similar to `read` but uses a global pipe initialized with `initPipe`. +/// blocking call. +pub fn pipeRead() !ParsedMessage { + return plumb.pipeRead(); +} + +/// similar to `write` but uses a global pipe initialized with `initPipe`. +/// blocking but normally buffered. +/// callers must deallocate resources with ParsedMessage.deinit when done. +pub fn pipeWrite(m: Message) !void { + return plumb.pipeWrite(m); +} + /// common errors returned by read/write functions. pub const Error = error{ CommReadInvalidTag, @@ -210,7 +244,7 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !ParsedMessage { /// outputs the message msg using writer. /// all allocated resources are freed upon return. pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { - var data = ByteArrayList.init(allocator); + var data = types.ByteArrayList.init(allocator); defer data.deinit(); switch (msg) { .ping, .pong, .poweroff, .standby, .wakeup => {}, // zero length payload diff --git a/src/nd.zig b/src/nd.zig index ab7f467..befdf26 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -171,8 +171,10 @@ pub fn main() !void { // note: read(2) indicates file destriptor i/o is atomic linux since 3.14. const uireader = ngui.stdout.?.reader(); const uiwriter = ngui.stdin.?.writer(); + comm.initPipe(gpa, .{ .r = ngui.stdout.?, .w = ngui.stdin.? }); + // send UI a ping right away to make sure pipes are working, crash otherwise. - comm.write(gpa, uiwriter, .ping) catch |err| { + comm.pipeWrite(.ping) catch |err| { logger.err("comm.write ping: {any}", .{err}); return err; }; diff --git a/src/ngui.zig b/src/ngui.zig index 01682e2..bfce8a5 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -13,8 +13,6 @@ const symbol = @import("ui/symbol.zig"); const logger = std.log.scoped(.ngui); // these are auto-closed as soon as main fn terminates. -const stdin = std.io.getStdIn().reader(); -const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); extern "c" fn ui_update_network_status(text: [*:0]const u8, wifi_list: ?[*:0]const u8) void; @@ -130,7 +128,7 @@ export fn nm_check_idle_time(_: *lvgl.LvTimer) void { /// once all's done, the daemon will send a SIGTERM back to ngui. export fn nm_sys_shutdown() void { const msg = comm.Message.poweroff; - comm.write(gpa, stdout, msg) catch |err| logger.err("nm_sys_shutdown: {any}", .{err}); + comm.pipeWrite(msg) catch |err| logger.err("nm_sys_shutdown: {any}", .{err}); state = .alert; // prevent screen sleep wakeup.set(); // wake up from standby, if any } @@ -138,13 +136,13 @@ export fn nm_sys_shutdown() void { export fn nm_tab_settings_active() void { logger.info("starting wifi scan", .{}); const msg = comm.Message{ .get_network_report = .{ .scan = true } }; - comm.write(gpa, stdout, msg) catch |err| logger.err("nm_tab_settings_active: {any}", .{err}); + comm.pipeWrite(msg) catch |err| logger.err("nm_tab_settings_active: {any}", .{err}); } export fn nm_request_network_status(t: *lvgl.LvTimer) void { t.destroy(); const msg: comm.Message = .{ .get_network_report = .{ .scan = false } }; - comm.write(gpa, stdout, msg) catch |err| logger.err("nm_request_network_status: {any}", .{err}); + comm.pipeWrite(msg) catch |err| logger.err("nm_request_network_status: {any}", .{err}); } /// ssid and password args must not outlive this function. @@ -154,9 +152,7 @@ export fn nm_wifi_start_connect(ssid: [*:0]const u8, password: [*:0]const u8) vo .password = std.mem.span(password), } }; logger.info("connect to wifi [{s}]", .{msg.wifi_connect.ssid}); - comm.write(gpa, stdout, msg) catch |err| { - logger.err("comm.write: {any}", .{err}); - }; + comm.pipeWrite(msg) catch |err| logger.err("nm_wifi_start_connect: {any}", .{err}); } /// callers must hold ui mutex for the whole duration. @@ -225,12 +221,12 @@ fn commThreadLoop() void { /// the UI accordingly. /// holds ui mutex for most of the duration. fn commThreadLoopCycle() !void { - const msg = try comm.read(gpa, stdin); // blocking + const msg = try comm.pipeRead(); // blocking ui_mutex.lock(); // guards the state and all UI calls below defer ui_mutex.unlock(); switch (state) { .standby => switch (msg.value) { - .ping => try comm.write(gpa, stdout, comm.Message.pong), + .ping => try comm.pipeWrite(comm.Message.pong), .network_report, .bitcoind_report, .lightning_report, @@ -238,7 +234,7 @@ fn commThreadLoopCycle() !void { else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}), }, .active, .alert => switch (msg.value) { - .ping => try comm.write(gpa, stdout, comm.Message.pong), + .ping => try comm.pipeWrite(comm.Message.pong), .poweroff_progress => |rep| { ui.poweroff.updateStatus(rep) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); msg.deinit(); @@ -275,9 +271,7 @@ fn uiThreadLoop() void { .standby => { // go into a screen sleep mode due to no user activity wakeup.reset(); - comm.write(gpa, stdout, comm.Message.standby) catch |err| { - logger.err("comm.write standby: {any}", .{err}); - }; + comm.pipeWrite(comm.Message.standby) catch |err| logger.err("standby: {any}", .{err}); screen.sleep(&ui_mutex, &wakeup); // blocking // wake up due to touch screen activity or wakeup event is set @@ -286,9 +280,7 @@ fn uiThreadLoop() void { defer ui_mutex.unlock(); if (state == .standby) { state = .active; - comm.write(gpa, stdout, comm.Message.wakeup) catch |err| { - logger.err("comm.write wakeup: {any}", .{err}); - }; + comm.pipeWrite(comm.Message.wakeup) catch |err| logger.err("wakeup: {any}", .{err}); lvgl.resetIdle(); last_report.mu.lock(); @@ -369,6 +361,9 @@ pub fn main() anyerror!void { // the UI is unusable otherwise. tick_timer = try time.Timer.start(); + // initialize global nd/ngui pipe plumbing. + comm.initPipe(gpa, .{ .r = std.io.getStdIn(), .w = std.io.getStdOut() }); + // initalizes display, input driver and finally creates the user interface. ui.init() catch |err| { logger.err("ui.init: {any}", .{err}); diff --git a/src/test.zig b/src/test.zig index b5744c5..f158045 100644 --- a/src/test.zig +++ b/src/test.zig @@ -2,6 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const nif = @import("nif"); +const comm = @import("comm.zig"); +const types = @import("types.zig"); + comptime { if (!builtin.is_test) @compileError("test-only module"); } @@ -19,6 +22,27 @@ export fn lv_disp_get_inactive_time(disp: *opaque {}) u32 { return 0; } +var global_gpa_state: std.heap.GeneralPurposeAllocator(.{}) = undefined; +var global_gpa: std.mem.Allocator = undefined; +var initGlobalOnce = std.once(initGlobalFn); + +/// initializes globals like the `comm.initPipe`. +/// can be called from any test, multiple times: the initialization is enforced to happen only once. +/// safe for concurrent use. needs not deinit'ing: resources are released by the OS +/// when the test binary terminates. +pub fn initGlobal() void { + initGlobalOnce.call(); +} + +fn initGlobalFn() void { + global_gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + global_gpa = global_gpa_state.allocator(); + var pipe = types.IoPipe.create() catch |err| { + std.debug.panic("IoPipe.create: {any}", .{err}); + }; + comm.initPipe(global_gpa, pipe); +} + /// TestTimer always reports the same fixed value. pub const TestTimer = struct { value: u64, -- 2.41.0 From 664c75a9c9e68e51ccdaed5561251571a703813c Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 28 Sep 2023 16:35:41 +0200 Subject: [PATCH 3/7] nd: add a persistent configuration support the config will be used by the daemon to store user choices across reboots. closes https://git.qcode.ch/nakamochi/ndg/issues/6 --- src/nd.zig | 31 ++++- src/nd/Config.zig | 306 ++++++++++++++++++++++++++++++++++++++++++++++ src/nd/Daemon.zig | 47 +++++-- src/test.zig | 40 ++++++ 4 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 src/nd/Config.zig diff --git a/src/nd.zig b/src/nd.zig index befdf26..5170141 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -17,7 +17,7 @@ const stderr = std.io.getStdErr().writer(); /// prints usage help text to stderr. fn usage(prog: []const u8) !void { try stderr.print( - \\usage: {s} -gui path/to/ngui -gui-user username -wpa path + \\usage: {[prog]s} -gui path/to/ngui -gui-user username -wpa path [-conf {[confpath]s}] \\ \\nd is a short for nakamochi daemon. \\the daemon executes ngui as a child process and runs until @@ -25,16 +25,21 @@ fn usage(prog: []const u8) !void { \\ \\nd logs messages to stderr. \\ - , .{prog}); + , .{ .prog = prog, .confpath = NdArgs.defaultConf }); } /// nd program flags. see usage. const NdArgs = struct { + conf: ?[:0]const u8 = null, gui: ?[:0]const u8 = null, gui_user: ?[:0]const u8 = null, wpa: ?[:0]const u8 = null, + /// default path for nd config file, read or created during startup. + const defaultConf = "/home/uiuser/conf.json"; + fn deinit(self: @This(), allocator: std.mem.Allocator) void { + if (self.conf) |p| allocator.free(p); if (self.gui) |p| allocator.free(p); if (self.gui_user) |p| allocator.free(p); if (self.wpa) |p| allocator.free(p); @@ -51,12 +56,18 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { var lastarg: enum { none, + conf, gui, gui_user, wpa, } = .none; while (args.next()) |a| { switch (lastarg) { + .conf => { + flags.conf = try gpa.dupeZ(u8, a); + lastarg = .none; + continue; + }, .gui => { flags.gui = try gpa.dupeZ(u8, a); lastarg = .none; @@ -80,6 +91,8 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { } else if (std.mem.eql(u8, a, "-v")) { try stderr.print("{any}\n", .{buildopts.semver}); std.process.exit(0); + } else if (std.mem.eql(u8, a, "-conf")) { + lastarg = .conf; } else if (std.mem.eql(u8, a, "-gui")) { lastarg = .gui; } else if (std.mem.eql(u8, a, "-gui-user")) { @@ -91,11 +104,14 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { return error.UnknownArgName; } } - if (lastarg != .none) { logger.err("invalid arg: {s} requires a value", .{@tagName(lastarg)}); return error.MissinArgValue; } + + if (flags.conf == null) { + flags.conf = NdArgs.defaultConf; + } if (flags.gui == null) { logger.err("missing -gui arg", .{}); return error.MissingGuiFlag; @@ -130,6 +146,7 @@ pub fn main() !void { logger.err("memory leaks detected", .{}); }; const gpa = gpa_state.allocator(); + // parse program args first thing and fail fast if invalid const args = try parseArgs(gpa); defer args.deinit(gpa); @@ -179,7 +196,13 @@ pub fn main() !void { return err; }; - var nd = try Daemon.init(gpa, uireader, uiwriter, args.wpa.?); + var nd = try Daemon.init(.{ + .allocator = gpa, + .confpath = args.conf.?, + .uir = uireader, + .uiw = uiwriter, + .wpa = args.wpa.?, + }); defer nd.deinit(); try nd.start(); diff --git a/src/nd/Config.zig b/src/nd/Config.zig new file mode 100644 index 0000000..7a68052 --- /dev/null +++ b/src/nd/Config.zig @@ -0,0 +1,306 @@ +//! ndg persistent configuration, loaded from and stored to disk in JSON format. +//! the structure is defined in `Data`. + +const std = @import("std"); + +const logger = std.log.scoped(.config); + +// default values +const SYSUPDATES_CRON_SCRIPT_PATH = "/etc/cron.hourly/sysupdate"; +const SYSUPDATES_RUN_SCRIPT_NAME = "update.sh"; +const SYSUPDATES_RUN_SCRIPT_PATH = "/ssd/sysupdates/" ++ SYSUPDATES_RUN_SCRIPT_NAME; + +arena: *std.heap.ArenaAllocator, // data is allocated here +confpath: []const u8, // fs path to where data is persisted + +mu: std.Thread.RwLock = .{}, +data: Data, + +/// top struct stored on disk. +/// access with `safeReadOnly` or lock/unlock `mu`. +pub const Data = struct { + syschannel: SysupdatesChannel, + syscronscript: []const u8, + sysrunscript: []const u8, +}; + +/// enums must match git branches in https://git.qcode.ch/nakamochi/sysupdates. +pub const SysupdatesChannel = enum { + master, // stable + dev, // edge +}; + +const Config = @This(); + +/// confpath must outlive returned Config instance. +pub fn init(allocator: std.mem.Allocator, confpath: []const u8) !Config { + var arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(allocator); + errdefer { + arena.deinit(); + allocator.destroy(arena); + } + return .{ + .arena = arena, + .data = try initData(arena.allocator(), confpath), + .confpath = confpath, + }; +} + +pub fn deinit(self: Config) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); +} + +fn initData(allocator: std.mem.Allocator, filepath: []const u8) !Data { + const maxsize: usize = 1 << 20; // 1Mb JSON conf file size should be more than enough + const bytes = std.fs.cwd().readFileAlloc(allocator, filepath, maxsize) catch |err| switch (err) { + error.FileNotFound => return inferData(), + else => return err, + }; + defer allocator.free(bytes); + const jopt = std.json.ParseOptions{ .ignore_unknown_fields = true, .allocate = .alloc_always }; + return std.json.parseFromSliceLeaky(Data, allocator, bytes, jopt) catch |err| { + logger.err("initData: {any}", .{err}); + return error.BadConfigSyntax; + }; +} + +fn inferData() Data { + return .{ + .syschannel = inferSysupdatesChannel(SYSUPDATES_CRON_SCRIPT_PATH), + .syscronscript = SYSUPDATES_CRON_SCRIPT_PATH, + .sysrunscript = SYSUPDATES_RUN_SCRIPT_PATH, + }; +} + +fn inferSysupdatesChannel(cron_script_path: []const u8) SysupdatesChannel { + var buf: [1024]u8 = undefined; + const bytes = std.fs.cwd().readFile(cron_script_path, &buf) catch return .master; + var it = std.mem.tokenizeScalar(u8, bytes, '\n'); + // looking for "/ssd/sysupdates/update.sh ?" where may be in quotes + const needle = SYSUPDATES_RUN_SCRIPT_NAME; + while (it.next()) |line| { + if (std.mem.indexOf(u8, line, needle)) |i| { + var s = line[i + needle.len ..]; + s = std.mem.trim(u8, s, " \n'\""); + return std.meta.stringToEnum(SysupdatesChannel, s) orelse .master; + } + } + return .master; +} + +/// calls F while holding a readonly lock and passes on F's result as is. +pub fn safeReadOnly(self: *Config, comptime F: anytype) @typeInfo(@TypeOf(F)).Fn.return_type.? { + self.mu.lockShared(); + defer self.mu.unlockShared(); + return F(self.data); +} + +/// stores current `Config.data` to disk, into `Config.confpath`. +pub fn dump(self: *Config) !void { + self.mu.lock(); + defer self.mu.unlock(); + return self.dumpUnguarded(); +} + +fn dumpUnguarded(self: Config) !void { + const allocator = self.arena.child_allocator; + const opt = .{ .mode = 0o600 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), self.confpath, opt); + defer file.destroy(); + try std.json.stringify(self.data, .{ .whitespace = .indent_2 }, file.writer()); + try file.finish(); +} + +/// when run is set, executes the update after changing the channel. +/// executing an update may terminate and start a new nd+ngui instance. +pub fn switchSysupdates(self: *Config, chan: SysupdatesChannel, opt: struct { run: bool }) !void { + self.mu.lock(); + defer self.mu.unlock(); + + self.data.syschannel = chan; + try self.dumpUnguarded(); + + try self.genSysupdatesCronScript(); + if (opt.run) { + try runSysupdates(self.arena.child_allocator, self.data.syscronscript); + } +} + +/// caller must hold self.mu. +fn genSysupdatesCronScript(self: Config) !void { + if (self.data.sysrunscript.len == 0) { + return error.NoSysRunScriptPath; + } + const allocator = self.arena.child_allocator; + const opt = .{ .mode = 0o755 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), self.data.syscronscript, opt); + defer file.destroy(); + + const script = + \\#!/bin/sh + \\exec {[path]s} "{[chan]s}" + ; + try std.fmt.format(file.writer(), script, .{ + .path = self.data.sysrunscript, + .chan = @tagName(self.data.syschannel), + }); + try file.finish(); +} + +/// the scriptpath is typically the cronjob script, not a SYSUPDATES_RUN_SCRIPT +/// because the latter requires command args which is what cron script does. +fn runSysupdates(allocator: std.mem.Allocator, scriptpath: []const u8) !void { + const res = try std.ChildProcess.exec(.{ .allocator = allocator, .argv = &.{scriptpath} }); + defer { + allocator.free(res.stdout); + allocator.free(res.stderr); + } + switch (res.term) { + .Exited => |code| if (code != 0) { + logger.err("runSysupdates: {s} exit code = {d}; stderr: {s}", .{ scriptpath, code, res.stderr }); + return error.RunSysupdatesBadExit; + }, + else => { + logger.err("runSysupdates: {s} term = {any}", .{ scriptpath, res.term }); + return error.RunSysupdatesBadTerm; + }, + } +} + +test "init existing" { + const t = std.testing; + const tt = @import("../test.zig"); + + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + try tmp.dir.writeFile("conf.json", + \\{ + \\"syschannel": "dev", + \\"syscronscript": "/cron/sysupdates.sh", + \\"sysrunscript": "/sysupdates/run.sh" + \\} + ); + const conf = try init(t.allocator, try tmp.join(&.{"conf.json"})); + defer conf.deinit(); + try t.expectEqual(SysupdatesChannel.dev, conf.data.syschannel); + try t.expectEqualStrings("/cron/sysupdates.sh", conf.data.syscronscript); + try t.expectEqualStrings("/sysupdates/run.sh", conf.data.sysrunscript); +} + +test "init null" { + const t = std.testing; + + const conf = try init(t.allocator, "/non/existent/config/file"); + defer conf.deinit(); + try t.expectEqual(SysupdatesChannel.master, conf.data.syschannel); + try t.expectEqualStrings(SYSUPDATES_CRON_SCRIPT_PATH, conf.data.syscronscript); + try t.expectEqualStrings(SYSUPDATES_RUN_SCRIPT_PATH, conf.data.sysrunscript); +} + +test "dump" { + const t = std.testing; + const tt = @import("../test.zig"); + + // the arena used only for the config instance. + // purposefully skip arena deinit - expecting no mem leaks in conf usage here. + var conf_arena = std.heap.ArenaAllocator.init(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + const confpath = try tmp.join(&.{"conf.json"}); + var conf = Config{ + .arena = &conf_arena, + .confpath = confpath, + .data = .{ + .syschannel = .master, + .syscronscript = "cronscript.sh", + .sysrunscript = "runscript.sh", + }, + }; + // purposefully skip conf.deinit() - expecting no leaking allocations in conf.dump. + try conf.dump(); + + const parsed = try testLoadConfigData(confpath); + defer parsed.deinit(); + try t.expectEqual(SysupdatesChannel.master, parsed.value.syschannel); + try t.expectEqualStrings("cronscript.sh", parsed.value.syscronscript); + try t.expectEqualStrings("runscript.sh", parsed.value.sysrunscript); +} + +test "switch sysupdates and infer" { + const t = std.testing; + const tt = @import("../test.zig"); + + // the arena used only for the config instance. + // purposefully skip arena deinit - expecting no mem leaks in conf usage here. + var conf_arena = std.heap.ArenaAllocator.init(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + try tmp.dir.writeFile("conf.json", ""); + const confpath = try tmp.join(&.{"conf.json"}); + const cronscript = try tmp.join(&.{"cronscript.sh"}); + var conf = Config{ + .arena = &conf_arena, + .confpath = confpath, + .data = .{ + .syschannel = .master, + .syscronscript = cronscript, + .sysrunscript = SYSUPDATES_RUN_SCRIPT_PATH, + }, + }; + // purposefully skip conf.deinit() - expecting no leaking allocations. + + try conf.switchSysupdates(.dev, .{ .run = false }); + const parsed = try testLoadConfigData(confpath); + defer parsed.deinit(); + try t.expectEqual(SysupdatesChannel.dev, parsed.value.syschannel); + try t.expectEqual(SysupdatesChannel.dev, inferSysupdatesChannel(cronscript)); +} + +test "switch sysupdates with .run=true" { + const t = std.testing; + const tt = @import("../test.zig"); + + // no arena deinit: expecting Config to + var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + const runscript = "runscript.sh"; + try tmp.dir.writeFile(runscript, + \\#!/bin/sh + \\printf "$1" > "$(dirname "$0")/success" + ); + { + const file = try tmp.dir.openFile(runscript, .{}); + defer file.close(); + try file.chmod(0o755); + } + var conf = Config{ + .arena = conf_arena, + .confpath = try tmp.join(&.{"conf.json"}), + .data = .{ + .syschannel = .master, + .syscronscript = try tmp.join(&.{"cronscript.sh"}), + .sysrunscript = try tmp.join(&.{runscript}), + }, + }; + defer conf.deinit(); + + try conf.switchSysupdates(.dev, .{ .run = true }); + var buf: [10]u8 = undefined; + try t.expectEqualStrings("dev", try tmp.dir.readFile("success", &buf)); +} + +fn testLoadConfigData(path: []const u8) !std.json.Parsed(Data) { + const allocator = std.testing.allocator; + const bytes = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 20); + defer allocator.free(bytes); + const jopt = .{ .ignore_unknown_fields = true, .allocate = .alloc_always }; + return try std.json.parseFromSlice(Data, allocator, bytes, jopt); +} diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 2a91ef2..144866c 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -17,6 +17,7 @@ const time = std.time; const bitcoindrpc = @import("../bitcoindrpc.zig"); const comm = @import("../comm.zig"); +const Config = @import("Config.zig"); const lndhttp = @import("../lndhttp.zig"); const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); @@ -26,6 +27,7 @@ const types = @import("../types.zig"); const logger = std.log.scoped(.daemon); allocator: mem.Allocator, +conf: Config, uireader: std.fs.File.Reader, // ngui stdout uiwriter: std.fs.File.Writer, // ngui stdin wpa_ctrl: types.WpaControl, // guarded by mu once start'ed @@ -68,24 +70,36 @@ services: []SysService = &.{}, const Daemon = @This(); +const InitOpt = struct { + allocator: std.mem.Allocator, + confpath: []const u8, + uir: std.fs.File.Reader, + uiw: std.fs.File.Writer, + wpa: [:0]const u8, +}; + /// initializes a daemon instance using the provided GUI stdout reader and stdin writer, /// and a filesystem path to WPA control socket. /// callers must deinit when done. -pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, wpa: [:0]const u8) !Daemon { - var svlist = std.ArrayList(SysService).init(a); +pub fn init(opt: InitOpt) !Daemon { + var svlist = std.ArrayList(SysService).init(opt.allocator); errdefer { for (svlist.items) |*sv| sv.deinit(); svlist.deinit(); } // the order is important. when powering off, the services are shut down // in the same order appended here. - try svlist.append(SysService.init(a, "lnd", .{ .stop_wait_sec = 600 })); - try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 })); + try svlist.append(SysService.init(opt.allocator, "lnd", .{ .stop_wait_sec = 600 })); + try svlist.append(SysService.init(opt.allocator, "bitcoind", .{ .stop_wait_sec = 600 })); + + const conf = try Config.init(opt.allocator, opt.confpath); + errdefer conf.deinit(); return .{ - .allocator = a, - .uireader = r, - .uiwriter = w, - .wpa_ctrl = try types.WpaControl.open(wpa), + .allocator = opt.allocator, + .conf = conf, + .uireader = opt.uir, + .uiwriter = opt.uiw, + .wpa_ctrl = try types.WpaControl.open(opt.wpa), .state = .stopped, .services = try svlist.toOwnedSlice(), // send a network report right at start without wifi scan to make it faster. @@ -104,6 +118,7 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, /// releases all associated resources. /// the daemon must be stop'ed and wait'ed before deiniting. pub fn deinit(self: *Daemon) void { + defer self.conf.deinit(); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); for (self.services) |*sv| { sv.deinit(); @@ -725,7 +740,13 @@ test "start-stop" { const t = std.testing; const pipe = try types.IoPipe.create(); - var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); + var daemon = try Daemon.init(.{ + .allocator = t.allocator, + .confpath = "/unused.json", + .uir = pipe.reader(), + .uiw = pipe.writer(), + .wpa = "/dev/null", + }); daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; @@ -770,7 +791,13 @@ test "start-poweroff" { const gui_stdin = try types.IoPipe.create(); const gui_stdout = try types.IoPipe.create(); const gui_reader = gui_stdin.reader(); - var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); + var daemon = try Daemon.init(.{ + .allocator = arena, + .confpath = "/unused.json", + .uir = gui_stdout.reader(), + .uiw = gui_stdin.writer(), + .wpa = "/dev/null", + }); daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; diff --git a/src/test.zig b/src/test.zig index f158045..b885375 100644 --- a/src/test.zig +++ b/src/test.zig @@ -43,6 +43,46 @@ fn initGlobalFn() void { comm.initPipe(global_gpa, pipe); } +pub const TempDir = struct { + abspath: []const u8, + dir: std.fs.Dir, + + tmp: std.testing.TmpDir, + arena: *std.heap.ArenaAllocator, + + pub fn create() !TempDir { + var arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); + errdefer { + arena.deinit(); + std.testing.allocator.destroy(arena); + } + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + return .{ + .abspath = try tmp.dir.realpathAlloc(arena.allocator(), "."), + .dir = tmp.dir, + .tmp = tmp, + .arena = arena, + }; + } + + pub fn cleanup(self: *TempDir) void { + self.tmp.cleanup(); + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); + } + + pub fn join(self: *TempDir, paths: []const []const u8) ![]const u8 { + var list = std.ArrayList([]const u8).init(self.arena.child_allocator); + defer list.deinit(); + try list.append(self.abspath); + try list.appendSlice(paths); + return std.fs.path.join(self.arena.allocator(), list.items); + } +}; + /// TestTimer always reports the same fixed value. pub const TestTimer = struct { value: u64, -- 2.41.0 From 094f6406e3be882057bc5f0f506f5ab519171e9e Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 3 Oct 2023 12:07:24 +0200 Subject: [PATCH 4/7] ui/lvgl: add a couple convenience methods for next commits small things like show/hide and enable/disable. also, the text of the button can now be set directly using its label field. --- src/ui/lvgl.zig | 53 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 0c5cadb..75ae62c 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -1,13 +1,14 @@ -///! LVGL types in zig. -/// -/// lv_xxx functions are directly linked against LVGL lib. -/// other functions, without the lv_ prefix are defined in zig and provide type safety -/// and extra functionality, sometimes composed of multiple calls to lv_xxx. -/// -/// nm_xxx functions defined here are exported with "c" convention to allow -/// calls from C code. -/// -/// the module usage must be started with a call to init. +//! LVGL types in zig. +//! +//! lv_xxx functions are directly linked against LVGL lib. +//! other functions, without the lv_ prefix are defined in zig and provide type safety +//! and extra functionality, sometimes composed of multiple calls to lv_xxx. +//! +//! nm_xxx functions defined here are exported with "c" convention to allow +//! calls from C code. +//! +//! the module usage must be started with a call to init. + const std = @import("std"); const c = @cImport({ @cInclude("lvgl/lvgl.h"); @@ -438,6 +439,24 @@ pub const WidgetMethods = struct { pub fn setBackgroundColor(self: anytype, v: Color, sel: LvStyle.Selector) void { lv_obj_set_style_bg_color(self.lvobj, v, sel.value()); } + + pub fn show(self: anytype) void { + self.clearFlag(.hidden); + } + + pub fn hide(self: anytype) void { + self.setFlag(.hidden); + } +}; + +pub const InteractiveMethods = struct { + pub fn enable(self: anytype) void { + lv_obj_clear_state(self.lvobj, c.LV_STATE_DISABLED); + } + + pub fn disable(self: anytype) void { + lv_obj_add_state(self.lvobj, c.LV_STATE_DISABLED); + } }; /// a base layer object which all the other UI elements are placed onto. @@ -669,7 +688,12 @@ pub const Label = struct { lv_label_set_text(self.lvobj, text); } - /// formats a new label text. + /// sets label text without heap alloc but assumes text outlives the label obj. + pub fn setTextStatic(self: Label, text: [*:0]const u8) void { + lv_label_set_text_static(self.lvobj, text); + } + + /// formats a new label text and passes it on to `setText`. /// the buffer can be dropped once the function returns. pub fn setTextFmt(self: Label, buf: []u8, comptime format: []const u8, args: anytype) !void { var s = try std.fmt.bufPrintZ(buf, format, args); @@ -685,15 +709,16 @@ pub const Label = struct { /// a button with a child Label. pub const TextButton = struct { lvobj: *LvObj, - label: *LvObj, + label: Label, pub usingnamespace BaseObjMethods; pub usingnamespace WidgetMethods; + pub usingnamespace InteractiveMethods; pub fn new(parent: anytype, text: [*:0]const u8) !TextButton { const btn = lv_btn_create(parent.lvobj) orelse return error.OutOfMemory; const label = try Label.new(Container{ .lvobj = btn }, text, .{ .long_mode = .dot, .pos = .center }); - return .{ .lvobj = btn, .label = label.lvobj }; + return .{ .lvobj = btn, .label = label }; } }; @@ -964,6 +989,8 @@ extern fn lv_obj_del(obj: *LvObj) void; /// deletes children of the obj. extern fn lv_obj_clean(obj: *LvObj) void; +extern fn lv_obj_add_state(obj: *LvObj, c.lv_state_t) void; +extern fn lv_obj_clear_state(obj: *LvObj, c.lv_state_t) void; extern fn lv_obj_add_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; extern fn lv_obj_clear_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; extern fn lv_obj_has_flag(obj: *LvObj, v: c.lv_obj_flag_t) bool; -- 2.41.0 From e8838b3eaf0c26b4ff790e18b38d769c6b8e6bba Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 3 Oct 2023 12:10:49 +0200 Subject: [PATCH 5/7] ui/lvgl: add an optional spinner to card widget some cards will need to show a nondeterministic progress. the spinner in the top right corner does exactly that, controlled via the new spin function. the default is no spinner. --- src/ui/bitcoin.zig | 6 +++--- src/ui/lightning.zig | 6 +++--- src/ui/lvgl.zig | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/ui/bitcoin.zig b/src/ui/bitcoin.zig index efda243..c9423d5 100644 --- a/src/ui/bitcoin.zig +++ b/src/ui/bitcoin.zig @@ -45,7 +45,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { // blockchain section { - const card = try lvgl.Card.new(parent, "BLOCKCHAIN"); + const card = try lvgl.Card.new(parent, "BLOCKCHAIN", .{}); const row = try lvgl.FlexLayout.new(card, .row, .{}); row.setWidth(lvgl.sizePercent(100)); row.setHeightToContent(); @@ -69,7 +69,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { } // balance section { - const card = try lvgl.Card.new(parent, "ON-CHAIN BALANCE"); + const card = try lvgl.Card.new(parent, "ON-CHAIN BALANCE", .{}); const row = try lvgl.FlexLayout.new(card, .row, .{}); row.setWidth(lvgl.sizePercent(100)); row.setHeightToContent(); @@ -93,7 +93,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { } // mempool section { - const card = try lvgl.Card.new(parent, "MEMPOOL"); + const card = try lvgl.Card.new(parent, "MEMPOOL", .{}); const row = try lvgl.FlexLayout.new(card, .row, .{}); row.setWidth(lvgl.sizePercent(100)); row.clearFlag(.scrollable); diff --git a/src/ui/lightning.zig b/src/ui/lightning.zig index 3b7100a..a12abea 100644 --- a/src/ui/lightning.zig +++ b/src/ui/lightning.zig @@ -39,7 +39,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { // info section { - const card = try lvgl.Card.new(parent, "INFO"); + const card = try lvgl.Card.new(parent, "INFO", .{}); const row = try lvgl.FlexLayout.new(card, .row, .{}); row.setHeightToContent(); row.setWidth(lvgl.sizePercent(100)); @@ -63,7 +63,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { } // balance section { - const card = try lvgl.Card.new(parent, "BALANCE"); + const card = try lvgl.Card.new(parent, "BALANCE", .{}); const row = try lvgl.FlexLayout.new(card, .row, .{}); row.setWidth(lvgl.sizePercent(100)); row.clearFlag(.scrollable); @@ -89,7 +89,7 @@ pub fn initTabPanel(cont: lvgl.Container) !void { } // channels section { - const card = try lvgl.Card.new(parent, "CHANNELS"); + const card = try lvgl.Card.new(parent, "CHANNELS", .{}); tab.channels_cont = try lvgl.FlexLayout.new(card, .column, .{}); tab.channels_cont.setHeightToContent(); tab.channels_cont.setWidth(lvgl.sizePercent(100)); diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 75ae62c..2788afd 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -623,17 +623,44 @@ pub const Window = struct { pub const Card = struct { lvobj: *LvObj, title: Label, + spinner: ?Spinner = null, pub usingnamespace BaseObjMethods; pub usingnamespace WidgetMethods; - pub fn new(parent: anytype, title: [*:0]const u8) !Card { + pub const Opt = struct { + /// embeds a spinner in the top-right corner; control with spin fn. + spinner: bool = false, + }; + + pub fn new(parent: anytype, title: [*:0]const u8, opt: Opt) !Card { const flex = (try Container.new(parent)).flex(.column, .{}); flex.setHeightToContent(); flex.setWidth(sizePercent(100)); - const tl = try Label.new(flex, title, .{}); - tl.addStyle(nm_style_title(), .{}); - return .{ .lvobj = flex.lvobj, .title = tl }; + var card: Card = .{ .lvobj = flex.lvobj, .title = undefined }; + + if (opt.spinner) { + const row = try FlexLayout.new(flex, .row, .{}); + row.setWidth(sizePercent(100)); + row.setHeightToContent(); + card.title = try Label.new(row, title, .{}); + card.title.flexGrow(1); + card.spinner = try Spinner.new(row); + card.spinner.?.flexGrow(0); + card.spin(.off); + } else { + card.title = try Label.new(flex, title, .{}); + } + card.title.addStyle(nm_style_title(), .{}); + + return card; + } + + pub fn spin(self: Card, onoff: enum { on, off }) void { + if (self.spinner) |p| switch (onoff) { + .on => p.show(), + .off => p.hide(), + }; } }; -- 2.41.0 From 1d8d67a987b93bfca76ecfbe72272794afb13049 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 3 Oct 2023 12:14:07 +0200 Subject: [PATCH 6/7] ui/lvgl: add a dropdown widget --- src/ui/lvgl.zig | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 2788afd..195eeb3 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -790,6 +790,76 @@ pub const Bar = struct { } }; +pub const Dropdown = struct { + lvobj: *LvObj, + + pub usingnamespace BaseObjMethods; + pub usingnamespace WidgetMethods; + pub usingnamespace InteractiveMethods; + + /// creates a new dropdown with the options provided in ostr, a '\n' delimited list. + /// the options are alloc-duped by LVGL and free'd when the dropdown is destroy'ed. + /// LVGL's lv_dropdown drawing supports up to 128 chars. + pub fn new(parent: anytype, ostr: [*:0]const u8) !Dropdown { + const o = lv_dropdown_create(parent.lvobj) orelse return error.OutOfMemory; + lv_dropdown_set_options(o, ostr); + return .{ .lvobj = o }; + } + + /// same as new except options are not alloc-duplicated. the ostr must live at least + /// as long as the dropdown object. + pub fn newStatic(parent: anytype, ostr: [*:0]const u8) !Dropdown { + const o = lv_dropdown_create(parent.lvobj) orelse return error.OutOfMemory; + lv_dropdown_set_options_static(o, ostr); + return .{ .lvobj = o }; + } + + /// once set, the text is shown regardless of the selected option until removed + /// with `clearText`. the text must outlive the dropdown object. + pub fn setText(self: Dropdown, text: [*:0]const u8) void { + lv_dropdown_set_text(self.lvobj, text); + } + + /// deletes the text set with `setText`. + pub fn clearText(self: Dropdown) void { + lv_dropdown_set_text(self.lvobj, null); + } + + /// the options are alloc-duped by LVGL and free'd when the dropdown is destroy'ed. + /// LVGL's lv_dropdown drawing supports up to 128 chars. + pub fn setOptions(self: Dropdown, opts: [*:0]const u8) void { + lv_dropdown_set_options(self.lvobj, opts); + } + + pub fn clearOptions(self: Dropdown) void { + lv_dropdown_clear_options(self.lvobj); + } + + pub fn addOption(self: Dropdown, opt: [*:0]const u8, pos: u32) void { + lv_dropdown_add_option(self.lvobj, opt, pos); + } + + /// idx is 0-based index of the option item provided to new or newStatic. + pub fn setSelected(self: Dropdown, idx: u16) void { + lv_dropdown_set_selected(self.lvobj, idx); + } + + pub fn getSelected(self: Dropdown) u16 { + return lv_dropdown_get_selected(self.lvobj); + } + + /// returns selected option as a slice of the buf. + /// LVGL's lv_dropdown drawing supports up to 128 chars. + pub fn getSelectedStr(self: Dropdown, buf: []u8) [:0]const u8 { + const buflen: u32 = @min(buf.len, std.math.maxInt(u32)); + lv_dropdown_get_selected_str(self.lvobj, buf.ptr, buflen); + buf[buf.len - 1] = 0; + const cbuf: [*c]u8 = buf.ptr; + const name: [:0]const u8 = std.mem.span(cbuf); + return name; + } +}; + /// represents lv_obj_t type in C. pub const LvObj = opaque { /// feature-flags controlling object's behavior. @@ -1045,6 +1115,16 @@ extern fn lv_label_set_text_static(label: *LvObj, text: [*:0]const u8) void; extern fn lv_label_set_long_mode(label: *LvObj, mode: c.lv_label_long_mode_t) void; extern fn lv_label_set_recolor(label: *LvObj, enable: bool) void; +extern fn lv_dropdown_create(parent: *LvObj) ?*LvObj; +extern fn lv_dropdown_set_text(obj: *LvObj, text: ?[*:0]const u8) void; +extern fn lv_dropdown_set_options(obj: *LvObj, options: [*:0]const u8) void; +extern fn lv_dropdown_set_options_static(obj: *LvObj, options: [*:0]const u8) void; +extern fn lv_dropdown_add_option(obj: *LvObj, option: [*:0]const u8, pos: u32) void; +extern fn lv_dropdown_clear_options(obj: *LvObj) void; +extern fn lv_dropdown_set_selected(obj: *LvObj, idx: u16) void; +extern fn lv_dropdown_get_selected(obj: *const LvObj) u16; +extern fn lv_dropdown_get_selected_str(obj: *const LvObj, buf: [*]u8, bufsize: u32) void; + extern fn lv_spinner_create(parent: *LvObj, speed_ms: u32, arc_deg: u32) ?*LvObj; extern fn lv_bar_create(parent: *LvObj) ?*LvObj; -- 2.41.0 From c82848d1862d796c485db64b983a0effc64d270a Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 3 Oct 2023 12:28:15 +0200 Subject: [PATCH 7/7] nd,ngui: let users switch sysupdates channel from the UI at the moment, there are two channels: edge and stable. this builds up on few previous commits, most notably the persistent configuration storage. --- src/comm.zig | 79 +++++++++++++++++++------- src/nd/Daemon.zig | 56 ++++++++++++++++++ src/ngui.zig | 3 + src/test/guiplay.zig | 12 ++++ src/ui/c/ui.c | 21 ++++++- src/ui/settings.zig | 131 +++++++++++++++++++++++++++++++++++++++++++ src/ui/symbol.zig | 1 + src/ui/ui.zig | 14 ++++- 8 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 src/ui/settings.zig diff --git a/src/comm.zig b/src/comm.zig index c8b8bbb..e1e5c91 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -50,6 +50,32 @@ pub const Error = error{ CommWriteTooLarge, }; +/// it is important to preserve ordinal values for future compatiblity, +/// especially when nd and gui may temporary diverge in their implementations. +pub const MessageTag = enum(u16) { + ping = 0x01, + pong = 0x02, + poweroff = 0x03, + wifi_connect = 0x04, + network_report = 0x05, + get_network_report = 0x06, + // ngui -> nd: screen timeout, no user activity; no reply + standby = 0x07, + // ngui -> nd: resume screen due to user touch; no reply + wakeup = 0x08, + // nd -> ngui: reports poweroff progress + poweroff_progress = 0x09, + // nd -> ngui: bitcoin core daemon status report + bitcoind_report = 0x0a, + // nd -> ngui: lnd status and stats report + lightning_report = 0x0b, + // ngui -> nd: switch sysupdates channel + switch_sysupdates = 0x0c, + // nd -> ngui: all ndg settings + settings = 0x0d, + // next: 0x0e +}; + /// daemon and gui exchange messages of this type. pub const Message = union(MessageTag) { ping: void, @@ -63,6 +89,8 @@ pub const Message = union(MessageTag) { poweroff_progress: PoweroffProgress, bitcoind_report: BitcoinReport, lightning_report: LightningReport, + switch_sysupdates: SysupdatesChan, + settings: Settings, pub const WifiConnect = struct { ssid: []const u8, @@ -159,28 +187,17 @@ pub const Message = union(MessageTag) { }, }, }; -}; -/// it is important to preserve ordinal values for future compatiblity, -/// especially when nd and gui may temporary diverge in their implementations. -pub const MessageTag = enum(u16) { - ping = 0x01, - pong = 0x02, - poweroff = 0x03, - wifi_connect = 0x04, - network_report = 0x05, - get_network_report = 0x06, - // ngui -> nd: screen timeout, no user activity; no reply - standby = 0x07, - // ngui -> nd: resume screen due to user touch; no reply - wakeup = 0x08, - // nd -> ngui: reports poweroff progress - poweroff_progress = 0x09, - // nd -> ngui: bitcoin core daemon status report - bitcoind_report = 0x0a, - // nd -> ngui: lnd status and stats report - lightning_report = 0x0b, - // next: 0x0c + pub const SysupdatesChan = enum { + stable, // master branch in sysupdates + edge, // dev branch in sysupdates + }; + + pub const Settings = struct { + sysupdates: struct { + channel: SysupdatesChan, + }, + }; }; /// the return value type from `read` fn. @@ -254,6 +271,8 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()), .bitcoind_report => try json.stringify(msg.bitcoind_report, .{}, data.writer()), .lightning_report => try json.stringify(msg.lightning_report, .{}, data.writer()), + .switch_sysupdates => try json.stringify(msg.switch_sysupdates, .{}, data.writer()), + .settings => try json.stringify(msg.settings, .{}, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; @@ -320,6 +339,24 @@ test "write" { try t.expectEqualStrings(js.items, buf.items); } +test "write enum" { + const t = std.testing; + + var buf = std.ArrayList(u8).init(t.allocator); + defer buf.deinit(); + const msg = Message{ .switch_sysupdates = .edge }; + try write(t.allocator, buf.writer(), msg); + + const payload = "\"edge\""; + var js = std.ArrayList(u8).init(t.allocator); + defer js.deinit(); + try js.writer().writeIntLittle(u16, @intFromEnum(msg)); + try js.writer().writeIntLittle(u64, payload.len); + try js.appendSlice(payload); + + try t.expectEqualStrings(js.items, buf.items); +} + test "write/read void tags" { const t = std.testing; diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 144866c..6e9ac23 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -48,6 +48,8 @@ comm_thread: ?std.Thread = null, poweroff_thread: ?std.Thread = null, want_stop: bool = false, // tells daemon main loop to quit +// send all settings to ngui +want_settings: bool = false, // network flags want_network_report: bool, // start gathering network status and send out as soon as ready want_wifi_scan: bool, // initiate wifi scan at the next loop cycle @@ -102,6 +104,8 @@ pub fn init(opt: InitOpt) !Daemon { .wpa_ctrl = try types.WpaControl.open(opt.wpa), .state = .stopped, .services = try svlist.toOwnedSlice(), + // send persisted settings immediately on start + .want_settings = true, // send a network report right at start without wifi scan to make it faster. .want_network_report = true, .want_wifi_scan = false, @@ -285,6 +289,28 @@ fn mainThreadLoop(self: *Daemon) void { fn mainThreadLoopCycle(self: *Daemon) !void { self.mu.lock(); defer self.mu.unlock(); + + if (self.want_settings) { + const ok = self.conf.safeReadOnly(struct { + fn f(conf: Config.Data) bool { + const msg: comm.Message.Settings = .{ + .sysupdates = .{ + .channel = switch (conf.syschannel) { + .dev => .edge, + .master => .stable, + }, + }, + }; + comm.pipeWrite(.{ .settings = msg }) catch |err| { + logger.err("{}", .{err}); + return false; + }; + return true; + } + }.f); + self.want_settings = !ok; + } + self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); if (self.want_wifi_scan) { if (self.startWifiScan()) { @@ -300,6 +326,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { logger.err("network.sendReport: {any}", .{err}); } } + if (self.want_bitcoind_report or self.bitcoin_timer.read() > self.bitcoin_report_interval) { if (self.sendBitcoindReport()) { self.bitcoin_timer.reset(); @@ -374,6 +401,13 @@ fn commThreadLoop(self: *Daemon) void { logger.info("wakeup from standby", .{}); self.wakeup() catch |err| logger.err("nd.wakeup: {any}", .{err}); }, + .switch_sysupdates => |chan| { + logger.info("switching sysupdates channel to {s}", .{@tagName(chan)}); + self.switchSysupdates(chan) catch |err| { + logger.err("switchSysupdates: {any}", .{err}); + // TODO: send err back to ngui + }; + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } @@ -736,6 +770,26 @@ fn sendLightningReport(self: *Daemon) !void { try comm.write(self.allocator, self.uiwriter, .{ .lightning_report = lndrep }); } +fn switchSysupdates(self: *Daemon, chan: comm.Message.SysupdatesChan) !void { + const th = try std.Thread.spawn(.{}, switchSysupdatesThread, .{ self, chan }); + th.detach(); +} + +fn switchSysupdatesThread(self: *Daemon, chan: comm.Message.SysupdatesChan) void { + const conf_chan: Config.SysupdatesChannel = switch (chan) { + .stable => .master, + .edge => .dev, + }; + self.conf.switchSysupdates(conf_chan, .{ .run = true }) catch |err| { + logger.err("config.switchSysupdates: {any}", .{err}); + // TODO: send err back to ngui + }; + // schedule settings report for ngui + self.mu.lock(); + defer self.mu.unlock(); + self.want_settings = true; +} + test "start-stop" { const t = std.testing; @@ -747,6 +801,7 @@ test "start-stop" { .uiw = pipe.writer(), .wpa = "/dev/null", }); + daemon.want_settings = false; daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; @@ -798,6 +853,7 @@ test "start-poweroff" { .uiw = gui_stdin.writer(), .wpa = "/dev/null", }); + daemon.want_settings = false; daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; diff --git a/src/ngui.zig b/src/ngui.zig index bfce8a5..3c74e5a 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -251,6 +251,9 @@ fn commThreadLoopCycle() !void { ui.lightning.updateTabPanel(rep) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, + .settings => |sett| { + ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}), }, } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 1e7d144..2a05d3b 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -129,6 +129,7 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var sectimer = try time.Timer.start(); var block_count: u32 = 801365; + var settings_sent = false; while (true) { time.sleep(time.ns_per_s); @@ -137,6 +138,17 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { } sectimer.reset(); + if (!settings_sent) { + settings_sent = true; + const sett: comm.Message.Settings = .{ + .sysupdates = .{ .channel = .edge }, + }; + comm.write(gpa, w, .{ .settings = sett }) catch |err| { + logger.err("{}", .{err}); + settings_sent = false; + }; + } + block_count += 1; const now = time.timestamp(); diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index eb28f4c..ce4f142 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -30,6 +30,11 @@ int nm_create_bitcoin_panel(lv_obj_t *parent); */ int nm_create_lightning_panel(lv_obj_t *parent); +/** + * creates the sysupdates section of the settings panel. + */ +lv_obj_t *nm_create_settings_sysupdates(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -149,7 +154,7 @@ static void wifi_connect_btn_callback(lv_event_t *e) nm_wifi_start_connect(buf, lv_textarea_get_text(settings.wifi_pwd_obj)); } -static void create_settings_panel(lv_obj_t *parent) +static int create_settings_panel(lv_obj_t *parent) { /******************** * wifi panel @@ -221,6 +226,12 @@ static void create_settings_panel(lv_obj_t *parent) lv_label_set_text_static(power_halt_btn_label, "SHUTDOWN"); lv_obj_center(power_halt_btn_label); + /******************** + * sysupdates panel + ********************/ + // ported to zig; + lv_obj_t *sysupdates_panel = nm_create_settings_sysupdates(parent); + /******************** * layout ********************/ @@ -228,10 +239,12 @@ static void create_settings_panel(lv_obj_t *parent) static lv_coord_t parent_grid_rows[] = {/**/ LV_GRID_CONTENT, /* wifi panel */ LV_GRID_CONTENT, /* power panel */ + LV_GRID_CONTENT, /* sysupdates panel */ LV_GRID_TEMPLATE_LAST}; lv_obj_set_grid_dsc_array(parent, parent_grid_cols, parent_grid_rows); lv_obj_set_grid_cell(wifi_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 0, 1); lv_obj_set_grid_cell(power_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 1, 1); + lv_obj_set_grid_cell(sysupdates_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 2, 1); static lv_coord_t wifi_grid_cols[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; static lv_coord_t wifi_grid_rows[] = {/**/ @@ -269,6 +282,8 @@ static void create_settings_panel(lv_obj_t *parent) lv_obj_set_grid_cell(poweroff_text, LV_GRID_ALIGN_START, 0, 1, LV_GRID_ALIGN_START, 2, 1); /* column 1 */ lv_obj_set_grid_cell(power_halt_btn, LV_GRID_ALIGN_STRETCH, 1, 1, LV_GRID_ALIGN_CENTER, 2, 1); + + return 0; } static void tab_changed_event_cb(lv_event_t *e) @@ -346,7 +361,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_settings == NULL) { return -1; } - create_settings_panel(tab_settings); + if (create_settings_panel(tab_settings) != 0) { + return -1; + } lv_obj_t *tab_info = lv_tabview_add_tab(tabview, NM_SYMBOL_INFO); if (tab_info == NULL) { diff --git a/src/ui/settings.zig b/src/ui/settings.zig new file mode 100644 index 0000000..c022361 --- /dev/null +++ b/src/ui/settings.zig @@ -0,0 +1,131 @@ +//! settings main tab. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. +//! +//! TODO: at the moment, most of the code is still in C; need to port to zig from src/ui/c/ui.c + +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const symbol = @import("symbol.zig"); + +const logger = std.log.scoped(.ui); + +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; +/// button text +const textSwitch = "SWITCH"; + +/// the settings tab alive for the whole duration of the process. +var tab: struct { + sysupdates: struct { + card: lvgl.Card, + chansel: lvgl.Dropdown, + switchbtn: lvgl.TextButton, + currchan: lvgl.Label, + }, +} = undefined; + +/// holds last values received from the daemon. +var state: struct { + curr_sysupdates_chan: ?comm.Message.SysupdatesChan = null, +} = .{}; + +/// creates a settings panel UI to control system updates channel. +/// must be called only once at program startup. +pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card { + tab.sysupdates.card = try lvgl.Card.new(cont, symbol.Loop ++ " SYSUPDATES", .{ .spinner = true }); + const l1 = try lvgl.Label.new(tab.sysupdates.card, "" // + ++ "https://git.qcode.ch/nakamochi/sysupdates " // TODO: make this configurable? + ++ "is the source of system updates.", .{}); + l1.setPad(15, .top, .{}); + l1.setWidth(lvgl.sizePercent(100)); + l1.setHeightToContent(); + + const row = try lvgl.FlexLayout.new(tab.sysupdates.card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.flexGrow(1); + left.setPad(10, .row, .{}); + left.setHeightToContent(); + tab.sysupdates.currchan = try lvgl.Label.new(left, cmark ++ "CURRENT CHANNEL:# unknown", .{ .recolor = true }); + tab.sysupdates.currchan.setHeightToContent(); + const lab = try lvgl.Label.new(left, "edge channel may contain some experimental and unstable features.", .{}); + lab.setWidth(lvgl.sizePercent(100)); + lab.setHeightToContent(); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.flexGrow(1); + right.setPad(10, .row, .{}); + right.setHeightToContent(); + tab.sysupdates.chansel = try lvgl.Dropdown.newStatic(right, blk: { + // items order must match that of the switch in update fn. + break :blk @tagName(comm.Message.SysupdatesChan.stable) // index 0 + ++ "\n" ++ @tagName(comm.Message.SysupdatesChan.edge); // index 1 + }); + tab.sysupdates.chansel.setWidth(lvgl.sizePercent(100)); + tab.sysupdates.chansel.setText(""); // show no pre-selected value + _ = tab.sysupdates.chansel.on(.value_changed, nm_sysupdates_chansel_changed, null); + tab.sysupdates.switchbtn = try lvgl.TextButton.new(right, textSwitch); + tab.sysupdates.switchbtn.setWidth(lvgl.sizePercent(100)); + // disable channel switch button 'till data received from the daemon + // or user-selected value. + tab.sysupdates.switchbtn.disable(); + _ = tab.sysupdates.switchbtn.on(.click, nm_sysupdates_switch_click, null); + + return tab.sysupdates.card; +} + +/// updates the UI with the data from the provided settings arg. +pub fn update(sett: comm.Message.Settings) !void { + var buf: [512]u8 = undefined; + try tab.sysupdates.currchan.setTextFmt(&buf, cmark ++ "CURRENT CHANNEL:# {s}", .{@tagName(sett.sysupdates.channel)}); + state.curr_sysupdates_chan = sett.sysupdates.channel; +} + +export fn nm_sysupdates_chansel_changed(_: *lvgl.LvEvent) void { + var buf = [_]u8{0} ** 32; + const name = tab.sysupdates.chansel.getSelectedStr(&buf); + const chan = std.meta.stringToEnum(comm.Message.SysupdatesChan, name) orelse return; + if (state.curr_sysupdates_chan) |curr_chan| { + if (chan != curr_chan) { + tab.sysupdates.switchbtn.enable(); + tab.sysupdates.chansel.clearText(); // show selected value + } else { + tab.sysupdates.switchbtn.disable(); + tab.sysupdates.chansel.setText(""); // hide selected value + } + } else { + tab.sysupdates.switchbtn.enable(); + tab.sysupdates.chansel.clearText(); // show selected value + } +} + +export fn nm_sysupdates_switch_click(_: *lvgl.LvEvent) void { + var buf = [_]u8{0} ** 32; + const name = tab.sysupdates.chansel.getSelectedStr(&buf); + switchSysupdates(name) catch |err| logger.err("switchSysupdates: {any}", .{err}); +} + +fn switchSysupdates(name: []const u8) !void { + const chan = std.meta.stringToEnum(comm.Message.SysupdatesChan, name) orelse return error.InvalidSysupdateChannel; + logger.debug("switching sysupdates to channel {}", .{chan}); + + tab.sysupdates.switchbtn.disable(); + tab.sysupdates.switchbtn.label.setTextStatic("UPDATING ..."); + tab.sysupdates.chansel.disable(); + tab.sysupdates.card.spin(.on); + errdefer { + tab.sysupdates.card.spin(.off); + tab.sysupdates.chansel.enable(); + tab.sysupdates.switchbtn.enable(); + tab.sysupdates.switchbtn.label.setTextStatic(textSwitch); + } + + try comm.pipeWrite(.{ .switch_sysupdates = chan }); +} diff --git a/src/ui/symbol.zig b/src/ui/symbol.zig index e830295..9ee63b0 100644 --- a/src/ui/symbol.zig +++ b/src/ui/symbol.zig @@ -1,4 +1,5 @@ ///! see lv_symbols_def.h +pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 }; pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c }; pub const Power = &[_]u8{ 0xef, 0x80, 0x91 }; pub const Warning = &[_]u8{ 0xef, 0x81, 0xb1 }; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 5940bb9..e892f46 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -2,13 +2,15 @@ const buildopts = @import("build_options"); const std = @import("std"); const comm = @import("../comm.zig"); -const lvgl = @import("lvgl.zig"); const drv = @import("drv.zig"); +const lvgl = @import("lvgl.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); -pub const poweroff = @import("poweroff.zig"); + pub const bitcoin = @import("bitcoin.zig"); pub const lightning = @import("lightning.zig"); +pub const poweroff = @import("poweroff.zig"); +pub const settings = @import("settings.zig"); const logger = std.log.scoped(.ui); @@ -52,6 +54,14 @@ export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_settings_sysupdates(parent: *lvgl.LvObj) ?*lvgl.LvObj { + const card = settings.initSysupdatesPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("initSysupdatesPanel: {any}", .{err}); + return null; + }; + return card.lvobj; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; -- 2.41.0