mirror of
https://github.com/craigerl/aprsd.git
synced 2026-08-17 00:54:03 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e417d3040b | |||
| d06390add8 | |||
| db3a0428c0 | |||
| b064ac97a4 | |||
| 5cc918e5c2 | |||
| 9146ff76c9 | |||
| 0c515d45fe | |||
| d3281cff0b | |||
| d8134c4531 | |||
| 3a47571b60 | |||
| 4ecdec0aa4 |
@@ -44,6 +44,13 @@ class APRSLibClient(aprslib.IS):
|
||||
select_timeout = 1
|
||||
lock = threading.Lock()
|
||||
|
||||
# Shared lock between RX (reader) and TX (writer) to prevent
|
||||
# socket state races. The reader sets setblocking(0) and the
|
||||
# writer's sendall (in aprslib) sets setblocking(1). Without
|
||||
# synchronization, these can race causing partial writes and
|
||||
# stream corruption on retransmits.
|
||||
_socket_lock = threading.Lock()
|
||||
|
||||
def stop(self):
|
||||
self.thread_stop = True
|
||||
LOG.warning('Shutdown Aprsdis client.')
|
||||
@@ -54,8 +61,15 @@ class APRSLibClient(aprslib.IS):
|
||||
|
||||
@wrapt.synchronized(lock)
|
||||
def send(self, packet: core.Packet):
|
||||
"""Send an APRS Message object."""
|
||||
self.sendall(packet.raw)
|
||||
"""Send an APRS Message object.
|
||||
|
||||
Uses _socket_lock to prevent racing with the reader thread's
|
||||
setblocking(0) call. The upstream aprslib sendall() sets
|
||||
setblocking(1) before writing, which can corrupt in-progress
|
||||
recv() calls if unsynchronized.
|
||||
"""
|
||||
with self._socket_lock:
|
||||
self.sendall(packet.raw)
|
||||
|
||||
def is_alive(self):
|
||||
"""If the connection is alive or not."""
|
||||
@@ -141,7 +155,13 @@ class APRSLibClient(aprslib.IS):
|
||||
continue
|
||||
|
||||
try:
|
||||
short_buf = self.sock.recv(4096)
|
||||
with self._socket_lock:
|
||||
# Re-ensure non-blocking mode inside the lock.
|
||||
# The send() path (via aprslib sendall) sets
|
||||
# setblocking(1), so we must restore non-blocking
|
||||
# before recv() to avoid blocking indefinitely.
|
||||
self.sock.setblocking(0)
|
||||
short_buf = self.sock.recv(4096)
|
||||
|
||||
# sock.recv returns empty if the connection drops
|
||||
if not short_buf:
|
||||
|
||||
@@ -130,7 +130,7 @@ def send_message(
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
APRSDClient().client # noqa: B018
|
||||
APRSDClient() # noqa: B018
|
||||
except LoginError:
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
@@ -53,8 +53,9 @@ class DupePacketFilter:
|
||||
# We haven't seen this packet before, so we process it.
|
||||
return packet
|
||||
|
||||
if not packet.processed:
|
||||
# We haven't processed this packet through the plugins.
|
||||
if not found.processed:
|
||||
# The previously seen packet hasn't been processed yet,
|
||||
# so let this one through too.
|
||||
return packet
|
||||
elif abs(packet.timestamp - found.timestamp) < CONF.packet_dupe_timeout:
|
||||
# If the packet came in within N seconds of the
|
||||
|
||||
@@ -88,9 +88,25 @@ class PacketTrack(objectstore.ObjectStoreMixin):
|
||||
self._remove(packet.ackMsgNo)
|
||||
|
||||
def tx(self, packet: type[core.Packet]) -> None:
|
||||
"""Add a packet that was sent."""
|
||||
"""Add a packet that was sent.
|
||||
|
||||
BeaconPackets are skipped — they are fire-and-forget and never
|
||||
receive an ack, so tracking them only causes the scheduler to
|
||||
re-transmit them as unwanted duplicates.
|
||||
|
||||
AckPackets that are already being tracked are NOT reset — this
|
||||
prevents digipeated duplicates of the same message from restarting
|
||||
the ack retry counter, which caused ack floods on RF.
|
||||
"""
|
||||
if isinstance(packet, core.BeaconPacket):
|
||||
return
|
||||
with self.lock:
|
||||
key = packet.msgNo
|
||||
if key in self.data and isinstance(packet, core.AckPacket):
|
||||
# Already tracking this ack — don't reset send_count.
|
||||
# This happens when the same message arrives via multiple
|
||||
# digipeater paths and each copy triggers an ack send.
|
||||
return
|
||||
packet.send_count = 0
|
||||
self.data[key] = packet
|
||||
self.total_tracked += 1
|
||||
@@ -98,6 +114,29 @@ class PacketTrack(objectstore.ObjectStoreMixin):
|
||||
def remove(self, key):
|
||||
self._remove(key)
|
||||
|
||||
def load(self):
|
||||
"""Load tracked packets from disk, filtering out stale BeaconPackets.
|
||||
|
||||
BeaconPackets should never be retried (they are fire-and-forget),
|
||||
but older versions persisted them to disk. Strip them on load so
|
||||
they don't get retransmitted after a restart.
|
||||
"""
|
||||
super().load()
|
||||
with self.lock:
|
||||
stale = [
|
||||
key
|
||||
for key, pkt in self.data.items()
|
||||
if isinstance(pkt, core.BeaconPacket)
|
||||
or (isinstance(pkt, dict) and pkt.get('_type') == 'BeaconPacket')
|
||||
]
|
||||
for key in stale:
|
||||
del self.data[key]
|
||||
if stale:
|
||||
LOG.info(
|
||||
f'PacketTrack: removed {len(stale)} stale BeaconPacket(s) '
|
||||
f'from persisted data.',
|
||||
)
|
||||
|
||||
def _remove(self, key):
|
||||
with self.lock:
|
||||
try:
|
||||
|
||||
+19
-2
@@ -267,9 +267,18 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
|
||||
|
||||
# Check if packet is still being tracked (not acked)
|
||||
if packet.send_count >= packet.retry_count:
|
||||
# Max retries reached, will be cleaned up by worker
|
||||
# Max retries reached, clean up
|
||||
pkt_tracker.remove(msg_no)
|
||||
continue
|
||||
|
||||
# Don't submit if we sent recently (prevents threadpool race
|
||||
# where multiple workers fire before send_count is incremented)
|
||||
if packet.last_send_time:
|
||||
now = int(round(time.time()))
|
||||
sleeptime = (packet.send_count + 1) * 31
|
||||
if now - packet.last_send_time < sleeptime:
|
||||
continue
|
||||
|
||||
# Submit send task to threadpool
|
||||
# The worker will check timing and send if needed
|
||||
self.executor.submit(_send_packet_worker, msg_no)
|
||||
@@ -318,9 +327,17 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
|
||||
|
||||
# Check if ack is still being tracked
|
||||
if packet.send_count >= self.max_retries:
|
||||
# Max retries reached, will be cleaned up by worker
|
||||
# Max retries reached, clean up
|
||||
pkt_tracker.remove(msg_no)
|
||||
continue
|
||||
|
||||
# Don't submit if we sent recently (prevents threadpool race
|
||||
# where multiple workers fire before send_count is incremented)
|
||||
if packet.last_send_time:
|
||||
now = int(round(time.time()))
|
||||
if now - packet.last_send_time < 31:
|
||||
continue
|
||||
|
||||
# Submit send task to threadpool
|
||||
self.executor.submit(_send_ack_worker, msg_no, self.max_retries)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# APRS Chat Bulletin Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone bulletin script that announces APRS Chat on Google Play and includes short follow-up bulletin lines.
|
||||
|
||||
**Architecture:** Add one new shell script in `tools/` that matches the existing bulletin-script pattern already used in this repo, with a minimal `set -e` safety guard so failures are not masked by later `sleep` commands. Protect the behavior with one focused Python regression test that checks the script path, executable bit, and exact bulletin command lines.
|
||||
|
||||
**Tech Stack:** Bash, pytest, Python `pathlib`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `tools/bulletin-aprschat.sh` | Create | Standalone APRS Chat Google Play bulletin sender |
|
||||
| `tests/test_bulletin_scripts.py` | Create | Regression test for bulletin script presence and content |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: APRS Chat Bulletin Script
|
||||
|
||||
### Task 1: Add the bulletin script with a focused regression test
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_bulletin_scripts.py`
|
||||
- Create: `tools/bulletin-aprschat.sh`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/test_bulletin_scripts.py` with:
|
||||
|
||||
```python
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bulletin_aprschat_script_contents():
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
|
||||
|
||||
assert script.exists()
|
||||
assert script.stat().st_mode & stat.S_IXUSR
|
||||
|
||||
lines = script.read_text().splitlines()
|
||||
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
|
||||
|
||||
assert send_lines == [
|
||||
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
|
||||
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
|
||||
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
|
||||
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_bulletin_scripts.py -v`
|
||||
Expected: FAIL because `tools/bulletin-aprschat.sh` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation**
|
||||
|
||||
Create `tools/bulletin-aprschat.sh` with:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Send APRS bulletins announcing APRS Chat on Google Play
|
||||
|
||||
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
|
||||
|
||||
export APRS_LOGIN=WB4BOR
|
||||
export APRS_PASSWORD=24496
|
||||
|
||||
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
|
||||
sleep 2
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Make the script executable**
|
||||
|
||||
Run: `chmod +x tools/bulletin-aprschat.sh`
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_bulletin_scripts.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Run a second verification pass**
|
||||
|
||||
Run: `bash -n tools/bulletin-aprschat.sh && pytest tests/test_bulletin_scripts.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_bulletin_scripts.py tools/bulletin-aprschat.sh
|
||||
git commit -m "Add APRS Chat bulletin script"
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
# APRS Chat Google Play Bulletin Script
|
||||
|
||||
**Date:** 2026-05-21
|
||||
**Status:** Draft
|
||||
**Scope:** `tools/` bulletin helper scripts
|
||||
|
||||
## Overview
|
||||
|
||||
Add a new standalone bulletin script that announces APRS Chat on the Google Play Store and follows with a few short explanatory bulletin lines.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add a dedicated script for the APRS Chat Play Store announcement.
|
||||
2. Match the existing `tools/bulletin-*.sh` style and operational pattern.
|
||||
3. Keep bulletin text short, direct, and suitable for APRS bulletin usage.
|
||||
|
||||
## Current State
|
||||
|
||||
- Existing bulletin scripts in `tools/` are small standalone shell wrappers.
|
||||
- They activate the local virtual environment, export APRS credentials, send a few `BLN` messages, and pause with `sleep 2` between lines.
|
||||
- There is no dedicated APRS Chat Google Play bulletin script today.
|
||||
|
||||
## Design
|
||||
|
||||
### Script Shape
|
||||
|
||||
Create `tools/bulletin-aprschat.sh` using the same pattern as `tools/bulletin-aprsthursday.sh`, with one small safety improvement so the script exits immediately if activation or any bulletin send fails:
|
||||
|
||||
- `#!/bin/bash`
|
||||
- `set -e`
|
||||
- brief header comment describing purpose
|
||||
- `source ~/devel/mine/hamradio/aprsd/.venv/bin/activate`
|
||||
- export `APRS_LOGIN=WB4BOR` and `APRS_PASSWORD=24496`
|
||||
- send four numbered bulletin messages with `aprsd send-message -n BLN* ...`
|
||||
- pause with `sleep 2` between each message
|
||||
|
||||
### Bulletin Content
|
||||
|
||||
The script will send these lines:
|
||||
|
||||
1. `BLN0 APRS Chat now on Google Play Store!`
|
||||
2. `BLN1 Install: https://tinyurl.com/APRSChat`
|
||||
3. `BLN2 Android app for APRS chat and messaging`
|
||||
4. `BLN3 Search Google Play for APRS Chat`
|
||||
|
||||
This keeps the first line focused on the announcement, the second on the install URL, and the remaining lines as short follow-up guidance.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `tools/bulletin-aprschat.sh` - new standalone bulletin script
|
||||
2. `tests/test_bulletin_scripts.py` - regression test for script content and expected bulletin lines
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Write a failing test first asserting:
|
||||
- the new script exists
|
||||
- it is executable
|
||||
- it contains the expected ordered `BLN0`-`BLN3` send lines
|
||||
2. Add the script with the approved content.
|
||||
3. Re-run the focused test to confirm it passes.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Refactoring existing bulletin scripts into a shared helper
|
||||
- Folding the APRS Chat announcement into `tools/bulletin.sh`
|
||||
- Adding scheduling or automation around bulletin execution
|
||||
+20
-20
@@ -1,45 +1,45 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt
|
||||
aprslib @ git+https://github.com/hemna/aprs-python.git@09cd7a2829a2e9d28ee1566881c843cc4769e590 # via -r requirements.in
|
||||
attrs==25.4.0 # via ax253, kiss3, rush
|
||||
attrs==26.1.0 # via ax253, kiss3, rush
|
||||
ax253==0.1.5.post1 # via kiss3
|
||||
bitarray==3.8.0 # via ax253, kiss3
|
||||
certifi==2025.11.12 # via requests
|
||||
charset-normalizer==3.4.4 # via requests
|
||||
click==8.3.1 # via -r requirements.in
|
||||
bitarray==3.8.1 # via ax253, kiss3
|
||||
certifi==2026.5.20 # via requests
|
||||
charset-normalizer==3.4.7 # via requests
|
||||
click==8.4.1 # via -r requirements.in
|
||||
dataclasses-json==0.6.7 # via -r requirements.in
|
||||
haversine==2.9.0 # via -r requirements.in
|
||||
idna==3.11 # via requests
|
||||
importlib-metadata==8.7.0 # via ax253, kiss3
|
||||
idna==3.18 # via requests
|
||||
importlib-metadata==9.0.0 # via ax253, kiss3
|
||||
kiss3==8.0.0 # via -r requirements.in
|
||||
loguru==0.7.3 # via -r requirements.in
|
||||
markdown-it-py==4.0.0 # via rich
|
||||
markdown-it-py==4.2.0 # via rich
|
||||
marshmallow==3.26.2 # via dataclasses-json
|
||||
mdurl==0.1.2 # via markdown-it-py
|
||||
mypy-extensions==1.1.0 # via typing-inspect
|
||||
netaddr==1.3.0 # via oslo-config
|
||||
oslo-config==10.1.0 # via -r requirements.in
|
||||
oslo-i18n==6.7.1 # via oslo-config
|
||||
packaging==25.0 # via marshmallow
|
||||
oslo-config==10.4.0 # via -r requirements.in
|
||||
oslo-i18n==6.8.0 # via oslo-config
|
||||
packaging==26.2 # via marshmallow
|
||||
pbr==7.0.3 # via oslo-i18n
|
||||
pluggy==1.6.0 # via -r requirements.in
|
||||
pygments==2.19.2 # via rich
|
||||
pygments==2.20.0 # via rich
|
||||
pyserial==3.5 # via pyserial-asyncio
|
||||
pyserial-asyncio==0.6 # via kiss3
|
||||
pytz==2025.2 # via -r requirements.in
|
||||
pytz==2026.2 # via -r requirements.in
|
||||
pyyaml==6.0.3 # via oslo-config
|
||||
requests==2.32.5 # via oslo-config, update-checker, -r requirements.in
|
||||
requests==2.34.2 # via oslo-config, -r requirements.in
|
||||
rfc3986==2.0.0 # via oslo-config
|
||||
rich==14.2.0 # via -r requirements.in
|
||||
rich==15.0.0 # via -r requirements.in
|
||||
rush==2021.4.0 # via -r requirements.in
|
||||
setuptools==82.0.1 # via pbr
|
||||
stevedore==5.6.0 # via oslo-config
|
||||
stevedore==5.8.0 # via oslo-config
|
||||
thesmuggler==1.0.1 # via -r requirements.in
|
||||
timeago==1.0.16 # via -r requirements.in
|
||||
typing-extensions==4.15.0 # via typing-inspect
|
||||
typing-inspect==0.9.0 # via dataclasses-json
|
||||
tzlocal==5.3.1 # via -r requirements.in
|
||||
update-checker==0.18.0 # via -r requirements.in
|
||||
urllib3==2.6.3 # via requests
|
||||
wrapt==2.0.1 # via -r requirements.in
|
||||
zipp==3.23.0 # via importlib-metadata
|
||||
update-checker==1.0.0 # via -r requirements.in
|
||||
urllib3==2.7.0 # via requests
|
||||
wrapt==2.2.1 # via -r requirements.in
|
||||
zipp==4.1.0 # via importlib-metadata
|
||||
|
||||
@@ -70,18 +70,22 @@ class TestDupePacketFilter(unittest.TestCase):
|
||||
self.assertEqual(result, packet)
|
||||
|
||||
def test_filter_duplicate_within_timeout(self):
|
||||
"""Test filter() with duplicate within timeout."""
|
||||
"""Test filter() with duplicate within timeout.
|
||||
|
||||
The found (previously stored) packet is already processed.
|
||||
The new incoming duplicate should be dropped.
|
||||
"""
|
||||
from oslo_config import cfg
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.packet_dupe_timeout = 60
|
||||
|
||||
packet = fake.fake_packet(msg_number='123')
|
||||
packet.processed = True
|
||||
packet.timestamp = 1000
|
||||
|
||||
mock_list_instance = mock.MagicMock()
|
||||
found_packet = fake.fake_packet(msg_number='123')
|
||||
found_packet.processed = True # the stored packet was already processed
|
||||
found_packet.timestamp = 1050 # Within 60 second timeout
|
||||
mock_list_instance.find.return_value = found_packet
|
||||
self.filter.pl = mock_list_instance
|
||||
@@ -92,18 +96,23 @@ class TestDupePacketFilter(unittest.TestCase):
|
||||
mock_log.warning.assert_called()
|
||||
|
||||
def test_filter_duplicate_after_timeout(self):
|
||||
"""Test filter() with duplicate after timeout."""
|
||||
"""Test filter() with duplicate after timeout.
|
||||
|
||||
The found (previously stored) packet is already processed,
|
||||
but it arrived long ago (outside the dupe timeout window).
|
||||
The new incoming duplicate should be re-processed with a warning.
|
||||
"""
|
||||
from oslo_config import cfg
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.packet_dupe_timeout = 60
|
||||
|
||||
packet = fake.fake_packet(msg_number='123')
|
||||
packet.processed = True
|
||||
packet.timestamp = 2000
|
||||
|
||||
mock_list_instance = mock.MagicMock()
|
||||
found_packet = fake.fake_packet(msg_number='123')
|
||||
found_packet.processed = True # the stored packet was already processed
|
||||
found_packet.timestamp = 1000 # More than 60 seconds ago
|
||||
mock_list_instance.find.return_value = found_packet
|
||||
self.filter.pl = mock_list_instance
|
||||
@@ -112,3 +121,49 @@ class TestDupePacketFilter(unittest.TestCase):
|
||||
result = self.filter.filter(packet)
|
||||
self.assertEqual(result, packet) # Should pass
|
||||
mock_log.warning.assert_called()
|
||||
|
||||
def test_filter_aprs_retransmit_via_different_digi(self):
|
||||
"""Regression test for the production duplicate-reply bug.
|
||||
|
||||
Scenario (observed 2026-06-02 in aprsd-REPEAT logs):
|
||||
09:49:14 RX MessagePacket:9028 KM6LYW→...→qAO→KM6LYW-2→REPEAT "N 2"
|
||||
→ processed, NearestPlugin replies sent (msg 2385, 2386)
|
||||
09:49:47 TX AckPacket:9028 (2 of 3) ← KM6LYW never got the ack
|
||||
09:50:17 RX MessagePacket:9028 KM6LYW→...→qAR→GTOWN→REPEAT "N 2"
|
||||
← same msgNo, different digipeater path, 63 seconds later
|
||||
→ BUG: DupePacketFilter passed it through because it was
|
||||
checking packet.processed (new packet, always False)
|
||||
instead of found.processed (stored packet, True)
|
||||
→ NearestPlugin ran again → 4 replies sent instead of 2
|
||||
|
||||
The fix: check found.processed, not packet.processed.
|
||||
"""
|
||||
from oslo_config import cfg
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.packet_dupe_timeout = 300 # 5 minute default
|
||||
|
||||
# The retransmit arrives 63 seconds after the first receipt.
|
||||
first_timestamp = 1000.0
|
||||
retransmit_timestamp = first_timestamp + 63
|
||||
|
||||
# Incoming duplicate — freshly decoded, processed=False (always)
|
||||
retransmit = fake.fake_packet(msg_number='9028')
|
||||
retransmit.timestamp = retransmit_timestamp
|
||||
|
||||
# What PacketList holds from the first receipt — already processed
|
||||
first_receipt = fake.fake_packet(msg_number='9028')
|
||||
first_receipt.processed = True
|
||||
first_receipt.timestamp = first_timestamp
|
||||
|
||||
mock_list_instance = mock.MagicMock()
|
||||
mock_list_instance.find.return_value = first_receipt
|
||||
self.filter.pl = mock_list_instance
|
||||
|
||||
with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log:
|
||||
result = self.filter.filter(retransmit)
|
||||
self.assertIsNone(result) # Must be dropped — no duplicate reply
|
||||
mock_log.warning.assert_called_once()
|
||||
warning_msg = mock_log.warning.call_args[0][0]
|
||||
self.assertIn('9028', warning_msg)
|
||||
self.assertIn('already tracked', warning_msg)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from aprsd.packets import tracker
|
||||
from aprsd.packets import core, tracker
|
||||
from tests import fake
|
||||
|
||||
|
||||
@@ -218,3 +218,137 @@ class TestPacketTrack(unittest.TestCase):
|
||||
|
||||
pt.tx(fake.fake_packet(msg_number='456'))
|
||||
self.assertEqual(len(pt), 2)
|
||||
|
||||
def test_tx_skips_beacon_packet(self):
|
||||
"""Test tx() does not track BeaconPackets.
|
||||
|
||||
BeaconPackets are fire-and-forget — they never receive an ack,
|
||||
so tracking them would cause the scheduler to retransmit them
|
||||
as unwanted duplicates flooding RF.
|
||||
"""
|
||||
pt = tracker.PacketTrack()
|
||||
beacon = core.BeaconPacket(
|
||||
from_call='KFAKE',
|
||||
to_call='APRS',
|
||||
latitude=38.0,
|
||||
longitude=-121.0,
|
||||
comment='Test Beacon',
|
||||
)
|
||||
beacon.prepare(create_msg_number=True)
|
||||
initial_total = pt.total_tracked
|
||||
|
||||
pt.tx(beacon)
|
||||
|
||||
# Beacon should NOT be tracked
|
||||
self.assertEqual(len(pt), 0)
|
||||
self.assertEqual(pt.total_tracked, initial_total)
|
||||
|
||||
def test_tx_beacon_not_tracked_even_with_retry_count(self):
|
||||
"""Test tx() skips BeaconPacket regardless of retry_count setting."""
|
||||
pt = tracker.PacketTrack()
|
||||
beacon = core.BeaconPacket(
|
||||
from_call='KFAKE',
|
||||
to_call='APDW16',
|
||||
latitude=38.0,
|
||||
longitude=-121.0,
|
||||
comment='WebChat Beacon',
|
||||
)
|
||||
beacon.retry_count = 3 # Even with retries set, don't track
|
||||
beacon.prepare(create_msg_number=True)
|
||||
|
||||
pt.tx(beacon)
|
||||
|
||||
self.assertEqual(len(pt), 0)
|
||||
|
||||
def test_tx_ack_not_reset_when_already_tracked(self):
|
||||
"""Test tx() does not reset send_count for an ack already being tracked.
|
||||
|
||||
When the same message arrives via multiple digipeater paths, each
|
||||
copy triggers an ack send. The tracker must NOT reset send_count
|
||||
on the existing ack, otherwise the retry counter restarts and
|
||||
floods RF with duplicate acks.
|
||||
"""
|
||||
pt = tracker.PacketTrack()
|
||||
|
||||
# First ack for msgNo '8817'
|
||||
ack1 = core.AckPacket(
|
||||
from_call='KM6LYW',
|
||||
to_call='KM6LYW-9',
|
||||
msgNo='8817',
|
||||
)
|
||||
pt.tx(ack1)
|
||||
self.assertIn('8817', pt.data)
|
||||
self.assertEqual(pt.data['8817'].send_count, 0)
|
||||
|
||||
# Simulate ack being partially sent (scheduler incremented send_count)
|
||||
pt.data['8817'].send_count = 2
|
||||
|
||||
# Second ack for the same msgNo (from a digi copy of the message)
|
||||
ack2 = core.AckPacket(
|
||||
from_call='KM6LYW',
|
||||
to_call='KM6LYW-9',
|
||||
msgNo='8817',
|
||||
)
|
||||
pt.tx(ack2)
|
||||
|
||||
# send_count must NOT be reset to 0
|
||||
self.assertEqual(pt.data['8817'].send_count, 2)
|
||||
# total_tracked should not have incremented again
|
||||
self.assertEqual(pt.total_tracked, 1)
|
||||
|
||||
def test_tx_ack_tracked_on_first_occurrence(self):
|
||||
"""Test tx() properly tracks an ack on first occurrence."""
|
||||
pt = tracker.PacketTrack()
|
||||
ack = core.AckPacket(
|
||||
from_call='KM6LYW',
|
||||
to_call='KM6LYW-9',
|
||||
msgNo='100',
|
||||
)
|
||||
pt.tx(ack)
|
||||
|
||||
self.assertIn('100', pt.data)
|
||||
self.assertEqual(pt.data['100'].send_count, 0)
|
||||
self.assertEqual(pt.total_tracked, 1)
|
||||
|
||||
def test_tx_message_packet_still_resets_on_duplicate(self):
|
||||
"""Test that non-ack packets still get reset if sent again.
|
||||
|
||||
MessagePackets may legitimately need to be re-sent with fresh
|
||||
retry state (e.g., user re-sends a message).
|
||||
"""
|
||||
pt = tracker.PacketTrack()
|
||||
pkt = fake.fake_packet(msg_number='999')
|
||||
pt.tx(pkt)
|
||||
pt.data['999'].send_count = 2
|
||||
|
||||
# Re-sending the same message should reset
|
||||
pkt2 = fake.fake_packet(msg_number='999')
|
||||
pt.tx(pkt2)
|
||||
|
||||
self.assertEqual(pt.data['999'].send_count, 0)
|
||||
|
||||
def test_heavy_traffic_multiple_digi_paths(self):
|
||||
"""Simulate heavy traffic: same message arrives via 5 digipeater paths.
|
||||
|
||||
Each arrival triggers an ack. Only the first ack should be tracked.
|
||||
Subsequent ack sends for the same msgNo must not reset the tracker,
|
||||
preventing an ack flood on RF.
|
||||
"""
|
||||
pt = tracker.PacketTrack()
|
||||
|
||||
# Simulate 5 copies of the same message arriving via different paths
|
||||
for i in range(5):
|
||||
ack = core.AckPacket(
|
||||
from_call='KM6LYW',
|
||||
to_call='KM6LYW-9',
|
||||
msgNo='8817',
|
||||
)
|
||||
pt.tx(ack)
|
||||
|
||||
# After first add, simulate partial sending
|
||||
if i == 0:
|
||||
pt.data['8817'].send_count = 1
|
||||
|
||||
# Only tracked once, send_count preserved from after first send
|
||||
self.assertEqual(pt.total_tracked, 1)
|
||||
self.assertEqual(pt.data['8817'].send_count, 1)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_bulletin_aprschat_script_contents():
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
|
||||
|
||||
assert script.exists()
|
||||
assert script.stat().st_mode & stat.S_IXUSR
|
||||
|
||||
lines = script.read_text().splitlines()
|
||||
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
|
||||
|
||||
assert send_lines == [
|
||||
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
|
||||
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
|
||||
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
|
||||
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
|
||||
]
|
||||
@@ -822,3 +822,208 @@ class TestBeaconSendThread(unittest.TestCase):
|
||||
mock_log.error.assert_called()
|
||||
mock_client_class.return_value.reset.assert_called()
|
||||
thread.stop()
|
||||
|
||||
|
||||
class TestSchedulerTimingGuards(unittest.TestCase):
|
||||
"""Tests for scheduler timing guards that prevent threadpool race conditions.
|
||||
|
||||
These tests verify that the scheduler does NOT submit workers to the
|
||||
threadpool when a packet was recently sent, preventing the race where
|
||||
multiple workers fire before send_count is incremented.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
from oslo_config import cfg
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.default_ack_send_count = 3
|
||||
tracker.PacketTrack._instance = None
|
||||
tracker.PacketTrack.data = {}
|
||||
tracker.PacketTrack.total_tracked = 0
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests."""
|
||||
tracker.PacketTrack._instance = None
|
||||
tracker.PacketTrack.data = {}
|
||||
tracker.PacketTrack.total_tracked = 0
|
||||
|
||||
def test_ack_scheduler_skips_recently_sent(self):
|
||||
"""AckSendSchedulerThread must not re-submit if sent < 31 seconds ago.
|
||||
|
||||
This prevents the threadpool race where multiple workers fire for
|
||||
the same ack before send_count is incremented, causing rapid-fire
|
||||
duplicate acks on RF.
|
||||
"""
|
||||
scheduler = tx.AckSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
ack_packet = fake.fake_ack_packet()
|
||||
ack_packet.send_count = 1
|
||||
ack_packet.last_send_time = int(round(time.time())) # Just sent
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['12']
|
||||
mock_tracker.get.return_value = ack_packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should NOT submit — sent too recently
|
||||
mock_submit.assert_not_called()
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_ack_scheduler_submits_after_31_seconds(self):
|
||||
"""AckSendSchedulerThread submits worker after 31 second cooldown."""
|
||||
scheduler = tx.AckSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
ack_packet = fake.fake_ack_packet()
|
||||
ack_packet.send_count = 1
|
||||
ack_packet.last_send_time = int(round(time.time())) - 32 # 32 seconds ago
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['12']
|
||||
mock_tracker.get.return_value = ack_packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should submit — enough time has passed
|
||||
mock_submit.assert_called_once()
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_ack_scheduler_submits_first_send(self):
|
||||
"""AckSendSchedulerThread submits worker on first send (no last_send_time)."""
|
||||
scheduler = tx.AckSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
ack_packet = fake.fake_ack_packet()
|
||||
ack_packet.send_count = 0
|
||||
ack_packet.last_send_time = None # Never sent
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['12']
|
||||
mock_tracker.get.return_value = ack_packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should submit — first time, no last_send_time
|
||||
mock_submit.assert_called_once()
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_packet_scheduler_skips_recently_sent(self):
|
||||
"""PacketSendSchedulerThread must not re-submit if sent recently.
|
||||
|
||||
Similar to ack scheduler, prevents threadpool race for message
|
||||
packets where multiple workers could fire before send_count updates.
|
||||
"""
|
||||
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
packet = fake.fake_packet(msg_number='123')
|
||||
packet.send_count = 0
|
||||
packet.retry_count = 3
|
||||
packet.last_send_time = int(round(time.time())) # Just sent
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['123']
|
||||
mock_tracker.get.return_value = packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should NOT submit — sent too recently
|
||||
mock_submit.assert_not_called()
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_packet_scheduler_submits_after_backoff(self):
|
||||
"""PacketSendSchedulerThread submits after exponential backoff elapses."""
|
||||
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
packet = fake.fake_packet(msg_number='123')
|
||||
packet.send_count = 1 # Second send: backoff = (1+1)*31 = 62s
|
||||
packet.retry_count = 3
|
||||
packet.last_send_time = int(round(time.time())) - 63 # 63 seconds ago
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['123']
|
||||
mock_tracker.get.return_value = packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should submit — backoff period has elapsed
|
||||
mock_submit.assert_called_once()
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_ack_scheduler_cleans_up_max_retries(self):
|
||||
"""AckSendSchedulerThread removes packets that hit max retries."""
|
||||
scheduler = tx.AckSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
ack_packet = fake.fake_ack_packet()
|
||||
ack_packet.send_count = 3 # At max
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['12']
|
||||
mock_tracker.get.return_value = ack_packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_submit.assert_not_called()
|
||||
# Should have called remove to clean up
|
||||
mock_tracker.remove.assert_called_once_with('12')
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
def test_packet_scheduler_cleans_up_max_retries(self):
|
||||
"""PacketSendSchedulerThread removes packets that hit max retries."""
|
||||
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
|
||||
try:
|
||||
packet = fake.fake_packet(msg_number='123')
|
||||
packet.send_count = 3
|
||||
packet.retry_count = 3
|
||||
|
||||
mock_tracker = mock.MagicMock()
|
||||
mock_tracker.keys.return_value = ['123']
|
||||
mock_tracker.get.return_value = packet
|
||||
with mock.patch(
|
||||
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
|
||||
):
|
||||
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
|
||||
result = scheduler.loop()
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_submit.assert_not_called()
|
||||
# Should have called remove to clean up
|
||||
mock_tracker.remove.assert_called_once_with('123')
|
||||
finally:
|
||||
scheduler.stop()
|
||||
scheduler.executor.shutdown(wait=False)
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Send APRS bulletins announcing APRS Chat on Google Play
|
||||
|
||||
set -e
|
||||
|
||||
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
|
||||
|
||||
export APRS_LOGIN=WB4BOR
|
||||
export APRS_PASSWORD=24496
|
||||
|
||||
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
|
||||
sleep 2
|
||||
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
|
||||
sleep 2
|
||||
@@ -1325,28 +1325,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.9.26"
|
||||
version = "0.11.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/6a/ef4ea19097ecdfd7df6e608f93874536af045c68fd70aa628c667815c458/uv-0.9.26.tar.gz", hash = "sha256:8b7017a01cc48847a7ae26733383a2456dd060fc50d21d58de5ee14f6b6984d7", size = 3790483, upload-time = "2026-01-15T20:51:33.582Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e1/5c0b17833d5e3b51a897957348ff8d937a3cdfc5eea5c4a7075d8d7b9870/uv-0.9.26-py3-none-linux_armv6l.whl", hash = "sha256:7dba609e32b7bd13ef81788d580970c6ff3a8874d942755b442cffa8f25dba57", size = 22638031, upload-time = "2026-01-15T20:51:44.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8b/68ac5825a615a8697e324f52ac0b92feb47a0ec36a63759c5f2931f0c3a0/uv-0.9.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b815e3b26eeed00e00f831343daba7a9d99c1506883c189453bb4d215f54faac", size = 21507805, upload-time = "2026-01-15T20:50:42.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/a2/664a338aefe009f6e38e47455ee2f64a21da7ad431dbcaf8b45d8b1a2b7a/uv-0.9.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b012e6c4dfe767f818cbb6f47d02c207c9b0c82fee69a5de6d26ffb26a3ef3c", size = 20249791, upload-time = "2026-01-15T20:50:49.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3d/b8186a7dec1346ca4630c674b760517d28bffa813a01965f4b57596bacf3/uv-0.9.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ea296b700d7c4c27acdfd23ffaef2b0ecdd0aa1b58d942c62ee87df3b30f06ac", size = 22039108, upload-time = "2026-01-15T20:51:00.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a9/687fd587e7a3c2c826afe72214fb24b7f07b0d8b0b0300e6a53b554180ea/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1ba860d2988efc27e9c19f8537a2f9fa499a8b7ebe4afbe2d3d323d72f9aee61", size = 22174763, upload-time = "2026-01-15T20:50:46.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/69/7fa03ee7d59e562fca1426436f15a8c107447d41b34e0899e25ee69abfad/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8610bdfc282a681a0a40b90495a478599aa3484c12503ef79ef42cd271fd80fe", size = 22189861, upload-time = "2026-01-15T20:51:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/2d/4be446a2ec09f3c428632b00a138750af47c76b0b9f987e9a5b52fef0405/uv-0.9.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4bf700bd071bd595084b9ee0a8d77c6a0a10ca3773d3771346a2599f306bd9c", size = 23005589, upload-time = "2026-01-15T20:50:57.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/16/860990b812136695a63a8da9fb5f819c3cf18ea37dcf5852e0e1b795ca0d/uv-0.9.26-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:89a7beea1c692f76a6f8da13beff3cbb43f7123609e48e03517cc0db5c5de87c", size = 24713505, upload-time = "2026-01-15T20:51:04.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/43/5d7f360d551e62d8f8bf6624b8fca9895cea49ebe5fce8891232d7ed2321/uv-0.9.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:182f5c086c7d03ad447e522b70fa29a0302a70bcfefad4b8cd08496828a0e179", size = 24342500, upload-time = "2026-01-15T20:51:47.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/9c/2bae010a189e7d8e5dc555edcfd053b11ce96fad2301b919ba0d9dd23659/uv-0.9.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d8c62a501f13425b4b0ce1dd4c6b82f3ce5a5179e2549c55f4bb27cc0eb8ef8", size = 23222578, upload-time = "2026-01-15T20:51:36.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/16/a07593a040fe6403c36f3b0a99b309f295cbfe19a1074dbadb671d5d4ef7/uv-0.9.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7e89798bd3df7dcc4b2b4ac4e2fc11d6b3ff4fe7d764aa3012d664c635e2922", size = 23250201, upload-time = "2026-01-15T20:51:19.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/a0/45893e15ad3ab842db27c1eb3b8605b9b4023baa5d414e67cfa559a0bff0/uv-0.9.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60a66f1783ec4efc87b7e1f9bd66e8fd2de3e3b30d122b31cb1487f63a3ea8b7", size = 22229160, upload-time = "2026-01-15T20:51:22.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c0/20a597a5c253702a223b5e745cf8c16cd5dd053080f896bb10717b3bedec/uv-0.9.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:63c6a1f1187facba1fb45a2fa45396980631a3427ac11b0e3d9aa3ebcf2c73cf", size = 23090730, upload-time = "2026-01-15T20:51:26.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/c9/744537867d9ab593fea108638b57cca1165a0889cfd989981c942b6de9a5/uv-0.9.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c6d8650fbc980ccb348b168266143a9bd4deebc86437537caaf8ff2a39b6ea50", size = 22436632, upload-time = "2026-01-15T20:51:12.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e2/be683e30262f2cf02dcb41b6c32910a6939517d50ec45f502614d239feb7/uv-0.9.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:25278f9298aa4dade38241a93d036739b0c87278dcfad1ec1f57e803536bfc49", size = 23480064, upload-time = "2026-01-15T20:50:53.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/3e/4a7e6bc5db2beac9c4966f212805f1903d37d233f2e160737f0b24780ada/uv-0.9.26-py3-none-win32.whl", hash = "sha256:10d075e0193e3a0e6c54f830731c4cb965d6f4e11956e84a7bed7ed61d42aa27", size = 21000052, upload-time = "2026-01-15T20:51:40.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5d/eb80c6eff2a9f7d5cf35ec84fda323b74aa0054145db28baf72d35a7a301/uv-0.9.26-py3-none-win_amd64.whl", hash = "sha256:0315fc321f5644b12118f9928086513363ed9b29d74d99f1539fda1b6b5478ab", size = 23684930, upload-time = "2026-01-15T20:51:08.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/9d/3b2631931649b1783f5024796ca8ad2b42a01a829b9ce1202d973cc7bce5/uv-0.9.26-py3-none-win_arm64.whl", hash = "sha256:344ff38749b6cd7b7dfdfb382536f168cafe917ae3a5aa78b7a63746ba2a905b", size = 22158123, upload-time = "2026-01-15T20:51:30.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user