Skip to content

Commit

Permalink
feat: add support for APRS Service Registry (#57)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
andrewthetechie authored Apr 28, 2024
1 parent d5b57eb commit 245b2f7
Show file tree
Hide file tree
Showing 19 changed files with 498 additions and 117 deletions.
10 changes: 6 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ repos:
- id: debug-statements
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/charliermarsh/ruff-pre-commit
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: "v0.3.3"
rev: v0.4.2
hooks:
# Run the linter.
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types: [python]
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/rhysd/actionlint
rev: v1.6.27
hooks:
Expand Down
21 changes: 21 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# err-aprs-backend config options

These options can be set in your errbot config.py to configure the APRS backend.

* APRS_FROM_CALLSIGN - default is your bot identity callsign, but you can set to reply as a different callsign
* APRS_LISTENED_CALLSIGNS - default (), set of callsigns to listen to
* 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
* APRS_MAX_DROPPED_PACKETS - default "25", how many packets we can drop before the bot backend will restart
* APRS_MAX_CACHED_PACKETS - default "2048", how many packets to hold in the cache to dedupe.
* APRS_MAX_AGE_CACHED_PACETS_SECONDS - default "3600", how long to hold onto a package in the cache for deduping
* 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
* APRS_MESSAGE_RETRY_WAIT - default "90", how many seconds to wait between retrying message sending
* APRS_STRIP_NEWLINES - default "true", strip newlines out of plugin responses, probably best to leave it as true
* 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!
* APRS_LANGUAGE_FILTER_EXTRA_WORDS - default [], list of extra words to drop as profanity.
* APRS_REGISTRY_ENABLED - default "false", if true, will enable reporting to the APRS Service Registry https://aprs.hemna.com/
* APRS_REGISTRY_URL - default "https://aprs.hemna.com/api/v1/registry", the APRS registry to report your service
* APRS_REGISTRY_FREQUENCY_SECONDS - default "3600", how often in seconds to report your service to the APRS registry
* APRS_REGISTRY_DESCRIPTION - default "err-aprs-backend powered bot", description for your bot in the Service Regsitry
* APRS_REGISTRY_WEBSTIRE - default "", website for your service on the APRS registry
* APRS_REGISTRY_SOFTWARE - default "err-aprs-backend {version} errbot {errbot version}", software string for APRS service registry
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ BOT_IDENTITY = {
}
```

See [CONFIG.md](CONFIG.md) for more configuration options

### Disable the default plugins

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

### Bot Prefix
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
send it on every commadn
send it on every command

```python
BOT_PREFIX_OPTIONAL_ON_CHAT = True
Expand Down
3 changes: 2 additions & 1 deletion aprs_backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .aprs import APRSBackend
from .version import __version__

__all__ = ["APRSBackend"]
__all__ = ["APRSBackend", "__version__"]
115 changes: 70 additions & 45 deletions aprs_backend/aprs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from aprs_backend.clients import APRSISClient
from aprs_backend.person import APRSPerson
from aprs_backend.room import APRSRoom
from aprs_backend.version import __version__ as ERR_APRS_VERSION
from errbot.backends.base import Message
from errbot.backends.base import ONLINE
from errbot.core import ErrBot
Expand All @@ -18,20 +19,18 @@
from random import randint
from datetime import datetime
from better_profanity import profanity
from errbot import botcmd

from aprs_backend.clients.aprs_registry import APRSRegistryClient, RegistryAppConfig
import logging
import asyncio
from errbot.version import VERSION as ERR_VERSION


log = logging.getLogger(__name__)

for handler in log.handlers:
handler.setFormatter(logging.Formatter('%(filename)s: '
'%(levelname)s: '
'%(funcName)s(): '
'%(lineno)d:\t'
'%(message)s'))
handler.setFormatter(
logging.Formatter("%(filename)s: " "%(levelname)s: " "%(funcName)s(): " "%(lineno)d:\t" "%(message)s")
)


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

self._message_counter = MessageCounter(initial_value=randint(1, 20)) # nosec not used cryptographically
self._max_dropped_packets = int(getattr(self._errbot_config, "APRS_MAX_DROPPED_PACKETS", "25"))
self._max_cached_packets = int(getattr(self._errbot_config, "APRS_MAX_CACHED_PACKETS", "2048"))
self._message_max_retry = int(getattr(self._errbot_config, "APRS_MESSAGE_MAX_RETRIES", "7"))
self._message_retry_wait = int(getattr(self._errbot_config, "APRS_MESSAGE_RETRY_WAIT", "90"))
self._max_dropped_packets = int(self._get_from_config("APRS_MAX_DROPPED_PACKETS", "25"))
self._max_cached_packets = int(self._get_from_config("APRS_MAX_CACHED_PACKETS", "2048"))
self._message_max_retry = int(self._get_from_config("APRS_MESSAGE_MAX_RETRIES", "7"))
self._message_retry_wait = int(self._get_from_config("APRS_MESSAGE_RETRY_WAIT", "90"))

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

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

self._max_age_cached_packets_seconds = int(getattr(self._errbot_config, "APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
self._packet_cache = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
self._max_age_cached_packets_seconds = int(self._get_from_config("APRS_MAX_AGE_CACHED_PACETS_SECONDS", "3600"))
self._packet_cache = ExpiringDict(
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
)
self._packet_cache_lock = asyncio.Lock()
self._waiting_ack = ExpiringDict(max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds)
self._waiting_ack = ExpiringDict(
max_len=self._max_cached_packets, max_age_seconds=self._max_age_cached_packets_seconds
)
self._waiting_ack_lock = asyncio.Lock()

super().__init__(config)
self.registry_enabled = self._get_from_config("APRS_REGISTRY_ENABLED", "false").lower() == "true"
if self.registry_enabled:
self.registry_app_config = RegistryAppConfig(
description=self._get_from_config("APRS_REGISTRY_DESCRIPTION", "err-aprs-backend powered bot"),
website=self._get_from_config("APRS_REGISTRY_WEBSITE", ""),
listening_callsigns=[self.from_call] + [call for call in self.listened_callsigns],
software=self._get_from_config(
"APRS_REGISTRY_SOFTWARE", f"err-aprs-backend {ERR_APRS_VERSION} errbot {ERR_VERSION}"
),
)
if (registry_software := self._get_from_config("APRS_REGISTRY_SOFTWARE", None)) is not None:
self.registry_app_config.software = registry_software
self.registry_client = APRSRegistryClient(
registry_url=self._get_from_config("APRS_REGISTRY_URL", "https://aprs.hemna.com/api/v1/registry"),
log=log,
frequency_seconds=int(self._get_from_config("APRS_REGISTRY_FREQUENCY_SECONDS", "3600")),
app_config=self.registry_app_config,
)
else:
self.registry_client = None

@botcmd
def testcmd(self, msg: Message) -> str:
return "test successful"
super().__init__(config)

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

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


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

async def receive_worker(self) -> bool:
"""_summary_
"""
"""_summary_"""
log.debug("Receive worker started")
try:
await self._client.connect()
Expand All @@ -211,13 +229,25 @@ async def receive_worker(self) -> bool:
if parsed_packet.to == self.callsign or parsed_packet.to in self.listened_callsigns:
await self.process_packet(parsed_packet)
else:
log.info("Packet was not addressed to bot or listened callsigns, not processing %s", packet_str)
log.info(
"Packet was not addressed to bot or listened callsigns, not processing %s", packet_str
)
else:
log.info("This packet parsed to be None: %s", packet_str)
except PacketParseError as exc:
log.error("Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
log.error(
"Dropping packet %s due to Parsing error: %s. Total Dropped Packets: %s",
packet_str,
exc,
self._dropped_packets,
)
except ProcessorError as exc:
log.err("Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s", packet_str, exc, self._dropped_packets)
log.err(
"Dropping packet %s due to Processor error: %s. Total Dropped Packets: %s",
packet_str,
exc,
self._dropped_packets,
)
finally:
self._dropped_packets += 1
if self._dropped_packets > self._max_dropped_packets:
Expand All @@ -232,10 +262,10 @@ async def receive_worker(self) -> bool:
async def async_serve_once(self) -> bool:
receive_task = asyncio.create_task(self.receive_worker())

worker_tasks = [
asyncio.create_task(self.send_worker()),
asyncio.create_task(self.retry_worker())
]
worker_tasks = [asyncio.create_task(self.send_worker()), asyncio.create_task(self.retry_worker())]
# if reporting to the aprs service registry is enabled, start a task for it
if self.registry_client is not None:
worker_tasks.append(asyncio.create_task(self.registry_client()))
result = await asyncio.gather(receive_task, return_exceptions=True)
await self._send_queue.join()
for task in worker_tasks:
Expand Down Expand Up @@ -281,7 +311,7 @@ def send_message(self, msg: Message) -> None:
addresse=msg.to.callsign,
message_text=msg_text,
msgNo=msgNo,
last_send_attempt=last_send_attempt
last_send_attempt=last_send_attempt,
)
msg_packet._build_raw()
try:
Expand Down Expand Up @@ -334,10 +364,7 @@ async def __drop_message_from_waiting(self, message_hash: str) -> None:

def handle_help(self, msg: APRSMessage) -> None:
"""Returns simplified help text for the APRS backend"""
help_msg = APRSMessage(
body=self.help_text,
extras=msg.extras
)
help_msg = APRSMessage(body=self.help_text, extras=msg.extras)
help_msg.to = msg.frm
help_msg.frm = APRSPerson(callsign=self.from_call)
self.send_message(help_msg)
Expand All @@ -363,11 +390,9 @@ async def _process_message(self, packet: MessagePacket) -> None:

async def _ack_message(self, packet: MessagePacket) -> None:
log.debug("Sending ack for packet %s", packet.json)
this_ack = AckPacket(from_call=self.from_call,
to_call=packet.from_call,
addresse=packet.from_call,
msgNo=packet.msgNo
)
this_ack = AckPacket(
from_call=self.from_call, to_call=packet.from_call, addresse=packet.from_call, msgNo=packet.msgNo
)
await this_ack.prepare(self._message_counter)
this_ack.update_timestamp()
await self._client._send(this_ack.raw)
Expand Down
3 changes: 2 additions & 1 deletion aprs_backend/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .aprsis import APRSISClient
from .kiss import KISSClient
from .aprs_registry import APRSRegistryClient, RegistryAppConfig

__all__ = ["APRSISClient", "KISSClient"]
__all__ = ["APRSISClient", "KISSClient", "APRSRegistryClient", "RegistryAppConfig"]
69 changes: 69 additions & 0 deletions aprs_backend/clients/aprs_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from dataclasses import dataclass
import httpx
from functools import cached_property
import asyncio


@dataclass
class RegistryAppConfig:
description: str
listening_callsigns: list[str]
website: str = ""
software: str = ""

@cached_property
def post_jsons(self) -> list[dict]:
return [
{
"callsign": str(this_call),
"description": self.description,
"service_website": self.website,
"software": self.software,
}
for this_call in self.listening_callsigns
]


class APRSRegistryClient:
def __init__(self, registry_url: str, app_config: RegistryAppConfig, log, frequency_seconds: int = 3600) -> None:
self.registry_url = registry_url
self.log = log
self.frequency_seconds = frequency_seconds
self.app_config = app_config

async def __call__(self) -> None:
"""Posts to the aprs registry url for each listening callsign for the bot
Run as an asyncio task
"""
self.log.debug("Staring APRS Registry Client")
try:
while True:
async with httpx.AsyncClient() as client:
for post_json in self.app_config.post_jsons:
self.log.debug("Posting %s to %s", post_json, self.registry_url)
try:
response = await client.post(self.registry_url, json=post_json)
self.log.debug(response)
response.raise_for_status()
except httpx.RequestError as exc:
self.log.error(
"Request Error while posting %s to %s. Error: %s, response: %s",
post_json,
self.registry_url,
exc,
response,
)
except httpx.HTTPStatusError as exc:
self.log.error(
"Error while posting %s to %s. Error: %s, response: %s",
post_json,
self.registry_url,
exc,
response,
)
# instead of sleeping in one big chunk, sleep in smaller chunks for easier cacnellation
for i in range(self.frequency_seconds * 10):
await asyncio.sleep(0.1)
except asyncio.CancelledError:
self.log.info("APRS client cancelled, stopping")
return
Loading

0 comments on commit 245b2f7

Please sign in to comment.