Skip to content

Commit 245b2f7

Browse files
feat: add support for APRS Service Registry (#57)
* feat: add support for APRS Service Registry This adds support for adding the bot to the APRS Service registry using the standard setup by WB4BOR for his HEMNA registry https://aprs.hemna.com/ By default, registry is off. This PR also adds documentation of all of the config options, including the ones related to enabling registry pings. * ci: ruff format
1 parent d5b57eb commit 245b2f7

File tree

19 files changed

+498
-117
lines changed

19 files changed

+498
-117
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ repos:
77
- id: debug-statements
88
- id: end-of-file-fixer
99
- id: trailing-whitespace
10-
- repo: https://github.com/charliermarsh/ruff-pre-commit
10+
- repo: https://github.com/astral-sh/ruff-pre-commit
1111
# Ruff version.
12-
rev: "v0.3.3"
12+
rev: v0.4.2
1313
hooks:
14+
# Run the linter.
1415
- id: ruff
15-
args: [--fix, --exit-non-zero-on-fix]
16-
types: [python]
16+
args: [ --fix ]
17+
# Run the formatter.
18+
- id: ruff-format
1719
- repo: https://github.com/rhysd/actionlint
1820
rev: v1.6.27
1921
hooks:

CONFIG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# err-aprs-backend config options
2+
3+
These options can be set in your errbot config.py to configure the APRS backend.
4+
5+
* APRS_FROM_CALLSIGN - default is your bot identity callsign, but you can set to reply as a different callsign
6+
* APRS_LISTENED_CALLSIGNS - default (), set of callsigns to listen to
7+
* APRS_HELP_TEXT - default "APRSBot,Errbot & err-aprs-backend", set this to your text. Probably a good idea to set it to website for complex help text due to message character limits
8+
* APRS_MAX_DROPPED_PACKETS - default "25", how many packets we can drop before the bot backend will restart
9+
* APRS_MAX_CACHED_PACKETS - default "2048", how many packets to hold in the cache to dedupe.
10+
* APRS_MAX_AGE_CACHED_PACETS_SECONDS - default "3600", how long to hold onto a package in the cache for deduping
11+
* APRS_MESSAGE_MAX_RETRIES - default "7", how many times to retry sending a message if the bot does do not get an ack or a rej
12+
* APRS_MESSAGE_RETRY_WAIT - default "90", how many seconds to wait between retrying message sending
13+
* APRS_STRIP_NEWLINES - default "true", strip newlines out of plugin responses, probably best to leave it as true
14+
* APRS_LANGUAGE_FILTER - default "true", attempts to strip any profanity out of a message before sending it so the FCC doesn't get mad. Not a smart filter, very brute force. You are still responsible for what you transmit!
15+
* APRS_LANGUAGE_FILTER_EXTRA_WORDS - default [], list of extra words to drop as profanity.
16+
* APRS_REGISTRY_ENABLED - default "false", if true, will enable reporting to the APRS Service Registry https://aprs.hemna.com/
17+
* APRS_REGISTRY_URL - default "https://aprs.hemna.com/api/v1/registry", the APRS registry to report your service
18+
* APRS_REGISTRY_FREQUENCY_SECONDS - default "3600", how often in seconds to report your service to the APRS registry
19+
* APRS_REGISTRY_DESCRIPTION - default "err-aprs-backend powered bot", description for your bot in the Service Regsitry
20+
* APRS_REGISTRY_WEBSTIRE - default "", website for your service on the APRS registry
21+
* APRS_REGISTRY_SOFTWARE - default "err-aprs-backend {version} errbot {errbot version}", software string for APRS service registry

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ BOT_IDENTITY = {
6969
}
7070
```
7171

72+
See [CONFIG.md](CONFIG.md) for more configuration options
73+
7274
### Disable the default plugins
7375

7476
The default plugins allow configuring the bot, installing plugins, and returing detailed help information. These don't work well over APRS because
@@ -92,7 +94,7 @@ SUPPRESS_CMD_NOT_FOUND = True
9294

9395
### Bot Prefix
9496
You can leave your bot prefix on, but that's just extra characters. I prefer to make it optional so users don't have to
95-
send it on every commadn
97+
send it on every command
9698

9799
```python
98100
BOT_PREFIX_OPTIONAL_ON_CHAT = True

aprs_backend/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .aprs import APRSBackend
2+
from .version import __version__
23

3-
__all__ = ["APRSBackend"]
4+
__all__ = ["APRSBackend", "__version__"]

aprs_backend/aprs.py

Lines changed: 70 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from aprs_backend.clients import APRSISClient
55
from aprs_backend.person import APRSPerson
66
from aprs_backend.room import APRSRoom
7+
from aprs_backend.version import __version__ as ERR_APRS_VERSION
78
from errbot.backends.base import Message
89
from errbot.backends.base import ONLINE
910
from errbot.core import ErrBot
@@ -18,20 +19,18 @@
1819
from random import randint
1920
from datetime import datetime
2021
from better_profanity import profanity
21-
from errbot import botcmd
22-
22+
from aprs_backend.clients.aprs_registry import APRSRegistryClient, RegistryAppConfig
2323
import logging
2424
import asyncio
25+
from errbot.version import VERSION as ERR_VERSION
2526

2627

2728
log = logging.getLogger(__name__)
2829

2930
for handler in log.handlers:
30-
handler.setFormatter(logging.Formatter('%(filename)s: '
31-
'%(levelname)s: '
32-
'%(funcName)s(): '
33-
'%(lineno)d:\t'
34-
'%(message)s'))
31+
handler.setFormatter(
32+
logging.Formatter("%(filename)s: " "%(levelname)s: " "%(funcName)s(): " "%(lineno)d:\t" "%(message)s")
33+
)
3534

3635

3736
class ProcessorError(APRSBackendException):
@@ -63,40 +62,61 @@ def __init__(self, config):
6362
self._multiline = False
6463
self._client = APRSISClient(**aprs_config, logger=log)
6564
self._send_queue = asyncio.Queue(maxsize=int(getattr(self._errbot_config, "APRS_SEND_MAX_QUEUE", "2048")))
66-
self.help_text = getattr(self._errbot_config, "APRS_HELP_TEXT", "APRS Bot provided by Errbot.")
65+
self.help_text = getattr(self._errbot_config, "APRS_HELP_TEXT", "APRSBot,Errbot & err-aprs-backend")
6766

6867
self._message_counter = MessageCounter(initial_value=randint(1, 20)) # nosec not used cryptographically
69-
self._max_dropped_packets = int(getattr(self._errbot_config, "APRS_MAX_DROPPED_PACKETS", "25"))
70-
self._max_cached_packets = int(getattr(self._errbot_config, "APRS_MAX_CACHED_PACKETS", "2048"))
71-
self._message_max_retry = int(getattr(self._errbot_config, "APRS_MESSAGE_MAX_RETRIES", "7"))
72-
self._message_retry_wait = int(getattr(self._errbot_config, "APRS_MESSAGE_RETRY_WAIT", "90"))
68+
self._max_dropped_packets = int(self._get_from_config("APRS_MAX_DROPPED_PACKETS", "25"))
69+
self._max_cached_packets = int(self._get_from_config("APRS_MAX_CACHED_PACKETS", "2048"))
70+
self._message_max_retry = int(self._get_from_config("APRS_MESSAGE_MAX_RETRIES", "7"))
71+
self._message_retry_wait = int(self._get_from_config("APRS_MESSAGE_RETRY_WAIT", "90"))
7372

7473
# strip newlines out of plugin responses before sending to aprs, probably best to leave it true, nothing in aprs will handle
7574
# a stray newline
76-
self._strip_newlines = str(getattr(self._errbot_config, "APRS_STRIP_NEWLINES", "true")).lower() == "true"
75+
self._strip_newlines = str(self._get_from_config("APRS_STRIP_NEWLINES", "true")).lower() == "true"
7776

7877
# try to strip out "foul" language the FCC would not like. It is possible/probably an errbot bot response could
7978
# go out over the airwaves. This is configurable, but probably should remain on.
80-
self._language_filter = str(getattr(self._errbot_config, "APRS_LANGUAGE_FILTER", "true")).lower() == "true"
79+
self._language_filter = str(self._get_from_config("APRS_LANGUAGE_FILTER", "true")).lower() == "true"
8180
if self._language_filter:
82-
profanity.load_censor_words(getattr(self._errbot_config, "APRS_LANGUAGE_FILTER_EXTRA_WORDS", []))
81+
profanity.load_censor_words(self._get_from_config("APRS_LANGUAGE_FILTER_EXTRA_WORDS", []))
8382

84-
self._max_age_cached_packets_seconds = int(getattr(self._errbot_config, "APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
85-
self._packet_cache = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
83+
self._max_age_cached_packets_seconds = int(self._get_from_config("APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
84+
self._packet_cache = ExpiringDict(
85+
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
86+
)
8687
self._packet_cache_lock = asyncio.Lock()
87-
self._waiting_ack = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
88+
self._waiting_ack = ExpiringDict(
89+
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
90+
)
8891
self._waiting_ack_lock = asyncio.Lock()
8992

90-
super().__init__(config)
93+
self.registry_enabled = self._get_from_config("APRS_REGISTRY_ENABLED", "false").lower() == "true"
94+
if self.registry_enabled:
95+
self.registry_app_config = RegistryAppConfig(
96+
description=self._get_from_config("APRS_REGISTRY_DESCRIPTION", "err-aprs-backend powered bot"),
97+
website=self._get_from_config("APRS_REGISTRY_WEBSITE", ""),
98+
listening_callsigns=[self.from_call] + [call for call in self.listened_callsigns],
99+
software=self._get_from_config(
100+
"APRS_REGISTRY_SOFTWARE", f"err-aprs-backend {ERR_APRS_VERSION} errbot {ERR_VERSION}"
101+
),
102+
)
103+
if (registry_software := self._get_from_config("APRS_REGISTRY_SOFTWARE", None)) is not None:
104+
self.registry_app_config.software = registry_software
105+
self.registry_client = APRSRegistryClient(
106+
registry_url=self._get_from_config("APRS_REGISTRY_URL", "https://aprs.hemna.com/api/v1/registry"),
107+
log=log,
108+
frequency_seconds=int(self._get_from_config("APRS_REGISTRY_FREQUENCY_SECONDS", "3600")),
109+
app_config=self.registry_app_config,
110+
)
111+
else:
112+
self.registry_client = None
91113

92-
@botcmd
93-
def testcmd(self, msg: Message) -> str:
94-
return "test successful"
114+
super().__init__(config)
95115

116+
def _get_from_config(self, key: str, default: any = None) -> any:
117+
return getattr(self._errbot_config, key, default)
96118

97-
def build_reply(
98-
self, msg: Message, text: str, private: bool = False, threaded: bool = False
99-
) -> Message:
119+
def build_reply(self, msg: Message, text: str, private: bool = False, threaded: bool = False) -> Message:
100120
log.debug(msg)
101121
reply = Message(
102122
body=text,
@@ -161,7 +181,6 @@ async def retry_worker(self) -> None:
161181
# release the loop for a bit longer after we've gone through all keys
162182
await asyncio.sleep(0.1)
163183

164-
165184
async def send_worker(self) -> None:
166185
"""Processes self._send_queue to send messages to APRS"""
167186
log.debug("send_worker started")
@@ -184,8 +203,7 @@ async def send_worker(self) -> None:
184203
await asyncio.sleep(0.01)
185204

186205
async def receive_worker(self) -> bool:
187-
"""_summary_
188-
"""
206+
"""_summary_"""
189207
log.debug("Receive worker started")
190208
try:
191209
await self._client.connect()
@@ -211,13 +229,25 @@ async def receive_worker(self) -> bool:
211229
if parsed_packet.to == self.callsign or parsed_packet.to in self.listened_callsigns:
212230
await self.process_packet(parsed_packet)
213231
else:
214-
log.info("Packet was not addressed to bot or listened callsigns, not processing %s", packet_str)
232+
log.info(
233+
"Packet was not addressed to bot or listened callsigns, not processing %s", packet_str
234+
)
215235
else:
216236
log.info("This packet parsed to be None: %s", packet_str)
217237
except PacketParseError as exc:
218-
log.error("Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
238+
log.error(
239+
"Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s",
240+
packet_str,
241+
exc,
242+
self._dropped_packets,
243+
)
219244
except ProcessorError as exc:
220-
log.err("Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
245+
log.err(
246+
"Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s",
247+
packet_str,
248+
exc,
249+
self._dropped_packets,
250+
)
221251
finally:
222252
self._dropped_packets += 1
223253
if self._dropped_packets > self._max_dropped_packets:
@@ -232,10 +262,10 @@ async def receive_worker(self) -> bool:
232262
async def async_serve_once(self) -> bool:
233263
receive_task = asyncio.create_task(self.receive_worker())
234264

235-
worker_tasks = [
236-
asyncio.create_task(self.send_worker()),
237-
asyncio.create_task(self.retry_worker())
238-
]
265+
worker_tasks = [asyncio.create_task(self.send_worker()), asyncio.create_task(self.retry_worker())]
266+
# if reporting to the aprs service registry is enabled, start a task for it
267+
if self.registry_client is not None:
268+
worker_tasks.append(asyncio.create_task(self.registry_client()))
239269
result = await asyncio.gather(receive_task, return_exceptions=True)
240270
await self._send_queue.join()
241271
for task in worker_tasks:
@@ -281,7 +311,7 @@ def send_message(self, msg: Message) -> None:
281311
addresse=msg.to.callsign,
282312
message_text=msg_text,
283313
msgNo=msgNo,
284-
last_send_attempt=last_send_attempt
314+
last_send_attempt=last_send_attempt,
285315
)
286316
msg_packet._build_raw()
287317
try:
@@ -334,10 +364,7 @@ async def __drop_message_from_waiting(self, message_hash: str) -> None:
334364

335365
def handle_help(self, msg: APRSMessage) -> None:
336366
"""Returns simplified help text for the APRS backend"""
337-
help_msg = APRSMessage(
338-
body=self.help_text,
339-
extras=msg.extras
340-
)
367+
help_msg = APRSMessage(body=self.help_text, extras=msg.extras)
341368
help_msg.to = msg.frm
342369
help_msg.frm = APRSPerson(callsign=self.from_call)
343370
self.send_message(help_msg)
@@ -363,11 +390,9 @@ async def _process_message(self, packet: MessagePacket) -> None:
363390

364391
async def _ack_message(self, packet: MessagePacket) -> None:
365392
log.debug("Sending ack for packet %s", packet.json)
366-
this_ack = AckPacket(from_call=self.from_call,
367-
to_call=packet.from_call,
368-
addresse=packet.from_call,
369-
msgNo=packet.msgNo
370-
)
393+
this_ack = AckPacket(
394+
from_call=self.from_call, to_call=packet.from_call, addresse=packet.from_call, msgNo=packet.msgNo
395+
)
371396
await this_ack.prepare(self._message_counter)
372397
this_ack.update_timestamp()
373398
await self._client._send(this_ack.raw)

aprs_backend/clients/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .aprsis import APRSISClient
22
from .kiss import KISSClient
3+
from .aprs_registry import APRSRegistryClient, RegistryAppConfig
34

4-
__all__ = ["APRSISClient", "KISSClient"]
5+
__all__ = ["APRSISClient", "KISSClient", "APRSRegistryClient", "RegistryAppConfig"]

aprs_backend/clients/aprs_registry.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from dataclasses import dataclass
2+
import httpx
3+
from functools import cached_property
4+
import asyncio
5+
6+
7+
@dataclass
8+
class RegistryAppConfig:
9+
description: str
10+
listening_callsigns: list[str]
11+
website: str = ""
12+
software: str = ""
13+
14+
@cached_property
15+
def post_jsons(self) -> list[dict]:
16+
return [
17+
{
18+
"callsign": str(this_call),
19+
"description": self.description,
20+
"service_website": self.website,
21+
"software": self.software,
22+
}
23+
for this_call in self.listening_callsigns
24+
]
25+
26+
27+
class APRSRegistryClient:
28+
def __init__(self, registry_url: str, app_config: RegistryAppConfig, log, frequency_seconds: int = 3600) -> None:
29+
self.registry_url = registry_url
30+
self.log = log
31+
self.frequency_seconds = frequency_seconds
32+
self.app_config = app_config
33+
34+
async def __call__(self) -> None:
35+
"""Posts to the aprs registry url for each listening callsign for the bot
36+
Run as an asyncio task
37+
"""
38+
self.log.debug("Staring APRS Registry Client")
39+
try:
40+
while True:
41+
async with httpx.AsyncClient() as client:
42+
for post_json in self.app_config.post_jsons:
43+
self.log.debug("Posting %s to %s", post_json, self.registry_url)
44+
try:
45+
response = await client.post(self.registry_url, json=post_json)
46+
self.log.debug(response)
47+
response.raise_for_status()
48+
except httpx.RequestError as exc:
49+
self.log.error(
50+
"Request Error while posting %s to %s. Error: %s, response: %s",
51+
post_json,
52+
self.registry_url,
53+
exc,
54+
response,
55+
)
56+
except httpx.HTTPStatusError as exc:
57+
self.log.error(
58+
"Error while posting %s to %s. Error: %s, response: %s",
59+
post_json,
60+
self.registry_url,
61+
exc,
62+
response,
63+
)
64+
# instead of sleeping in one big chunk, sleep in smaller chunks for easier cacnellation
65+
for i in range(self.frequency_seconds * 10):
66+
await asyncio.sleep(0.1)
67+
except asyncio.CancelledError:
68+
self.log.info("APRS client cancelled, stopping")
69+
return

0 commit comments

Comments
 (0)