├── docs ├── source │ ├── _static │ │ ├── .gitkeep │ │ └── custom.css │ ├── python_api │ │ └── index.rst │ ├── index.rst │ └── conf.py ├── .gitignore ├── versions.json ├── pyproject.toml └── Makefile ├── doc ├── .gitignore └── ping.png ├── .gitmodules ├── ci ├── deploy-whitelist ├── deploy-gh-pages.sh ├── travis-ci-script.sh ├── deploy.sh └── ci-functions.sh ├── .gitignore ├── brping ├── __init__.py └── pingmessage.py ├── .travis.yml ├── .github └── workflows │ ├── docs-verify.yml │ ├── deploy.yml │ ├── deploy_to_pypi.yml │ └── docs-publish.yml ├── LICENSE ├── setup.py ├── generate ├── templates │ ├── pingmessage-definitions.py.in │ ├── ping1d.py.in │ ├── ping360.py.in │ ├── device.py.in │ ├── s500.py.in │ ├── omniscan450.py.in │ └── surveyor240.py.in └── generate-python.py ├── examples ├── simplePingExample.py ├── ping360AutoScan.py ├── s500Example.py ├── omniscan450Example.py └── surveyor240Example.py ├── tools ├── pingproxy.py └── ping1d-simulation.py └── README.md /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | latex 2 | html 3 | -------------------------------------------------------------------------------- /doc/ping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluerobotics/ping-python/HEAD/doc/ping.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/ping-protocol"] 2 | path = lib/ping-protocol 3 | url = https://github.com/bluerobotics/ping-protocol -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | _build 3 | source/python_api/* 4 | !/source/python_api/index.rst 5 | source/cpp_api 6 | source/lua_api 7 | **.pyc 8 | -------------------------------------------------------------------------------- /docs/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "versions": [ 3 | { 4 | "name": "latest", 5 | "branch": "master", 6 | "is_default": true 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /docs/source/python_api/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | 6 | .. include:: unabridged_api.rst.include 7 | :start-after: -------- 8 | 9 | -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Hide third level of secondary sidebar onwards */ 2 | .md-sidebar--secondary .md-nav__list .md-nav__list .md-nav__list { 3 | display: none; 4 | } 5 | -------------------------------------------------------------------------------- /ci/deploy-whitelist: -------------------------------------------------------------------------------- 1 | brping/__init__.py 2 | brping/definitions.py 3 | brping/device.py 4 | brping/ping1d.py 5 | brping/ping360.py 6 | brping/surveyor240.py 7 | brping/s500.py 8 | brping/omniscan450.py 9 | brping/pingmessage.py 10 | examples 11 | tools 12 | LICENSE 13 | setup.py 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **__pycache__ 2 | *.pyc 3 | dist 4 | bluerobotics_ping.egg-info 5 | build 6 | brping/definitions.py 7 | brping/device.py 8 | brping/ping1d.py 9 | brping/ping360.py 10 | brping/s500.py 11 | brping/surveyor240.py 12 | brping/omniscan450.py 13 | doc/xml 14 | examples/logs 15 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | ping-python 3 | =========== 4 | 5 | A python implementation of the Blue Robotics Ping messaging protocol and a device API for the Blue Robotics Ping1D echosounder. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Contents: 10 | 11 | python_api/index 12 | -------------------------------------------------------------------------------- /brping/__init__.py: -------------------------------------------------------------------------------- 1 | #'Ping python package' 2 | from brping.definitions import * 3 | from brping.pingmessage import * 4 | from brping.device import PingDevice 5 | from brping.ping1d import Ping1D 6 | from brping.ping360 import Ping360 7 | from brping.surveyor240 import Surveyor240 8 | from brping.s500 import S500 9 | from brping.omniscan450 import Omniscan450 -------------------------------------------------------------------------------- /ci/deploy-gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Deploy repository documentation 4 | 5 | source ci/ci-functions.sh 6 | 7 | doc_path="doc" 8 | 9 | echob "generating message api..." 10 | test pip install jinja2 11 | test generate/generate-python.py --output-dir=brping 12 | 13 | echob "Build doxygen documentation." 14 | test cd $doc_path 15 | test doxygen Doxyfile 16 | 17 | echo "- Check files" 18 | ls -A "html/" 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | 3 | language: python 4 | 5 | addons: 6 | apt: 7 | packages: 8 | - doxygen 9 | - graphviz 10 | 11 | deploy: 12 | provider: pypi 13 | user: __token__ 14 | distributions: sdist 15 | password: $PYPI_TOKEN 16 | on: 17 | tags: true 18 | condition: $TRAVIS_TAG =~ v[0-9]+.[0-9]+.[0-9]+.* && $(if grep -q "${BASH_REMATCH:1}" "$TRAVIS_BUILD_DIR/setup.py"; then echo 0; fi) 19 | 20 | script: 21 | - ci/travis-ci-script.sh || travis_terminate 1 22 | -------------------------------------------------------------------------------- /ci/travis-ci-script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source ci/ci-functions.sh 4 | 5 | echob "generating message api..." 6 | test pip install jinja2 7 | test generate/generate-python.py --output-dir=brping 8 | 9 | echob "installing package..." 10 | test python setup.py install 11 | 12 | echob "testing message api..." 13 | test python brping/pingmessage.py 14 | 15 | echob "update gh pages..." 16 | test pip install pyOpenSSL 17 | test ci/deploy-gh-pages.sh 18 | 19 | echob "deploying..." 20 | test ci/deploy.sh 21 | -------------------------------------------------------------------------------- /docs/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "docs-boilerplate" 3 | version = "0.0.1" 4 | description = "A boilerplate for Blue Robotics documentation projects, built on the Sphinx framework." 5 | authors = ["TechDocs Studio "] 6 | exclude = [".github", "docs"] 7 | readme = "README.md" 8 | package-mode = false 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | myst-parser = "^4.0.0" 13 | sphinx = "^8.1.3" 14 | sphinx-blue-robotics-theme = { version = "^0.0.2", extras = ["extras", "cpp"] } 15 | jinja2 = "^3.1.5" 16 | 17 | [tool.poetry.dev-dependencies] 18 | pytest = "^8.3.2" 19 | sphinx-autobuild = "^2024.10.3" -------------------------------------------------------------------------------- /ci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # source helper functions 4 | source ci/ci-functions.sh 5 | 6 | # TODO make test() work with | pipe 7 | cat ci/deploy-whitelist | xargs git add -f 8 | # commit generated files if necessary, it's ok if commit fails 9 | git commit -m "temporary commit" 10 | # move to deployment branch 11 | test git checkout deployment 12 | test rm -rf * 13 | # get the list of files that should be version controlled in deployment branch 14 | test git checkout HEAD@{1} ci/deploy-whitelist 15 | # add those files 16 | cat ci/deploy-whitelist | xargs git checkout HEAD@{1} 17 | test git --no-pager diff --staged 18 | # unstage the whitelist 19 | test git rm -f ci/deploy-whitelist 20 | -------------------------------------------------------------------------------- /ci/ci-functions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # print control for echob() 4 | bold=$(tput bold) 5 | normal=$(tput sgr0) 6 | 7 | # counter for travis_fold: https://github.com/travis-ci/travis-ci/issues/2285 8 | if [ -z $testN ]; then testN=0; fi 9 | 10 | # echo with bold text 11 | echob() { 12 | echo "${bold}${@}${normal}" 13 | } 14 | 15 | # run command helper for ci scripts 16 | test() { 17 | echo -en "travis_fold:start:$testN\r \r" 18 | echob "$@" 19 | "$@" 20 | exitcode=$? 21 | echo -en "travis_fold:end:$testN\r \r" 22 | echob "$@ exited with $exitcode" 23 | if [ $exitcode -ne 0 ]; then exit $exitcode; fi 24 | testN=$(($testN+1)) 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/docs-verify.yml: -------------------------------------------------------------------------------- 1 | name: "Test documentation" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | paths: 8 | - "docs/**" 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | persist-credentials: false 18 | fetch-depth: 0 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.12' 23 | 24 | - name: Install dependencies 25 | run: make -C docs setupenv 26 | 27 | - name: Install Doxygen (optional) 28 | run: sudo apt-get update && sudo apt-get install -y doxygen 29 | 30 | - name: Build docs 31 | run: make -C docs test BUILDDIR="_build/$output_dir" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Blue Robotics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@v2 15 | with: 16 | submodules: recursive 17 | 18 | - name: Setup Environment 19 | run: | 20 | pip install jinja2 21 | generate/generate-python.py --output-dir=brping 22 | git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" 23 | git fetch origin deployment 24 | git config user.email "support@bluerobotics.com" 25 | git config user.name "BlueRobotics-CI" 26 | 27 | - name: install and test 28 | run: | 29 | echo "installing package..." 30 | pip install . --user 31 | 32 | echo "testing message api..." 33 | python brping/pingmessage.py 34 | 35 | 36 | - name: Generate and Commit Files 37 | run: | 38 | ci/deploy.sh 39 | 40 | - name: Commit Changes 41 | run: | 42 | git commit -m "update autogenerated files for $(git rev-parse HEAD@{2})" || exit 0 43 | 44 | - name: Push changes 45 | uses: ad-m/github-push-action@master 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | branch: deployment 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup, find_packages 4 | 5 | long_description = """ 6 | Python library for the Blue Robotics ping-protocol, and devices that implement it. 7 | 8 | This library provides message apis to use the protocol, as well as device apis to use with the 9 | Blue Robotics Ping Echosounder and Ping360 scanning sonar. 10 | """ 11 | 12 | setup(name='bluerobotics-ping', 13 | version='0.2.3', 14 | python_requires='>=3.4', 15 | description='A python module for the Blue Robotics ping-protocol and products', 16 | long_description=long_description, 17 | long_description_content_type='text/markdown', 18 | author='Blue Robotics', 19 | author_email='support@bluerobotics.com', 20 | url='https://www.bluerobotics.com', 21 | packages=find_packages(), install_requires=['pyserial', 'future'], 22 | classifiers=[ 23 | "Programming Language :: Python", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | ], 27 | scripts=[ 28 | "examples/simplePingExample.py", 29 | "examples/s500Example.py", 30 | "examples/omniscan450Example.py", 31 | "examples/surveyor240Example.py", 32 | "tools/pingproxy.py", 33 | "tools/ping1d-simulation.py"] 34 | ) 35 | -------------------------------------------------------------------------------- /generate/templates/pingmessage-definitions.py.in: -------------------------------------------------------------------------------- 1 | {% for msg_type in messages %} 2 | {% for msg in messages[msg_type] %} 3 | {% set m = messages[msg_type][msg] %} 4 | {{base|upper}}_{{msg|upper}} = {{m.id}} 5 | {% endfor %} 6 | {% endfor %} 7 | 8 | # variable length fields are formatted with 's', and always occur at the end of the payload 9 | # the format string for these messages is adjusted at runtime, and 's' inserted appropriately at runtime 10 | # see PingMessage.get_payload_format() 11 | payload_dict_{{base}} = { 12 | {% for msg_type in messages %} 13 | {% for msg in messages[msg_type] %} 14 | {% set m = messages[msg_type][msg] %} 15 | {{base|upper}}_{{msg|upper}}: { 16 | "name": "{{msg}}", 17 | "format": " 18 | {%- for field in m.payload %} 19 | {% if generator.is_vector(field) %} 20 | {% if field.vector.sizetype %} 21 | {{structToken[field.vector.sizetype]}} 22 | {%- endif %} 23 | {% else %} 24 | {{structToken[field.type]}} 25 | {%- endif %} 26 | {% endfor %}{# for each field #}", 27 | "field_names": ( 28 | {% for field in m.payload %} 29 | {% if generator.is_vector(field) %} 30 | {% if field.vector.sizetype %} 31 | "{{field.name}}_length", 32 | {% endif %} 33 | {% endif %} 34 | "{{field.name}}", 35 | {% endfor %}{# for each field #} 36 | ), 37 | "payload_length": {{generator.calc_payload(m.payload)}} 38 | }, 39 | 40 | {% endfor %} 41 | {% endfor %} 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/deploy_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # This will trigger the workflow only when a tag that matches the pattern is pushed 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Code 17 | uses: actions/checkout@v2 18 | with: 19 | submodules: recursive 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: '3.x' 25 | 26 | - name: Check Tag and setup.py Version Match 27 | run: | 28 | TAG_VERSION=${GITHUB_REF#refs/tags/v} 29 | SETUP_VERSION=$(grep -oE "version='([^']+)" setup.py | grep -oE "[^'=]+$") 30 | if [[ "$TAG_VERSION" != "$SETUP_VERSION" ]]; then 31 | echo "Tag version $TAG_VERSION does not match setup.py version $SETUP_VERSION." 32 | exit 1 33 | fi 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install jinja2 setuptools wheel 39 | generate/generate-python.py --output-dir=brping 40 | 41 | - name: Build package 42 | run: | 43 | python setup.py sdist bdist_wheel 44 | 45 | - name: Publish package distributions to PyPI 46 | uses: pypa/gh-action-pypi-publish@release/v1 47 | with: 48 | packages-dir: ./dist 49 | -------------------------------------------------------------------------------- /examples/simplePingExample.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #simplePingExample.py 4 | from brping import Ping1D 5 | import time 6 | import argparse 7 | 8 | from builtins import input 9 | 10 | ##Parse Command line options 11 | ############################ 12 | 13 | parser = argparse.ArgumentParser(description="Ping python library example.") 14 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 15 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 16 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9090") 17 | args = parser.parse_args() 18 | if args.device is None and args.udp is None: 19 | parser.print_help() 20 | exit(1) 21 | 22 | # Make a new Ping 23 | myPing = Ping1D() 24 | if args.device is not None: 25 | myPing.connect_serial(args.device, args.baudrate) 26 | elif args.udp is not None: 27 | (host, port) = args.udp.split(':') 28 | myPing.connect_udp(host, int(port)) 29 | 30 | if myPing.initialize() is False: 31 | print("Failed to initialize Ping!") 32 | exit(1) 33 | 34 | print("------------------------------------") 35 | print("Starting Ping..") 36 | print("Press CTRL+C to exit") 37 | print("------------------------------------") 38 | 39 | input("Press Enter to continue...") 40 | 41 | # Read and print distance measurements with confidence 42 | while True: 43 | data = myPing.get_distance() 44 | if data: 45 | print("Distance: %s\tConfidence: %s%%" % (data["distance"], data["confidence"])) 46 | else: 47 | print("Failed to get distance data") 48 | time.sleep(0.1) 49 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Global variables 2 | # These can be overridden from the command line. 3 | POETRY = poetry 4 | SPHINXOPTS = -j auto 5 | SPHINXBUILD = $(POETRY) run sphinx-build 6 | PAPER = 7 | BUILDDIR = _build 8 | SOURCEDIR = source 9 | ROOT_DIR = .. 10 | 11 | # Internal variables 12 | PAPEROPT_a4 = -D latex_paper_size=a4 13 | PAPEROPT_letter = -D latex_paper_size=letter 14 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) $(SOURCEDIR) 15 | AUTOBUILDOPTS = --ignore "source/python_api/*" --ignore "source/cpp_api/*" --ignore "source/lua_api/*" 16 | TESTSPHINXOPTS = $(ALLSPHINXOPTS) -W --keep-going 17 | 18 | .PHONY: all 19 | all: dirhtml 20 | 21 | # Setup commands 22 | .PHONY: setupenv 23 | setupenv: 24 | pip install -q poetry 25 | 26 | .PHONY: setup 27 | setup: 28 | $(POETRY) install --all-extras 29 | cd $(ROOT_DIR) && git submodule update --init 30 | $(POETRY) run python $(ROOT_DIR)/generate/generate-python.py --output-dir=$(ROOT_DIR)/brping 31 | 32 | .PHONY: update 33 | update: 34 | $(POETRY) update 35 | 36 | .PHONY: clean 37 | clean: 38 | rm -rf $(BUILDDIR)/* 39 | 40 | # Output generation commands 41 | dirhtml: setup 42 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 43 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 44 | 45 | .PHONY: singlehtml 46 | singlehtml: setup 47 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 48 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 49 | 50 | .PHONY: preview 51 | preview: setup 52 | $(POETRY) run sphinx-autobuild -b dirhtml $(ALLSPHINXOPTS) $(AUTOBUILDOPTS) $(BUILDDIR)/dirhtml --port 5500 53 | 54 | # Testing commands 55 | .PHONY: test 56 | test: setup 57 | $(SPHINXBUILD) -b dirhtml $(TESTSPHINXOPTS) $(BUILDDIR)/dirhtml 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | .PHONY: linkcheck 61 | linkcheck: setup 62 | $(SPHINXBUILD) -b linkcheck $(SOURCEDIR) $(BUILDDIR)/linkcheck 63 | -------------------------------------------------------------------------------- /examples/ping360AutoScan.py: -------------------------------------------------------------------------------- 1 | from brping import definitions 2 | from brping import Ping360 3 | import time 4 | import argparse 5 | 6 | from builtins import input 7 | 8 | ## Parse Command line options 9 | ############################ 10 | 11 | parser = argparse.ArgumentParser(description="Ping360 auto scan example") 12 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 13 | parser.add_argument('--baudrate', action="store", type=int, default=2000000, help="Ping device baudrate. E.g: 115200") 14 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9090") 15 | args = parser.parse_args() 16 | if args.device is None and args.udp is None: 17 | parser.print_help() 18 | exit(1) 19 | 20 | # Make a new Ping360 21 | myPing360 = Ping360() 22 | if args.device is not None: 23 | myPing360.connect_serial(args.device, args.baudrate) 24 | elif args.udp is not None: 25 | (host, port) = args.udp.split(':') 26 | myPing360.connect_udp(host, int(port)) 27 | 28 | if myPing360.initialize() is False: 29 | print("Failed to initialize Ping!") 30 | exit(1) 31 | 32 | print("------------------------------------") 33 | print("Ping360 auto scan..") 34 | print("Press CTRL+C to exit") 35 | print("------------------------------------") 36 | 37 | input("Press Enter to continue...") 38 | 39 | myPing360.control_auto_transmit( 40 | mode = 1, 41 | gain_setting = 0, 42 | transmit_duration = 80, 43 | sample_period = 80, 44 | transmit_frequency = 750, 45 | number_of_samples = 1024, 46 | start_angle = 0, 47 | stop_angle = 399, 48 | num_steps = 1, 49 | delay = 0 50 | ) 51 | 52 | # Print the scanning head angle 53 | for n in range(400): 54 | m = myPing360.wait_message([definitions.PING360_AUTO_DEVICE_DATA]) 55 | if m: 56 | print(m.angle) 57 | time.sleep(0.001) 58 | 59 | # if it is a serial device, reconnect to send a line break 60 | # and stop auto-transmitting 61 | if args.device is not None: 62 | myPing360.connect_serial(args.device, args.baudrate) 63 | 64 | # turn the motor off 65 | myPing360.control_motor_off() 66 | -------------------------------------------------------------------------------- /tools/pingproxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # PingProxy.py 4 | # Connect multiple udp clients to a single serial device 5 | 6 | from brping import PingParser 7 | import serial 8 | import socket 9 | import time 10 | from collections import deque 11 | import errno 12 | 13 | 14 | class PingClient(object): 15 | def __init__(self): 16 | ## Queued messages received from client 17 | self.rx_msgs = deque([]) 18 | 19 | ## Parser to verify client comms 20 | self.parser = PingParser() 21 | 22 | ## Digest incoming client data 23 | # @return None 24 | def parse(self, data): 25 | for b in bytearray(data): 26 | if self.parser.parse_byte(b) == PingParser.NEW_MESSAGE: 27 | self.rx_msgs.append(self.parser.rx_msg) 28 | 29 | ## Dequeue a message received from client 30 | # @return None: if there are no comms in the queue 31 | # @return PingMessage: the next ping message in the queue 32 | def dequeue(self): 33 | if len(self.rx_msgs) == 0: 34 | return None 35 | return self.rx_msgs.popleft() 36 | 37 | 38 | class PingProxy(object): 39 | def __init__(self, device=None, port=None): 40 | ## A serial object for ping device comms 41 | self.device = device 42 | 43 | ## UDP port number for server 44 | self.port = port 45 | 46 | if self.device is None: 47 | raise Exception("A device is required") 48 | 49 | if self.port is None: 50 | raise Exception("A port is required") 51 | 52 | ## Connected client dictionary 53 | self.clients = {} 54 | 55 | ## Socket to serve on 56 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 57 | 58 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 59 | self.socket.setblocking(False) 60 | self.socket.bind(('0.0.0.0', self.port)) 61 | 62 | ## Run proxy tasks 63 | # @return None 64 | def run(self): 65 | try: 66 | data, address = self.socket.recvfrom(4096) 67 | 68 | # new client 69 | if address not in self.clients: 70 | self.clients[address] = PingClient() 71 | 72 | # digest data coming in from client 73 | self.clients[address].parse(data) 74 | 75 | except Exception as e: 76 | if isinstance(e, OSError) and e.errno == errno.EAGAIN: 77 | pass # waiting for data 78 | else: 79 | print("Error reading data", e) 80 | 81 | # read ping device 82 | device_data = self.device.read(self.device.in_waiting) 83 | 84 | # send ping device data to all clients 85 | if device_data: # don't write empty data 86 | for client in self.clients: 87 | # print("writing to client", client) 88 | self.socket.sendto(device_data, client) 89 | 90 | # send all client comms to ping device 91 | for client in self.clients: 92 | c = self.clients[client] 93 | msg = c.dequeue() 94 | while msg is not None: 95 | self.device.write(msg.msg_data) 96 | msg = c.dequeue() 97 | 98 | 99 | if __name__ == '__main__': 100 | import argparse 101 | 102 | parser = argparse.ArgumentParser(description="Ping udp proxy server.") 103 | parser.add_argument('--device', action="store", required=True, type=str, help="Ping device serial port.") 104 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate.") 105 | parser.add_argument('--port', action="store", type=int, default=9090, help="Server udp port.") 106 | args = parser.parse_args() 107 | 108 | s = serial.Serial(args.device, args.baudrate, exclusive=True) 109 | proxy = PingProxy(s, args.port) 110 | 111 | while True: 112 | proxy.run() 113 | time.sleep(0.001) 114 | -------------------------------------------------------------------------------- /generate/generate-python.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | from pathlib import Path 6 | 7 | scriptPath = Path(__file__).parent.absolute() 8 | generatorPath = "%s/../lib/ping-protocol/src" % scriptPath 9 | 10 | import sys 11 | sys.path.append(generatorPath) 12 | 13 | from generator import Generator 14 | 15 | parser = argparse.ArgumentParser(description="generate markdown documentation files for message definitions") 16 | parser.add_argument('--output-directory', action="store", default="./", type=str, help="directory to save output files") 17 | args = parser.parse_args() 18 | 19 | if not os.path.exists(args.output_directory): 20 | os.makedirs(args.output_directory) 21 | 22 | definitionPath = "%s/../lib/ping-protocol/src/definitions" % scriptPath 23 | templatePath = "%s/templates" % scriptPath 24 | 25 | templateFile = "%s/pingmessage-definitions.py.in" % templatePath 26 | 27 | g = Generator() 28 | 29 | definitions = [ "common", 30 | "ping1d", 31 | "ping360", 32 | "surveyor240", 33 | "s500", 34 | "omniscan450"] 35 | 36 | struct_token = {"u8": "B", 37 | "u16": "H", 38 | "u32": "I", 39 | "i8": "b", 40 | "i16": "h", 41 | "i32": "i", 42 | "char": "s", 43 | "float": "f", 44 | "bool": "B", 45 | "u64": "Q"} 46 | 47 | f = open("%s/definitions.py" % args.output_directory, "w") 48 | 49 | for definition in definitions: 50 | definitionFile = "%s/%s.json" % (definitionPath, definition) 51 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token, "base": definition})) 52 | 53 | #allString = "payload_dict_all = {}\n" 54 | # add PINGMESSAGE_UNDEFINED for legacy request support 55 | allString = '\ 56 | PINGMESSAGE_UNDEFINED = 0\n\ 57 | payload_dict_all = {\n\ 58 | PINGMESSAGE_UNDEFINED: {\n\ 59 | "name": "undefined",\n\ 60 | "format": "",\n\ 61 | "field_names": (),\n\ 62 | "payload_length": 0\n\ 63 | },\n\ 64 | }\n' 65 | 66 | f.write(allString) 67 | 68 | for definition in definitions: 69 | f.write("payload_dict_all.update(payload_dict_") 70 | f.write(definition) 71 | f.write(")\n") 72 | 73 | f.close() 74 | 75 | definitionFile = "%s/common.json" % definitionPath 76 | templateFile = "%s/device.py.in" % templatePath 77 | f = open("%s/device.py" % args.output_directory, "w") 78 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 79 | f.close() 80 | 81 | definitionFile = "%s/ping1d.json" % definitionPath 82 | templateFile = "%s/ping1d.py.in" % templatePath 83 | f = open("%s/ping1d.py" % args.output_directory, "w") 84 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 85 | f.close() 86 | 87 | definitionFile = "%s/ping360.json" % definitionPath 88 | templateFile = "%s/ping360.py.in" % templatePath 89 | f = open("%s/ping360.py" % args.output_directory, "w") 90 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 91 | f.close() 92 | 93 | definitionFile = "%s/surveyor240.json" % definitionPath 94 | templateFile = "%s/surveyor240.py.in" % templatePath 95 | f = open("%s/surveyor240.py" % args.output_directory, "w") 96 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 97 | f.close() 98 | 99 | definitionFile = "%s/s500.json" % definitionPath 100 | templateFile = "%s/s500.py.in" % templatePath 101 | f = open("%s/s500.py" % args.output_directory, "w") 102 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 103 | f.close() 104 | 105 | definitionFile = "%s/omniscan450.json" % definitionPath 106 | templateFile = "%s/omniscan450.py.in" % templatePath 107 | f = open("%s/omniscan450.py" % args.output_directory, "w") 108 | f.write(g.generate(definitionFile, templateFile, {"structToken": struct_token})) 109 | f.close() -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import logging 5 | from datetime import date 6 | 7 | # Global variables 8 | root_path = os.path.abspath(os.path.join("..", "..")) 9 | 10 | sys.path.insert(0, root_path) 11 | 12 | 13 | SITE_URL = "https://docs.bluerobotics.com/ping-python/" 14 | REPO_URL = "https://github.com/bluerobotics/ping-python/" 15 | REPO_NAME = "ping-python" 16 | PROJECT_NAME ="ping-python" 17 | 18 | # Project information 19 | project = PROJECT_NAME 20 | copyright = f"{date.today().year} - Blue Robotics Inc" 21 | author = "Blue Robotics contributors" 22 | 23 | # General configuration 24 | extensions = [ 25 | "sphinx.ext.doctest", 26 | "sphinx.ext.extlinks", 27 | "sphinx.ext.githubpages", 28 | "sphinx.ext.extlinks", 29 | "sphinx.ext.intersphinx", 30 | "sphinx.ext.mathjax", 31 | "sphinx.ext.napoleon", 32 | "sphinx.ext.viewcode", 33 | "myst_parser", 34 | "sphinx_blue_robotics_theme", 35 | "sphinx_blue_robotics_theme.extensions.extras", 36 | "sphinx_blue_robotics_theme.extensions.cpp", 37 | "breathe", 38 | "exhale", 39 | ] 40 | master_doc = "index" 41 | source_suffix = {'.rst': 'restructuredtext', '.md': 'restructuredtext'} 42 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "python_api/library_root.rst"] 43 | 44 | # Syntax highlighting 45 | pygments_style = "sphinx" 46 | 47 | # Substitutions 48 | myst_substitutions = { 49 | "project_name": "Blue Robotics" 50 | } 51 | 52 | # External links 53 | extlinks = { 54 | 'issue': (REPO_URL + '/issues/%s', 'issue %s') 55 | } 56 | 57 | # HTML output configuration 58 | html_theme = "sphinx_blue_robotics_theme" 59 | html_static_path = ["_static"] 60 | html_css_files = ['custom.css'] 61 | 62 | html_theme_options = { 63 | "site_url": SITE_URL, 64 | "repo_url": REPO_URL, 65 | "repo_name": REPO_NAME, 66 | "icon": { 67 | "repo": "fontawesome/brands/github", 68 | "edit": "material/file-edit-outline", 69 | }, 70 | "globaltoc_collapse": False, 71 | "edit_uri": "blob/master/docs/source", 72 | "features": [ 73 | "navigation.sections", 74 | "navigation.megamenu", 75 | "navigation.top", 76 | "toc.follow", 77 | "toc.sticky", 78 | "content.tabs.link", 79 | "announce.dismiss", 80 | ], 81 | "palette": [ 82 | { 83 | "media": "(prefers-color-scheme: light)", 84 | "scheme": "default", 85 | "toggle": { 86 | "icon": "octicons/moon-16", 87 | "name": "Switch to dark mode", 88 | } 89 | }, 90 | { 91 | "media": "(prefers-color-scheme: dark)", 92 | "scheme": "slate", 93 | "toggle": { 94 | "icon": "octicons/sun-16", 95 | "name": "Switch to light mode", 96 | } 97 | }, 98 | ], 99 | "toc_title_is_page_title": True, 100 | } 101 | 102 | html_last_updated_fmt = "%d %b %Y" 103 | htmlhelp_basename = "BlueRoboticsDocumentationdoc" 104 | html_baseurl = SITE_URL 105 | html_context = { 106 | "homepage_url": "https://bluerobotics.com", 107 | "project_url": html_baseurl, 108 | "project": project, 109 | "exclude_comments": True 110 | } 111 | 112 | # Breathe and Exhale configuration 113 | breathe_projects = {project: os.path.join(root_path, "doc", "xml")} 114 | breathe_default_project = project 115 | breathe_domain_by_extension = {"h": "cpp", "py": "py"} 116 | 117 | # Silence doxygen warnings 118 | suppress_warnings = [ 119 | "ref.duplicate", # Suppress warnings about duplicate references 120 | "app.add_directive", # Suppress warnings about directives 121 | "autodoc.duplicate_object_description", # Suppress warnings about duplicate object descriptions 122 | ] 123 | 124 | exhale_args = { 125 | "containmentFolder": "./python_api", 126 | "rootFileName": "library_root.rst", 127 | "doxygenStripFromPath": root_path, 128 | "rootFileTitle": "API Reference", 129 | "createTreeView": False, 130 | "contentsDirectives": False, 131 | "exhaleExecutesDoxygen": True, 132 | "fullToctreeMaxDepth": 1, 133 | "exhaleDoxygenStdin": f"INPUT = {os.path.join(root_path, 'brping')}", 134 | "verboseBuild": True, 135 | } 136 | 137 | # Myst Parser options 138 | myst_enable_extensions = ["substitution", "colon_fence"] 139 | 140 | def setup(app): 141 | # Suppress Breathe duplicate object & parameter mismatch warnings 142 | logger = logging.getLogger("sphinx") 143 | logger.addFilter(lambda record: "duplicate object description" not in record.getMessage()) 144 | logger.addFilter(lambda record: "does not match any of the parameters" not in record.getMessage()) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ping-python 2 | 3 | 4 | 5 | 6 | 7 | [![Travis Build Status](https://travis-ci.org/bluerobotics/ping-python.svg?branch=master)](https://travis-ci.org/bluerobotics/ping-python) 8 | [![Gitter](https://img.shields.io/badge/gitter-online-green.svg)](https://gitter.im/bluerobotics/discussion/) 9 | [![PyPI version](https://badge.fury.io/py/bluerobotics-ping.svg)](https://badge.fury.io/py/bluerobotics-ping) 10 | 11 | Python library for the Ping sonar. Ping is the simple, affordable, and compact ultrasonic altimeter for any aquatic project. 12 | 13 | This library exposes all functionality of the device, such as getting profiles, controlling parameters, switching modes, or just simply reading in the distance measurement. 14 | 15 | [Available here](https://www.bluerobotics.com/store/sensors-sonars-cameras/sonar/ping-sonar-r2-rp/) 16 | 17 |
18 |
19 | 20 | ## Resources 21 | 22 | * [API Reference](https://docs.bluerobotics.com/ping-python/) 23 | * [Device Specifications](https://www.bluerobotics.com/store/sensors-sonars-cameras/sonar/ping-sonar-r2-rp/#tab-technical-details) 24 | * [Communication Protocol](https://github.com/bluerobotics/ping-protocol) 25 | * [Support](https://gitter.im/bluerobotics/discussion) 26 | * [License](https://github.com/bluerobotics/ping-python/blob/master/LICENSE) 27 | 28 | 29 | ## Installing 30 | 31 | ### pip 32 | 33 | ```sh 34 | $ pip install --user bluerobotics-ping --upgrade 35 | ``` 36 | 37 | ### From source 38 | 39 | ```sh 40 | $ git clone --single-branch --branch deployment https://github.com/bluerobotics/ping-python.git 41 | $ cd ping-python 42 | $ python setup.py install --user 43 | ``` 44 | 45 | The library is ready to use: `import brping`. If you would like to use the command line [examples](/examples) or [tools](/tools) provided by this package, follow the notes in python's [installing to user site](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site) directions (eg `export PATH=$PATH:~/.local/bin`). 46 | 47 | #### From master branch 48 | 49 | If you wish to build from scratch the project using master branch or wip pull requests to test, you should compile and generate the definitions file: 50 | 51 | ```sh 52 | $ git clone https://github.com/bluerobotics/ping-python.git 53 | $ pip install jinja2 54 | $ cd ping-python 55 | $ git submodule update --init 56 | $ python generate/generate-python.py --output-dir=brping 57 | $ python setup.py install --user 58 | $ python -c "import brping" # It works! 59 | ``` 60 | 61 | ## Quick Start 62 | 63 | The `bluerobotics-ping` package installs a `simplePingExample.py` script to get started. Place your device's file descriptor (eg. `/dev/ttyUSB0`, `COM1`) after the --device option. 64 | 65 | `$ simplePingExample.py --device ` 66 | 67 | It's also possible to connect via UDP server using the `--udp` option with IP:PORT as input (e.g `192.168.2.2:9090`). 68 | 69 | ## Usage 70 | 71 | The [Ping1D](https://docs.bluerobotics.com/ping-python/classPing_1_1Ping1D_1_1Ping1D.html) class provides an easy interface to configure a Ping device and retrieve data. 72 | 73 | A Ping1D object must be initialized with the serial device path and the baudrate. 74 | 75 | ```py 76 | from brping import Ping1D 77 | myPing = Ping1D() 78 | myPing.connect_serial("/dev/ttyUSB0", 115200) 79 | # For UDP 80 | # myPing.connect_udp("192.168.2.2", 9090) 81 | ``` 82 | 83 | Call initialize() to establish communications with the device. 84 | 85 | ```py 86 | if myPing.initialize() is False: 87 | print("Failed to initialize Ping!") 88 | exit(1) 89 | ``` 90 | 91 | Use [`get_`](https://github.com/bluerobotics/ping-protocol#get) to request data from the device. The data is returned as a dictionary with keys matching the names of the message payload fields. The messages you may request are documented in the [ping-protocol](https://github.com/bluerobotics/ping-protocol). 92 | 93 | ```py 94 | data = myPing.get_distance() 95 | if data: 96 | print("Distance: %s\tConfidence: %s%%" % (data["distance"], data["confidence"])) 97 | else: 98 | print("Failed to get distance data") 99 | ``` 100 | 101 | Use the [`set_*`](https://github.com/bluerobotics/ping-protocol#set) messages (eg. [set_speed_of_sound()](https://docs.bluerobotics.com/ping-python/classPing_1_1Ping1D_1_1Ping1D.html#a79a3931e5564644187198ad2063e5ed9)) to change settings on the Ping device. 102 | 103 | ```py 104 | # set the speed of sound to use for distance calculations to 105 | # 1450000 mm/s (1450 m/s) 106 | myPing.set_speed_of_sound(1450000) 107 | ``` 108 | 109 | See the [doxygen](https://docs.bluerobotics.com/ping-python/) documentation for complete API documentation. 110 | -------------------------------------------------------------------------------- /generate/templates/ping1d.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ping1d.py 4 | # A device API for the Blue Robotics Ping1D echosounder 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import PingDevice 13 | from brping import pingmessage 14 | 15 | class Ping1D(PingDevice): 16 | 17 | def legacyRequest(self, m_id, timeout=0.5): 18 | msg = pingmessage.PingMessage() 19 | # legacy hack logic is in PingMessage 20 | # TODO: remove that logic and construct/assemble an arbitrary PingMessage 21 | msg.request_id = m_id 22 | msg.pack_msg_data() 23 | self.write(msg.msg_data) 24 | 25 | # uncomment to return nacks in addition to m_id 26 | # return self.wait_message([m_id, definitions.COMMON_NACK], timeout) 27 | 28 | return self.wait_message([m_id], timeout) 29 | 30 | def initialize(self): 31 | if not PingDevice.initialize(self): 32 | return False 33 | if self.legacyRequest(definitions.PING1D_GENERAL_INFO) is None: 34 | return False 35 | return True 36 | 37 | {% for msg in messages["get"]|sort %} 38 | ## 39 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 40 | # Message description:\n 41 | # {{messages["get"][msg].description}} 42 | # 43 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 44 | {% for field in messages["get"][msg].payload %} 45 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 46 | {% endfor%} 47 | def get_{{msg}}(self): 48 | if self.legacyRequest(definitions.PING1D_{{msg|upper}}) is None: 49 | return None 50 | data = ({ 51 | {% for field in messages["get"][msg].payload %} 52 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 53 | {% endfor %} 54 | }) 55 | return data 56 | 57 | {% endfor %} 58 | {% for msg in messages["set"]|sort %} 59 | ## 60 | # @brief Send a {{msg}} message to the device\n 61 | # Message description:\n 62 | # {{messages["set"][msg].description}}\n 63 | # Send the message to write the device parameters, then read the values back from the device\n 64 | # 65 | {% for field in messages["set"][msg].payload %} 66 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 67 | {% endfor %} 68 | # 69 | # @return If verify is False, True on successful communication with the device. If verify is False, True if the new device parameters are verified to have been written correctly. False otherwise (failure to read values back or on verification failure) 70 | def {{msg}}(self{% for field in messages["set"][msg].payload %}, {{field.name}}{% endfor %}, verify=True): 71 | m = pingmessage.PingMessage(definitions.PING1D_{{msg|upper}}) 72 | {% for field in messages["set"][msg].payload %} 73 | m.{{field.name}} = {{field.name}} 74 | {% endfor %} 75 | m.pack_msg_data() 76 | self.write(m.msg_data) 77 | if self.legacyRequest(definitions.PING1D_{{msg|replace("set_", "")|upper}}) is None: 78 | return False 79 | # Read back the data and check that changes have been applied 80 | if (verify 81 | {% if messages["set"][msg].payload %} 82 | and ({% for field in messages["set"][msg].payload %}self._{{field.name}} != {{field.name}}{{ " or " if not loop.last }}{% endfor %})): 83 | {% endif %} 84 | return False 85 | return True # success 86 | 87 | {% endfor %} 88 | 89 | {% for msg in messages["control"]|sort %} 90 | ## 91 | # @brief Send a {{msg}} message to the device\n 92 | # Message description:\n 93 | # {{messages["control"][msg].description}}\n 94 | # 95 | {% for field in messages["control"][msg].payload %} 96 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 97 | {% endfor %} 98 | def control_{{msg}}(self{% for field in messages["control"][msg].payload %}, {{field.name}}{% endfor %}): 99 | m = pingmessage.PingMessage(definitions.PING1D_{{msg|upper}}) 100 | {% for field in messages["control"][msg].payload %} 101 | m.{{field.name}} = {{field.name}} 102 | {% endfor %} 103 | m.pack_msg_data() 104 | self.write(m.msg_data) 105 | 106 | 107 | {% endfor %} 108 | 109 | if __name__ == "__main__": 110 | import argparse 111 | 112 | parser = argparse.ArgumentParser(description="Ping python library example.") 113 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 114 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 115 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9090") 116 | args = parser.parse_args() 117 | if args.device is None and args.udp is None: 118 | parser.print_help() 119 | exit(1) 120 | 121 | p = Ping1D() 122 | if args.device is not None: 123 | p.connect_serial(args.device, args.baudrate) 124 | elif args.udp is not None: 125 | (host, port) = args.udp.split(':') 126 | p.connect_udp(host, int(port)) 127 | 128 | print("Initialized: %s" % p.initialize()) 129 | 130 | {% for msg in messages["get"]|sort %} 131 | print("\ntesting get_{{msg}}") 132 | result = p.get_{{msg}}() 133 | print(" " + str(result)) 134 | print(" > > pass: %s < <" % (result is not None)) 135 | 136 | {% endfor %} 137 | print("\ntesting set_device_id") 138 | print(" > > pass: %s < <" % p.set_device_id(43)) 139 | print("\ntesting set_mode_auto") 140 | print(" > > pass: %s < <" % p.set_mode_auto(False)) 141 | print("\ntesting set_range") 142 | print(" > > pass: %s < <" % p.set_range(1000, 2000)) 143 | print("\ntesting set_speed_of_sound") 144 | print(" > > pass: %s < <" % p.set_speed_of_sound(1444000)) 145 | print("\ntesting set_ping_interval") 146 | print(" > > pass: %s < <" % p.set_ping_interval(36)) 147 | print("\ntesting set_gain_setting") 148 | print(" > > pass: %s < <" % p.set_gain_setting(3)) 149 | print("\ntesting set_ping_enable") 150 | print(" > > pass: %s < <" % p.set_ping_enable(True)) 151 | 152 | print(p) 153 | -------------------------------------------------------------------------------- /tools/ping1d-simulation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This script simulates a Blue Robotics Ping Echosounder device 4 | # A client may connect to the device simulation on local UDP port 6676 5 | 6 | from brping import definitions, PingMessage, PingParser 7 | import socket 8 | import time 9 | import errno 10 | import math 11 | 12 | payload_dict = definitions.payload_dict_all 13 | 14 | class Ping1DSimulation(object): 15 | def __init__(self): 16 | self.client = None # (ip address, port) of connected client (if any) 17 | self.parser = PingParser() # used to parse incoming client comunications 18 | 19 | 20 | # Socket to serve on 21 | self.sockit = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 22 | self.sockit.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 23 | self.sockit.setblocking(False) 24 | self.sockit.bind(('0.0.0.0', 6676)) 25 | 26 | self._profile_data_length = 200 # number of data points in profile messages (constant for now) 27 | self._pulse_duration = 100 # length of acoustic pulse (constant for now) 28 | self._ping_number = 0 # count the total measurements taken since boot 29 | self._ping_interval = 100 # milliseconds between measurements 30 | self._mode_auto = True # automatic gain and range selection 31 | self._mode_continuous = False # automatic continuous output of profile messages 32 | 33 | # read incoming client data 34 | def read(self): 35 | try: 36 | data, self.client = self.sockit.recvfrom(4096) 37 | 38 | # digest data coming in from client 39 | for byte in data: 40 | if self.parser.parse_byte(byte) == PingParser.NEW_MESSAGE: 41 | # we decoded a message from the client 42 | self.handleMessage(self.parser.rx_msg) 43 | 44 | except EnvironmentError as e: 45 | if e.errno == errno.EAGAIN: 46 | pass # waiting for data 47 | else: 48 | print("Error reading data", e) 49 | 50 | except KeyError as e: 51 | print("skipping unrecognized message id: %d" % self.parser.rx_msg.message_id) 52 | print("contents: %s" % self.parser.rx_msg.msg_data) 53 | pass 54 | 55 | # write data to client 56 | def write(self, data): 57 | if self.client is not None: 58 | self.sockit.sendto(data, self.client) 59 | 60 | # Send a message to the client, the message fields are populated by the 61 | # attributes of this object (either variable or method) with names matching 62 | # the message field names 63 | def sendMessage(self, message_id): 64 | msg = PingMessage(message_id) 65 | print("sending message %d\t(%s)" % (msg.message_id, msg.name)) 66 | 67 | # pull attributes of this class into the message fields (they are named the same) 68 | for attr in payload_dict[message_id]["field_names"]: 69 | try: 70 | # see if we have a function for this attribute (dynamic data) 71 | # if so, call it and put the result in the message field 72 | setattr(msg, attr, getattr(self, attr)()) 73 | except AttributeError as e: 74 | try: 75 | # if we don't have a function for this attribute, check for a _ member 76 | # these are static values (or no function implemented yet) 77 | setattr(msg, attr, getattr(self, "_" + attr)) 78 | except AttributeError as e: 79 | # anything else we haven't implemented yet, just send a sine wave 80 | setattr(msg, attr, self.periodicFnInt(20, 120)) 81 | 82 | # send the message to the client 83 | msg.pack_msg_data() 84 | self.write(msg.msg_data) 85 | 86 | # handle an incoming client message 87 | def handleMessage(self, message): 88 | print("receive message %d\t(%s)" % (message.message_id, message.name)) 89 | if message.message_id == definitions.COMMON_GENERAL_REQUEST: 90 | # the client is requesting a message from us 91 | self.sendMessage(message.requested_id) 92 | # hack for legacy requests 93 | elif message.payload_length == 0: 94 | self.sendMessage(message.message_id) 95 | else: 96 | # the client is controlling some parameter of the device 97 | self.setParameters(message) 98 | 99 | # Extract message fields into attribute values 100 | # This should only be performed with the 'set' category of messages 101 | # TODO: mechanism to filter by "set" 102 | def setParameters(self, message): 103 | for attr in payload_dict[message.message_id]["field_names"]: 104 | setattr(self, "_" + attr, getattr(message, attr)) 105 | 106 | ########### 107 | # Helpers for generating periodic data 108 | ########### 109 | def periodicFn(self, amplitude = 0, offset = 0, frequency = 1.0, shift = 0): 110 | return amplitude * math.sin(frequency * time.time() + shift) + offset 111 | 112 | def periodicFnInt(self, amplitude = 0, offset = 0, frequency = 1.0, shift = 0): 113 | return int(self.periodicFn(amplitude, offset, frequency, shift)) 114 | 115 | ########### 116 | # Device properties/state 117 | ########### 118 | def distance(self): 119 | return self.periodicFnInt(self.scan_length() / 2, self.scan_start() + self.scan_length() / 2, 5) 120 | 121 | def confidence(self): 122 | return self.periodicFnInt(40, 50) 123 | 124 | def scan_start(self): 125 | if self._mode_auto: 126 | return 0 127 | return self._scan_start 128 | 129 | def scan_length(self): 130 | if self._mode_auto: 131 | self._scan_length = self.periodicFnInt(2000, 3000, 0.2) 132 | return self._scan_length 133 | 134 | def profile_data(self): 135 | stops = 20 136 | len = int(200/stops) 137 | data = [] 138 | for x in range(stops): 139 | data = data + [int(x*255/stops)]*len 140 | return bytearray(data) # stepwise change in signal strength 141 | #return bytearray(range(0,200)) # linear change in signal strength 142 | 143 | def pcb_temperature(self): 144 | return self.periodicFnInt(250, 3870, 3) 145 | 146 | def processor_temperature(self): 147 | return self.periodicFnInt(340, 3400) 148 | 149 | def voltage_5(self): 150 | return self.periodicFnInt(100, 3500) 151 | 152 | def profile_data_length(self): 153 | return self._profile_data_length 154 | 155 | def gain_index(self): 156 | return 2 157 | 158 | # The simulation to use 159 | sim = Ping1DSimulation() 160 | 161 | # Last measurement time 162 | lastUpdate = 0 163 | 164 | while True: 165 | # read any incoming client communications 166 | sim.read() 167 | 168 | # Update background ping count and continuous output 169 | if time.time() > lastUpdate + sim._ping_interval / 1000.0: 170 | lastUpdate = time.time() 171 | sim._ping_number += 1 172 | if sim._mode_continuous: 173 | sim.sendMessage(definitions.PING1D_PROFILE) 174 | 175 | # don't max cpu 176 | time.sleep(0.01) 177 | -------------------------------------------------------------------------------- /examples/s500Example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #simpleS500Example.py 4 | from brping import definitions 5 | from brping import S500 6 | from brping import PingMessage 7 | import time 8 | import argparse 9 | 10 | from builtins import input 11 | 12 | import signal 13 | import sys 14 | from datetime import datetime 15 | from pathlib import Path 16 | 17 | ##Parse Command line options 18 | ############################ 19 | 20 | parser = argparse.ArgumentParser(description="Ping python library example.") 21 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 22 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 23 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9090") 24 | parser.add_argument('--tcp', action="store", required=False, type=str, help="Sounder IP:Port. E.g: 192.168.2.86:51200") 25 | parser.add_argument('--range', action="store", required=False, type=str, help="Set range. E.g: 5000 or 0:5000") 26 | parser.add_argument('--log', action="store", nargs='?', const=True, type=str, help="Log filename or foldername. Will log if it doesn't exist, or replay if it does. Blank creates new log.") 27 | args = parser.parse_args() 28 | if args.device is None and args.udp is None and args.tcp is None and args.log is None: 29 | parser.print_help() 30 | exit(1) 31 | 32 | # Signal handler to stop pinging on the S500 33 | def signal_handler(sig, frame): 34 | print("Stopping pinging on S500...") 35 | myS500.control_set_ping_params(report_id=0) 36 | if myS500.iodev: 37 | try: 38 | myS500.iodev.close() 39 | except Exception as e: 40 | print(f"Failed to close socket: {e}") 41 | sys.exit(0) 42 | 43 | signal.signal(signal.SIGINT, signal_handler) 44 | 45 | # Check for log argument and make new S500 46 | # If no .svlog is specified, create one using default directory 47 | # If directory specified, .svlog be created in specified directory 48 | # If a .svlog is specified, existing log will be opened 49 | new_log = False 50 | log_path = "" 51 | replay_path = None 52 | default_dir = Path("logs/s500").resolve() 53 | if args.log is not None: 54 | if args.log is True: 55 | # Logging to default directory 56 | default_dir.mkdir(parents=True, exist_ok=True) 57 | myS500 = S500(logging=True, log_directory=default_dir) 58 | # print(f"Logging to new file in: {default_dir}") 59 | new_log = True 60 | elif isinstance(args.log, str): 61 | log_path = Path(args.log).expanduser() 62 | 63 | if log_path.suffix == ".svlog" and log_path.parent == Path("."): 64 | log_path = default_dir / log_path.name 65 | 66 | log_path = log_path.resolve() 67 | 68 | if log_path.suffix == ".svlog": 69 | if log_path.exists() and log_path.is_file(): 70 | # File exists, replaying 71 | new_log = False 72 | myS500 = S500(logging=False) 73 | replay_path = log_path 74 | print(f"Replaying from: {replay_path}") 75 | else: 76 | raise FileNotFoundError(f"Log file not found: {log_path}") 77 | 78 | elif log_path.is_dir() or log_path.suffix == "": 79 | # Path is directory, logging to that directory 80 | myS500 = S500(logging=True, log_directory=log_path) 81 | # print(f"Logging to new file: {S500.current_log}") 82 | new_log = True 83 | 84 | else: 85 | raise ValueError(f"Invalid log argument: {args.log}") 86 | else: 87 | myS500 = S500() 88 | 89 | if args.log is None or new_log: 90 | if args.device is not None: 91 | myS500.connect_serial(args.device, args.baudrate) 92 | elif args.udp is not None: 93 | (host, port) = args.udp.split(':') 94 | myS500.connect_udp(host, int(port)) 95 | elif args.tcp is not None: 96 | (host, port) = args.tcp.split(':') 97 | myS500.connect_tcp(host, int(port)) 98 | 99 | if myS500.initialize() is False: 100 | print("Failed to initialize S500!") 101 | exit(1) 102 | 103 | print("------------------------------------") 104 | print("Starting S500..") 105 | print("Press CTRL+C to exit") 106 | print("------------------------------------") 107 | 108 | input("Press Enter to continue...") 109 | 110 | # Running s500Example.py from existing log file 111 | if args.log is not None and not new_log: 112 | with open(log_path, 'rb') as f: 113 | while True: 114 | data = S500.read_packet(f) 115 | 116 | if data is None: 117 | break # EOF or bad packet 118 | 119 | # Uncomment to print out all packets contained in log file 120 | # print(f"ID: {data.message_id}\tName: {data.name}") 121 | 122 | if data.message_id == definitions.S500_PROFILE6_T: 123 | scaled_result = S500.scale_power(data) 124 | try: 125 | print(f"Average power: {sum(scaled_result) / len(scaled_result)}") 126 | except ZeroDivisionError: 127 | print("Length of scaled_result is 0") 128 | 129 | # Connected to physical S500 130 | else: 131 | # Tell S500 to send profile6 data 132 | if args.range is not None: 133 | parts = args.range.split(':') 134 | if len(parts) == 2: 135 | myS500.control_set_ping_params( 136 | start_mm=int(parts[0]), 137 | length_mm=int(parts[1]), 138 | msec_per_ping=0, 139 | report_id=definitions.S500_PROFILE6_T, 140 | chirp=1 141 | ) 142 | elif len(parts) == 1: 143 | myS500.control_set_ping_params( 144 | start_mm=0, 145 | length_mm=int(parts[0]), 146 | msec_per_ping=0, 147 | report_id=definitions.S500_PROFILE6_T, 148 | chirp=1 149 | ) 150 | else: 151 | print("Invalid range input, using default range") 152 | myS500.control_set_ping_params( 153 | msec_per_ping=0, 154 | report_id=definitions.S500_PROFILE6_T, 155 | chirp=1 156 | ) 157 | else: 158 | myS500.control_set_ping_params( 159 | msec_per_ping=0, 160 | report_id=definitions.S500_PROFILE6_T, 161 | chirp=1 162 | ) 163 | 164 | if new_log: 165 | print("Logging...\nCTRL+C to stop logging") 166 | else: 167 | print("CTRL-C to end program...") 168 | try: 169 | while True: 170 | # Read and print profile6 data 171 | data = myS500.wait_message([definitions.S500_PROFILE6_T, 172 | definitions.S500_DISTANCE2]) 173 | if data: 174 | scaled_result = S500.scale_power(data) 175 | try: 176 | print(f"Average power: {sum(scaled_result) / len(scaled_result)}") 177 | except ZeroDivisionError: 178 | print("Length of scaled_result is 0") 179 | elif not data: 180 | print("Failed to get message") 181 | except KeyboardInterrupt: 182 | print("Stopping logging...") 183 | 184 | # Stop pinging 185 | myS500.control_set_ping_params(report_id=0) 186 | if myS500.iodev: 187 | try: 188 | myS500.iodev.close() 189 | except Exception as e: 190 | print(f"Failed to close socket: {e}") -------------------------------------------------------------------------------- /generate/templates/ping360.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ping360.py 4 | # A device API for the Blue Robotics Ping360 scanning sonar 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import PingDevice 13 | from brping import pingmessage 14 | import time 15 | 16 | class Ping360(PingDevice): 17 | def initialize(self): 18 | if not PingDevice.initialize(self): 19 | return False 20 | if (self.readDeviceInformation() is None): 21 | return False 22 | return True 23 | 24 | {% for msg in messages["get"]|sort %} 25 | ## 26 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 27 | # Message description:\n 28 | # {{messages["get"][msg].description}} 29 | # 30 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 31 | {% for field in messages["get"][msg].payload %} 32 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 33 | {% endfor%} 34 | def get_{{msg}}(self): 35 | if self.request(definitions.PING360_{{msg|upper}}) is None: 36 | return None 37 | data = ({ 38 | {% for field in messages["get"][msg].payload %} 39 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 40 | {% endfor %} 41 | }) 42 | return data 43 | 44 | {% endfor %} 45 | {% for msg in messages["set"]|sort %} 46 | ## 47 | # @brief Send a {{msg}} message to the device\n 48 | # Message description:\n 49 | # {{messages["set"][msg].description}}\n 50 | # Send the message to write the device parameters, then read the values back from the device\n 51 | # 52 | {% for field in messages["set"][msg].payload %} 53 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 54 | {% endfor %} 55 | # 56 | # @return If verify is False, True on successful communication with the device. If verify is False, True if the new device parameters are verified to have been written correctly. False otherwise (failure to read values back or on verification failure) 57 | def {{msg}}(self{% for field in messages["set"][msg].payload %}, {{field.name}}{% endfor %}, verify=True): 58 | m = pingmessage.PingMessage(definitions.PING360_{{msg|upper}}) 59 | {% for field in messages["set"][msg].payload %} 60 | m.{{field.name}} = {{field.name}} 61 | {% endfor %} 62 | m.pack_msg_data() 63 | self.write(m.msg_data) 64 | if self.request(definitions.PING360_{{msg|replace("set_", "")|upper}}) is None: 65 | return False 66 | # Read back the data and check that changes have been applied 67 | if (verify 68 | {% if messages["set"][msg].payload %} 69 | and ({% for field in messages["set"][msg].payload %}self._{{field.name}} != {{field.name}}{{ " or " if not loop.last }}{% endfor %})): 70 | {% endif %} 71 | return False 72 | return True # success{% for field in messages["set"][msg].payload %} 73 | m.{{field.name}} = {{field.name}} 74 | {% endfor %} 75 | m.pack_msg_data() 76 | self.write(m.msg_data) 77 | 78 | {% endfor %} 79 | 80 | {% for msg in messages["control"]|sort %} 81 | def control_{{msg}}(self{% for field in messages["control"][msg].payload %}, {{field.name}}{% endfor %}): 82 | m = pingmessage.PingMessage(definitions.PING360_{{msg|upper}}) 83 | {% for field in messages["control"][msg].payload %} 84 | m.{{field.name}} = {{field.name}} 85 | {% endfor %} 86 | m.pack_msg_data() 87 | self.write(m.msg_data) 88 | 89 | {% endfor %} 90 | 91 | {% for field in messages["control"]["transducer"].payload %} 92 | {% if field.name != "transmit" and field.name != "reserved" %} 93 | def set_{{field.name}}(self, {{field.name}}): 94 | self.control_transducer( 95 | {% for field2 in messages["control"]["transducer"].payload %} 96 | {% if field2.name != "transmit" and field2.name != "reserved" %} 97 | {% if field == field2 %} 98 | {{field2.name}}, 99 | {% else %} 100 | self._{{field2.name}}, 101 | {% endif %} 102 | {% endif %} 103 | {% endfor %} 104 | 0, 105 | 0 106 | ) 107 | return self.wait_message([definitions.PING360_DEVICE_DATA, definitions.COMMON_NACK], 4.0) 108 | 109 | {% endif %} 110 | {% endfor %} 111 | 112 | def readDeviceInformation(self): 113 | return self.request(definitions.PING360_DEVICE_DATA) 114 | 115 | def transmitAngle(self, angle): 116 | self.control_transducer( 117 | 0, # reserved 118 | self._gain_setting, 119 | angle, 120 | self._transmit_duration, 121 | self._sample_period, 122 | self._transmit_frequency, 123 | self._number_of_samples, 124 | 1, 125 | 0 126 | ) 127 | return self.wait_message([definitions.PING360_DEVICE_DATA, definitions.COMMON_NACK], 4.0) 128 | 129 | def transmit(self): 130 | return self.transmitAngle(self._angle) 131 | 132 | if __name__ == "__main__": 133 | import argparse 134 | 135 | parser = argparse.ArgumentParser(description="Ping python library example.") 136 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 137 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 138 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9092") 139 | args = parser.parse_args() 140 | if args.device is None and args.udp is None: 141 | parser.print_help() 142 | exit(1) 143 | 144 | p = Ping360() 145 | if args.device is not None: 146 | p.connect_serial(args.device, args.baudrate) 147 | elif args.udp is not None: 148 | (host, port) = args.udp.split(':') 149 | p.connect_udp(host, int(port)) 150 | 151 | print("Initialized: %s" % p.initialize()) 152 | 153 | print(p.set_transmit_frequency(800)) 154 | print(p.set_sample_period(80)) 155 | print(p.set_number_of_samples(200)) 156 | 157 | tstart_s = time.time() 158 | for x in range(400): 159 | p.transmitAngle(x) 160 | tend_s = time.time() 161 | 162 | print(p) 163 | 164 | print("full scan in %dms, %dHz" % (1000*(tend_s - tstart_s), 400/(tend_s - tstart_s))) 165 | 166 | # turn on auto-scan with 1 grad steps 167 | p.control_auto_transmit(0,399,1,0) 168 | 169 | tstart_s = time.time() 170 | # wait for 400 device_data messages to arrive 171 | for x in range(400): 172 | p.wait_message([definitions.PING360_DEVICE_DATA]) 173 | tend_s = time.time() 174 | 175 | print("full scan in %dms, %dHz" % (1000*(tend_s - tstart_s), 400/(tend_s - tstart_s))) 176 | 177 | # stop the auto-transmit process 178 | p.control_motor_off() 179 | 180 | # turn on auto-transmit with 10 grad steps 181 | p.control_auto_transmit(0,399,10,0) 182 | 183 | tstart_s = time.time() 184 | # wait for 40 device_data messages to arrive (40 * 10grad steps = 400 grads) 185 | for x in range(40): 186 | p.wait_message([definitions.PING360_DEVICE_DATA]) 187 | tend_s = time.time() 188 | 189 | print("full scan in %dms, %dHz" % (1000*(tend_s - tstart_s), 400/(tend_s - tstart_s))) 190 | 191 | p.control_reset(0, 0) 192 | -------------------------------------------------------------------------------- /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publish documentation" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - docs/** 9 | workflow_dispatch: 10 | inputs: 11 | branch: 12 | description: 'Branch to build (leave empty to build all versions)' 13 | required: false 14 | type: string 15 | 16 | jobs: 17 | # Load versions configuration from the default branch 18 | prepare: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | versions: ${{ steps.set-matrix.outputs.versions }} 22 | default_version: ${{ steps.set-matrix.outputs.default_version }} 23 | multiversion_enabled: ${{ steps.set-matrix.outputs.multiversion_enabled }} 24 | event_branch: ${{ steps.set-matrix.outputs.event_branch }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.event.repository.default_branch }} 29 | 30 | - id: set-matrix 31 | run: | 32 | content=$(cd docs && cat versions.json) 33 | 34 | # Extract versions from versions.json 35 | versions=$(echo "$content" | jq -c '.versions') 36 | echo "versions=$versions" >> $GITHUB_OUTPUT 37 | 38 | # Extract default version 39 | default_version=$(echo "$content" | jq -r '.versions[] | select(.is_default == true) | .name') 40 | echo "default_version=$default_version" >> $GITHUB_OUTPUT 41 | 42 | # Check if multi-version is enabled 43 | version_count=$(echo "$content" | jq '.versions | length') 44 | if [[ $version_count -gt 1 ]]; then 45 | echo "multiversion_enabled=true" >> $GITHUB_OUTPUT 46 | else 47 | echo "multiversion_enabled=false" >> $GITHUB_OUTPUT 48 | fi 49 | 50 | # Set event_branch 51 | event_branch="" 52 | if [[ "${{ github.event_name }}" == "push" ]]; then 53 | event_branch="${{ github.ref_name }}" 54 | elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]] && [ -n "${{ github.event.inputs.branch }}" ]; then 55 | event_branch="${{ github.event.inputs.branch }}" 56 | fi 57 | echo "event_branch=$event_branch" >> $GITHUB_OUTPUT 58 | 59 | # If event_branch is defined, check if it exists in versions.json 60 | if [ -n "$event_branch" ]; then 61 | # Check if the branch exists in the versions.json 62 | branch_exists=$(echo "$content" | jq -r --arg branch "$event_branch" '.versions[] | select(.branch == $branch) | .branch') 63 | if [ -z "$branch_exists" ]; then 64 | echo "error: Branch $event_branch not found in versions.json" >&2 65 | exit 1 66 | fi 67 | # If branch exists, filter out only that version 68 | filtered_versions=$(echo "$content" | jq --arg branch "$event_branch" -c '[.versions[] | select(.branch == $branch)]') 69 | echo "versions=$filtered_versions" >> $GITHUB_OUTPUT 70 | fi 71 | 72 | - name: Debug set-matrix output 73 | run: | 74 | echo "Versions: ${{ steps.set-matrix.outputs.versions }}" 75 | echo "Default Version: ${{ steps.set-matrix.outputs.default_version }}" 76 | echo "Multiversion Enabled: ${{ steps.set-matrix.outputs.multiversion_enabled }}" 77 | echo "Event Branch: ${{ steps.set-matrix.outputs.event_branch }}" 78 | 79 | - name: Save versions.json to artifact 80 | run: | 81 | mkdir -p /tmp/versions 82 | cp docs/versions.json /tmp/versions/versions.json 83 | 84 | - name: Upload versions.json artifact 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: versions-json 88 | path: /tmp/versions/ 89 | 90 | # Build all docs 91 | build: 92 | needs: prepare 93 | runs-on: ubuntu-latest 94 | strategy: 95 | matrix: 96 | version: ${{ fromJson(needs.prepare.outputs.versions) }} 97 | fail-fast: false 98 | steps: 99 | - uses: actions/checkout@v4 100 | with: 101 | ref: ${{ matrix.version.branch }} 102 | 103 | - name: Download versions.json artifact 104 | uses: actions/download-artifact@v4 105 | with: 106 | name: versions-json 107 | path: /tmp/versions/ 108 | 109 | - name: Override versions.json 110 | run: cp /tmp/versions/versions.json docs/versions.json 111 | 112 | - name: Set up Python 113 | uses: actions/setup-python@v5 114 | with: 115 | python-version: '3.12' 116 | 117 | - name: Install Doxygen (optional) 118 | run: sudo apt-get update && sudo apt-get install -y doxygen 119 | 120 | - name: Install dependencies 121 | run: make -C docs setupenv 122 | 123 | - name: Build docs 124 | env: 125 | MULTIVERSION_CURRENT_NAME: ${{ matrix.version.name }} 126 | MULTIVERSION_CURRENT_BRANCH: ${{ matrix.version.branch }} 127 | MULTIVERSION_ENABLED: ${{ needs.prepare.outputs.multiversion_enabled }} 128 | run: | 129 | output_dir="${{ matrix.version.name }}" 130 | make -C docs dirhtml BUILDDIR="_build/$output_dir" 131 | 132 | - name: Save build output to artifact 133 | run: | 134 | mkdir -p /tmp/build-output 135 | cp -r docs/_build/${{ matrix.version.name }}/dirhtml/* /tmp/build-output/ 136 | 137 | - name: Upload build output artifact 138 | uses: actions/upload-artifact@v4 139 | with: 140 | name: build-output-${{ matrix.version.name }} 141 | path: /tmp/build-output 142 | 143 | # Deploy to gh-pages branch 144 | deploy: 145 | needs: [prepare, build] 146 | runs-on: ubuntu-latest 147 | steps: 148 | - name: Checkout gh-pages branch 149 | uses: actions/checkout@v4 150 | with: 151 | ref: gh-pages 152 | 153 | - name: Download all build output artifacts 154 | uses: actions/download-artifact@v4 155 | with: 156 | path: /tmp/build-output 157 | 158 | - name: Replace folder if only one version was built 159 | if: ${{ needs.prepare.outputs.event_branch != '' }} 160 | run: | 161 | version_name=$(echo '${{ needs.prepare.outputs.versions }}' | jq -r '.[0].name') 162 | rm -rf $version_name 163 | mkdir -p $version_name 164 | cp -r /tmp/build-output/build-output-$version_name/* $version_name/ 165 | 166 | - name: Clear all and replace folders if multiple versions were built 167 | if: ${{ needs.prepare.outputs.event_branch == '' }} 168 | run: | 169 | versions_json='${{ needs.prepare.outputs.versions }}' 170 | rm -rf * 171 | for version in $(echo "$versions_json" | jq -c '.[]'); do 172 | version_name=$(echo "$version" | jq -r '.name') 173 | mkdir -p $version_name 174 | cp -r /tmp/build-output/build-output-$version_name/* $version_name/ 175 | done 176 | 177 | - name: Create redirect to default version 178 | env: 179 | DEFAULT_VERSION: ${{ needs.prepare.outputs.default_version }} 180 | run: | 181 | cat > index.html << EOF 182 | 183 | 184 | 185 | 186 | 187 | 188 | EOF 189 | 190 | - name: Create .nojekyll 191 | run: touch .nojekyll 192 | 193 | - name: Commit and push changes 194 | run: | 195 | git config user.name "GitHub Actions" 196 | git config user.email "actions@github.com" 197 | git add . 198 | git commit -m "Update documentation" 199 | git push origin gh-pages -------------------------------------------------------------------------------- /examples/omniscan450Example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #simpleOmniscan450Example.py 4 | from brping import definitions 5 | from brping import Omniscan450 6 | from brping import PingMessage 7 | import time 8 | import argparse 9 | 10 | from builtins import input 11 | 12 | import signal 13 | import sys 14 | from pathlib import Path 15 | from datetime import datetime 16 | 17 | ##Parse Command line options 18 | ############################ 19 | 20 | parser = argparse.ArgumentParser(description="Ping python library example.") 21 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 22 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 23 | parser.add_argument('--udp', action="store", required=False, type=str, help="Omniscan IP:Port. E.g: 192.168.2.92:51200") 24 | parser.add_argument('--tcp', action="store", required=False, type=str, help="Omniscan IP:Port. E.g: 192.168.2.92:51200") 25 | parser.add_argument('--range', action="store", required=False, type=str, help="Set range. E.g: 5000 or 0:5000") 26 | parser.add_argument('--log', action="store", nargs='?', const=True, type=str, help="Log filename and/or directory path. Will create new log if blank or directory is specified. Will replay if file is specified and exists.") 27 | args = parser.parse_args() 28 | if args.device is None and args.udp is None and args.tcp is None and args.log is None: 29 | parser.print_help() 30 | exit(1) 31 | 32 | # Signal handler to stop pinging on the Omniscan450 33 | def signal_handler(sig, frame): 34 | print("\nStopping pinging on Omniscan450...") 35 | myOmniscan450.control_os_ping_params(enable=0) 36 | if myOmniscan450.iodev: 37 | try: 38 | myOmniscan450.iodev.close() 39 | except Exception as e: 40 | print(f"Failed to close socket: {e}") 41 | sys.exit(0) 42 | 43 | signal.signal(signal.SIGINT, signal_handler) 44 | 45 | # Check for log argument and make new Omniscan450 46 | # If no .svlog is specified, create one using default directory 47 | # If directory specified, .svlog be created in specified directory 48 | # If a .svlog is specified, existing log will be opened 49 | new_log = False 50 | log_path = "" 51 | replay_path = None 52 | default_dir = Path("logs/omniscan").resolve() 53 | if args.log is not None: 54 | if args.log is True: 55 | # Logging to default directory 56 | default_dir.mkdir(parents=True, exist_ok=True) 57 | myOmniscan450 = Omniscan450(logging=True, log_directory=default_dir) 58 | new_log = True 59 | elif isinstance(args.log, str): 60 | log_path = Path(args.log).expanduser() 61 | 62 | if log_path.suffix == ".svlog" and log_path.parent == Path("."): 63 | log_path = default_dir / log_path.name 64 | 65 | log_path = log_path.resolve() 66 | 67 | if log_path.suffix == ".svlog": 68 | if log_path.exists() and log_path.is_file(): 69 | # File exists, replaying 70 | new_log = False 71 | myOmniscan450 = Omniscan450(logging=False) 72 | replay_path = log_path 73 | print(f"Replaying from: {replay_path}") 74 | else: 75 | raise FileNotFoundError(f"Log file not found: {log_path}") 76 | 77 | elif log_path.is_dir() or log_path.suffix == "": 78 | # Path is directory, logging to that directory 79 | myOmniscan450 = Omniscan450(logging=True, log_directory=log_path) 80 | new_log = True 81 | 82 | else: 83 | raise ValueError(f"Invalid log argument: {args.log}") 84 | else: 85 | myOmniscan450 = Omniscan450() 86 | 87 | if args.log is None or new_log: 88 | if args.device is not None: 89 | myOmniscan450.connect_serial(args.device, args.baudrate) 90 | elif args.udp is not None: 91 | (host, port) = args.udp.split(':') 92 | myOmniscan450.connect_udp(host, int(port)) 93 | elif args.tcp is not None: 94 | (host, port) = args.tcp.split(':') 95 | myOmniscan450.connect_tcp(host, int(port)) 96 | 97 | if myOmniscan450.initialize() is False: 98 | print("Failed to initialize Omniscan450!") 99 | exit(1) 100 | 101 | data1 = myOmniscan450.readDeviceInformation() 102 | print("Device type: %s" % data1.device_type) 103 | 104 | print("------------------------------------") 105 | print("Starting Omniscan450..") 106 | print("Press CTRL+C to exit") 107 | print("------------------------------------") 108 | 109 | input("Press Enter to continue...") 110 | 111 | # Running omniscan450Example.py from existing log file 112 | if args.log is not None and not new_log: 113 | with open(log_path, 'rb') as f: 114 | while True: 115 | data = Omniscan450.read_packet(f) 116 | 117 | if data is None: 118 | break # EOF or bad packet 119 | 120 | print(f"ID: {data.message_id}\tName: {data.name}") 121 | if data.message_id == definitions.OMNISCAN450_OS_MONO_PROFILE: 122 | # # print(data) 123 | 124 | # Printing the same results as if directly connected to the Omniscan 125 | scaled_result = Omniscan450.scale_power(data) 126 | print(f"Average power: {sum(scaled_result) / len(scaled_result)}") 127 | 128 | # Connected to physical omniscan 129 | else: 130 | if args.range is not None: 131 | parts = args.range.split(':') 132 | 133 | if len(parts) == 2: 134 | myOmniscan450.control_os_ping_params( 135 | start_mm=int(parts[0]), 136 | length_mm=int(parts[1]), 137 | enable=1 138 | ) 139 | elif len(parts) == 1: 140 | myOmniscan450.control_os_ping_params( 141 | start_mm=0, 142 | length_mm=int(parts[0]), 143 | enable=1 144 | ) 145 | else: 146 | print("Invalid range input, using default range") 147 | myOmniscan450.control_os_ping_params(enable=1) 148 | else: 149 | # For default settings, just set enable pinging 150 | myOmniscan450.control_os_ping_params(enable=1) 151 | 152 | # For a custom ping rate 153 | custom_msec_per_ping = Omniscan450.calc_msec_per_ping(1000) # 1000 Hz 154 | 155 | # To find pulse length percent 156 | custom_pulse_length = Omniscan450.calc_pulse_length_pc(0.2) # 0.2% 157 | 158 | ## Set these attributes like this 159 | # myOmniscan450.control_os_ping_params( 160 | # msec_per_ping=custom_msec_per_ping, 161 | # pulse_len_percent=custom_pulse_length, 162 | # num_results=200, 163 | # enable=1 164 | # ) 165 | 166 | # View power results 167 | if new_log: 168 | print("Logging...\nCTRL+C to stop logging") 169 | else: 170 | print("CTRL-C to end program...") 171 | try: 172 | while True: 173 | data = myOmniscan450.wait_message([definitions.OMNISCAN450_OS_MONO_PROFILE]) 174 | if data: 175 | scaled_result = Omniscan450.scale_power(data) 176 | try: 177 | print(f"Average power: {sum(scaled_result) / len(scaled_result)}") 178 | except ZeroDivisionError: 179 | print("Length of scaled_result is 0") 180 | elif not data: 181 | print("Failed to get message") 182 | except KeyboardInterrupt: 183 | print("Stopping logging...") 184 | 185 | # Disable pinging and close socket 186 | myOmniscan450.control_os_ping_params(enable=0) 187 | if myOmniscan450.iodev: 188 | try: 189 | myOmniscan450.iodev.close() 190 | except Exception as e: 191 | print(f"Failed to close socket: {e}") 192 | -------------------------------------------------------------------------------- /examples/surveyor240Example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #simpleSurveyor240Example.py 4 | from brping import definitions 5 | from brping import Surveyor240 6 | from brping import PingMessage 7 | import time 8 | import argparse 9 | 10 | from builtins import input 11 | 12 | import signal 13 | import sys 14 | import math 15 | from datetime import datetime 16 | from pathlib import Path 17 | 18 | ##Parse Command line options 19 | ############################ 20 | 21 | parser = argparse.ArgumentParser(description="Ping python library example.") 22 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 23 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 24 | parser.add_argument('--udp', action="store", required=False, type=str, help="Surveyor IP:Port. E.g: 192.168.2.86:62312") 25 | parser.add_argument('--tcp', action="store", required=False, type=str, help="Surveyor IP:Port. E.g: 192.168.2.86:62312") 26 | parser.add_argument('--range', action="store", required=False, type=str, help="Set range. E.g: 5000 or 0:5000") 27 | parser.add_argument('--log', action="store", nargs='?', const=True, type=str, help="Log filename and/or directory path. Will create new log if blank or directory is specified. Will replay if file is specified and exists.") 28 | args = parser.parse_args() 29 | if args.device is None and args.udp is None and args.tcp is None and args.log is None: 30 | parser.print_help() 31 | exit(1) 32 | 33 | # Signal handler to stop pinging on the Surveyor240 34 | def signal_handler(sig, frame): 35 | print("Stopping pinging on Surveyor240...") 36 | mySurveyor240.control_set_ping_parameters(ping_enable = False) 37 | # Close socket if open 38 | if mySurveyor240.iodev: 39 | try: 40 | mySurveyor240.iodev.close() 41 | except Exception as e: 42 | print(f"Failed to close socket: {e}") 43 | sys.exit(0) 44 | 45 | signal.signal(signal.SIGINT, signal_handler) 46 | 47 | # Check for log argument and make new Surveyor240 48 | # If no .svlog is specified, create one using default directory 49 | # If directory specified, .svlog be created in specified directory 50 | # If a .svlog is specified, existing log will be opened 51 | new_log = False 52 | log_path = "" 53 | replay_path = None 54 | default_dir = Path("logs/surveyor").resolve() 55 | if args.log is not None: 56 | if args.log is True: 57 | # Logging to default directory 58 | default_dir.mkdir(parents=True, exist_ok=True) 59 | mySurveyor240 = Surveyor240(logging=True, log_directory=default_dir) 60 | new_log = True 61 | elif isinstance(args.log, str): 62 | log_path = Path(args.log).expanduser() 63 | 64 | if log_path.suffix == ".svlog" and log_path.parent == Path("."): 65 | log_path = default_dir / log_path.name 66 | 67 | log_path = log_path.resolve() 68 | 69 | if log_path.suffix == ".svlog": 70 | if log_path.exists() and log_path.is_file(): 71 | # File exists, replaying 72 | new_log = False 73 | mySurveyor240 = Surveyor240(logging=False) 74 | replay_path = log_path 75 | print(f"Replaying from: {replay_path}") 76 | else: 77 | raise FileNotFoundError(f"Log file not found: {log_path}") 78 | 79 | elif log_path.is_dir() or log_path.suffix == "": 80 | # Path is directory, logging to that directory 81 | mySurveyor240 = Surveyor240(logging=True, log_directory=log_path) 82 | new_log = True 83 | 84 | else: 85 | raise ValueError(f"Invalid log argument: {args.log}") 86 | else: 87 | mySurveyor240 = Surveyor240() 88 | 89 | if args.log is None or new_log: 90 | if args.device is not None: 91 | mySurveyor240.connect_serial(args.device, args.baudrate) 92 | elif args.udp is not None: 93 | (host, port) = args.udp.split(':') 94 | mySurveyor240.connect_udp(host, int(port)) 95 | elif args.tcp is not None: 96 | (host, port) = args.tcp.split(':') 97 | mySurveyor240.connect_tcp(host, int(port)) 98 | 99 | if mySurveyor240.initialize() is False: 100 | print("Failed to initialize Surveyor240!") 101 | exit(1) 102 | 103 | print("------------------------------------") 104 | print("Starting Surveyor240..") 105 | print("Press CTRL+C to exit") 106 | print("------------------------------------") 107 | 108 | input("Press Enter to continue...") 109 | 110 | # Running surveyor240Example.py from existing log file 111 | if args.log is not None and not new_log: 112 | with open(log_path, 'rb') as f: 113 | while True: 114 | data = Surveyor240.read_packet(f) 115 | 116 | if data is None: 117 | break # EOF or bad packet 118 | 119 | # print(f"ID: {data.message_id}\tName: {data.name}") 120 | 121 | ## Surveyor will report the Water Stats packet if temperature and/or pressure sensor is connected 122 | # if data.message_id == definitions.SURVEYOR240_WATER_STATS: 123 | # print(f"Temperature: {(data.temperature * 9/5) + 32} F") 124 | # print(f"Temperature: {data.temperature} C") 125 | # print(f"Pressure: {data.pressure} bar") 126 | 127 | if data.message_id == definitions.SURVEYOR240_ATTITUDE_REPORT: 128 | # Print pitch and roll data 129 | vector = (data.up_vec_x, data.up_vec_y, data.up_vec_z) 130 | pitch = math.asin(vector[0]) 131 | roll = math.atan2(vector[1], vector[2]) 132 | print(f"Pitch: {pitch}\tRoll: {roll}") 133 | 134 | # if data.message_id == definitions.SURVEYOR240_YZ_POINT_DATA: 135 | # # Display YZ point data in a table 136 | # yz_data = Surveyor240.create_yz_point_data(data) 137 | # print(f"Length of yz_data: {len(yz_data)}\tNum_points: {data.num_points}") 138 | # print("Index\tY\tZ") 139 | # for i in range(0, len(yz_data), 2): 140 | # print(f"{i//2}\t{yz_data[i]:.2f}\t{yz_data[i+1]:.2f}") 141 | # print(f"Temperature: {(data.water_degC * 9/5) + 32} F") 142 | # print(f"Temperature: {data.water_degC} C") 143 | # print(f"Pressure: {data.water_bar} Bar") 144 | 145 | # if data.message_id == definitions.SURVEYOR240_ATOF_POINT_DATA: 146 | # # Just an example packet, could check for other packet types and 147 | # # show results from those too 148 | 149 | # # Use create_atof_list to get formatted atof_t[num_points] list 150 | # atof_data = Surveyor240.create_atof_list(data) 151 | # if len(atof_data) == 0: 152 | # continue 153 | # else: 154 | # # Just the first data point in atof[] 155 | # distance = 0.5 * data.sos_mps * atof_data[0].tof 156 | # y = distance * math.sin(atof_data[0].angle) 157 | # z = -distance * math.cos(atof_data[0].angle) 158 | # print(f"Distance: {distance:.3f} meters\tY: {y:.3f}\tZ: {z:.3f}\t{atof_data[0]}") 159 | 160 | # Connected to physical Surveyor 161 | else: 162 | if args.range is not None: 163 | parts = args.range.split(':') 164 | 165 | if len(parts) == 2: 166 | mySurveyor240.control_set_ping_parameters( 167 | start_mm=int(parts[0]), 168 | end_mm=int(parts[1]), 169 | ping_enable=True, 170 | enable_yz_point_data=True, 171 | enable_atof_data=True 172 | ) 173 | elif len(parts) == 1: 174 | mySurveyor240.control_set_ping_parameters( 175 | start_mm=0, 176 | end_mm=int(parts[0]), 177 | ping_enable=True, 178 | enable_yz_point_data=True, 179 | enable_atof_data=True 180 | ) 181 | else: 182 | print("Invalid range input, using default range") 183 | mySurveyor240.control_set_ping_parameters( 184 | ping_enable=True, 185 | enable_yz_point_data=True, 186 | enable_atof_data=True 187 | ) 188 | else: 189 | mySurveyor240.control_set_ping_parameters( 190 | ping_enable = True, 191 | enable_yz_point_data = True, 192 | enable_atof_data = True, 193 | ) 194 | 195 | if new_log: 196 | print("Logging...\nCTRL+C to stop logging") 197 | else: 198 | print("CTRL-C to end program...") 199 | try: 200 | while True: 201 | # Set multiple packets to listen for 202 | data = mySurveyor240.wait_message([definitions.SURVEYOR240_ATOF_POINT_DATA, 203 | definitions.SURVEYOR240_ATTITUDE_REPORT, 204 | definitions.SURVEYOR240_YZ_POINT_DATA, 205 | definitions.SURVEYOR240_WATER_STATS]) 206 | 207 | if data: 208 | ## To watch pitch and roll data in real time while recording, uncomment this block 209 | if data.message_id == definitions.SURVEYOR240_ATTITUDE_REPORT: 210 | # Print pitch and roll data 211 | vector = (data.up_vec_x, data.up_vec_y, data.up_vec_z) 212 | pitch = math.asin(vector[0]) 213 | roll = math.atan2(vector[1], vector[2]) 214 | print(f"Pitch: {pitch}\tRoll: {roll}") 215 | 216 | except KeyboardInterrupt: 217 | if new_log: 218 | print("Stopping logging...") 219 | 220 | 221 | # Stop pinging from Surveyor 222 | mySurveyor240.control_set_ping_parameters(ping_enable = False) 223 | if mySurveyor240.iodev: 224 | try: 225 | mySurveyor240.iodev.close() 226 | except Exception as e: 227 | print(f"Failed to close socket: {e}") 228 | -------------------------------------------------------------------------------- /generate/templates/device.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # device.py 4 | # A device API for devices implementing Blue Robotics ping-protocol 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import pingmessage 13 | from collections import deque 14 | import serial 15 | import socket 16 | import time 17 | 18 | class PingDevice(object): 19 | {% for field in all_fields|sort %} 20 | _{{field}} = None 21 | {% endfor%} 22 | 23 | _input_buffer = deque() 24 | def __init__(self): 25 | ## A helper class to take care of decoding the input stream 26 | self.parser = pingmessage.PingParser() 27 | 28 | ## device id of this Ping1D object, used for dst_device_id in outgoing messages 29 | self.my_id = 255 30 | 31 | # IO device 32 | self.iodev = None 33 | self.server_address = None 34 | 35 | ## 36 | # @brief Do the connection via an serial link 37 | # 38 | # @param device_name: Serial device name. E.g: /dev/ttyUSB0 or COM5 39 | # @param baudrate: Connection baudrate used in the serial communication 40 | # 41 | def connect_serial(self, device_name: str, baudrate: int =115200): 42 | if device_name is None: 43 | print("Device name is required") 44 | return 45 | 46 | try: 47 | print("Opening %s at %d bps" % (device_name, baudrate)) 48 | 49 | ## Serial object for device communication 50 | # write_timeout fixes it getting stuck forever atempting to write to 51 | # /dev/ttyAMA0 on Raspberry Pis, this raises an exception instead. 52 | # exclusive=True ensures that we don't get stuck due to multiple processes 53 | # trying to access the same serial port. 54 | self.iodev = serial.Serial(device_name, baudrate, write_timeout=1.0, exclusive=True) 55 | try: 56 | self.iodev.set_low_latency_mode(True) 57 | except Exception as exception: 58 | print("Failed to set low latency mode: {0}".format(exception)) 59 | self.iodev.send_break() 60 | time.sleep(0.001) 61 | self.iodev.write("U".encode("ascii")) 62 | 63 | except Exception as exception: 64 | raise Exception("Failed to open the given serial port: {0}".format(exception)) 65 | 66 | ## 67 | # @brief Do the connection via an UDP link 68 | # 69 | # @param host: UDP server address (IPV4) or name 70 | # @param port: port used to connect with server 71 | # 72 | def connect_udp(self, host: str = None, port: int = 12345): 73 | if host is None: 74 | host = '0.0.0.0' # Connect to local host 75 | 76 | self.server_address = (host, port) 77 | try: 78 | print("Opening %s:%d" % self.server_address) 79 | ## Serial object for device communication 80 | self.iodev = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 81 | self.iodev.connect(self.server_address) 82 | self.iodev.setblocking(0) 83 | 84 | except Exception as exception: 85 | raise Exception("Failed to open the given UDP port: {0}".format(exception)) 86 | 87 | ## 88 | # @brief Read available data from the io device 89 | def read_io(self): 90 | if self.iodev == None: 91 | raise Exception("IO device is null, please configure a connection before using the class.") 92 | elif type(self.iodev).__name__ == 'Serial': 93 | bytes = self.iodev.read(self.iodev.in_waiting) 94 | self._input_buffer.extendleft(bytes) 95 | else: # Socket 96 | udp_buffer_size = 4096 97 | try: # Check if we are reading before closing a connection 98 | bytes = self.iodev.recv(udp_buffer_size) 99 | self._input_buffer.extendleft(bytes) 100 | if len(bytes) == udp_buffer_size: 101 | self.update_input_buffer() 102 | except BlockingIOError as exception: 103 | pass # Ignore exceptions related to read before connection, a result of UDP nature 104 | 105 | ## 106 | # @brief Consume rx buffer data until a new message is successfully decoded 107 | # 108 | # @return A new PingMessage: as soon as a message is parsed (there may be data remaining in the buffer to be parsed, thus requiring subsequent calls to read()) 109 | # @return None: if the buffer is empty and no message has been parsed 110 | def read(self): 111 | self.read_io() 112 | while len(self._input_buffer): 113 | b = self._input_buffer.pop() 114 | 115 | if self.parser.parse_byte(b) == pingmessage.PingParser.NEW_MESSAGE: 116 | # a successful read depends on a successful handling 117 | if not self.handle_message(self.parser.rx_msg): 118 | return None 119 | else: 120 | return self.parser.rx_msg 121 | return None 122 | 123 | ## 124 | # @brief Write data to device 125 | # 126 | # @param data: bytearray to write to device 127 | # 128 | # @return Number of bytes written 129 | def write(self, data): 130 | if self.iodev == None: 131 | raise Exception("IO device is null, please configure a connection before using the class.") 132 | elif type(self.iodev).__name__ == 'Serial': 133 | return self.iodev.write(data) 134 | else: # Socket 135 | return self.iodev.send(data) 136 | 137 | ## 138 | # @brief Make sure there is a device on and read some initial data 139 | # 140 | # @return True if the device replies with expected data, False otherwise 141 | def initialize(self): 142 | return self.request(definitions.COMMON_PROTOCOL_VERSION) is not None 143 | 144 | ## 145 | # @brief Request the given message ID 146 | # 147 | # @param m_id: The message ID to request from the device 148 | # @param timeout: The time in seconds to wait for the device to send 149 | # the requested message before timing out and returning 150 | # 151 | # @return PingMessage: the device reply if it is received within timeout period, None otherwise 152 | # 153 | # @todo handle nack to exit without blocking 154 | def request(self, m_id, timeout=0.5): 155 | msg = pingmessage.PingMessage(definitions.COMMON_GENERAL_REQUEST) 156 | msg.requested_id = m_id 157 | msg.pack_msg_data() 158 | self.write(msg.msg_data) 159 | 160 | # uncomment to return nacks in addition to m_id 161 | # return self.wait_message([m_id, definitions.COMMON_NACK], timeout) 162 | 163 | return self.wait_message([m_id], timeout) 164 | 165 | ## 166 | # @brief Wait until we receive a message from the device with the desired message_id for timeout seconds 167 | # 168 | # @param message_id: The message id to wait to receive from the device 169 | # @param timeout: The timeout period in seconds to wait 170 | # 171 | # @return PingMessage: the message from the device if it is received within timeout period, None otherwise 172 | def wait_message(self, message_ids, timeout=0.5): 173 | tstart = time.time() 174 | while time.time() < tstart + timeout: 175 | msg = self.read() 176 | if msg is not None: 177 | if msg.message_id in message_ids: 178 | return msg 179 | time.sleep(0.005) 180 | return None 181 | 182 | ## 183 | # @brief Handle an incoming message from the device. 184 | # Extract message fields into self attributes. 185 | # 186 | # @param msg: the PingMessage to handle. 187 | # @return True if the PingMessage was handled successfully 188 | def handle_message(self, msg): 189 | # TODO is this message for us? 190 | setattr(self, "_src_device_id", msg.src_device_id) 191 | setattr(self, "_dst_device_id", msg.dst_device_id) 192 | 193 | if msg.message_id in pingmessage.payload_dict: 194 | try: 195 | for attr in pingmessage.payload_dict[msg.message_id]["field_names"]: 196 | setattr(self, "_" + attr, getattr(msg, attr)) 197 | except AttributeError as e: 198 | print("attribute error while handling msg %d (%s): %s" % (msg.message_id, msg.name, msg.msg_data)) 199 | return False 200 | else: 201 | print("Unrecognized message: %d", msg) 202 | return False 203 | 204 | return True 205 | 206 | ## 207 | # @brief Dump object into string representation. 208 | # 209 | # @return string: a string representation of the object 210 | def __repr__(self): 211 | representation = "---------------------------------------------------------\n~Ping Object~" 212 | 213 | attrs = vars(self) 214 | for attr in sorted(attrs): 215 | try: 216 | if attr != 'iodev': 217 | representation += "\n - " + attr + "(hex): " + str([hex(item) for item in getattr(self, attr)]) 218 | if attr != 'data': 219 | representation += "\n - " + attr + "(string): " + str(getattr(self, attr)) 220 | # TODO: Better filter this exception 221 | except: 222 | representation += "\n - " + attr + ": " + str(getattr(self, attr)) 223 | return representation 224 | 225 | {% for msg in messages["get"]|sort %} 226 | ## 227 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 228 | # Message description:\n 229 | # {{messages["get"][msg].description}} 230 | # 231 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 232 | {% for field in messages["get"][msg].payload %} 233 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 234 | {% endfor%} 235 | def get_{{msg}}(self): 236 | if self.request(definitions.COMMON_{{msg|upper}}) is None: 237 | return None 238 | data = ({ 239 | {% for field in messages["get"][msg].payload %} 240 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 241 | {% endfor %} 242 | }) 243 | return data 244 | 245 | {% endfor %} 246 | 247 | if __name__ == "__main__": 248 | import argparse 249 | 250 | parser = argparse.ArgumentParser(description="Ping python library example.") 251 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 252 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 253 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 0.0.0.0:12345") 254 | args = parser.parse_args() 255 | if args.device is None and args.udp is None: 256 | parser.print_help() 257 | exit(1) 258 | 259 | p = PingDevice() 260 | if args.device is not None: 261 | p.connect_serial(args.device, args.baudrate) 262 | elif args.udp is not None: 263 | (host, port) = args.udp.split(':') 264 | p.connect_udp(host, int(port)) 265 | 266 | print("Initialized: %s" % p.initialize()) 267 | 268 | {% for msg in messages["get"]|sort %} 269 | print("\ntesting get_{{msg}}") 270 | result = p.get_{{msg}}() 271 | print(" " + str(result)) 272 | print(" > > pass: %s < <" % (result is not None)) 273 | 274 | {% endfor %} 275 | 276 | print(p) 277 | -------------------------------------------------------------------------------- /generate/templates/s500.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # s500.py 4 | # A device API for the Cerulean Sonar S500 sonar 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import PingDevice 13 | from brping import pingmessage 14 | import time 15 | import struct 16 | import socket 17 | from datetime import datetime, timezone 18 | from pathlib import Path 19 | 20 | # Imports for svlog header 21 | import json 22 | import sys 23 | import platform 24 | 25 | MAX_LOG_SIZE_MB = 500 26 | 27 | class S500(PingDevice): 28 | def __init__(self, logging = False, log_directory = None): 29 | super().__init__() 30 | self.logging = logging 31 | self.log_directory = log_directory 32 | self.bytes_written = None 33 | self.current_log = None 34 | 35 | def initialize(self): 36 | if (self.readDeviceInformation() is None): 37 | return False 38 | if self.logging: 39 | self.new_log(self.log_directory) 40 | return True 41 | 42 | {% for msg in messages["get"]|sort %} 43 | ## 44 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 45 | # Message description:\n 46 | # {{messages["get"][msg].description}} 47 | # 48 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 49 | {% for field in messages["get"][msg].payload %} 50 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 51 | {% endfor%} 52 | def get_{{msg}}(self): 53 | if self.request(definitions.S500_{{msg|upper}}) is None: 54 | return None 55 | data = ({ 56 | {% for field in messages["get"][msg].payload %} 57 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 58 | {% endfor %} 59 | }) 60 | return data 61 | 62 | {% endfor %} 63 | {% for msg in messages["set"]|sort %} 64 | ## 65 | # @brief Send a {{msg}} message to the device\n 66 | # Message description:\n 67 | # {{messages["set"][msg].description}}\n 68 | # Send the message to write the device parameters, then read the values back from the device\n 69 | # 70 | {% for field in messages["set"][msg].payload %} 71 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 72 | {% endfor %} 73 | # 74 | # @return If verify is False, True on successful communication with the device. If verify is False, True if the new device parameters are verified to have been written correctly. False otherwise (failure to read values back or on verification failure) 75 | def {{msg}}(self{% for field in messages["set"][msg].payload %}, {{field.name}}{% endfor %}, verify=True): 76 | m = pingmessage.PingMessage(definitions.S500_{{msg|upper}}) 77 | {% for field in messages["set"][msg].payload %} 78 | m.{{field.name}} = {{field.name}} 79 | {% endfor %} 80 | m.pack_msg_data() 81 | self.write(m.msg_data) 82 | if self.request(definitions.S500_{{msg|replace("set_", "")|upper}}) is None: 83 | return False 84 | # Read back the data and check that changes have been applied 85 | if (verify 86 | {% if messages["set"][msg].payload %} 87 | and ({% for field in messages["set"][msg].payload %}self._{{field.name}} != {{field.name}}{{ " or " if not loop.last }}{% endfor %})): 88 | {% endif %} 89 | return False 90 | return True # success{% for field in messages["set"][msg].payload %} 91 | m.{{field.name}} = {{field.name}} 92 | {% endfor %} 93 | m.pack_msg_data() 94 | self.write(m.msg_data) 95 | 96 | {% endfor %} 97 | 98 | {% for msg in messages["control"]|sort %} 99 | def control_{{msg}}(self{% for field in messages["control"][msg].payload %}, {{field.name}}{% if field.default is defined %}={{field.default}}{% endif %}{% endfor %}): 100 | m = pingmessage.PingMessage(definitions.S500_{{msg|upper}}) 101 | {% for field in messages["control"][msg].payload %} 102 | m.{{field.name}} = {{field.name}} 103 | {% endfor %} 104 | m.pack_msg_data() 105 | self.write(m.msg_data) 106 | 107 | {% endfor %} 108 | 109 | def readDeviceInformation(self): 110 | return self.request(definitions.COMMON_DEVICE_INFORMATION) 111 | 112 | ## 113 | # @brief Do the connection via an TCP link 114 | # 115 | # @param host: TCP server address (IPV4) or name 116 | # @param port: port used to connect with server 117 | # 118 | def connect_tcp(self, host: str = None, port: int = 12345, timeout: float = 5.0): 119 | if host is None: 120 | host = '0.0.0.0' 121 | 122 | self.server_address = (host, port) 123 | try: 124 | print("Opening %s:%d" % self.server_address) 125 | self.iodev = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 126 | self.iodev.settimeout(timeout) 127 | self.iodev.connect(self.server_address) 128 | self.iodev.setblocking(0) 129 | 130 | except socket.timeout: 131 | print("Unable to connect to device") 132 | raise Exception("Connection timed out after {0} seconds".format(timeout)) 133 | except Exception as exception: 134 | raise Exception("Failed to open the given TCP port: {0}".format(exception)) 135 | 136 | ## 137 | # @brief Read available data from the io device 138 | def read_io(self): 139 | if self.iodev == None: 140 | raise Exception("IO device is null, please configure a connection before using the class.") 141 | elif type(self.iodev).__name__ == 'Serial': 142 | bytes = self.iodev.read(self.iodev.in_waiting) 143 | self._input_buffer.extendleft(bytes) 144 | else: # Socket 145 | buffer_size = 4096 146 | while True: 147 | try: # Check if we are reading before closing a connection 148 | bytes = self.iodev.recv(buffer_size) 149 | if not bytes: 150 | # if recv() returns empty, connection is closed (TCP) 151 | if self.iodev.type == socket.SOCK_STREAM: 152 | raise ConnectionError("TCP connection closed by peer.") 153 | 154 | self._input_buffer.extendleft(bytes) 155 | 156 | if len(bytes) < buffer_size: 157 | break 158 | 159 | except BlockingIOError as exception: 160 | pass # Ignore exceptions related to read before connection, a result of UDP nature 161 | 162 | except ConnectionResetError as e: 163 | raise ConnectionError("Socket connection was reset: %s" % str(e)) 164 | 165 | # Converts power results to correct format 166 | @staticmethod 167 | def scale_power(msg): 168 | scaled_power_results = [] 169 | for i in range(len(msg.pwr_results)): 170 | scaled_power_results.append(msg.min_pwr_db + (msg.pwr_results[i] / 65535.0) * (msg.max_pwr_db - msg.min_pwr_db)) 171 | final_power_results = tuple(scaled_power_results) 172 | return final_power_results 173 | 174 | # Reads a single packet from a file 175 | @staticmethod 176 | def read_packet(file): 177 | sync = file.read(2) 178 | if sync != b'BR': 179 | return None 180 | 181 | payload_len_bytes = file.read(2) 182 | if len(payload_len_bytes) < 2: 183 | return None 184 | payload_len = int.from_bytes(payload_len_bytes, 'little') 185 | 186 | msg_id = file.read(2) 187 | if len(msg_id) < 2: 188 | return None 189 | 190 | remaining = 2 + payload_len + 2 191 | rest = file.read(remaining) 192 | if len(rest) < remaining: 193 | return None 194 | 195 | msg_bytes = sync + payload_len_bytes + msg_id + rest 196 | return pingmessage.PingMessage(msg_data=msg_bytes) 197 | 198 | # Builds the packet containing metadata for the beginning of .svlog 199 | def build_metadata_packet(self): 200 | protocol = "tcp" # default fallback 201 | if self.iodev: 202 | if self.iodev.type == socket.SOCK_STREAM: 203 | protocol = "tcp" 204 | elif self.iodev.type == socket.SOCK_DGRAM: 205 | protocol = "udp" 206 | 207 | if self.server_address: 208 | url = f"{protocol}://{self.server_address[0]}:{self.server_address[1]}" 209 | else: 210 | url = f"{protocol}://unknown" 211 | 212 | content = { 213 | "session_id": 1, 214 | "session_uptime": 0.0, 215 | "session_devices": [ 216 | { 217 | "url": url, 218 | "product_id": "s500" 219 | } 220 | ], 221 | "session_platform": None, 222 | "session_clients": [], 223 | "session_plan_name": None, 224 | 225 | "is_recording": True, 226 | "sonarlink_version": "", 227 | "os_hostname": platform.node(), 228 | "os_uptime": None, 229 | "os_version": platform.version(), 230 | "os_platform": platform.system().lower(), 231 | "os_release": platform.release(), 232 | 233 | "process_path": sys.executable, 234 | "process_version": f"v{platform.python_version()}", 235 | "process_uptime": time.process_time(), 236 | "process_arch": platform.machine(), 237 | 238 | "timestamp": datetime.now(timezone.utc).isoformat(), 239 | "timestamp_timezone_offset": datetime.now().astimezone().utcoffset().total_seconds() // 60 240 | } 241 | 242 | json_bytes = json.dumps(content, indent=2).encode("utf-8") 243 | 244 | m = pingmessage.PingMessage(definitions.OMNISCAN450_JSON_WRAPPER) 245 | m.payload = json_bytes 246 | m.payload_length = len(json_bytes) 247 | 248 | msg_data = bytearray() 249 | msg_data += b"BR" 250 | msg_data += m.payload_length.to_bytes(2, "little") 251 | msg_data += m.message_id.to_bytes(2, "little") 252 | msg_data += m.dst_device_id.to_bytes(1, "little") 253 | msg_data += m.src_device_id.to_bytes(1, "little") 254 | msg_data += m.payload 255 | 256 | checksum = sum(msg_data) & 0xFFFF 257 | msg_data += bytearray(struct.pack(pingmessage.PingMessage.endianess + pingmessage.PingMessage.checksum_format, checksum)) 258 | 259 | m.msg_data = msg_data 260 | m.checksum = checksum 261 | 262 | return m 263 | 264 | # Enable logging 265 | def start_logging(self, new_log = False, log_directory = None): 266 | if self.logging: 267 | return 268 | 269 | self.logging = True 270 | 271 | if self.current_log is None or new_log: 272 | self.new_log(log_directory) 273 | 274 | def stop_logging(self): 275 | self.logging = False 276 | 277 | # Creates a new log file 278 | def new_log(self, log_directory=None): 279 | dt = datetime.now() 280 | save_name = dt.strftime("%Y-%m-%d-%H-%M") 281 | 282 | if log_directory is None: 283 | project_root = Path.cwd().parent 284 | self.log_directory = project_root / "logs/s500" 285 | else: 286 | self.log_directory = Path(log_directory) 287 | 288 | self.log_directory.mkdir(parents=True, exist_ok=True) 289 | 290 | log_path = self.log_directory / f"{save_name}.svlog" 291 | 292 | if log_path.exists(): 293 | log_path.unlink() # delete existing file (program was restarted quickly) 294 | 295 | self.current_log = log_path 296 | self.logging = True 297 | self.bytes_written = 0 298 | 299 | print(f"Logging to {self.current_log}") 300 | 301 | self.write_data(self.build_metadata_packet()) 302 | 303 | # Write data to .svlog file 304 | def write_data(self, msg): 305 | if not self.logging or not self.current_log: 306 | return 307 | 308 | try: 309 | if self.bytes_written > MAX_LOG_SIZE_MB * 1000000: 310 | self.new_log(log_directory=self.log_directory) 311 | 312 | with open(self.current_log, 'ab') as f: 313 | f.write(msg.msg_data) 314 | self.bytes_written += len(msg.msg_data) 315 | 316 | except (OSError, IOError) as e: 317 | print(f"[LOGGING ERROR] Failed to write to log file {self.current_log}: {e}") 318 | self.stop_logging() 319 | 320 | except Exception as e: 321 | print(f"[LOGGING ERROR] Unexpected error: {e}") 322 | self.stop_logging() 323 | 324 | # Override wait_message to format power results before returning 325 | def wait_message(self, message_ids, timeout=0.5): 326 | tstart = time.time() 327 | while time.time() < tstart + timeout: 328 | msg = self.read() 329 | if msg is not None: 330 | if (msg.message_id == definitions.S500_PROFILE6_T): 331 | power_byte_array = bytearray(msg.pwr_results) 332 | power_results = struct.unpack('<' + 'H' * int(msg.num_results), power_byte_array) 333 | msg.pwr_results = power_results 334 | 335 | if msg.message_id in message_ids: 336 | if self.logging: 337 | self.write_data(msg) 338 | return msg 339 | time.sleep(0.005) 340 | return None 341 | 342 | if __name__ == "__main__": 343 | import argparse 344 | 345 | parser = argparse.ArgumentParser(description="Ping python library example.") 346 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 347 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 348 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9092") 349 | parser.add_argument('--tcp', action="store", required=False, type=str, help="Sounder IP:Port. E.g: 192.168.2.86:51200") 350 | args = parser.parse_args() 351 | if args.device is None and args.udp is None and args.tcp is None: 352 | parser.print_help() 353 | exit(1) 354 | 355 | p = S500() 356 | if args.device is not None: 357 | p.connect_serial(args.device, args.baudrate) 358 | elif args.udp is not None: 359 | (host, port) = args.udp.split(':') 360 | p.connect_udp(host, int(port)) 361 | elif args.tcp is not None: 362 | (host, port) = args.tcp.split(':') 363 | p.connect_tcp(host, int(port)) 364 | 365 | print("Initialized: %s" % p.initialize()) 366 | if p.iodev: 367 | try: 368 | p.iodev.close() 369 | except Exception as e: 370 | print(f"Failed to close socket: {e}") -------------------------------------------------------------------------------- /generate/templates/omniscan450.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # omniscan450.py 4 | # A device API for the Cerulean Sonar Omniscan 450 sonar 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import PingDevice 13 | from brping import pingmessage 14 | import time 15 | import math 16 | import struct 17 | import socket 18 | from datetime import datetime, timezone 19 | from pathlib import Path 20 | 21 | # Imports for svlog header 22 | import json 23 | import os 24 | import sys 25 | import platform 26 | 27 | MAX_LOG_SIZE_MB = 500 28 | 29 | class Omniscan450(PingDevice): 30 | def __init__(self, logging = False, log_directory = None): 31 | super().__init__() 32 | self.logging = logging 33 | self.log_directory = log_directory 34 | self.bytes_written = None 35 | self.current_log = None 36 | {# if logging: 37 | self.new_log(log_directory) #} 38 | 39 | def initialize(self): 40 | if (self.readDeviceInformation() is None): 41 | return False 42 | if self.logging: 43 | self.new_log(self.log_directory) 44 | return True 45 | 46 | {% for msg in messages["get"]|sort %} 47 | ## 48 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 49 | # Message description:\n 50 | # {{messages["get"][msg].description}} 51 | # 52 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 53 | {% for field in messages["get"][msg].payload %} 54 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 55 | {% endfor%} 56 | def get_{{msg}}(self): 57 | if self.request(definitions.OMNISCAN450_{{msg|upper}}) is None: 58 | return None 59 | data = ({ 60 | {% for field in messages["get"][msg].payload %} 61 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 62 | {% endfor %} 63 | }) 64 | return data 65 | 66 | {% endfor %} 67 | {% for msg in messages["set"]|sort %} 68 | ## 69 | # @brief Send a {{msg}} message to the device\n 70 | # Message description:\n 71 | # {{messages["set"][msg].description}}\n 72 | # Send the message to write the device parameters, then read the values back from the device\n 73 | # 74 | {% for field in messages["set"][msg].payload %} 75 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 76 | {% endfor %} 77 | # 78 | # @return If verify is False, True on successful communication with the device. If verify is False, True if the new device parameters are verified to have been written correctly. False otherwise (failure to read values back or on verification failure) 79 | def {{msg}}(self{% for field in messages["set"][msg].payload %}, {{field.name}}{% endfor %}, verify=True): 80 | m = pingmessage.PingMessage(definitions.OMNISCAN450_{{msg|upper}}) 81 | {% for field in messages["set"][msg].payload %} 82 | m.{{field.name}} = {{field.name}} 83 | {% endfor %} 84 | m.pack_msg_data() 85 | self.write(m.msg_data) 86 | if self.request(definitions.OMNISCAN450_{{msg|replace("set_", "")|upper}}) is None: 87 | return False 88 | # Read back the data and check that changes have been applied 89 | if (verify 90 | {% if messages["set"][msg].payload %} 91 | and ({% for field in messages["set"][msg].payload %}self._{{field.name}} != {{field.name}}{{ " or " if not loop.last }}{% endfor %})): 92 | {% endif %} 93 | return False 94 | return True # success{% for field in messages["set"][msg].payload %} 95 | m.{{field.name}} = {{field.name}} 96 | {% endfor %} 97 | m.pack_msg_data() 98 | self.write(m.msg_data) 99 | 100 | {% endfor %} 101 | 102 | {% for msg in messages["control"]|sort %} 103 | def control_{{msg}}(self{% for field in messages["control"][msg].payload %}, {{field.name}}{% if field.default is defined %}={{field.default}}{% endif %}{% endfor %}): 104 | m = pingmessage.PingMessage(definitions.OMNISCAN450_{{msg|upper}}) 105 | {% for field in messages["control"][msg].payload %} 106 | m.{{field.name}} = {{field.name}} 107 | {% endfor %} 108 | m.pack_msg_data() 109 | self.write(m.msg_data) 110 | 111 | {% endfor %} 112 | 113 | def readDeviceInformation(self): 114 | return self.request(definitions.COMMON_DEVICE_INFORMATION) 115 | 116 | # Calculate the milliseconds per ping from a ping rate 117 | @staticmethod 118 | def calc_msec_per_ping(ping_rate): 119 | return math.floor(1000.0 / ping_rate) 120 | 121 | # Calculate pulse length percent from percent value 122 | @staticmethod 123 | def calc_pulse_length_pc(percent): 124 | if percent > 0: 125 | return 0.01 * percent 126 | else: 127 | return 1.5 / 1200 128 | 129 | # Converts power results to correct format 130 | @staticmethod 131 | def scale_power(msg): 132 | scaled_power_results = [] 133 | for i in range(len(msg.pwr_results)): 134 | scaled_power_results.append(msg.min_pwr_db + (msg.pwr_results[i] / 65535.0) * (msg.max_pwr_db - msg.min_pwr_db)) 135 | final_power_results = tuple(scaled_power_results) 136 | return final_power_results 137 | 138 | # Reads a single packet from a file 139 | @staticmethod 140 | def read_packet(file): 141 | sync = file.read(2) 142 | if sync != b'BR': 143 | return None 144 | 145 | payload_len_bytes = file.read(2) 146 | if len(payload_len_bytes) < 2: 147 | return None 148 | payload_len = int.from_bytes(payload_len_bytes, 'little') 149 | 150 | msg_id = file.read(2) 151 | if len(msg_id) < 2: 152 | return None 153 | 154 | remaining = 2 + payload_len + 2 155 | rest = file.read(remaining) 156 | if len(rest) < remaining: 157 | return None 158 | 159 | msg_bytes = sync + payload_len_bytes + msg_id + rest 160 | return pingmessage.PingMessage(msg_data=msg_bytes) 161 | 162 | # Builds the packet containing metadata for the beginning of .svlog 163 | def build_metadata_packet(self): 164 | protocol = "tcp" # default fallback 165 | if self.iodev: 166 | if self.iodev.type == socket.SOCK_STREAM: 167 | protocol = "tcp" 168 | elif self.iodev.type == socket.SOCK_DGRAM: 169 | protocol = "udp" 170 | 171 | if self.server_address: 172 | url = f"{protocol}://{self.server_address[0]}:{self.server_address[1]}" 173 | else: 174 | url = f"{protocol}://unknown" 175 | 176 | content = { 177 | "session_id": 1, 178 | "session_uptime": 0.0, 179 | "session_devices": [ 180 | { 181 | "url": url, 182 | "product_id": "os450" 183 | } 184 | ], 185 | "session_platform": None, 186 | "session_clients": [], 187 | "session_plan_name": None, 188 | 189 | "is_recording": True, 190 | "sonarlink_version": "", 191 | "os_hostname": platform.node(), 192 | "os_uptime": None, 193 | "os_version": platform.version(), 194 | "os_platform": platform.system().lower(), 195 | "os_release": platform.release(), 196 | 197 | "process_path": sys.executable, 198 | "process_version": f"v{platform.python_version()}", 199 | "process_uptime": time.process_time(), 200 | "process_arch": platform.machine(), 201 | 202 | "timestamp": datetime.now(timezone.utc).isoformat(), 203 | "timestamp_timezone_offset": datetime.now().astimezone().utcoffset().total_seconds() // 60 204 | } 205 | 206 | json_bytes = json.dumps(content, indent=2).encode("utf-8") 207 | 208 | m = pingmessage.PingMessage(definitions.OMNISCAN450_JSON_WRAPPER) 209 | m.payload = json_bytes 210 | m.payload_length = len(json_bytes) 211 | 212 | msg_data = bytearray() 213 | msg_data += b"BR" 214 | msg_data += m.payload_length.to_bytes(2, "little") 215 | msg_data += m.message_id.to_bytes(2, "little") 216 | msg_data += m.dst_device_id.to_bytes(1, "little") 217 | msg_data += m.src_device_id.to_bytes(1, "little") 218 | msg_data += m.payload 219 | 220 | checksum = sum(msg_data) & 0xFFFF 221 | msg_data += bytearray(struct.pack(pingmessage.PingMessage.endianess + pingmessage.PingMessage.checksum_format, checksum)) 222 | 223 | m.msg_data = msg_data 224 | m.checksum = checksum 225 | 226 | return m 227 | 228 | # Enable logging 229 | def start_logging(self, new_log = False, log_directory = None): 230 | if self.logging: 231 | return 232 | 233 | self.logging = True 234 | 235 | if self.current_log is None or new_log: 236 | self.new_log(log_directory) 237 | 238 | # Disable logging 239 | def stop_logging(self): 240 | self.logging = False 241 | 242 | # Creates a new log file 243 | def new_log(self, log_directory=None): 244 | dt = datetime.now() 245 | save_name = dt.strftime("%Y-%m-%d-%H-%M") 246 | 247 | if log_directory is None: 248 | project_root = Path.cwd().parent 249 | self.log_directory = project_root / "logs/omniscan" 250 | else: 251 | self.log_directory = Path(log_directory) 252 | 253 | self.log_directory.mkdir(parents=True, exist_ok=True) 254 | 255 | log_path = self.log_directory / f"{save_name}.svlog" 256 | 257 | if log_path.exists(): 258 | log_path.unlink() # delete existing file (program was restarted quickly) 259 | {# raise FileExistsError(f"Log file already exists: {log_path}") #} 260 | 261 | self.current_log = log_path 262 | self.logging = True 263 | self.bytes_written = 0 264 | 265 | print(f"Logging to {self.current_log}") 266 | 267 | self.write_data(self.build_metadata_packet()) 268 | 269 | # Write data to .svlog file 270 | def write_data(self, msg): 271 | if not self.logging or not self.current_log: 272 | return 273 | 274 | try: 275 | if self.bytes_written > MAX_LOG_SIZE_MB * 1000000: 276 | self.new_log(log_directory=self.log_directory) 277 | 278 | with open(self.current_log, 'ab') as f: 279 | f.write(msg.msg_data) 280 | self.bytes_written += len(msg.msg_data) 281 | 282 | except (OSError, IOError) as e: 283 | print(f"[LOGGING ERROR] Failed to write to log file {self.current_log}: {e}") 284 | self.stop_logging() 285 | 286 | except Exception as e: 287 | print(f"[LOGGING ERROR] Unexpected error: {e}") 288 | self.stop_logging() 289 | 290 | # Override wait_message to format power results before returning 291 | def wait_message(self, message_ids, timeout=0.5): 292 | tstart = time.time() 293 | while time.time() < tstart + timeout: 294 | msg = self.read() 295 | if msg is not None: 296 | if msg.message_id == definitions.OMNISCAN450_OS_MONO_PROFILE: 297 | power_byte_array = bytearray(msg.pwr_results) 298 | power_results = struct.unpack('<' + 'H' * int(msg.num_results), power_byte_array) 299 | msg.pwr_results = power_results 300 | 301 | if msg.message_id in message_ids: 302 | if self.logging: 303 | self.write_data(msg) 304 | return msg 305 | time.sleep(0.005) 306 | return None 307 | 308 | ## 309 | # @brief Do the connection via an TCP link 310 | # 311 | # @param host: TCP server address (IPV4) or name 312 | # @param port: port used to connect with server 313 | # 314 | def connect_tcp(self, host: str = None, port: int = 12345, timeout: float = 5.0): 315 | if host is None: 316 | host = '0.0.0.0' 317 | 318 | self.server_address = (host, port) 319 | try: 320 | print("Opening %s:%d" % self.server_address) 321 | self.iodev = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 322 | self.iodev.settimeout(timeout) 323 | self.iodev.connect(self.server_address) 324 | self.iodev.setblocking(0) 325 | 326 | except socket.timeout: 327 | print("Unable to connect to device") 328 | raise Exception("Connection timed out after {0} seconds".format(timeout)) 329 | except Exception as exception: 330 | raise Exception("Failed to open the given TCP port: {0}".format(exception)) 331 | 332 | ## 333 | # @brief Read available data from the io device 334 | def read_io(self): 335 | if self.iodev == None: 336 | raise Exception("IO device is null, please configure a connection before using the class.") 337 | elif type(self.iodev).__name__ == 'Serial': 338 | bytes = self.iodev.read(self.iodev.in_waiting) 339 | self._input_buffer.extendleft(bytes) 340 | else: # Socket 341 | buffer_size = 4096 342 | while True: 343 | try: # Check if we are reading before closing a connection 344 | bytes = self.iodev.recv(buffer_size) 345 | 346 | if not bytes: 347 | # if recv() returns empty, connection is closed (TCP) 348 | if self.iodev.type == socket.SOCK_STREAM: 349 | raise ConnectionError("TCP connection closed by peer.") 350 | 351 | self._input_buffer.extendleft(bytes) 352 | 353 | if len(bytes) < buffer_size: 354 | break 355 | 356 | except BlockingIOError as exception: 357 | pass # Ignore exceptions related to read before connection, a result of UDP nature 358 | 359 | except ConnectionResetError as e: 360 | raise ConnectionError("Socket connection was reset: %s" % str(e)) 361 | 362 | if __name__ == "__main__": 363 | import argparse 364 | 365 | parser = argparse.ArgumentParser(description="Ping python library example.") 366 | parser.add_argument('--device', action="store", required=False, type=str, help="Ping device port. E.g: /dev/ttyUSB0") 367 | parser.add_argument('--baudrate', action="store", type=int, default=115200, help="Ping device baudrate. E.g: 115200") 368 | parser.add_argument('--udp', action="store", required=False, type=str, help="Ping UDP server. E.g: 192.168.2.2:9092") 369 | parser.add_argument('--tcp', action="store", required=False, type=str, help="Omniscan IP:Port. E.g: 192.168.2.92:51200") 370 | args = parser.parse_args() 371 | if args.device is None and args.udp is None and args.tcp is None: 372 | parser.print_help() 373 | exit(1) 374 | 375 | p = Omniscan450() 376 | if args.device is not None: 377 | p.connect_serial(args.device, args.baudrate) 378 | elif args.udp is not None: 379 | (host, port) = args.udp.split(':') 380 | p.connect_udp(host, int(port)) 381 | elif args.tcp is not None: 382 | (host, port) = args.tcp.split(':') 383 | p.connect_tcp(host, int(port)) 384 | 385 | print("Initialized: %s" % p.initialize()) 386 | 387 | if p.iodev: 388 | try: 389 | p.iodev.close() 390 | except Exception as e: 391 | print(f"Failed to close socket: {e}") -------------------------------------------------------------------------------- /generate/templates/surveyor240.py.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # surveyor240.py 4 | # A device API for the Cerulean Sonar Surveyor240 scanning sonar 5 | 6 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 7 | # THIS IS AN AUTOGENERATED FILE 8 | # DO NOT EDIT 9 | # ~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~!~! 10 | 11 | from brping import definitions 12 | from brping import PingDevice 13 | from brping import pingmessage 14 | import math 15 | import time 16 | import struct 17 | import socket 18 | from datetime import datetime, timezone 19 | from pathlib import Path 20 | 21 | # Imports for svlog header 22 | import json 23 | import os 24 | import sys 25 | import platform 26 | 27 | MAX_LOG_SIZE_MB = 500 28 | 29 | class Surveyor240(PingDevice): 30 | def __init__(self, logging = False, log_directory = None): 31 | super().__init__() 32 | self.logging = logging 33 | self.log_directory = log_directory 34 | self.bytes_written = None 35 | self.current_log = None 36 | 37 | def initialize(self): 38 | if (self.readDeviceInformation() is None): 39 | return False 40 | if self.logging: 41 | self.new_log(self.log_directory) 42 | return True 43 | 44 | {% for msg in messages["get"]|sort %} 45 | ## 46 | # @brief Get a {{msg|replace("get_", "")}} message from the device\n 47 | # Message description:\n 48 | # {{messages["get"][msg].description}} 49 | # 50 | # @return None if there is no reply from the device, otherwise a dictionary with the following keys:\n 51 | {% for field in messages["get"][msg].payload %} 52 | # {{field.name}}: {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}}\n 53 | {% endfor%} 54 | def get_{{msg}}(self): 55 | if self.request(definitions.SURVEYOR240_{{msg|upper}}) is None: 56 | return None 57 | data = ({ 58 | {% for field in messages["get"][msg].payload %} 59 | "{{field.name}}": self._{{field.name}}, # {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 60 | {% endfor %} 61 | }) 62 | return data 63 | 64 | {% endfor %} 65 | {% for msg in messages["set"]|sort %} 66 | ## 67 | # @brief Send a {{msg}} message to the device\n 68 | # Message description:\n 69 | # {{messages["set"][msg].description}}\n 70 | # Send the message to write the device parameters, then read the values back from the device\n 71 | # 72 | {% for field in messages["set"][msg].payload %} 73 | # @param {{field.name}} - {% if field.units %}Units: {{field.units}}; {% endif %}{{field.description}} 74 | {% endfor %} 75 | # 76 | # @return If verify is False, True on successful communication with the device. If verify is False, True if the new device parameters are verified to have been written correctly. False otherwise (failure to read values back or on verification failure) 77 | def {{msg}}(self{% for field in messages["set"][msg].payload %}, {{field.name}}{% endfor %}, verify=True): 78 | m = pingmessage.PingMessage(definitions.SURVEYOR240_{{msg|upper}}) 79 | {% for field in messages["set"][msg].payload %} 80 | m.{{field.name}} = {{field.name}} 81 | {% endfor %} 82 | m.pack_msg_data() 83 | self.write(m.msg_data) 84 | if self.request(definitions.SURVEYOR240_{{msg|replace("set_", "")|upper}}) is None: 85 | return False 86 | # Read back the data and check that changes have been applied 87 | if (verify 88 | {% if messages["set"][msg].payload %} 89 | and ({% for field in messages["set"][msg].payload %}self._{{field.name}} != {{field.name}}{{ " or " if not loop.last }}{% endfor %})): 90 | {% endif %} 91 | return False 92 | return True # success{% for field in messages["set"][msg].payload %} 93 | m.{{field.name}} = {{field.name}} 94 | {% endfor %} 95 | m.pack_msg_data() 96 | self.write(m.msg_data) 97 | 98 | {% endfor %} 99 | 100 | {% for msg in messages["control"]|sort %} 101 | def control_{{msg}}(self{% for field in messages["control"][msg].payload %}, {{field.name}}{% if field.default is defined %}={{field.default}}{% endif %}{% endfor %}): 102 | m = pingmessage.PingMessage(definitions.SURVEYOR240_{{msg|upper}}) 103 | {% for field in messages["control"][msg].payload %} 104 | m.{{field.name}} = {{field.name}} 105 | {% endfor %} 106 | m.pack_msg_data() 107 | self.write(m.msg_data) 108 | 109 | {% endfor %} 110 | 111 | def readDeviceInformation(self): 112 | return self.request(definitions.COMMON_DEVICE_INFORMATION) 113 | 114 | # Calculate the milliseconds per ping from a ping rate 115 | @staticmethod 116 | def calc_msec_per_ping(ping_rate): 117 | return math.floor(1000.0 / ping_rate) 118 | 119 | def get_utc_time(self): 120 | clock_offset = 0 121 | round_trip_delay = 5000 122 | 123 | local_now = datetime.now(timezone.utc) 124 | corrected_time = local_now.timestamp() * 1000 + clock_offset / 2 125 | accuracy = round_trip_delay / 2 126 | 127 | utc_msec_u64 = int(corrected_time) & 0xFFFFFFFFFFFFFFFF 128 | accuracy_msec = int(accuracy) & 0xFFFFFFFF 129 | 130 | return utc_msec_u64, accuracy_msec 131 | 132 | # Reads a single packet from a file 133 | @staticmethod 134 | def read_packet(file): 135 | sync = file.read(2) 136 | if sync != b'BR': 137 | return None 138 | 139 | payload_len_bytes = file.read(2) 140 | if len(payload_len_bytes) < 2: 141 | return None 142 | payload_len = int.from_bytes(payload_len_bytes, 'little') 143 | 144 | msg_id = file.read(2) 145 | if len(msg_id) < 2: 146 | return None 147 | 148 | remaining = 2 + payload_len + 2 149 | rest = file.read(remaining) 150 | if len(rest) < remaining: 151 | return None 152 | 153 | msg_bytes = sync + payload_len_bytes + msg_id + rest 154 | return pingmessage.PingMessage(msg_data=msg_bytes) 155 | 156 | # Builds the packet containing metadata for the beginning of .svlog 157 | def build_metadata_packet(self): 158 | protocol = "tcp" # default fallback 159 | if self.iodev: 160 | if self.iodev.type == socket.SOCK_STREAM: 161 | protocol = "tcp" 162 | elif self.iodev.type == socket.SOCK_DGRAM: 163 | protocol = "udp" 164 | 165 | if self.server_address: 166 | url = f"{protocol}://{self.server_address[0]}:{self.server_address[1]}" 167 | else: 168 | url = f"{protocol}://unknown" 169 | 170 | content = { 171 | "session_id": 1, 172 | "session_uptime": 0.0, 173 | "session_devices": [ 174 | { 175 | "url": url, 176 | "product_id": "mbes24016" 177 | } 178 | ], 179 | "session_platform": None, 180 | "session_clients": [], 181 | "session_plan_name": None, 182 | 183 | "is_recording": True, 184 | "sonarlink_version": "", 185 | "os_hostname": platform.node(), 186 | "os_uptime": None, 187 | "os_version": platform.version(), 188 | "os_platform": platform.system().lower(), 189 | "os_release": platform.release(), 190 | 191 | "process_path": sys.executable, 192 | "process_version": f"v{platform.python_version()}", 193 | "process_uptime": time.process_time(), 194 | "process_arch": platform.machine(), 195 | 196 | "timestamp": datetime.now(timezone.utc).isoformat(), 197 | "timestamp_timezone_offset": datetime.now().astimezone().utcoffset().total_seconds() // 60 198 | } 199 | 200 | json_bytes = json.dumps(content, indent=2).encode("utf-8") 201 | 202 | m = pingmessage.PingMessage(definitions.OMNISCAN450_JSON_WRAPPER) 203 | m.payload = json_bytes 204 | m.payload_length = len(json_bytes) 205 | 206 | msg_data = bytearray() 207 | msg_data += b"BR" 208 | msg_data += m.payload_length.to_bytes(2, "little") 209 | msg_data += m.message_id.to_bytes(2, "little") 210 | msg_data += m.dst_device_id.to_bytes(1, "little") 211 | msg_data += m.src_device_id.to_bytes(1, "little") 212 | msg_data += m.payload 213 | 214 | checksum = sum(msg_data) & 0xFFFF 215 | msg_data += bytearray(struct.pack(pingmessage.PingMessage.endianess + pingmessage.PingMessage.checksum_format, checksum)) 216 | 217 | m.msg_data = msg_data 218 | m.checksum = checksum 219 | 220 | return m 221 | 222 | # Enable logging 223 | def start_logging(self, new_log = False, log_directory = None): 224 | if self.logging: 225 | return 226 | 227 | self.logging = True 228 | 229 | if self.current_log is None or new_log: 230 | self.new_log(log_directory) 231 | 232 | def stop_logging(self): 233 | self.logging = False 234 | 235 | # Creates a new log file 236 | def new_log(self, log_directory=None): 237 | dt = datetime.now() 238 | save_name = dt.strftime("%Y-%m-%d-%H-%M") 239 | 240 | if log_directory is None: 241 | project_root = Path.cwd().parent 242 | self.log_directory = project_root / "logs/surveyor" 243 | else: 244 | self.log_directory = Path(log_directory) 245 | 246 | self.log_directory.mkdir(parents=True, exist_ok=True) 247 | 248 | log_path = self.log_directory / f"{save_name}.svlog" 249 | 250 | if log_path.exists(): 251 | log_path.unlink() # delete existing file (program was restarted quickly) 252 | 253 | self.current_log = log_path 254 | self.logging = True 255 | self.bytes_written = 0 256 | 257 | print(f"Logging to {self.current_log}") 258 | 259 | self.write_data(self.build_metadata_packet()) 260 | 261 | # Write data to .svlog file 262 | def write_data(self, msg): 263 | if not self.logging or not self.current_log: 264 | return 265 | 266 | try: 267 | if self.bytes_written > MAX_LOG_SIZE_MB * 1000000: 268 | self.new_log(log_directory=self.log_directory) 269 | 270 | with open(self.current_log, 'ab') as f: 271 | f.write(msg.msg_data) 272 | self.bytes_written += len(msg.msg_data) 273 | 274 | except (OSError, IOError) as e: 275 | print(f"[LOGGING ERROR] Failed to write to log file {self.current_log}: {e}") 276 | self.stop_logging() 277 | 278 | except Exception as e: 279 | print(f"[LOGGING ERROR] Unexpected error: {e}") 280 | self.stop_logging() 281 | 282 | # Override handle_message to respond to a UTC request from Surveyor 283 | def handle_message(self, msg): 284 | if msg.message_id == definitions.SURVEYOR240_UTC_REQUEST: 285 | now, accuracy = self.get_utc_time() 286 | 287 | response = pingmessage.PingMessage(definitions.SURVEYOR240_UTC_RESPONSE) 288 | response.utc_msec = now 289 | response.accuracy_msec = accuracy 290 | response.pack_msg_data() 291 | self.write(response.msg_data) 292 | 293 | return True 294 | 295 | return super().handle_message(msg) 296 | 297 | # Override wait_message to also handle UTC requests from Surveyor and for creating atof_t data 298 | def wait_message(self, message_ids, timeout=0.5): 299 | tstart = time.time() 300 | while time.time() < tstart + timeout: 301 | msg = self.read() 302 | if msg is not None: 303 | if msg.message_id == definitions.SURVEYOR240_UTC_REQUEST: 304 | continue 305 | 306 | if msg.message_id == definitions.SURVEYOR240_ATOF_POINT_DATA: 307 | atof_byte_array = bytearray(msg.atof_point_data) 308 | formatted_atof_array = struct.unpack('<' + 'I' * (4*int(msg.num_points)), atof_byte_array) 309 | msg.atof_point_data = formatted_atof_array 310 | 311 | if msg.message_id in message_ids: 312 | if self.logging: 313 | self.write_data(msg) 314 | return msg 315 | time.sleep(0.005) 316 | return None 317 | 318 | ## 319 | # @brief Do the connection via an TCP link 320 | # 321 | # @param host: TCP server address (IPV4) or name 322 | # @param port: port used to connect with server 323 | # 324 | def connect_tcp(self, host: str = None, port: int = 12345, timeout: float = 5.0): 325 | if host is None: 326 | host = '0.0.0.0' 327 | 328 | self.server_address = (host, port) 329 | try: 330 | print("Opening %s:%d" % self.server_address) 331 | self.iodev = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 332 | self.iodev.settimeout(timeout) 333 | self.iodev.connect(self.server_address) 334 | self.iodev.setblocking(0) 335 | 336 | except socket.timeout: 337 | print("Unable to connect to device") 338 | raise Exception("Connection timed out after {0} seconds".format(timeout)) 339 | except Exception as exception: 340 | raise Exception("Failed to open the given TCP port: {0}".format(exception)) 341 | 342 | ## 343 | # @brief Read available data from the io device 344 | def read_io(self): 345 | if self.iodev == None: 346 | raise Exception("IO device is null, please configure a connection before using the class.") 347 | elif type(self.iodev).__name__ == 'Serial': 348 | bytes = self.iodev.read(self.iodev.in_waiting) 349 | self._input_buffer.extendleft(bytes) 350 | else: # Socket 351 | buffer_size = 4096 352 | while True: 353 | try: # Check if we are reading before closing a connection 354 | bytes = self.iodev.recv(buffer_size) 355 | 356 | if not bytes: 357 | # if recv() returns empty, connection is closed (TCP) 358 | if self.iodev.type == socket.SOCK_STREAM: 359 | raise ConnectionError("TCP connection closed by peer.") 360 | 361 | self._input_buffer.extendleft(bytes) 362 | 363 | if len(bytes) < buffer_size: 364 | break 365 | 366 | except BlockingIOError as exception: 367 | pass # Ignore exceptions related to read before connection, a result of UDP nature 368 | 369 | except ConnectionResetError as e: 370 | raise ConnectionError("Socket connection was reset: %s" % str(e)) 371 | 372 | # Class to represent the atof_t struct 373 | class atof_t: 374 | def __init__(self, angle=0.0, tof=0.0, reserved=(0,0)): 375 | self.angle = angle 376 | self.tof = tof 377 | self.reserved = reserved 378 | 379 | def __repr__(self): 380 | return f"angle: {self.angle}, tof: {self.tof}" 381 | 382 | # Creates atof_t[] and fills it with structured atof_ts using data from the message 383 | @staticmethod 384 | def create_atof_list(msg): 385 | raw_array = msg.atof_point_data 386 | 387 | atof_list = [] 388 | for i in range(msg.num_points): 389 | idx = i * 4 390 | 391 | angle = struct.unpack(' 0: 185 | ## The struct formatting string for the message payload 186 | self.payload_format = self.get_payload_format() 187 | 188 | # Extract payload 189 | try: 190 | payload = struct.unpack(PingMessage.endianess + self.payload_format, self.msg_data[PingMessage.headerLength:PingMessage.headerLength + self.payload_length]) 191 | except Exception as e: 192 | print("error unpacking payload: %s" % e) 193 | print("msg_data: %s, header: %s" % (msg_data, header)) 194 | print("format: %s, buf: %s" % (PingMessage.endianess + self.payload_format, self.msg_data[PingMessage.headerLength:PingMessage.headerLength + self.payload_length])) 195 | print(self.payload_format) 196 | else: # only use payload if didn't raise exception 197 | for i, attr in enumerate(self.payload_field_names): 198 | try: 199 | setattr(self, attr, payload[i]) 200 | # empty trailing variable data field 201 | except IndexError as e: 202 | if self.message_id in variable_msgs: 203 | setattr(self, attr, bytearray()) 204 | pass 205 | 206 | # Extract checksum 207 | self.checksum = struct.unpack(PingMessage.endianess + PingMessage.checksum_format, self.msg_data[PingMessage.headerLength + self.payload_length: PingMessage.headerLength + self.payload_length + PingMessage.checksumLength])[0] 208 | return True 209 | 210 | ## Calculate the checksum from the internal bytearray self.msg_data 211 | def calculate_checksum(self): 212 | return sum(self.msg_data[0:PingMessage.headerLength + self.payload_length]) & 0xffff 213 | 214 | ## Update the object checksum value 215 | # @return the object checksum value 216 | def update_checksum(self): 217 | self.checksum = self.calculate_checksum() 218 | return self.checksum 219 | 220 | ## Verify that the object checksum attribute is equal to the checksum calculated according to the internal bytearray self.msg_data 221 | def verify_checksum(self): 222 | return self.checksum == self.calculate_checksum() 223 | 224 | ## Update the payload_length attribute with the **current** payload length, including dynamic length fields (if present) 225 | def update_payload_length(self): 226 | if self.message_id in variable_msgs or self.message_id in asciiMsgs: 227 | # The last field self.payload_field_names[-1] is always the single dynamic-length field 228 | self.payload_length = payload_dict[self.message_id]["payload_length"] + len(getattr(self, self.payload_field_names[-1])) 229 | else: 230 | self.payload_length = payload_dict[self.message_id]["payload_length"] 231 | 232 | ## Get the python struct formatting string for the message payload 233 | # @return the payload struct format string 234 | def get_payload_format(self): 235 | # messages with variable length fields 236 | if self.message_id in variable_msgs or self.message_id in asciiMsgs: 237 | var_length = self.payload_length - payload_dict[self.message_id]["payload_length"] # Subtract static length portion from payload length 238 | if var_length <= 0: 239 | return payload_dict[self.message_id]["format"] # variable data portion is empty 240 | 241 | return payload_dict[self.message_id]["format"] + str(var_length) + "s" 242 | else: # messages with a static (constant) length 243 | return payload_dict[self.message_id]["format"] 244 | 245 | ## Dump object into string representation 246 | # @return string representation of the object 247 | def __repr__(self): 248 | header_string = "Header:" 249 | for attr in PingMessage.header_field_names: 250 | header_string += " " + attr + ": " + str(getattr(self, attr)) 251 | 252 | if self.payload_length == 0: # this is a hack/guard for empty body requests 253 | payload_string = "" 254 | else: 255 | payload_string = "Payload:" 256 | 257 | # handle variable length messages 258 | if self.message_id in variable_msgs: 259 | 260 | # static fields are handled as usual 261 | for attr in payload_dict[self.message_id]["field_names"][:-1]: 262 | payload_string += "\n - " + attr + ": " + str(getattr(self, attr)) 263 | 264 | # the variable length field is always the last field 265 | attr = payload_dict[self.message_id]["field_names"][-1:][0] 266 | 267 | # format this field as a list of hex values (rather than a string if we did not perform this handling) 268 | payload_string += "\n - " + attr + ": " + str([hex(item) for item in getattr(self, attr)]) 269 | 270 | else: # handling of static length messages and text messages 271 | for attr in payload_dict[self.message_id]["field_names"]: 272 | payload_string += "\n - " + attr + ": " + str(getattr(self, attr)) 273 | 274 | representation = ( 275 | "\n\n--------------------------------------------------\n" 276 | "ID: " + str(self.message_id) + " - " + self.name + "\n" + 277 | header_string + "\n" + 278 | payload_string + "\n" + 279 | "Checksum: " + str(self.checksum) + " check: " + str(self.calculate_checksum()) + " pass: " + str(self.verify_checksum()) 280 | ) 281 | 282 | return representation 283 | 284 | 285 | # A class to digest a serial stream and decode PingMessages 286 | class PingParser(object): 287 | # pre-declare instance variables for faster access and reduced memory overhead 288 | __slots__ = ( 289 | "buf", 290 | "state", 291 | "payload_length", 292 | "message_id", 293 | "errors", 294 | "parsed", 295 | "rx_msg", 296 | ) 297 | 298 | NEW_MESSAGE = 0 # Just got a complete checksum-verified message 299 | WAIT_START = 1 # Waiting for the first character of a message 'B' 300 | WAIT_HEADER = 2 # Waiting for the second character in the two-character sequence 'BR' 301 | WAIT_LENGTH_L = 3 # Waiting for the low byte of the payload length field 302 | WAIT_LENGTH_H = 4 # Waiting for the high byte of the payload length field 303 | WAIT_MSG_ID_L = 5 # Waiting for the low byte of the payload id field 304 | WAIT_MSG_ID_H = 6 # Waiting for the high byte of the payload id field 305 | WAIT_SRC_ID = 7 # Waiting for the source device id 306 | WAIT_DST_ID = 8 # Waiting for the destination device id 307 | WAIT_PAYLOAD = 9 # Waiting for the last byte of the payload to come in 308 | WAIT_CHECKSUM_L = 10 # Waiting for the checksum low byte 309 | WAIT_CHECKSUM_H = 11 # Waiting for the checksum high byte 310 | ERROR = 12 # Checksum didn't check out 311 | 312 | def __init__(self): 313 | self.buf = bytearray() 314 | self.state = self.WAIT_START 315 | self.payload_length = 0 # remaining for the message currently being parsed 316 | self.message_id = 0 # of the message currently being parsed 317 | self.errors = 0 318 | self.parsed = 0 319 | self.rx_msg = None # most recently parsed message 320 | 321 | def wait_start(self, msg_byte): 322 | self.buf = bytearray() 323 | if msg_byte == ord('B'): 324 | self.buf.append(msg_byte) 325 | self.state += 1 326 | 327 | def wait_header(self, msg_byte): 328 | if msg_byte == ord('R'): 329 | self.buf.append(msg_byte) 330 | self.state += 1 331 | else: 332 | self.state = self.WAIT_START 333 | 334 | def wait_length_l(self, msg_byte): 335 | self.payload_length = msg_byte 336 | self.buf.append(msg_byte) 337 | self.state += 1 338 | 339 | def wait_length_h(self, msg_byte): 340 | self.payload_length |= (msg_byte << 8) 341 | self.buf.append(msg_byte) 342 | self.state += 1 343 | 344 | def wait_msg_id_l(self, msg_byte): 345 | self.message_id = msg_byte 346 | self.buf.append(msg_byte) 347 | self.state += 1 348 | 349 | def wait_msg_id_h(self, msg_byte): 350 | self.message_id |= (msg_byte << 8) 351 | self.buf.append(msg_byte) 352 | self.state += 1 353 | 354 | def wait_src_id(self, msg_byte): 355 | self.buf.append(msg_byte) 356 | self.state += 1 357 | 358 | def wait_dst_id(self, msg_byte): 359 | self.buf.append(msg_byte) 360 | self.state += 1 361 | if self.payload_length == 0: # no payload bytes -> skip waiting 362 | self.state += 1 363 | 364 | def wait_payload(self, msg_byte): 365 | self.buf.append(msg_byte) 366 | self.payload_length -= 1 367 | if self.payload_length == 0: # no payload bytes remaining -> stop waiting: 368 | self.state += 1 369 | 370 | def wait_checksum_l(self, msg_byte): 371 | self.buf.append(msg_byte) 372 | self.state += 1 373 | 374 | def wait_checksum_h(self, msg_byte): 375 | self.state = self.WAIT_START 376 | self.payload_length = 0 377 | self.message_id = 0 378 | 379 | self.buf.append(msg_byte) 380 | self.rx_msg = PingMessage(msg_data=self.buf) 381 | 382 | if self.rx_msg.verify_checksum(): 383 | self.parsed += 1 384 | return self.NEW_MESSAGE 385 | else: 386 | self.errors += 1 387 | return self.ERROR 388 | 389 | return self.state 390 | 391 | def parse_byte(self, msg_byte): 392 | """ Returns the current parse state after feeding the parser a single byte. 393 | 394 | 'msg_byte' is the byte to parse. 395 | If it completes a valid message, returns PingParser.NEW_MESSAGE. 396 | The decoded PingMessage will be available in the self.rx_msg attribute 397 | until a new message is decoded. 398 | """ 399 | # Apply the relevant parsing method for the current state. 400 | # (offset by 1 because NEW_MESSAGE isn't processed - start at WAIT_START) 401 | result = self._PARSE_BYTE[self.state - 1](self, msg_byte) 402 | 403 | return self.state if result is None else result 404 | 405 | # Tuple of parsing methods, in order of parser state 406 | # at bottom because otherwise methods won't be defined 407 | _PARSE_BYTE = ( 408 | wait_start, 409 | wait_header, 410 | wait_length_l, 411 | wait_length_h, 412 | wait_msg_id_l, 413 | wait_msg_id_h, 414 | wait_src_id, 415 | wait_dst_id, 416 | wait_payload, 417 | wait_checksum_l, 418 | wait_checksum_h, 419 | ) 420 | 421 | 422 | if __name__ == "__main__": 423 | # Hand-written data buffers for testing and verification 424 | test_protocol_version_buf = bytearray([ 425 | 0x42, 426 | 0x52, 427 | 4, 428 | 0, 429 | definitions.COMMON_PROTOCOL_VERSION, 430 | 0, 431 | 77, 432 | 211, 433 | 1, 434 | 2, 435 | 3, 436 | 99, 437 | 0x26, 438 | 0x02]) 439 | 440 | test_profile_buf = bytearray([ 441 | 0x42, # 'B' 442 | 0x52, # 'R' 443 | 0x24, # 36_L payload length 444 | 0x00, # 36_H 445 | 0x14, # 1300_L message id 446 | 0x05, # 1300_H 447 | 56, 448 | 45, 449 | 0xe8, # 1000_L distance 450 | 0x03, # 1000_H 451 | 0x00, # 1000_H 452 | 0x00, # 1000_H 453 | 93, # 93_L confidence 454 | 0x00, # 93_H 455 | 0x3f, # 2111_L transmit duration 456 | 0x08, # 2111_H 457 | 0x1c, # 44444444_L ping number 458 | 0x2b, # 44444444_H 459 | 0xa6, # 44444444_H 460 | 0x02, # 44444444_H 461 | 0xa0, # 4000_L scan start 462 | 0x0f, # 4000_H 463 | 0x00, # 4000_H 464 | 0x00, # 4000_H 465 | 0xb8, # 35000_L scan length 466 | 0x88, # 35000_H 467 | 0x00, # 35000_H 468 | 0x00, # 35000_H 469 | 0x04, # 4_L gain setting 470 | 0x00, # 4_H 471 | 0x00, # 4_H 472 | 0x00, # 4_H 473 | 10, # 10_L profile data length 474 | 0x00, # 10_H 475 | 0,1,2,3,4,5,6,7,8,9, # profile data 476 | 0xde, # 1502_H checksum 477 | 0x05 # 1502_L 478 | ]) 479 | 480 | p = PingParser() 481 | 482 | result = None 483 | # A text message 484 | print("\n---Testing protocol_version---\n") 485 | for byte in test_protocol_version_buf: 486 | result = p.parse_byte(byte) 487 | 488 | if result == p.NEW_MESSAGE: 489 | print(p.rx_msg) 490 | else: 491 | print("fail:", result) 492 | exit(1) 493 | 494 | # A dynamic vector message 495 | print("\n---Testing profile---\n") 496 | for byte in test_profile_buf: 497 | result = p.parse_byte(byte) 498 | 499 | if result == p.NEW_MESSAGE: 500 | print(p.rx_msg) 501 | else: 502 | print("fail:", result) 503 | exit(1) 504 | --------------------------------------------------------------------------------