From 319679e74ab60b440802dca53815bc4bcff0f16f Mon Sep 17 00:00:00 2001 From: James Date: Tue, 16 Jun 2026 20:39:15 +0100 Subject: [PATCH 1/5] Updated all pyproject files with additional fields Version, desc, license, authors added for most plugins and set classifiers on core --- core/pyproject.toml | 21 +++++++++++++++++-- .../button_controller/pyproject.toml | 13 +++++++++--- .../ais_decoder_processor/pyproject.toml | 13 +++++++++--- .../com_message_source/pyproject.toml | 13 +++++++++--- .../daisy_message_source/pyproject.toml | 13 +++++++++--- .../mock_message_source/pyproject.toml | 13 +++++++++--- .../renderers/image_renderer/pyproject.toml | 15 +++++++++---- .../renderers/inky_renderer/pyproject.toml | 15 +++++++++---- plugins/screens/map_screen/pyproject.toml | 15 +++++++++---- plugins/screens/table_screen/pyproject.toml | 15 +++++++++---- plugins/screens/zone_screen/pyproject.toml | 15 +++++++++---- 11 files changed, 124 insertions(+), 37 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index c2a73ae..ce695e7 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -1,11 +1,24 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "vessel-frame-core" -version = "0.0.0" +version = "0.1.0" +description = "Core services and plugin framework for Vessel Frame, an extensible AIS ship-tracking display for Raspberry Pi." requires-python = ">=3.11" +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: GIS", +] dependencies = [ "tomli-w>=1.2.0", "aiosqlite>=0.21.0", @@ -23,6 +36,10 @@ dev = [ "pytest-asyncio>=0.24", ] +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" + [project.scripts] vf = "vf_core.main:main" diff --git a/plugins/controllers/button_controller/pyproject.toml b/plugins/controllers/button_controller/pyproject.toml index c528850..5965e96 100644 --- a/plugins/controllers/button_controller/pyproject.toml +++ b/plugins/controllers/button_controller/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "button-controller" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "GPIO button controller plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "gpiozero>=2.0", "lgpio>=0.2"] [tool.setuptools.packages.find] @@ -13,3 +16,7 @@ where = ["src"] [project.entry-points."vesselframe.plugins.controllers"] button_controller = "button_controller:make_plugin" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/message_processors/ais_decoder_processor/pyproject.toml b/plugins/message_processors/ais_decoder_processor/pyproject.toml index 7d711d2..5182e6b 100644 --- a/plugins/message_processors/ais_decoder_processor/pyproject.toml +++ b/plugins/message_processors/ais_decoder_processor/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "ais-decoder-processor" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "AIS (NMEA) message decoder plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pyais>=2.13.2"] [tool.setuptools.packages.find] @@ -13,3 +16,7 @@ where = ["src"] [project.entry-points."vesselframe.plugins.messageprocessors"] ais_decoder_processor = "ais_decoder_processor:make_plugin" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/message_sources/com_message_source/pyproject.toml b/plugins/message_sources/com_message_source/pyproject.toml index fdde440..d7bef73 100644 --- a/plugins/message_sources/com_message_source/pyproject.toml +++ b/plugins/message_sources/com_message_source/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "com-message-source" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Serial (COM port) AIS message source plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pyserial-asyncio"] [tool.setuptools.packages.find] @@ -16,3 +19,7 @@ com_message_source = "com_message_source:make_plugin" [project.entry-points."vesselframe.config.schemas"] com_message_source = "com_message_source:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/message_sources/daisy_message_source/pyproject.toml b/plugins/message_sources/daisy_message_source/pyproject.toml index b28afa0..05f5445 100644 --- a/plugins/message_sources/daisy_message_source/pyproject.toml +++ b/plugins/message_sources/daisy_message_source/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "daisy-message-source" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Wegmatt Daisy I2C AIS receiver source plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "smbus2>=0.5.0"] [tool.setuptools.packages.find] @@ -16,3 +19,7 @@ daisy_message_source = "daisy_message_source:make_plugin" [project.entry-points."vesselframe.config.schemas"] daisy_message_source = "daisy_message_source:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/message_sources/mock_message_source/pyproject.toml b/plugins/message_sources/mock_message_source/pyproject.toml index ceccbb4..b5e5da4 100644 --- a/plugins/message_sources/mock_message_source/pyproject.toml +++ b/plugins/message_sources/mock_message_source/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "mock-message-source" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Mock AIS message source plugin for Vessel Frame. Emits pre-recorded NMEA sentences for testing." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core"] [tool.setuptools.packages.find] @@ -13,3 +16,7 @@ where = ["src"] [project.entry-points."vesselframe.plugins.messagesource"] mock_message_source = "mock_message_source:make_plugin" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/renderers/image_renderer/pyproject.toml b/plugins/renderers/image_renderer/pyproject.toml index 9344de3..5ca9eee 100644 --- a/plugins/renderers/image_renderer/pyproject.toml +++ b/plugins/renderers/image_renderer/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "image-renderer" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "PNG image renderer plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pillow>=12.0.0"] [tool.setuptools.packages.find] @@ -15,4 +18,8 @@ where = ["src"] image_renderer = "image_renderer:make_plugin" [project.entry-points."vesselframe.config.schemas"] -image_renderer = "image_renderer:get_config_schema" \ No newline at end of file +image_renderer = "image_renderer:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/renderers/inky_renderer/pyproject.toml b/plugins/renderers/inky_renderer/pyproject.toml index f600840..e27f3eb 100644 --- a/plugins/renderers/inky_renderer/pyproject.toml +++ b/plugins/renderers/inky_renderer/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "inky-renderer" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Pimoroni Inky e-ink renderer plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pillow>=12.0.0", "inky>=2.2.1"] [tool.setuptools.packages.find] @@ -15,4 +18,8 @@ where = ["src"] inky_renderer = "inky_renderer:make_plugin" [project.entry-points."vesselframe.config.schemas"] -inky_renderer = "inky_renderer:get_config_schema" \ No newline at end of file +inky_renderer = "inky_renderer:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/screens/map_screen/pyproject.toml b/plugins/screens/map_screen/pyproject.toml index 4028250..825dce2 100644 --- a/plugins/screens/map_screen/pyproject.toml +++ b/plugins/screens/map_screen/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "map-screen" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Map screen layout plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pillow>=12.0.0"] [tool.setuptools.packages.find] @@ -15,4 +18,8 @@ where = ["src"] map_screen = "map_screen:make_plugin" [project.entry-points."vesselframe.config.schemas"] -map_screen = "map_screen:get_config_schema" \ No newline at end of file +map_screen = "map_screen:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/screens/table_screen/pyproject.toml b/plugins/screens/table_screen/pyproject.toml index 3a30a78..7e2a2e7 100644 --- a/plugins/screens/table_screen/pyproject.toml +++ b/plugins/screens/table_screen/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "table-screen" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Vessel table screen layout plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pillow>=12.0.0"] [tool.setuptools.packages.find] @@ -15,4 +18,8 @@ where = ["src"] table_screen = "table_screen:make_plugin" [project.entry-points."vesselframe.config.schemas"] -table_screen = "table_screen:get_config_schema" \ No newline at end of file +table_screen = "table_screen:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/screens/zone_screen/pyproject.toml b/plugins/screens/zone_screen/pyproject.toml index 1130a1a..f876dad 100644 --- a/plugins/screens/zone_screen/pyproject.toml +++ b/plugins/screens/zone_screen/pyproject.toml @@ -1,11 +1,14 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "zone-screen" -version = "0.0.0" -requires-python = ">=3.10" +version = "0.1.0" +description = "Zone (proximity) screen layout plugin for Vessel Frame." +license = "GPL-3.0-only" +authors = [{ name = "Spook Workshop" }] +requires-python = ">=3.11" dependencies = ["vessel-frame-core", "pillow>=12.0.0"] [tool.setuptools.packages.find] @@ -15,4 +18,8 @@ where = ["src"] zone_screen = "zone_screen:make_plugin" [project.entry-points."vesselframe.config.schemas"] -zone_screen = "zone_screen:get_config_schema" \ No newline at end of file +zone_screen = "zone_screen:get_config_schema" + +[project.urls] +Homepage = "https://github.com/SpookWorkshop/vessel-frame" +Repository = "https://github.com/SpookWorkshop/vessel-frame" From 789e1d548a9b726f77839f3484c1e73e382176d8 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 16 Jun 2026 21:56:06 +0100 Subject: [PATCH 2/5] Add contributing and license links to the readme --- .gitignore | 1 + readme.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index af73758..6500413 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.egg* config.toml *.log +*.log.* *.sqlite **/.secrets data diff --git a/readme.md b/readme.md index 7d7a669..141a92a 100644 --- a/readme.md +++ b/readme.md @@ -256,3 +256,11 @@ At the end of the file add the following. It is correct that this is commented o # Allow manual management of wlan0 for AP/Client switching # denyinterfaces wlan0 ``` + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, running the tests and linter, and how to write a plugin. + +## License + +Vessel Frame is licensed under the GNU General Public License v3.0 only (GPL-3.0-only). See [LICENSE](LICENSE) for the full text. From ae4a7fca2f0b7b82dde15479aa882fb3d252774a Mon Sep 17 00:00:00 2001 From: James Date: Tue, 16 Jun 2026 22:27:17 +0100 Subject: [PATCH 3/5] Moved daisy source our of core and udp source in --- .github/workflows/ci.yml | 3 +- .../src/daisy_message_source/__init__.py | 241 ------------------ .../pyproject.toml | 12 +- .../src/udp_message_source/__init__.py | 123 +++++++++ 4 files changed, 131 insertions(+), 248 deletions(-) delete mode 100644 plugins/message_sources/daisy_message_source/src/daisy_message_source/__init__.py rename plugins/message_sources/{daisy_message_source => udp_message_source}/pyproject.toml (60%) create mode 100644 plugins/message_sources/udp_message_source/src/udp_message_source/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 520ff6e..0e19216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,11 @@ jobs: pip install ./core pip install \ ./plugins/message_sources/mock_message_source \ + ./plugins/message_sources/udp_message_source \ ./plugins/message_processors/ais_decoder_processor \ ./plugins/renderers/image_renderer - name: Import smoke test - run: python -c "import vf_core.main, mock_message_source, ais_decoder_processor, image_renderer" + run: python -c "import vf_core.main, mock_message_source, udp_message_source, ais_decoder_processor, image_renderer" test: name: Tests (py${{ matrix.python-version }}) diff --git a/plugins/message_sources/daisy_message_source/src/daisy_message_source/__init__.py b/plugins/message_sources/daisy_message_source/src/daisy_message_source/__init__.py deleted file mode 100644 index 7091e2e..0000000 --- a/plugins/message_sources/daisy_message_source/src/daisy_message_source/__init__.py +++ /dev/null @@ -1,241 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from concurrent.futures import ThreadPoolExecutor -from contextlib import suppress -from typing import Any - -from smbus2 import SMBus -from vf_core.message_bus import MessageBus -from vf_core.plugin_types import ( - ConfigField, - ConfigFieldType, - ConfigSchema, - Plugin, - require_plugin_args, -) - - -class DaisyMessageSource: - """Source plugin that reads messages from a Daisy AIS device.""" - - BYTES_AVAIL_H_ADDR = 0xFD - BYTES_AVAIL_L_ADDR = 0xFE - MESSAGE_BUFF_ADDR = 0xFF - MAX_BLOCK_SIZE = 32 - RECONNECT_DELAY: float = 5.0 - - def __init__( - self, - *, - bus: MessageBus, - topic: str = "ais.raw", - i2c_bus: int | str = 1, - i2c_addr: int | str = 0x33, - block_size: int | str = 32, - **kwargs: Any, - ) -> None: - require_plugin_args(bus=bus) - self._logger = logging.getLogger(__name__) - self._bus = bus - self._topic = topic - self._i2c_bus = int(i2c_bus) if isinstance(i2c_bus, str) else i2c_bus - self._i2c_addr = self._parse_i2c_address(i2c_addr) - self._block_size = int(block_size) if isinstance(block_size, str) else block_size - self._i2c = None - self._message_buffer = b"" - self._task: asyncio.Task[None] | None = None - self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="i2c_ais_reader") - self._running = False - - self._logger.info(f"Created Daisy source on bus {self._i2c_bus}, addr: {self._i2c_addr}") - - def _parse_i2c_address(self, addr: str | int) -> int: - """Parse I2C address from hex/decimal string or int.""" - if isinstance(addr, int): - return addr - - addr = addr.strip() - if addr.startswith("0x") or addr.startswith("0X"): - return int(addr, 16) # Parse as hex - else: - return int(addr) # Parse as decimal - - async def start(self) -> None: - """ - Start the i2c read loop. - - Creates a background task that continuously reads i2c - data and publishes it on the configured topic. - """ - if self._task and not self._task.done(): - return - - self._running = True - self._task = asyncio.create_task(self._loop()) - - async def stop(self) -> None: - """ - Stop the i2c read loop and close the connection. - - Cancels the background task, thread and closes the i2c connection if open. - """ - self._running = False - - if self._task and not self._task.done(): - self._task.cancel() - - with suppress(asyncio.CancelledError): - await self._task - - if self._i2c is not None: - self._i2c.close() - - self._executor.shutdown(wait=True) - - def _read_byte(self, addr: int) -> int: - """Read a single byte from a register.""" - try: - self._i2c.write_byte(self._i2c_addr, addr) - return self._i2c.read_byte(self._i2c_addr) - except Exception: - self._logger.exception(f"Error reading byte from register 0x{addr:02X}") - return 0 - - def _read_available_count(self) -> int: - """Get number of bytes available to read.""" - try: - high = self._read_byte(self.BYTES_AVAIL_H_ADDR) - low = self._read_byte(self.BYTES_AVAIL_L_ADDR) - return (high << 8) | low - except Exception: - self._logger.exception("Error reading available byte count") - return 0 - - def _read_block(self, size: int) -> bytes: - """Read a block of specified size from I2C device.""" - try: - buff = bytearray() - while size > 0: - block_size = min(size, self.MAX_BLOCK_SIZE) - block = self._i2c.read_i2c_block_data( - self._i2c_addr, - self.MESSAGE_BUFF_ADDR, - block_size - ) - buff.extend(block) - size -= block_size - return bytes(buff) - except Exception: - self._logger.exception("Error reading block from I2C") - return b'' - - async def _loop(self) -> None: - """Continuously read from I2C and publish complete messages.""" - loop = asyncio.get_running_loop() - - while self._running: - try: - self._logger.info(f"Connecting to I2C bus {self._i2c_bus}, address 0x{self._i2c_addr:02X}") - - self._i2c = await loop.run_in_executor( - self._executor, - lambda: SMBus(self._i2c_bus) - ) - - self._logger.info(f"Connected to I2C bus {self._i2c_bus}, address 0x{self._i2c_addr:02X}") - - # Initialise message buffer register - await loop.run_in_executor( - self._executor, - lambda: self._i2c.write_byte(self._i2c_addr, self.MESSAGE_BUFF_ADDR) - ) - - while self._running: - available = await loop.run_in_executor( - self._executor, - self._read_available_count - ) - - if available == 0: - await asyncio.sleep(0.05) - continue - - self._logger.debug(f"Bytes available: {available}") - - data = await loop.run_in_executor( - self._executor, - lambda available=available: self._read_block(available) - ) - - if not data: - await asyncio.sleep(0.01) - continue - - self._message_buffer += data - - while b"\r\n" in self._message_buffer: - complete, _, remainder = self._message_buffer.partition(b"\r\n") - - message = complete.decode("ascii", errors="ignore").strip() - if message: - self._logger.debug(f"Message: {message}") - await self._bus.publish(self._topic, message) - - self._message_buffer = remainder - - except asyncio.CancelledError: - raise - except Exception: - self._logger.exception( - f"Daisy I2C error, reconnecting in {self.RECONNECT_DELAY}s" - ) - finally: - if self._i2c is not None: - with suppress(Exception): - self._i2c.close() - self._i2c = None - self._message_buffer = b"" - - if self._running: - await asyncio.sleep(self.RECONNECT_DELAY) - - -def get_config_schema() -> ConfigSchema: - """Return the config schema for this plugin. - - Defines editable fields for the admin panel. - - Returns: - ConfigSchema: Schema describing this plugin's configuration options. - """ - return ConfigSchema( - plugin_name="daisy_message_source", - plugin_type="source", - fields=[ - ConfigField( - key="i2c_bus", - label="I2C Bus Number", - field_type=ConfigFieldType.INTEGER, - default=1, - description="I2C bus number" - ), - ConfigField( - key="i2c_addr", - label="I2C Device Address", - field_type=ConfigFieldType.STRING, - default="0x33", - description="I2C device address in hex format" - ), - ], - ) - - -def make_plugin(**kwargs: Any) -> Plugin: - """ - Factory function required by the entry point. - Receives the MessageBus from the core. - """ - - return DaisyMessageSource(**kwargs) diff --git a/plugins/message_sources/daisy_message_source/pyproject.toml b/plugins/message_sources/udp_message_source/pyproject.toml similarity index 60% rename from plugins/message_sources/daisy_message_source/pyproject.toml rename to plugins/message_sources/udp_message_source/pyproject.toml index 05f5445..db7f04b 100644 --- a/plugins/message_sources/daisy_message_source/pyproject.toml +++ b/plugins/message_sources/udp_message_source/pyproject.toml @@ -3,22 +3,22 @@ requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] -name = "daisy-message-source" +name = "udp-message-source" version = "0.1.0" -description = "Wegmatt Daisy I2C AIS receiver source plugin for Vessel Frame." +description = "UDP AIS message source plugin for Vessel Frame. Receives NMEA sentences over UDP (eg from AIS-catcher) and republishes them on ais.raw" +requires-python = ">=3.11" license = "GPL-3.0-only" authors = [{ name = "Spook Workshop" }] -requires-python = ">=3.11" -dependencies = ["vessel-frame-core", "smbus2>=0.5.0"] +dependencies = ["vessel-frame-core"] [tool.setuptools.packages.find] where = ["src"] [project.entry-points."vesselframe.plugins.messagesource"] -daisy_message_source = "daisy_message_source:make_plugin" +udp_message_source = "udp_message_source:make_plugin" [project.entry-points."vesselframe.config.schemas"] -daisy_message_source = "daisy_message_source:get_config_schema" +udp_message_source = "udp_message_source:get_config_schema" [project.urls] Homepage = "https://github.com/SpookWorkshop/vessel-frame" diff --git a/plugins/message_sources/udp_message_source/src/udp_message_source/__init__.py b/plugins/message_sources/udp_message_source/src/udp_message_source/__init__.py new file mode 100644 index 0000000..3ab961e --- /dev/null +++ b/plugins/message_sources/udp_message_source/src/udp_message_source/__init__.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from typing import Any + +from vf_core.message_bus import MessageBus +from vf_core.plugin_types import ( + ConfigField, + ConfigFieldType, + ConfigSchema, + Plugin, + require_plugin_args, +) + + +class UDPMessageSource: + """Source plugin that receives AIS sentences over UDP.""" + + def __init__( + self, + *, + bus: MessageBus, + host: str = "0.0.0.0", + port: int = 10110, + topic: str = "ais.raw", + **kwargs: Any, + ) -> None: + require_plugin_args(bus=bus) + self._logger = logging.getLogger(__name__) + self._bus = bus + self._host = host + self._port = port + self._topic = topic + self._transport: asyncio.BaseTransport | None = None + self._task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start listening for UDP datagrams.""" + if self._task and not self._task.done(): + return + + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + """Stop the UDP listener and close the socket.""" + if self._task and not self._task.done(): + self._task.cancel() + + with suppress(asyncio.CancelledError): + await self._task + + if self._transport is not None: + self._transport.close() + + async def _loop(self) -> None: + """Open a UDP socket and publish received sentences to the bus.""" + try: + loop = asyncio.get_running_loop() + + self._transport, _ = await loop.create_datagram_endpoint( + lambda: _AISDatagramProtocol(self._bus, self._topic, self._logger), + local_addr=(self._host, self._port), + ) + + self._logger.info(f"UDP message source listening on {self._host}:{self._port}") + + # Hold open until cancelled + await asyncio.get_running_loop().create_future() + + except asyncio.CancelledError: + raise + except Exception: + self._logger.exception("UDP message source error") + finally: + if self._transport is not None: + self._transport.close() + + +class _AISDatagramProtocol(asyncio.DatagramProtocol): + """asyncio datagram protocol that publishes received AIS sentences on the bus.""" + + def __init__(self, bus: MessageBus, topic: str, logger: logging.Logger) -> None: + self._bus = bus + self._topic = topic + self._logger = logger + self._loop = asyncio.get_running_loop() + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + text = data.decode("ascii", errors="ignore").strip() + if text: + self._loop.create_task(self._bus.publish(self._topic, text)) + + def error_received(self, exc: Exception) -> None: + self._logger.error(f"UDP error: {exc}") + + +def get_config_schema() -> ConfigSchema: + return ConfigSchema( + plugin_name="udp_message_source", + plugin_type="source", + fields=[ + ConfigField( + key="host", + label="Bind Address", + field_type=ConfigFieldType.STRING, + default="0.0.0.0", + description="IP address to listen on (0.0.0.0 for all interfaces)", + ), + ConfigField( + key="port", + label="Port", + field_type=ConfigFieldType.INTEGER, + default=10110, + description="UDP port to listen on", + ), + ], + ) + + +def make_plugin(**kwargs: Any) -> Plugin: + return UDPMessageSource(**kwargs) From aebd774384fe0d91e30768a0f50cee5a19a26c3f Mon Sep 17 00:00:00 2001 From: James Date: Tue, 16 Jun 2026 23:20:12 +0100 Subject: [PATCH 4/5] Reworked setup script to use whiptail and set up for sdr instead of inky --- scripts/setup.sh | 489 ++++++++++++++++++++--------------------------- 1 file changed, 207 insertions(+), 282 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index a821eaf..e4d4890 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -7,10 +7,17 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Colour +section() { echo ""; echo -e "${GREEN}$1${NC}"; } +note() { echo -e "${YELLOW}$1${NC}"; } + echo -e "${GREEN}=== Vessel Frame Setup ===${NC}" echo "" -# Check we're in the right directory +# --------------------------------------------------------------------------- +# Sanity checks +# --------------------------------------------------------------------------- + +# Must be run from the repo root if [ ! -f "scripts/setup.sh" ]; then echo -e "${RED}Error: This script must be run from the vessel-frame directory${NC}" echo "Please run: cd vessel-frame && bash scripts/setup.sh" @@ -24,258 +31,198 @@ if [ "$EUID" -eq 0 ]; then exit 1 fi -# Check if running on a Raspberry Pi +confirm_unsupported() { + read -p "Continue anyway? [y/N] " -n 1 -r; echo + [[ $REPLY =~ ^[Yy]$ ]] || { echo "Setup cancelled"; exit 0; } +} + +# Raspberry Pi check if [ ! -f /proc/device-tree/model ]; then - echo -e "${YELLOW}Warning: Cannot detect device model${NC}" - echo "This script is designed for Raspberry Pi hardware" - read -p "Continue anyway? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Setup cancelled" - exit 0 - fi + note "Warning: cannot detect device model. This script targets Raspberry Pi hardware." + confirm_unsupported elif ! grep -q "Raspberry Pi" /proc/device-tree/model; then - echo -e "${YELLOW}Warning: This does not appear to be a Raspberry Pi${NC}" - echo "Detected: $(cat /proc/device-tree/model)" - echo "This script is designed for Raspberry Pi hardware" - read -p "Continue anyway? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Setup cancelled" - exit 0 - fi + note "Warning: this does not appear to be a Raspberry Pi (detected: $(tr -d '\0' < /proc/device-tree/model))." + confirm_unsupported fi # Check OS version if [ -f /etc/os-release ]; then . /etc/os-release if [[ "$VERSION_CODENAME" != "trixie" ]]; then - echo -e "${YELLOW}Warning: Unsupported OS version detected${NC}" - echo "Expected: Debian Trixie" - echo "Detected: $PRETTY_NAME (codename: $VERSION_CODENAME)" - echo "The installation may not work correctly" - read -p "Continue anyway? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Setup cancelled" - exit 0 - fi + note "Warning: expected Debian Trixie, detected $PRETTY_NAME (codename: $VERSION_CODENAME)." + confirm_unsupported fi else - echo -e "${YELLOW}Warning: Cannot detect OS version${NC}" - read -p "Continue anyway? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Setup cancelled" - exit 0 - fi + note "Warning: cannot detect OS version." + confirm_unsupported fi -echo -e "${YELLOW}This script will set up Vessel Frame on your Raspberry Pi${NC}" -echo "It will:" -echo " - Install system dependencies" -echo " - Enable I2C and SPI" -echo " - Set up a Python virtual environment" -echo " - Install core and plugins" -echo " - Create /var/lib/vessel-frame data directory" -echo " - Configure systemd services" -echo "" -read -p "Continue? [y/N] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Setup cancelled" - exit 0 +# whiptail drives the menus. It ships with Raspberry Pi OS but install as a safety net +if ! command -v whiptail >/dev/null 2>&1; then + note "Installing whiptail (needed for the setup menus)..." + sudo apt update && sudo apt install -y whiptail fi -echo "" -echo -e "${GREEN}Step 1: Installing system dependencies${NC}" -sudo apt update -sudo apt install -y python3.13-dev dnsmasq hostapd - -echo -e "${GREEN}System dependencies installed${NC}" +# --------------------------------------------------------------------------- +# Collect all choices up front +# --------------------------------------------------------------------------- + +cancelled() { echo "Setup cancelled"; exit 0; } + +AIS_SOURCE=$(whiptail --title "AIS Source" --radiolist \ + "Choose your AIS data source:" 12 74 2 \ + rtlsdr "RTL-SDR dongle via AIS-catcher" ON \ + skip "I'll configure my own source later" OFF \ + 3>&1 1>&2 2>&3) || cancelled + +RENDERER=$(whiptail --title "Display Renderer" --radiolist \ + "Choose your display renderer:" 12 74 3 \ + inky "Pimoroni Inky e-ink display" ON \ + image "PNG image output (no display hardware)" OFF \ + skip "I'll configure my own renderer later" OFF \ + 3>&1 1>&2 2>&3) || cancelled + +# --separate-output makes the checklist print one tag per line (no quoting) +SCREENS=$(whiptail --title "Screens" --separate-output --checklist \ + "Select screens to install (SPACE toggles, ENTER confirms):" 12 74 3 \ + table "Vessel table" ON \ + zone "Zone proximity (needs a Mapbox key)" OFF \ + map "Map view (needs a Mapbox key)" OFF \ + 3>&1 1>&2 2>&3) || cancelled + +if whiptail --title "Button Controller" --yesno \ + "Install the button controller for physical navigation buttons?" 8 74; then + INSTALL_BUTTON=yes +else + INSTALL_BUTTON=no +fi -echo "" -echo -e "${GREEN}Step 2: Enabling I2C and SPI${NC}" +if whiptail --title "Network Service" --yesno \ + "Install the WiFi AP/client network-mode service?\n\nRecommended for headless setups so you can switch the device between hotspot and home-WiFi modes." 11 74; then + INSTALL_NETWORK=yes +else + INSTALL_NETWORK=no +fi -# Enable I2C and SPI using raspi-config (0 = enabled) -sudo raspi-config nonint do_i2c 0 -sudo raspi-config nonint do_spi 0 +# Summary + single confirmation +SCREEN_LIST=$(echo $SCREENS | tr '\n' ' ') +[ -z "$SCREEN_LIST" ] && SCREEN_LIST="(none)" +whiptail --title "Confirm" --yesno \ +"About to install Vessel Frame with: -echo -e "${GREEN}I2C and SPI enabled${NC}" + AIS source: $AIS_SOURCE + Renderer: $RENDERER + Screens: $SCREEN_LIST + Button: $INSTALL_BUTTON + Network svc: $INSTALL_NETWORK -# Add SPI overlay to boot config if not already present -echo -e "${GREEN}Step 3: Configuring boot settings${NC}" +This will install system packages, set up a virtualenv, and configure +systemd services. Proceed?" 18 74 || cancelled -if ! grep -q "dtoverlay=spi0-0cs" /boot/firmware/config.txt; then - echo "dtoverlay=spi0-0cs" | sudo tee -a /boot/firmware/config.txt > /dev/null - echo -e "${GREEN}Added SPI overlay to boot config${NC}" -else - echo -e "${YELLOW}SPI overlay already present in boot config${NC}" -fi +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- -echo "" -echo -e "${GREEN}Step 4: Updating repository${NC}" - -# Check if there are uncommitted changes -if ! git diff-index --quiet HEAD --; then - echo -e "${YELLOW}Warning: You have uncommitted changes in the repository${NC}" - read -p "Skip git pull? [Y/n] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - echo -e "${YELLOW}Skipping git pull${NC}" +section "Step 1: Installing system dependencies" +APT_PKGS="python3-dev git curl" +[ "$INSTALL_NETWORK" = yes ] && APT_PKGS="$APT_PKGS dnsmasq hostapd" +sudo apt update +sudo apt install -y $APT_PKGS + +# I2C/SPI are only needed for the Inky display +if [ "$RENDERER" = inky ]; then + section "Step 2: Enabling I2C and SPI for the Inky display" + sudo raspi-config nonint do_i2c 0 + sudo raspi-config nonint do_spi 0 + if ! grep -q "dtoverlay=spi0-0cs" /boot/firmware/config.txt; then + echo "dtoverlay=spi0-0cs" | sudo tee -a /boot/firmware/config.txt > /dev/null + echo -e "${GREEN}Added SPI overlay to boot config${NC}" else - git pull - echo -e "${GREEN}Repository updated${NC}" + note "SPI overlay already present in boot config" fi +fi + +# RTL-SDR + AIS-catcher +if [ "$AIS_SOURCE" = rtlsdr ]; then + section "Step 3: Installing AIS-catcher (RTL-SDR decoder)" + # Official installer: pulls SDR libraries, builds AIS-catcher, and sets up + # the ais-catcher.service systemd unit. + curl -fsSL https://raw.githubusercontent.com/jvde-github/AIS-catcher/main/scripts/aiscatcher-install -o /tmp/aiscatcher-install + sudo bash /tmp/aiscatcher-install -p + rm -f /tmp/aiscatcher-install + + # Stop the kernel DVB-T driver from claiming the RTL-SDR dongle. + echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl-sdr.conf > /dev/null + + # Point AIS-catcher's output at our UDP source on localhost. + sudo mkdir -p /etc/AIS-catcher + echo "-u 127.0.0.1 10110" | sudo tee /etc/AIS-catcher/config.cmd > /dev/null + + sudo systemctl enable ais-catcher.service + sudo systemctl restart ais-catcher.service + echo -e "${GREEN}AIS-catcher installed and feeding udp://127.0.0.1:10110${NC}" else - git pull - echo -e "${GREEN}Repository updated${NC}" + note "Skipping AIS source install. Install and configure one before running Vessel Frame." fi -echo "" -echo -e "${GREEN}Step 5: Setting up Python virtual environment${NC}" +section "Step 4: Updating repository" +if git diff-index --quiet HEAD -- 2>/dev/null; then + git pull && echo -e "${GREEN}Repository updated${NC}" +else + note "Uncommitted changes present, skipping git pull" +fi -# Create venv if it doesn't exist +section "Step 5: Setting up Python virtual environment" if [ ! -d ".venv" ]; then python3 -m venv .venv --system-site-packages echo -e "${GREEN}Virtual environment created${NC}" else - echo -e "${YELLOW}Virtual environment already exists${NC}" + note "Virtual environment already exists" fi - -# Activate venv source .venv/bin/activate -echo -e "${GREEN}Virtual environment activated${NC}" - -echo "" -echo -e "${GREEN}Step 6: Installing core and plugins${NC}" - -# Always install core and AIS decoder -echo "Installing vf_core and AIS decoder..." +section "Step 6: Installing core and plugins" pip install ./core pip install ./plugins/message_processors/ais_decoder_processor -echo -e "${GREEN}Core and AIS decoder installed${NC}" -# Ask about hardware -echo "" -echo -e "${YELLOW}Hardware Configuration${NC}" - -# AIS receiver -echo "" -read -p "Are you using a Wegmatt Daisy Mini AIS receiver? [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - pip install ./plugins/message_sources/daisy_message_source - echo -e "${GREEN}Daisy message source installed${NC}" -else - echo -e "${YELLOW}Skipped - you'll need to install a message source for your AIS receiver${NC}" +if [ "$AIS_SOURCE" = rtlsdr ]; then + pip install ./plugins/message_sources/udp_message_source fi -# Display -echo "" -read -p "Are you using a Pimoroni Inky display? [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then +if [ "$RENDERER" = inky ]; then pip install ./plugins/renderers/inky_renderer - echo -e "${GREEN}Inky renderer installed${NC}" +elif [ "$RENDERER" = image ]; then + pip install ./plugins/renderers/image_renderer else - echo -e "${YELLOW}Skipped - you'll need to install a renderer for your display${NC}" -fi - -# Ask about screen plugins -echo "" -echo -e "${YELLOW}Screen Plugins${NC}" -echo "" - -SCREENS_DIR="./plugins/screens" -SCREENS_TO_INSTALL=() - -read -p "Install Table Screen plugin? [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - SCREENS_TO_INSTALL+=("table_screen") -fi - -read -p "Install Zone Screen plugin? [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - SCREENS_TO_INSTALL+=("zone_screen") + note "Skipping renderer install. Install and configure one before running Vessel Frame." fi -read -p "Install Map Screen plugin? [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - SCREENS_TO_INSTALL+=("map_screen") -fi - -SCREEN_COUNT=${#SCREENS_TO_INSTALL[@]} - -if [ $SCREEN_COUNT -gt 0 ]; then - for screen in "${SCREENS_TO_INSTALL[@]}"; do - if [ -d "$SCREENS_DIR/$screen" ]; then - pip install "$SCREENS_DIR/$screen" - echo -e "${GREEN}$screen installed${NC}" - else - echo -e "${RED}Warning: $screen not found in $SCREENS_DIR, skipping${NC}" - fi - done -else - echo -e "${YELLOW}No screens selected. You'll need to install at least one screen for the vessel frame to be useful.${NC}" -fi - -# Ask about button controller -echo "" -read -p "Install Button Controller? (recommended if your device has physical buttons) [Y/n] " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - pip install ./plugins/controllers/button_controller - echo -e "${GREEN}Button Controller installed${NC}" - if [ $SCREEN_COUNT -le 1 ]; then - echo -e "${YELLOW}Note: Button controller is most useful with multiple screens${NC}" - fi -else - if [ $SCREEN_COUNT -gt 1 ]; then - echo -e "${YELLOW}Note: You have multiple screens but no navigation controller${NC}" +for screen in $SCREENS; do + screen_dir="./plugins/screens/${screen}_screen" + if [ -d "$screen_dir" ]; then + pip install "$screen_dir" + echo -e "${GREEN}Installed ${screen}_screen${NC}" + else + echo -e "${RED}Warning: $screen_dir not found, skipping${NC}" fi -fi - -echo "" -echo -e "${GREEN}Step 7: Creating configuration directory${NC}" +done -if [ ! -d "/etc/vessel-frame" ]; then - sudo mkdir -p /etc/vessel-frame - sudo chown $USER:$USER /etc/vessel-frame - echo -e "${GREEN}Configuration directory created${NC}" -else - echo -e "${YELLOW}Configuration directory already exists${NC}" - # Make sure ownership is correct anyway - sudo chown $USER:$USER /etc/vessel-frame -fi - -echo "" -echo -e "${GREEN}Step 8: Creating data directory${NC}" - -if [ ! -d "/var/lib/vessel-frame" ]; then - sudo mkdir -p /var/lib/vessel-frame - sudo chown $USER:$USER /var/lib/vessel-frame - sudo chmod 700 /var/lib/vessel-frame - echo -e "${GREEN}Data directory created: /var/lib/vessel-frame${NC}" -else - echo -e "${YELLOW}Data directory already exists${NC}" - # Make sure ownership is correct anyway - sudo chown $USER:$USER /var/lib/vessel-frame +if [ "$INSTALL_BUTTON" = yes ]; then + pip install ./plugins/controllers/button_controller + echo -e "${GREEN}Button controller installed${NC}" fi - -echo "" -echo -e "${GREEN}Step 9: Setting up systemd services${NC}" - -# Get the current username and home directory -USERNAME=$USER -HOME_DIR=$HOME - -# Create the main vessel-frame service -echo "Creating vessel-frame.service..." +echo -e "${GREEN}Core and plugins installed${NC}" + +section "Step 7: Creating data and config directories" +# NetworkManager writes here. Core needs it writable even without the network service. +sudo mkdir -p /etc/vessel-frame +sudo chown "$USER:$USER" /etc/vessel-frame +sudo mkdir -p /var/lib/vessel-frame +sudo chown "$USER:$USER" /var/lib/vessel-frame +sudo chmod 700 /var/lib/vessel-frame +echo -e "${GREEN}Directories ready${NC}" + +section "Step 8: Setting up the Vessel Frame service" sudo tee /etc/systemd/system/vessel-frame.service > /dev/null < /dev/null < /dev/null < /dev/null < /dev/null < /dev/null + fi + echo -e "${GREEN}Network-mode service configured${NC}" fi -echo -e "${GREEN}hostapd configured${NC}" +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- -# Configure dhcpcd -echo "Configuring dhcpcd..." -if ! grep -q "denyinterfaces wlan0" /etc/dhcpcd.conf; then - echo "" | sudo tee -a /etc/dhcpcd.conf > /dev/null - echo "# Allow manual management of wlan0 for AP/Client switching" | sudo tee -a /etc/dhcpcd.conf > /dev/null - echo "# denyinterfaces wlan0" | sudo tee -a /etc/dhcpcd.conf > /dev/null - echo -e "${GREEN}dhcpcd configured${NC}" -else - echo -e "${YELLOW}dhcpcd already configured${NC}" -fi - -echo "" -echo -e "${GREEN}=== Setup Complete! ===${NC}" +section "=== Setup Complete! ===" echo "" -echo "Vessel Frame has been installed and configured." -echo "The system needs to reboot to apply all changes." +echo "Vessel Frame is installed. A reboot is needed to apply all changes." echo "" echo "After reboot:" -echo " 1. The vessel-frame service will start automatically" -echo " 2. Access the admin panel at http://$(hostname -I | awk '{print $1}'):8000" -echo " 3. Enable your installed plugins through the admin panel" -echo " 4. The display should update once vessels are in range" +echo " 1. The vessel-frame service starts automatically." +echo " 2. Open the admin panel at http://$(hostname -I | awk '{print $1}'):8000" +echo " 3. Enable your installed plugins there (and set zone/Mapbox details if you" +echo " installed the zone or map screens)." +echo " 4. The display updates once vessels are in range." echo "" -read -p "Reboot now? [Y/n] " -n 1 -r -echo - +read -p "Reboot now? [Y/n] " -n 1 -r; echo if [[ ! $REPLY =~ ^[Nn]$ ]]; then echo "Rebooting..." sudo reboot else - echo "" - echo -e "${YELLOW}Reboot postponed${NC}" - echo "Remember to reboot before running Vessel Frame:" - echo " sudo reboot" -fi \ No newline at end of file + note "Reboot postponed, reboot before starting Vessel Frame." +fi From 3282c5c08bd5300309a2553666674adad0c7323d Mon Sep 17 00:00:00 2001 From: James Date: Sat, 20 Jun 2026 17:26:27 +0100 Subject: [PATCH 5/5] Fix for multipart messages coming in as a single two-line bus message --- .../src/ais_decoder_processor/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/message_processors/ais_decoder_processor/src/ais_decoder_processor/__init__.py b/plugins/message_processors/ais_decoder_processor/src/ais_decoder_processor/__init__.py index 15421fb..9fa61e1 100644 --- a/plugins/message_processors/ais_decoder_processor/src/ais_decoder_processor/__init__.py +++ b/plugins/message_processors/ais_decoder_processor/src/ais_decoder_processor/__init__.py @@ -178,10 +178,17 @@ async def _receive_loop(self) -> None: """Receive AIS messages from the bus and enqueue them for decoding.""" try: async for msg in self._bus.subscribe(self._in_topic): - if isinstance(msg, str): - msg = msg.encode("utf-8") + if isinstance(msg, bytes): + msg = msg.decode("utf-8", errors="ignore") + + # Some sources push multipart messages as one bus + # message with multiple lines. Need to split and + # deal with them as two single messages + for line in msg.splitlines(): + line = line.strip() + if line: + self._message_queue.put_line(line.encode("utf-8")) - self._message_queue.put_line(msg) await asyncio.sleep(0) except asyncio.CancelledError: self._logger.info("Receive loop cancelled")