Skip to content

Commit dc66844

Browse files
committed
Release 1.0.3
1 parent add4f11 commit dc66844

12 files changed

Lines changed: 302 additions & 51 deletions

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22

33
All notable changes to pyCluster should be recorded here.
44

5-
## 1.0.3 - Unreleased
5+
## 1.0.3 - 2026-03-30
6+
7+
- `show/qrz` now targets real QRZ XML lookups when QRZ credentials are configured, and the prior local history view has moved to `show/lastspot`
8+
- `show/wm7d` now performs a real WM7D callsign lookup
9+
- cluster mail has started moving beyond node-local storage:
10+
- `PC10` is aligned back to talk/direct-message semantics
11+
- cluster mail transport now uses `PC28`-`PC33`
12+
- `msg` and `reply` can queue and route mail by the recipient's configured home node
13+
- pending mail is flushed when the target peer connects
14+
- message listings now show delivery state
15+
- top-level `links` now shows the richer direct link status view instead of the older `show/connect` session dump
616

717
## 1.0.2 - 2026-03-29
818

config/pycluster.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ cty_dat_path = "./fixtures/live/dxspider/cty.dat"
2929

3030
[store]
3131
sqlite_path = "./data/pycluster.db"
32+
33+
[qrz]
34+
username = ""
35+
password = ""
36+
agent = ""
37+
api_url = "https://xmldata.qrz.com/xml/current/"

docs/commands-inventory.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ show/prefix
188188
show/program
189189
show/qra
190190
show/qrz
191+
show/lastspot
191192
show/rcmd
192193
show/registered
193194
show/route

docs/dxspider-command-catalog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ Generated from `cmd/*` inventory on `dxcluster.ai3i.net`.
238238
- `show/program`
239239
- `show/qra`
240240
- `show/qrz`
241+
- `show/lastspot`
241242
- `show/rcmd`
242243
- `show/registered`
243244
- `show/route`

docs/dxspider-parity-matrix.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ Generated UTC: 2026-03-10T13:50:56.587344+00:00
218218
| `show/prefix` | `complete` | `show/prefix` | |
219219
| `show/program` | `complete` | `show/program` | |
220220
| `show/qra` | `complete` | `show/qra` | |
221-
| `show/qrz` | `complete` | `show/qrz` | |
221+
| `show/qrz` | `partial` | `show/qrz` | real QRZ XML lookup when configured; requires QRZ credentials |
222222
| `show/rcmd` | `complete` | `show/rcmd` | |
223223
| `show/registered` | `complete` | `show/registered` | |
224224
| `show/route` | `complete` | `show/route` | |
@@ -235,7 +235,7 @@ Generated UTC: 2026-03-10T13:50:56.587344+00:00
235235
| `show/vhfstats` | `complete` | `show/vhfstats` | |
236236
| `show/vhftable` | `complete` | `show/vhftable` | |
237237
| `show/wcy` | `complete` | `show/wcy` | |
238-
| `show/wm7d` | `complete` | `show/wm7d` | |
238+
| `show/wm7d` | `complete` | `show/wm7d` | real WM7D page lookup |
239239
| `show/wwv` | `complete` | `show/wwv` | |
240240
| `show/wx` | `complete` | `show/wx` | |
241241
| `shu` | `complete` | `shu` | |

src/pycluster/config.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from dataclasses import asdict, dataclass
3+
from dataclasses import asdict, dataclass, field
44
from pathlib import Path
55
import json
66
import tomllib
@@ -56,13 +56,22 @@ class StoreConfig:
5656
sqlite_path: str = "./data/pycluster.db"
5757

5858

59+
@dataclass(slots=True)
60+
class QRZConfig:
61+
username: str = ""
62+
password: str = ""
63+
agent: str = ""
64+
api_url: str = "https://xmldata.qrz.com/xml/current/"
65+
66+
5967
@dataclass(slots=True)
6068
class AppConfig:
6169
node: NodeConfig
6270
telnet: TelnetConfig
6371
web: WebConfig
6472
public_web: PublicWebConfig
6573
store: StoreConfig
74+
qrz: QRZConfig = field(default_factory=QRZConfig)
6675

6776

6877
def node_presentation_defaults(node: NodeConfig) -> dict[str, str]:
@@ -128,7 +137,9 @@ def load_config(path: str | Path) -> AppConfig:
128137
public_web = PublicWebConfig(**_load_section(data, "public_web")) if "public_web" in data else PublicWebConfig()
129138
store = StoreConfig(**_load_section(data, "store"))
130139

131-
return AppConfig(node=node, telnet=telnet, web=web, public_web=public_web, store=store)
140+
qrz = QRZConfig(**_load_section(data, "qrz")) if "qrz" in data else QRZConfig()
141+
142+
return AppConfig(node=node, telnet=telnet, web=web, public_web=public_web, store=store, qrz=qrz)
132143

133144

134145
def _toml_value(value: object) -> str:
@@ -152,9 +163,10 @@ def dump_config(config: AppConfig) -> str:
152163
"web": asdict(config.web),
153164
"public_web": asdict(config.public_web),
154165
"store": asdict(config.store),
166+
"qrz": asdict(config.qrz),
155167
}
156168
lines: list[str] = []
157-
for section in ("node", "telnet", "web", "public_web", "store"):
169+
for section in ("node", "telnet", "web", "public_web", "store", "qrz"):
158170
lines.append(f"[{section}]")
159171
for key, value in data[section].items():
160172
lines.append(f"{key} = {_toml_value(value)}")

src/pycluster/qrz.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from dataclasses import dataclass
5+
import urllib.parse
6+
import urllib.request
7+
import xml.etree.ElementTree as ET
8+
9+
from .config import QRZConfig
10+
from .__init__ import __version__
11+
12+
13+
class QRZLookupError(RuntimeError):
14+
pass
15+
16+
17+
@dataclass(slots=True)
18+
class QRZLookupResult:
19+
callsign: str
20+
fname: str = ""
21+
name: str = ""
22+
addr1: str = ""
23+
addr2: str = ""
24+
state: str = ""
25+
country: str = ""
26+
grid: str = ""
27+
county: str = ""
28+
lat: str = ""
29+
lon: str = ""
30+
dxcc: str = ""
31+
cqzone: str = ""
32+
ituzone: str = ""
33+
aliases: str = ""
34+
35+
36+
class QRZClient:
37+
def __init__(self, config: QRZConfig) -> None:
38+
self._config = config
39+
self._session_key = ""
40+
self._lock = asyncio.Lock()
41+
42+
def configured(self) -> bool:
43+
return bool(self._config.username.strip() and self._config.password.strip())
44+
45+
async def lookup(self, callsign: str) -> QRZLookupResult | None:
46+
if not self.configured():
47+
raise QRZLookupError("QRZ lookup is not configured on this node.")
48+
async with self._lock:
49+
return await self._lookup_locked(callsign.upper())
50+
51+
async def _lookup_locked(self, callsign: str) -> QRZLookupResult | None:
52+
if not self._session_key:
53+
self._session_key = await self._login_locked()
54+
payload = self._fetch_xml({"s": self._session_key, "callsign": callsign})
55+
session = payload.get("session", {})
56+
error = session.get("error", "")
57+
if error and any(token in error.lower() for token in ("session", "invalid", "timeout")):
58+
self._session_key = await self._login_locked()
59+
payload = self._fetch_xml({"s": self._session_key, "callsign": callsign})
60+
session = payload.get("session", {})
61+
error = session.get("error", "")
62+
if error:
63+
if "not found" in error.lower():
64+
return None
65+
raise QRZLookupError(error)
66+
data = payload.get("callsign", {})
67+
if not data:
68+
return None
69+
return QRZLookupResult(
70+
callsign=data.get("call", callsign),
71+
fname=data.get("fname", ""),
72+
name=data.get("name", ""),
73+
addr1=data.get("addr1", ""),
74+
addr2=data.get("addr2", ""),
75+
state=data.get("state", ""),
76+
country=data.get("country", ""),
77+
grid=data.get("grid", ""),
78+
county=data.get("county", ""),
79+
lat=data.get("lat", ""),
80+
lon=data.get("lon", ""),
81+
dxcc=data.get("dxcc", ""),
82+
cqzone=data.get("cqzone", ""),
83+
ituzone=data.get("ituzone", ""),
84+
aliases=data.get("aliases", ""),
85+
)
86+
87+
async def _login_locked(self) -> str:
88+
payload = self._fetch_xml(
89+
{
90+
"username": self._config.username.strip(),
91+
"password": self._config.password,
92+
"agent": self._config.agent.strip() or f"pyCluster/{__version__}",
93+
}
94+
)
95+
session = payload.get("session", {})
96+
key = session.get("key", "")
97+
error = session.get("error", "")
98+
if error:
99+
raise QRZLookupError(error)
100+
if not key:
101+
raise QRZLookupError("QRZ login did not return a session key.")
102+
return key
103+
104+
def _fetch_xml(self, params: dict[str, str]) -> dict[str, dict[str, str]]:
105+
query = urllib.parse.urlencode(params)
106+
req = urllib.request.Request(
107+
f"{self._config.api_url}?{query}",
108+
headers={"User-Agent": self._config.agent.strip() or f"pyCluster/{__version__}"},
109+
)
110+
with urllib.request.urlopen(req, timeout=10) as resp:
111+
xml_bytes = resp.read()
112+
root = ET.fromstring(xml_bytes)
113+
return _xml_payload(root)
114+
115+
116+
def _xml_payload(root: ET.Element) -> dict[str, dict[str, str]]:
117+
out: dict[str, dict[str, str]] = {}
118+
for child in root:
119+
section = _local_tag(child.tag)
120+
values: dict[str, str] = {}
121+
for node in child:
122+
values[_local_tag(node.tag)] = (node.text or "").strip()
123+
out[section] = values
124+
return out
125+
126+
127+
def _local_tag(tag: str) -> str:
128+
if "}" in tag:
129+
tag = tag.rsplit("}", 1)[1]
130+
return tag.lower()

src/pycluster/telnet_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6784,7 +6784,7 @@ async def _cmd_disconnect(self, _call: str, arg: str | None) -> str:
67846784
return f"Peer {peer} was not found.\r\n"
67856785

67866786
async def _cmd_links(self, call: str, arg: str | None) -> str:
6787-
return await self._cmd_show_connect(call, arg)
6787+
return await self._cmd_show_links(call, arg)
67886788

67896789
async def _cmd_announce(self, call: str, arg: str | None, scope: str = "LOCAL") -> str:
67906790
denied = await self._require_access(call, "telnet", "announce", "announce")

src/pycluster/web_admin.py

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,10 +1509,37 @@ def _render_index_html(self) -> str:
15091509
<header>
15101510
<div>
15111511
<h2>Users</h2>
1512-
<div class="subtle">Manage local users and see which people on this node have System Operator access.</div>
1512+
<div class="subtle">Review local users and login state first, then edit the selected account details.</div>
15131513
</div>
15141514
</header>
15151515
<div class="body">
1516+
<div class="users-columns">
1517+
<section>
1518+
<h3>Local Users</h3>
1519+
<div class="actions" style="margin-bottom:12px">
1520+
<input id="user_search" placeholder="Filter users by callsign, name, home node, QTH, email" title="Search local users by callsign, name, home node, QTH, or email." style="max-width:320px">
1521+
<button class="secondary" id="userSearch">Search</button>
1522+
<button class="secondary" id="userPrev">Previous</button>
1523+
<button class="secondary" id="userNext">Next</button>
1524+
<span class="subtle" id="userPageInfo">Page 1</span>
1525+
</div>
1526+
<div class="tablewrap">
1527+
<table>
1528+
<thead><tr><th>Callsign</th><th>Access</th><th>Home Node</th><th>Telnet</th><th>Web</th><th>Post</th><th>Last Login</th><th>Last Path</th></tr></thead>
1529+
<tbody id="userRows"><tr><td colspan="8">Loading local users...</td></tr></tbody>
1530+
</table>
1531+
</div>
1532+
</section>
1533+
<section>
1534+
<h3>Blocked Users</h3>
1535+
<div class="subtle" style="margin-bottom:12px">Calls blocked from login on this node, including matching SSID variants.</div>
1536+
<div class="tablewrap">
1537+
<table>
1538+
<thead><tr><th>Callsign</th><th>Home Node</th><th>Block Reason</th><th>Blocked</th><th>Last Path</th></tr></thead>
1539+
<tbody id="blockedRows"><tr><td colspan="5">Loading blocked users...</td></tr></tbody>
1540+
</table>
1541+
</div>
1542+
</section>
15161543
<section>
15171544
<h3>System Operators</h3>
15181545
<div class="tablewrap">
@@ -1522,6 +1549,7 @@ def _render_index_html(self) -> str:
15221549
</table>
15231550
</div>
15241551
</section>
1552+
</div>
15251553
<div class="users-editor" style="margin-top:14px">
15261554
<section>
15271555
<h3 id="userEditorTitle">User Details</h3>
@@ -1572,34 +1600,6 @@ def _render_index_html(self) -> str:
15721600
</div>
15731601
</section>
15741602
</div>
1575-
<div class="users-columns">
1576-
<section>
1577-
<h3>Blocked Users</h3>
1578-
<div class="subtle" style="margin-bottom:12px">Calls blocked from login on this node, including matching SSID variants.</div>
1579-
<div class="tablewrap">
1580-
<table>
1581-
<thead><tr><th>Callsign</th><th>Home Node</th><th>Block Reason</th><th>Blocked</th><th>Last Path</th></tr></thead>
1582-
<tbody id="blockedRows"><tr><td colspan="5">Loading blocked users...</td></tr></tbody>
1583-
</table>
1584-
</div>
1585-
</section>
1586-
<section>
1587-
<h3>Local Users</h3>
1588-
<div class="actions" style="margin-bottom:12px">
1589-
<input id="user_search" placeholder="Filter users by callsign, name, home node, QTH, email" title="Search local users by callsign, name, home node, QTH, or email." style="max-width:320px">
1590-
<button class="secondary" id="userSearch">Search</button>
1591-
<button class="secondary" id="userPrev">Previous</button>
1592-
<button class="secondary" id="userNext">Next</button>
1593-
<span class="subtle" id="userPageInfo">Page 1</span>
1594-
</div>
1595-
<div class="tablewrap">
1596-
<table>
1597-
<thead><tr><th>Callsign</th><th>Access</th><th>Home Node</th><th>Telnet</th><th>Web</th><th>Post</th><th>Last Login</th><th>Last Path</th></tr></thead>
1598-
<tbody id="userRows"><tr><td colspan="8">Loading local users...</td></tr></tbody>
1599-
</table>
1600-
</div>
1601-
</section>
1602-
</div>
16031603
</div>
16041604
</section>
16051605

0 commit comments

Comments
 (0)