From 2642a55477a22ef9dd7c8cddbf613bcecb5f1493 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Aug 2023 02:35:05 +0200 Subject: [PATCH 1/3] nd: add bitcoin core report sent to UI every min this adds a very simple bitcoind RPC client able to make a few queries like the blockchain and network status. the daemon uses the client to get the bitcoind status and sends a report through comms periodically. there's also a little playground program which simply dumps a query result to stderr. built on demand with "zig build btcrpc". --- build.zig | 10 ++ src/comm.zig | 37 +++++- src/nd/Daemon.zig | 59 ++++++++ src/nd/bitcoindrpc.zig | 296 +++++++++++++++++++++++++++++++++++++++++ src/test/btcrpc.zig | 20 +++ 5 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/nd/bitcoindrpc.zig create mode 100644 src/test/btcrpc.zig diff --git a/build.zig b/build.zig index 33aebc7..f1eb2d2 100644 --- a/build.zig +++ b/build.zig @@ -135,6 +135,16 @@ pub fn build(b: *std.build.Builder) void { guiplay_build_step.dependOn(&b.addInstallArtifact(guiplay).step); guiplay_build_step.dependOn(ngui_build_step); } + + { + const btcrpc = b.addExecutable("btcrpc", "src/test/btcrpc.zig"); + btcrpc.setTarget(target); + btcrpc.setBuildMode(mode); + btcrpc.strip = strip; + btcrpc.addPackagePath("bitcoindrpc", "src/nd/bitcoindrpc.zig"); + const btcrpc_build_step = b.step("btcrpc", "bitcoind RPC client playground"); + btcrpc_build_step.dependOn(&b.addInstallArtifact(btcrpc).step); + } } const DriverTarget = enum { diff --git a/src/comm.zig b/src/comm.zig index ea90080..dd2db44 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -27,6 +27,7 @@ pub const Message = union(MessageTag) { network_report: NetworkReport, get_network_report: GetNetworkReport, poweroff_progress: PoweroffProgress, + bitcoind_report: BitcoindReport, pub const WifiConnect = struct { ssid: []const u8, @@ -52,6 +53,34 @@ pub const Message = union(MessageTag) { err: ?[]const u8, }; }; + + pub const BitcoindReport = struct { + blocks: u64, + headers: u64, + timestamp: u64, // unix epoch + hash: []const u8, // best block hash + ibd: bool, // initial block download + verifyprogress: u8, // 0-100% + diskusage: u64, // estimated size on disk, in bytes + version: []const u8, // bitcoin core version string + conn_in: u16, + conn_out: u16, + warnings: []const u8, + localaddr: []struct { + addr: []const u8, + port: u16, + score: i16, + }, + mempool: struct { + loaded: bool, + txcount: usize, + usage: u64, // in memory, bytes + max: u64, // bytes + totalfee: f32, // in BTC + minfee: f32, // BTC/kvB + fullrbf: bool, + }, + }; }; /// it is important to preserve ordinal values for future compatiblity, @@ -69,7 +98,9 @@ pub const MessageTag = enum(u16) { wakeup = 0x08, // nd -> ngui: reports poweroff progress poweroff_progress = 0x09, - // next: 0x0a + // nd -> ngui: bitcoin core daemon status report + bitcoind_report = 0x0a, + // next: 0x0b }; /// reads and parses a single message from the input stream reader. @@ -111,6 +142,9 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !Message { .poweroff_progress => Message{ .poweroff_progress = try json.parse(Message.PoweroffProgress, &jstream, jopt), }, + .bitcoind_report => Message{ + .bitcoind_report = try json.parse(Message.BitcoindReport, &jstream, jopt), + }, }; } @@ -126,6 +160,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .network_report => try json.stringify(msg.network_report, jopt, data.writer()), .get_network_report => try json.stringify(msg.get_network_report, jopt, data.writer()), .poweroff_progress => try json.stringify(msg.poweroff_progress, jopt, data.writer()), + .bitcoind_report => try json.stringify(msg.bitcoind_report, jopt, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index b74c2dd..6abdfc1 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -20,6 +20,7 @@ const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); const types = @import("../types.zig"); const SysService = @import("SysService.zig"); +const bitcoindrpc = @import("bitcoindrpc.zig"); const logger = std.log.scoped(.daemon); @@ -50,6 +51,10 @@ want_wifi_scan: bool, // initiate wifi scan at the next loop cycle network_report_ready: bool, // indicates whether the network status is ready to be sent wifi_scan_in_progress: bool = false, wpa_save_config_on_connected: bool = false, +// bitcoin flags +want_bitcoind_report: bool, +bitcoin_timer: time.Timer, +bitcoin_report_interval: u64 = 1 * time.ns_per_min, /// system services actively managed by the daemon. /// these are stop'ed during poweroff and their shutdown progress sent to ngui. @@ -82,6 +87,9 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, .want_network_report = true, .want_wifi_scan = false, .network_report_ready = true, + // report bitcoind status immediately on start + .want_bitcoind_report = true, + .bitcoin_timer = try time.Timer.start(), }; } @@ -269,6 +277,14 @@ 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(); + self.want_bitcoind_report = false; + } else |err| { + logger.err("sendBitcoinReport: {any}", .{err}); + } + } } /// comm thread entry point: reads messages sent from ngui and acts accordinly. @@ -484,12 +500,54 @@ fn readWPACtrlMsg(self: *Daemon) !void { } } +fn sendBitcoindReport(self: *Daemon) !void { + var client = bitcoindrpc.Client{ + .allocator = self.allocator, + .cookiepath = "/ssd/bitcoind/mainnet/.cookie", + }; + const bcinfo = try client.call(.getblockchaininfo, {}); + defer bcinfo.deinit(); + const netinfo = try client.call(.getnetworkinfo, {}); + defer netinfo.deinit(); + const mempool = try client.call(.getmempoolinfo, {}); + defer mempool.deinit(); + + const btcrep: comm.Message.BitcoindReport = .{ + .blocks = bcinfo.value.blocks, + .headers = bcinfo.value.headers, + .timestamp = bcinfo.value.time, + .hash = bcinfo.value.bestblockhash, + .ibd = bcinfo.value.initialblockdownload, + .diskusage = bcinfo.value.size_on_disk, + .version = netinfo.value.subversion, + .conn_in = netinfo.value.connections_in, + .conn_out = netinfo.value.connections_out, + .warnings = bcinfo.value.warnings, // TODO: netinfo.result.warnings + .localaddr = &.{}, // TODO: populate + // something similar to this: + // @round(bcinfo.verificationprogress * 100) + .verifyprogress = 0, + .mempool = .{ + .loaded = mempool.value.loaded, + .txcount = mempool.value.size, + .usage = mempool.value.usage, + .max = mempool.value.maxmempool, + .totalfee = mempool.value.total_fee, + .minfee = mempool.value.mempoolminfee, + .fullrbf = mempool.value.fullrbf, + }, + }; + + try comm.write(self.allocator, self.uiwriter, .{ .bitcoind_report = btcrep }); +} + 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"); daemon.want_network_report = false; + daemon.want_bitcoind_report = false; try t.expect(daemon.state == .stopped); try daemon.start(); @@ -533,6 +591,7 @@ test "start-poweroff" { const gui_reader = gui_stdin.reader(); var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); daemon.want_network_report = false; + daemon.want_bitcoind_report = false; defer { daemon.deinit(); gui_stdin.close(); diff --git a/src/nd/bitcoindrpc.zig b/src/nd/bitcoindrpc.zig new file mode 100644 index 0000000..7f0ce5f --- /dev/null +++ b/src/nd/bitcoindrpc.zig @@ -0,0 +1,296 @@ +//! a bitcoin core RPC client. + +const std = @import("std"); +const ArenaAllocator = std.heap.ArenaAllocator; +const Atomic = std.atomic.Atomic; +const base64enc = std.base64.standard.Encoder; + +pub const Client = struct { + allocator: std.mem.Allocator, + cookiepath: []const u8, + addr: []const u8 = "127.0.0.1", + port: u16 = 8332, + + // each request gets a new ID with a value of reqid.fetchAdd(1, .Monotonic) + reqid: Atomic(u64) = Atomic(u64).init(1), + + pub const Method = enum { + getblockchaininfo, + getblockhash, + getmempoolinfo, + getnetworkinfo, + }; + + pub const RpcError = error{ + // json-rpc 2.0 + RpcInvalidRequest, + RpcMethodNotFound, + RpcInvalidParams, + RpcInternalError, + RpcParseError, + // general purpose errors + RpcMiscError, + RpcTypeError, + RpcInvalidAddressOrKey, + RpcOutOfMemory, + RpcInvalidParameter, + RpcDatabaseError, + RpcDeserializationError, + RpcVerifyError, + RpcVerifyRejected, + RpcVerifyAlreadyInChain, + RpcInWarmup, + RpcMethodDeprecated, + // p2p client errors + RpcClientNotConnected, + RpcClientInInitialDownload, + RpcClientNodeAlreadyAdded, + RpcClientNodeNotAdded, + RpcClientNodeNotConnected, + RpcClientInvalidIpOrSubnet, + RpcClientP2pDisabled, + RpcClientNodeCapacityReached, + // chain errors + RpcClientMempoolDisabled, + }; + + pub fn Result(comptime m: Method) type { + return struct { + value: ResultValue(m), + arena: *ArenaAllocator, + + pub fn deinit(self: @This()) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); + } + }; + } + + pub fn ResultValue(comptime m: Method) type { + return switch (m) { + .getblockchaininfo => BlockchainInfo, + .getblockhash => []const u8, + .getmempoolinfo => MempoolInfo, + .getnetworkinfo => NetworkInfo, + }; + } + + pub fn MethodArgs(comptime m: Method) type { + return switch (m) { + .getblockchaininfo, .getmempoolinfo, .getnetworkinfo => void, + .getblockhash => struct { height: u64 }, + }; + } + + fn RpcRequest(comptime m: Method) type { + return struct { + jsonrpc: []const u8 = "1.0", + id: u64, + method: []const u8, + params: MethodArgs(m), + }; + } + + fn RpcResponse(comptime m: Method) type { + return struct { + id: u64, + result: ?ResultValue(m), + @"error": ?struct { + code: isize, + //message: ?[]const u8, // no use for it atm + }, + }; + } + + /// makes an RPC call to the addr:port endpoint. + /// the returned value must be deinit'ed when done. + pub fn call(self: *Client, comptime method: Method, args: MethodArgs(method)) !Result(method) { + const addrport = try std.net.Address.resolveIp(self.addr, self.port); + const reqbytes = try self.formatreq(method, args); + defer self.allocator.free(reqbytes); + + // connect and send the request + const stream = try std.net.tcpConnectToAddress(addrport); + defer stream.close(); + const reader = stream.reader(); + _ = try stream.writer().writeAll(reqbytes); + + // read and parse the response + try skipResponseHeaders(reader, 4096); + const body = try reader.readAllAlloc(self.allocator, 1 << 20); // 1Mb should be enough for all response types + defer self.allocator.free(body); + return self.parseResponse(method, body); + } + + /// reads all response headers, at most `limit` bytes, and returns the index + /// at which response body starts or error.EndOfStream. + /// single header length must be at most `limit` or 1024, whichever is smaller. + fn skipResponseHeaders(r: anytype, comptime limit: usize) !void { + var n: usize = 0; + var buf: [@min(1024, limit)]u8 = undefined; + while (true) { + const slice = try r.readUntilDelimiter(&buf, '\n'); + n += slice.len + 1; // delimiter is not included in the slice + if (n > limit) { + return error.StreamTooLong; + } + if (slice.len == 0 or (slice.len == 1 and slice[0] == '\r')) { + return; + } + } + } + + fn parseResponse(self: Client, comptime m: Method, b: []const u8) !Result(m) { + var result = Result(m){ + .value = undefined, + .arena = try self.allocator.create(ArenaAllocator), + }; + errdefer self.allocator.destroy(result.arena); + result.arena.* = ArenaAllocator.init(self.allocator); + + var jstream = std.json.TokenStream.init(b); + const jopt = std.json.ParseOptions{ .allocator = result.arena.allocator(), .ignore_unknown_fields = true }; + const resp = try std.json.parse(RpcResponse(m), &jstream, jopt); + + errdefer result.arena.deinit(); + if (resp.@"error") |errfield| { + return rpcErrorFromCode(errfield.code) orelse error.UnknownError; + } + if (resp.result == null) { + return error.NullResult; + } + + result.value = resp.result.?; + return result; + } + + fn formatreq(self: *Client, comptime m: Method, args: MethodArgs(m)) ![]const u8 { + const req = RpcRequest(m){ + .id = self.reqid.fetchAdd(1, .Monotonic), + .method = @tagName(m), + .params = args, + }; + var jreq = std.ArrayList(u8).init(self.allocator); + defer jreq.deinit(); + try std.json.stringify(req, .{}, jreq.writer()); + + const auth = try self.getAuthBase64(); + defer self.allocator.free(auth); + + var bytes = std.ArrayList(u8).init(self.allocator); + const w = bytes.writer(); + try w.writeAll("POST / HTTP/1.0\r\n"); + //try w.writeAll("Host: 127.0.0.1\n", .{}); + try w.writeAll("Connection: close\r\n"); + try w.print("Authorization: Basic {s}\r\n", .{auth}); + try w.writeAll("Accept: application/json-rpc\r\n"); + try w.writeAll("Content-Type: application/json-rpc\r\n"); + try w.print("Content-Length: {d}\r\n", .{jreq.items.len}); + try w.writeAll("\r\n"); + try w.writeAll(jreq.items); + return bytes.toOwnedSlice(); + } + + fn getAuthBase64(self: Client) ![]const u8 { + const file = try std.fs.openFileAbsolute(self.cookiepath, .{ .mode = .read_only }); + defer file.close(); + const cookie = try file.readToEndAlloc(self.allocator, 1024); + defer self.allocator.free(cookie); + var auth = try self.allocator.alloc(u8, base64enc.calcSize(cookie.len)); + return base64enc.encode(auth, cookie); + } + + // taken from bitcoind source code. + // see https://github.com/bitcoin/bitcoin/blob/64440bb73/src/rpc/protocol.h#L23 + fn rpcErrorFromCode(code: isize) ?RpcError { + return switch (code) { + // json-rpc 2.0 + -32600 => error.RpcInvalidRequest, + -32601 => error.RpcMethodNotFound, + -32602 => error.RpcInvalidParams, + -32603 => error.RpcInternalError, + -32700 => error.RpcParseError, + // general purpose errors + -1 => error.RpcMiscError, + -3 => error.RpcTypeError, + -5 => error.RpcInvalidAddressOrKey, + -7 => error.RpcOutOfMemory, + -8 => error.RpcInvalidParameter, + -20 => error.RpcDatabaseError, + -22 => error.RpcDeserializationError, + -25 => error.RpcVerifyError, + -26 => error.RpcVerifyRejected, + -27 => error.RpcVerifyAlreadyInChain, + -28 => error.RpcInWarmup, + -32 => error.RpcMethodDeprecated, + // p2p client errors + -9 => error.RpcClientNotConnected, + -10 => error.RpcClientInInitialDownload, + -23 => error.RpcClientNodeAlreadyAdded, + -24 => error.RpcClientNodeNotAdded, + -29 => error.RpcClientNodeNotConnected, + -30 => error.RpcClientInvalidIpOrSubnet, + -31 => error.RpcClientP2pDisabled, + -34 => error.RpcClientNodeCapacityReached, + // chain errors + -33 => error.RpcClientMempoolDisabled, + else => null, + }; + } +}; + +pub const BlockchainInfo = struct { + chain: []const u8, + blocks: u64, + headers: u64, + bestblockhash: []const u8, + difficulty: f64, + time: u64, // block time, unix epoch + mediantime: u64, // median block time, unix epoch + verificationprogress: f32, // estimate in [0..1] + initialblockdownload: bool, + size_on_disk: u64, + pruned: bool, + //pruneheight: ?u64, // present if pruning is enabled + //automatic_prunning: ?bool, // present if pruning is enabled + //prune_target_size: ?u64, // present if automatic is enabled + warnings: []const u8, +}; + +pub const MempoolInfo = struct { + loaded: bool, // whether the mempool is fully loaded + size: usize, // tx count + bytes: u64, // sum of all virtual transaction sizes as per BIP-141 (discounted witness data) + usage: u64, // total memory usage + total_fee: f32, // total fees in BTC ignoring modified fees through prioritisetransaction + maxmempool: u64, // memory usage cap, in bytes + mempoolminfee: f32, // min fee rate in BTC/kvB for tx to be accepted + minrelaytxfee: f32, // current min relay fee rate + incrementalrelayfee: f32, // min fee rate increment for replacement, in BTC/kvB + unbroadcastcount: u64, // number of transactions that haven't passed initial broadcast yet + fullrbf: bool, // whether the mempool accepts RBF without replaceability signaling inspection +}; + +pub const NetworkInfo = struct { + version: u32, + subversion: []const u8, + protocolversion: u32, + connections: u16, // in + out + connections_in: u16, + connections_out: u16, + networkactive: bool, + networks: []struct { + name: []const u8, // ipv4, ipv6, onion, i2p, cjdns + limited: bool, // whether this network is limited with -onlynet flag + reachable: bool, + }, + relayfee: f32, // min rate, in BTC/kvB + incrementalfee: f32, // min rate increment for RBF in BTC/vkB + localaddresses: []struct { + address: []const u8, + port: u16, + score: i16, + }, + warnings: []const u8, +}; diff --git a/src/test/btcrpc.zig b/src/test/btcrpc.zig new file mode 100644 index 0000000..58c300f --- /dev/null +++ b/src/test/btcrpc.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const base64enc = std.base64.standard.Encoder; +const bitcoinrpc = @import("bitcoindrpc"); + +pub fn main() !void { + var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa_state.deinit()) { + std.debug.print("memory leaks detected!", .{}); + }; + const gpa = gpa_state.allocator(); + + var client = bitcoinrpc.Client{ + .allocator = gpa, + .cookiepath = "/ssd/bitcoind/mainnet/.cookie", + }; + + const res = try client.call(.getmempoolinfo, {}); + defer res.deinit(); + std.debug.print("{any}\n", .{res.value}); +} -- 2.41.0 From 0260d477479496167ddeb15907a97fdfddbe6f25 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Aug 2023 02:49:52 +0200 Subject: [PATCH 2/3] ui: visualize bitcoind status report on the tab panel the bitcoin tab has now some basic status and stats: parts of blockchain info, network info and mempool. this is far from complete but makes a good start for an initial version. the gui playground is also updated to sent some stub info via comms periodically. --- src/ngui.zig | 5 ++ src/test/guiplay.zig | 48 ++++++++++++++++- src/ui/bitcoin.zig | 125 +++++++++++++++++++++++++++++++++++++++++++ src/ui/c/ui.c | 18 +++---- src/ui/ui.zig | 9 ++++ src/xfmt.zig | 31 +++++++++++ 6 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/ui/bitcoin.zig create mode 100644 src/xfmt.zig diff --git a/src/ngui.zig b/src/ngui.zig index 0641a1f..7a803ac 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -185,6 +185,11 @@ fn commThreadLoopCycle() !void { defer ui_mutex.unlock(); ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); }, + .bitcoind_report => |rep| { + ui_mutex.lock(); + defer ui_mutex.unlock(); + ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 988e118..3063b97 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -72,7 +72,7 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { return flags; } -fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { +fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err}); while (true) { @@ -125,6 +125,47 @@ fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { sigquit.set(); } +fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { + var sectimer = try time.Timer.start(); + var block_count: u64 = 801365; + + while (true) { + time.sleep(time.ns_per_s); + if (sectimer.read() < time.ns_per_s) { + continue; + } + + sectimer.reset(); + block_count += 1; + const now = time.timestamp(); + + const btcrep: comm.Message.BitcoindReport = .{ + .blocks = block_count, + .headers = block_count, + .timestamp = @intCast(u64, now), + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .ibd = false, + .verifyprogress = 100, + .diskusage = 567119364054, + .version = "/Satoshi:24.0.1/", + .conn_in = 8, + .conn_out = 10, + .warnings = "", + .localaddr = &.{}, + .mempool = .{ + .loaded = true, + .txcount = 100000 + block_count, + .usage = std.math.min(200123456 + block_count * 10, 300000000), + .max = 300000000, + .totalfee = 2.23049932, + .minfee = 0.00004155, + .fullrbf = false, + }, + }; + comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } +} + pub fn main() !void { var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; defer if (gpa_state.deinit()) { @@ -145,7 +186,10 @@ pub fn main() !void { // ngui proc stdio is auto-closed as soon as its main process terminates. const uireader = ngui_proc.stdout.?.reader(); const uiwriter = ngui_proc.stdin.?.writer(); - _ = try std.Thread.spawn(.{}, commThread, .{ gpa, uireader, uiwriter }); + const th1 = try std.Thread.spawn(.{}, commReadThread, .{ gpa, uireader, uiwriter }); + th1.detach(); + const th2 = try std.Thread.spawn(.{}, commWriteThread, .{ gpa, uiwriter }); + th2.detach(); const sa = os.Sigaction{ .handler = .{ .handler = sighandler }, diff --git a/src/ui/bitcoin.zig b/src/ui/bitcoin.zig new file mode 100644 index 0000000..dab529e --- /dev/null +++ b/src/ui/bitcoin.zig @@ -0,0 +1,125 @@ +//! bitcoin main tab panel. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); +const fmt = std.fmt; + +const lvgl = @import("lvgl.zig"); +const comm = @import("../comm.zig"); +const xfmt = @import("../xfmt.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 "; + +var tab: struct { + // blockchain section + currblock: lvgl.Label, + timestamp: lvgl.Label, + blockhash: lvgl.Label, + // usage section + diskusage: lvgl.Label, + conn_in: lvgl.Label, + conn_out: lvgl.Label, + // mempool section + mempool: struct { + txcount: lvgl.Label, + totalfee: lvgl.Label, + usage_bar: lvgl.Bar, + usage_lab: lvgl.Label, + }, +} = undefined; + +/// creates the tab content with all elements. +/// must be called only once at UI init. +pub fn initTabPanel(cont: lvgl.Container) !void { + const parent = cont.flex(.column, .{}); + + // blockchain section + { + const card = try lvgl.Card.new(parent, "BLOCKCHAIN"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.currblock = try lvgl.Label.new(left, "HEIGHT\n", .{ .recolor = true }); + tab.timestamp = try lvgl.Label.new(left, "TIMESTAMP\n", .{ .recolor = true }); + tab.blockhash = try lvgl.Label.new(row, "BLOCK HASH\n", .{ .recolor = true }); + tab.blockhash.flexGrow(1); + } + + // mempool section + { + const card = try lvgl.Card.new(parent, "MEMPOOL"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(8, .top, .{}); + left.setPad(10, .row, .{}); + tab.mempool.usage_bar = try lvgl.Bar.new(left); + tab.mempool.usage_lab = try lvgl.Label.new(left, "0Mb out of 0Mb (0%)", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.mempool.txcount = try lvgl.Label.new(right, "TRANSACTIONS COUNT\n", .{ .recolor = true }); + tab.mempool.totalfee = try lvgl.Label.new(right, "TOTAL FEES\n", .{ .recolor = true }); + } + + // usage section + { + const card = try lvgl.Card.new(parent, "USAGE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.diskusage = try lvgl.Label.new(left, "DISK USAGE\n", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.conn_in = try lvgl.Label.new(right, "CONNECTIONS IN\n", .{ .recolor = true }); + tab.conn_out = try lvgl.Label.new(right, "CONNECTIONS OUT\n", .{ .recolor = true }); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.BitcoindReport) !void { + var buf: [512]u8 = undefined; + + // blockchain section + try tab.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.blocks}); + try tab.timestamp.setTextFmt(&buf, cmark ++ "TIMESTAMP#\n{}", .{xfmt.unix(rep.timestamp)}); + try tab.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + + // mempool section + const mempool_pct: f32 = pct: { + if (rep.mempool.usage > rep.mempool.max) { + break :pct 100; + } + if (rep.mempool.max == 0) { + break :pct 0; + } + const v = @intToFloat(f64, rep.mempool.usage) / @intToFloat(f64, rep.mempool.max); + break :pct @floatCast(f32, v * 100); + }; + tab.mempool.usage_bar.setValue(@floatToInt(i32, @round(mempool_pct))); + try tab.mempool.usage_lab.setTextFmt(&buf, "{:.1} " ++ cmark ++ "out of# {:.1} ({d:.1}%)", .{ + fmt.fmtIntSizeBin(rep.mempool.usage), + fmt.fmtIntSizeBin(rep.mempool.max), + mempool_pct, + }); + try tab.mempool.txcount.setTextFmt(&buf, cmark ++ "TRANSACTIONS COUNT#\n{d}", .{rep.mempool.txcount}); + try tab.mempool.totalfee.setTextFmt(&buf, cmark ++ "TOTAL FEES#\n{d:10} BTC", .{rep.mempool.totalfee}); + + // usage section + try tab.diskusage.setTextFmt(&buf, cmark ++ "DISK USAGE#\n{:.1}", .{fmt.fmtIntSizeBin(rep.diskusage)}); + try tab.conn_in.setTextFmt(&buf, cmark ++ "CONNECTIONS IN#\n{d}", .{rep.conn_in}); + try tab.conn_out.setTextFmt(&buf, cmark ++ "CONNECTIONS OUT#\n{d}", .{rep.conn_out}); +} diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index 66d6dff..bb12fb9 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -20,6 +20,11 @@ void nm_sys_shutdown(); */ int nm_create_info_panel(lv_obj_t *parent); +/** + * creates the bitcoin tab panel. + */ +int nm_create_bitcoin_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -103,15 +108,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_bitcoin_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "bitcoin tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static void create_lnd_panel(lv_obj_t *parent) { lv_obj_t *label = lv_label_create(parent); @@ -338,7 +334,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_btc == NULL) { return -1; } - create_bitcoin_panel(tab_btc); + if (nm_create_bitcoin_panel(tab_btc) != 0) { + return -1; + } lv_obj_t *tab_lnd = lv_tabview_add_tab(tabview, NM_SYMBOL_BOLT " LIGHTNING"); if (tab_lnd == NULL) { diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 9c50a47..e23f37b 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -7,6 +7,7 @@ const drv = @import("drv.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); +pub const bitcoin = @import("bitcoin.zig"); const logger = std.log.scoped(.ui); @@ -34,6 +35,14 @@ export fn nm_create_info_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { + bitcoin.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createBitcoinPanel: {any}", .{err}); + return -1; + }; + return 0; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; diff --git a/src/xfmt.zig b/src/xfmt.zig new file mode 100644 index 0000000..c55562f --- /dev/null +++ b/src/xfmt.zig @@ -0,0 +1,31 @@ +//! extra formatting utilities, missing from std.fmt. + +const std = @import("std"); + +/// formats a unix timestamp in YYYY-MM-DD HH:MM:SS UTC. +/// if the sec value greater than u47, outputs raw digits. +pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { + return .{ .data = sec }; +} + +fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + _ = fmt; // unused + _ = opts; + if (sec > std.math.maxInt(u47)) { + // EpochSeconds.getEpochDay trucates to u47 which results in a "truncated bits" + // panic for too big numbers. so, just print raw digits. + return std.fmt.format(w, "{d}", .{sec}); + } + const epoch: std.time.epoch.EpochSeconds = .{ .secs = sec }; + const daysec = epoch.getDaySeconds(); + const yearday = epoch.getEpochDay().calculateYearDay(); + const monthday = yearday.calculateMonthDay(); + return std.fmt.format(w, "{d}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", .{ + yearday.year, + monthday.month.numeric(), + monthday.day_index + 1, + daysec.getHoursIntoDay(), + daysec.getMinutesIntoHour(), + daysec.getSecondsIntoMinute(), + }); +} -- 2.41.0 From a06a4757b2eb0021176db07f3315efee46f3ed47 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Aug 2023 14:12:22 +0200 Subject: [PATCH 3/3] ngui: clarify and handle concurrency during screen sleep/standby the screen.sleep fn was actually called in concurrent-unsafe mode, i.e without acquiring UI mutex. in conjuction with commThreadLoopCycle this would have eventually led to LVGL primitives concurrent access. so, screen.sleep now takes UI mutex to hold during LVGL calls. a bit ugly but certainly better than relying on luck. --- src/ngui.zig | 48 +++++++++++++++++++++++++++-------------------- src/ui/screen.zig | 11 ++++++++++- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/ngui.zig b/src/ngui.zig index 7a803ac..6f8936d 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -67,8 +67,12 @@ export fn nm_get_curr_tick() u32 { export fn nm_check_idle_time(_: *lvgl.LvTimer) void { const standby_idle_ms = 60000; // 60sec const idle_ms = lvgl.idleTime(); - if (idle_ms > standby_idle_ms and state != .alert) { - state = .standby; + if (idle_ms < standby_idle_ms) { + return; + } + switch (state) { + .alert, .standby => {}, + .active => state = .standby, } } @@ -105,10 +109,8 @@ export fn nm_wifi_start_connect(ssid: [*:0]const u8, password: [*:0]const u8) vo }; } +/// callers must hold ui mutex for the whole duration. fn updateNetworkStatus(report: comm.Message.NetworkReport) !void { - ui_mutex.lock(); - defer ui_mutex.unlock(); - var wifi_list: ?[:0]const u8 = null; var wifi_list_ptr: ?[*:0]const u8 = null; if (report.wifi_scan_networks.len > 0) { @@ -171,26 +173,32 @@ fn commThreadLoop() void { /// runs one cycle of the commThreadLoop: read messages from stdin and update /// the UI accordingly. +/// holds ui mutex for most of the duration. fn commThreadLoopCycle() !void { const msg = try comm.read(gpa, stdin); defer comm.free(gpa, msg); logger.debug("got msg: {s}", .{@tagName(msg)}); - switch (msg) { - .ping => try comm.write(gpa, stdout, comm.Message.pong), - .network_report => |report| { - updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); - }, - .poweroff_progress => |report| { - ui_mutex.lock(); - defer ui_mutex.unlock(); - ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + + ui_mutex.lock(); // guards state and all UI calls below + defer ui_mutex.unlock(); + switch (state) { + .standby => switch (msg) { + .ping => try comm.write(gpa, stdout, comm.Message.pong), + else => logger.debug("ignoring: in standby", .{}), }, - .bitcoind_report => |rep| { - ui_mutex.lock(); - defer ui_mutex.unlock(); - ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); + .active, .alert => switch (msg) { + .ping => try comm.write(gpa, stdout, comm.Message.pong), + .network_report => |report| { + updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); + }, + .poweroff_progress => |report| { + ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + }, + .bitcoind_report => |rep| { + ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); + }, + else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), }, - else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } @@ -212,7 +220,7 @@ fn uiThreadLoop() void { comm.write(gpa, stdout, comm.Message.standby) catch |err| { logger.err("comm.write standby: {any}", .{err}); }; - screen.sleep(&wakeup); // blocking + screen.sleep(&ui_mutex, &wakeup); // blocking // wake up due to touch screen activity or wakeup event is set logger.info("waking up from sleep", .{}); diff --git a/src/ui/screen.zig b/src/ui/screen.zig index b48a74c..6f73f9d 100644 --- a/src/ui/screen.zig +++ b/src/ui/screen.zig @@ -13,10 +13,19 @@ const logger = std.log.scoped(.screen); /// a touch screen activity or wake event is triggered. /// sleep removes all input devices at enter and reinstates them at exit so that /// a touch event triggers no accidental action. -pub fn sleep(wake: *const Thread.ResetEvent) void { +/// +/// the UI mutex is held while calling LVGL UI functions, and released during +/// idling or waiting for wake event. +/// although sleep is safe for concurrent use, the input drivers init/deinit +/// implementation used on entry and exit might not be. +pub fn sleep(ui: *std.Thread.Mutex, wake: *const Thread.ResetEvent) void { + ui.lock(); drv.deinitInput(); widget.topdrop(.show); + ui.unlock(); defer { + ui.lock(); + defer ui.unlock(); drv.initInput() catch |err| logger.err("drv.initInput: {any}", .{err}); widget.topdrop(.remove); } -- 2.41.0