cln-plugins/summary/summary.py
daywalker90 9ff215d936 summary: make summary-availability-window an actual integer option
We were converting it to an integer anyways
2026-06-26 18:15:45 +02:00

408 lines
13 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pyln-client>=25.9.3",
# "requests[socks]>=2.34.0",
# "packaging>=26.2",
# ]
# ///
from pyln.client import Plugin, Millisatoshi
from packaging import version
from collections import namedtuple
from operator import attrgetter
from summary_avail import trace_availability, addpeer
import pyln.client
import requests
import threading
import time
import pickle
import sys
plugin = Plugin(autopatch=True)
datastore_key = ["summary", "avail"]
Channel = namedtuple(
"Channel",
[
"total",
"ours",
"theirs",
"pid",
"private",
"connected",
"scid",
"avail",
"base",
"ppm",
],
)
Charset = namedtuple(
"Charset", ["double_left", "left", "bar", "mid", "right", "double_right", "empty"]
)
draw_boxch = Charset("", "", "", "", "", "", "")
draw_ascii = Charset("#", "[", "-", "+", "]", "#", "|")
class PeerThread(threading.Thread):
def __init__(self):
super().__init__()
self.daemon = True
def run(self):
# delay initial execution, so peers have a chance to connect on startup
time.sleep(plugin.avail_interval)
while True:
try:
rpcpeers = plugin.rpc.listpeers()
trace_availability(plugin, rpcpeers)
write_datastore(plugin)
plugin.log(
"[PeerThread] Peerstate wrote to datastore. Sleeping now...",
"debug",
)
time.sleep(plugin.avail_interval)
except Exception as ex:
plugin.log("[PeerThread] " + str(ex), "warn")
class PriceThread(threading.Thread):
def __init__(self, proxies):
super().__init__()
self.daemon = True
self.proxies = proxies
def run(self):
while True:
try:
# NOTE: Bitstamp has a DNS/Proxy issues that can return 404
# Workaround: retry up to 5 times with a delay
for _ in range(5):
r = requests.get(
"https://www.bitstamp.net/api/v2/ticker/btc{}".format(
plugin.currency.lower()
),
proxies=self.proxies,
)
if not r.status_code == 200:
time.sleep(1)
continue
break
plugin.fiat_per_btc = float(r.json()["last"])
except Exception as ex:
plugin.log("[PriceThread] " + str(ex), "warn")
# Six hours is more than often enough for polling
time.sleep(6 * 3600)
def to_fiatstr(msat: Millisatoshi):
return "{}{:.2f}".format(
plugin.currency_prefix, int(msat) / 10**11 * plugin.fiat_per_btc
)
# appends an output table header that explains fields and capacity
def append_header(table, max_msat):
short_str = Millisatoshi(max_msat).to_approx_str()
draw = plugin.draw
table.append(
"%c%-13sOUT/OURS %c IN/THEIRS%12s%c SCID FLAG BASE PPM AVAIL ALIAS"
% (draw.left, short_str, draw.mid, short_str, draw.right)
)
# TODO: cln and pyln-client needs to be updated to get additional usage info.
# currently `usage` is generated by pyln-client.Plugin by just reading the args.
@plugin.method("summary")
def summary(plugin, exclude="", sortkey=None, ascii=None):
"""Gets summary information about this node.
Pass a list of scids to the {exclude} parameter to exclude some channels from the outputs.
Sort the result by using the {sortkey} parameter that can be one of 'total', 'ours', 'theirs', 'scid' (default), 'avail', 'base', 'ppm'.")
"""
# Sets ascii mode for this and future requests (if requested)
if ascii is not None:
if ascii:
plugin.draw = draw_ascii
else:
plugin.draw = draw_boxch
reply = {}
info = plugin.rpc.getinfo()
funds = plugin.rpc.listfunds()
peers = plugin.rpc.listpeers()["peers"]
# Make it stand out if we're not on mainnet.
if info["network"] != "bitcoin":
reply["network"] = info["network"].upper()
if hasattr(plugin, "my_address") and plugin.my_address:
reply["my_address"] = plugin.my_address
else:
reply["warning_no_address"] = "NO PUBLIC ADDRESSES"
utxos = [
int(f["amount_msat"]) for f in funds["outputs"] if f["status"] == "confirmed"
]
reply["num_utxos"] = len(utxos)
utxo_amount = Millisatoshi(sum(utxos))
reply["utxo_amount"] = utxo_amount.to_btc_str()
avail_out = Millisatoshi(0)
avail_in = Millisatoshi(0)
chans = []
reply["num_channels"] = 0
reply["num_connected"] = 0
reply["num_gossipers"] = 0
for p in peers:
pid = p["id"]
channels = []
if "channels" in p:
channels = p["channels"]
elif "num_channels" in p and p["num_channels"] > 0:
channels = plugin.rpc.listpeerchannels(pid)["channels"]
addpeer(plugin, p)
active_channel = False
for c in channels:
if c["state"] != "CHANNELD_NORMAL":
continue
active_channel = True
if c["short_channel_id"] in exclude:
continue
if p["connected"]:
reply["num_connected"] += 1
if c["our_reserve_msat"] < c["to_us_msat"]:
to_us = c["to_us_msat"] - c["our_reserve_msat"]
else:
to_us = Millisatoshi(0)
avail_out += to_us
# We have to derive amount to them
to_them = c["total_msat"] - c["to_us_msat"]
if c["their_reserve_msat"] < to_them:
to_them = to_them - c["their_reserve_msat"]
else:
to_them = Millisatoshi(0)
avail_in += to_them
reply["num_channels"] += 1
chans.append(
Channel(
c["total_msat"],
to_us,
to_them,
pid,
c["private"],
p["connected"],
c["short_channel_id"],
plugin.persist["p"][pid]["a"],
Millisatoshi(c["fee_base_msat"]),
c["fee_proportional_millionths"],
)
)
if not active_channel and p["connected"]:
reply["num_gossipers"] += 1
reply["avail_out"] = avail_out.to_btc_str()
reply["avail_in"] = avail_in.to_btc_str()
reply["fees_collected"] = Millisatoshi(info["fees_collected_msat"]).to_btc_str()
if plugin.fiat_per_btc > 0:
reply["utxo_amount"] += " ({})".format(to_fiatstr(utxo_amount))
reply["avail_out"] += " ({})".format(to_fiatstr(avail_out))
reply["avail_in"] += " ({})".format(to_fiatstr(avail_in))
reply["fees_collected"] += " ({})".format(
to_fiatstr(info["fees_collected_msat"])
)
if len(chans) > 0:
if sortkey is None or sortkey.lower() not in Channel._fields:
sortkey = plugin.sortkey
chans = sorted(chans, key=attrgetter(sortkey.lower()))
reply["channels_flags"] = "P:private O:offline"
reply["channels"] = ["\n"]
biggest = max(max(int(c.ours), int(c.theirs)) for c in chans)
append_header(reply["channels"], biggest)
for c in chans:
# Create simple line graph, 47 chars wide.
our_len = int(round(int(c.ours) / biggest * 23))
their_len = int(round(int(c.theirs) / biggest * 23))
# We put midpoint in the middle.
draw = plugin.draw
mid = draw.mid
if our_len == 0:
left = "{:>23}".format("")
mid = draw.double_left
else:
left = "{:>23}".format(draw.left + draw.bar * (our_len - 1))
if their_len == 0:
right = "{:23}".format("")
# Both 0 is a special case.
if our_len == 0:
mid = draw.empty
else:
mid = draw.double_right
else:
right = "{:23}".format(draw.bar * (their_len - 1) + draw.right)
s = left + mid + right
# output short channel id, so things can be copyNpasted easily
s += " {:14} ".format(c.scid)
extra = ""
if c.private:
extra += "P"
else:
extra += "_"
if not c.connected:
extra += "O"
else:
extra += "_"
s += "[{}] ".format(extra)
# append fees
s += " {:4}".format(c.base.millisatoshis)
s += " {:5} ".format(c.ppm)
# append 24hr availability
s += "{:4.0%} ".format(c.avail)
# append alias or id
node = plugin.rpc.listnodes(c.pid)["nodes"]
if len(node) != 0 and "alias" in node[0]:
s += node[0]["alias"]
else:
s += c.pid[0:32]
reply["channels"].append(s)
# Make modern lightning-cli format this human-readble by default!
reply["format-hint"] = "simple"
return reply
def new_datastore():
return {"p": {}, "r": 0, "v": 1} # see summary_avail.py for structure
def check_datastore(obj):
if "v" in obj and type(obj["v"]) is int and obj["v"] == 1:
return True
return False
def load_datastore(plugin):
entries = plugin.rpc.listdatastore(key=datastore_key)["datastore"]
if len(entries) == 0:
plugin.log(f"Creating a new datastore '{datastore_key}'", "debug")
return new_datastore()
persist = pickle.loads(bytearray.fromhex(entries[0]["hex"]))
if not check_datastore(persist):
plugin.log(f"Dismissing old datastore '{datastore_key}'", "debug")
return new_datastore()
plugin.log(
f"Reopened datastore '{datastore_key}' with {persist['r']} "
f"runs and {len(persist['p'])} entries",
"debug",
)
return persist
def write_datastore(plugin):
hexstr = pickle.dumps(plugin.persist).hex()
plugin.rpc.datastore(key=datastore_key, hex=hexstr, mode="create-or-replace")
@plugin.init()
def init(options, configuration, plugin):
plugin.sortkey = options["summary-sortkey"]
if plugin.sortkey not in Channel._fields:
plugin.sortkey = "scid" # default to 'scid' on unknown keys
plugin.currency = options["summary-currency"]
plugin.currency_prefix = options["summary-currency-prefix"]
plugin.fiat_per_btc = 0
plugin.avail_interval = float(options["summary-availability-interval"])
plugin.avail_window = 60 * 60 * int(options["summary-availability-window"])
plugin.persist = load_datastore(plugin)
plugin.draw = draw_ascii
# __version__ was introduced in 0.0.7.1, with utf8 passthrough support.
if hasattr(pyln.client, "__version__") and version.parse(
pyln.client.__version__
) >= version.parse("0.0.7.1"):
plugin.draw = draw_boxch
if options.get("summary-ascii"):
plugin.draw = draw_ascii
info = plugin.rpc.getinfo()
config = plugin.rpc.listconfigs()
if "always-use-proxy" in config and config["always-use-proxy"]:
paddr = config["proxy"]
# Default port in 9050
if ":" not in paddr:
paddr += ":9050"
proxies = {"https": "socks5h://" + paddr, "http": "socks5h://" + paddr}
else:
proxies = None
# Measure availability
PeerThread().start()
# Try to grab conversion price
PriceThread(proxies).start()
# Prefer IPv4, otherwise take any to give out address.
best_address = None
for a in info["address"]:
if best_address is None:
best_address = a
elif a["type"] == "ipv4" and best_address["type"] != "ipv4":
best_address = a
if best_address:
plugin.my_address = info["id"] + "@" + best_address["address"]
if best_address["port"] != 9735:
plugin.my_address += ":" + str(best_address["port"])
else:
plugin.my_address = None
plugin.log("Plugin summary.py initialized")
@plugin.subscribe("shutdown")
def on_rpc_command_callback(plugin, **kwargs):
# FIXME: Writing datastore does not work on exit, as daemon is already lost.
# plugin.log("Writing out datastore before shutting down")
# write_datastore(plugin)
sys.exit()
plugin.add_option(
"summary-currency", "USD", "What currency should I look up on btcaverage?"
)
plugin.add_option("summary-currency-prefix", "USD $", "What prefix to use for currency")
plugin.add_option(
"summary-availability-interval",
300,
"How often in seconds the availability should be calculated.",
)
plugin.add_option(
"summary-availability-window",
72,
"How many hours the availability should be averaged over.",
"int",
)
plugin.add_option(
"summary-sortkey",
"scid",
'Sort the channels list by a namedtuple key, defaults to "scid".',
)
plugin.add_option(
"summary-ascii", False, "If ascii mode should be enabled by default", "flag"
)
plugin.run()