nd,ngui: let users switch sysupdates channel from the UI #28

Manually merged
x1ddos merged 7 commits from sysupswitch into master 12 months ago

@ -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,
@ -16,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,
@ -29,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,
@ -125,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.
@ -210,7 +261,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
@ -220,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;
@ -286,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;

@ -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,

@ -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);
@ -171,13 +188,21 @@ 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;
};
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();

@ -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 <channel>?" where <channel> 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);
}

@ -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
@ -46,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
@ -68,26 +72,40 @@ 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 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,
@ -104,6 +122,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();
@ -270,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()) {
@ -285,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();
@ -359,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)}),
}
@ -721,11 +770,38 @@ 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;
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_settings = false;
daemon.want_network_report = false;
daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;
@ -770,7 +846,14 @@ 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_settings = false;
daemon.want_network_report = false;
daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;

@ -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();
@ -255,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)}),
},
}
@ -275,9 +274,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 +283,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 +364,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});

@ -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,67 @@ 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);
}
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,

@ -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();

@ -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);

@ -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) {

@ -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));

@ -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.
@ -604,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(),
};
}
};
@ -669,7 +715,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 +736,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 };
}
};
@ -738,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.
@ -964,6 +1086,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;
@ -991,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;

@ -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 });
}

@ -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 };

@ -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;